Add local collections support with GUI, CLI, and publishing workflow
Features: - Local collection definitions stored in ~/.cmdforge/collections/ - CLI commands: create, show, add, remove, delete, publish, status - GUI Collections page with local and registry tabs - Collection publishing with tool resolution and visibility checks - New API endpoints: GET /api/v1/me, GET /api/v1/tools/.../approved, POST /api/v1/collections - RegistryClient methods: get_me(), has_approved_public_tool(), publish_collection() Implementation: - collection.py: Collection dataclass, resolve_tool_references(), classify_tool_reference(), ToolResolutionResult - collections_page.py: GUI with background workers for install/publish - collections_commands.py: Full CLI command implementations - registry/app.py: New authenticated endpoints with validation Tests: - test_collection.py: 27 unit tests for collection module - test_collection_api.py: 20 tests (8 client, 12 API with Flask skip) Documentation updated: README, CHANGELOG, CLAUDE.md, tests/README Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
37e74bf339
commit
29869ef59f
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -6,6 +6,46 @@ All notable changes to CmdForge will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
#### Local Collections Support
|
||||||
|
- **Local collection definitions**: Create and manage collections locally before publishing
|
||||||
|
- Collections stored as YAML files in `~/.cmdforge/collections/`
|
||||||
|
- `Collection` dataclass with name, display_name, description, tools, pinned versions, tags
|
||||||
|
- `published`, `registry_name`, `pending_approval`, `pending_tools` fields for tracking state
|
||||||
|
|
||||||
|
- **CLI commands for local collections**:
|
||||||
|
- `cmdforge collections list --local` - List local collections
|
||||||
|
- `cmdforge collections create <name>` - Create new local collection
|
||||||
|
- `cmdforge collections show <name>` - View collection details
|
||||||
|
- `cmdforge collections add <name> <tool>` - Add tool to collection
|
||||||
|
- `cmdforge collections remove <name> <tool>` - Remove tool from collection
|
||||||
|
- `cmdforge collections delete <name>` - Delete local collection
|
||||||
|
- `cmdforge collections publish <name>` - Publish collection to registry
|
||||||
|
- `cmdforge collections status <name>` - Check tool visibility and approval status
|
||||||
|
- Flags: `--dry-run`, `--force`, `--continue` for publish workflow
|
||||||
|
|
||||||
|
- **GUI Collections page**: New page for managing collections
|
||||||
|
- Local collections tab: Create, edit, delete local collections
|
||||||
|
- Registry collections tab: Browse and install from registry
|
||||||
|
- Publish workflow with tool resolution analysis
|
||||||
|
- Background workers for non-blocking install/publish operations
|
||||||
|
|
||||||
|
- **Collection publishing workflow**:
|
||||||
|
- `resolve_tool_references()` function to transform local tool names to registry refs
|
||||||
|
- Visibility checking (tools must be public)
|
||||||
|
- Approval status checking (tools must have approved version)
|
||||||
|
- Options: publish tools first, skip unpublished, or cancel
|
||||||
|
- `ToolResolutionResult` dataclass for structured resolution data
|
||||||
|
|
||||||
|
- **New API endpoints**:
|
||||||
|
- `GET /api/v1/me` - Get current authenticated user info
|
||||||
|
- `GET /api/v1/tools/<owner>/<name>/approved` - Check if tool has approved public version
|
||||||
|
- `POST /api/v1/collections` - Publish/update collection (authenticated)
|
||||||
|
|
||||||
|
- **New RegistryClient methods**:
|
||||||
|
- `get_me()` - Get current user info
|
||||||
|
- `has_approved_public_tool(owner, name)` - Check tool approval status
|
||||||
|
- `publish_collection(data)` - Publish collection to registry
|
||||||
|
|
||||||
#### Registry Features
|
#### Registry Features
|
||||||
- **Fork tracking and display**: Tools now track their fork origin with `forked_from` and `forked_version` metadata
|
- **Fork tracking and display**: Tools now track their fork origin with `forked_from` and `forked_version` metadata
|
||||||
- Forked tools show a "Forked from" notice on the tool detail page
|
- Forked tools show a "Forked from" notice on the tool detail page
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,14 @@ cmdforge
|
||||||
|
|
||||||
### Core Modules (`src/cmdforge/`)
|
### Core Modules (`src/cmdforge/`)
|
||||||
|
|
||||||
- **cli/**: CLI commands entry points (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, refresh
|
- **cli/**: CLI commands entry points (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, refresh, collections
|
||||||
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
|
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
|
||||||
|
- **collection.py**: Collection management (`Collection` dataclass, `resolve_tool_references()`, `classify_tool_reference()`), local collection storage in `~/.cmdforge/collections/`
|
||||||
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
|
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
|
||||||
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml`
|
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml`
|
||||||
- **gui/**: PySide6 desktop GUI
|
- **gui/**: PySide6 desktop GUI
|
||||||
- **main_window.py**: Main application window with sidebar navigation
|
- **main_window.py**: Main application window with sidebar navigation
|
||||||
- **pages/**: Tools page, Tool Builder, Registry browser, Providers management
|
- **pages/**: Tools page, Tool Builder, Registry browser, Collections page, Providers management
|
||||||
- **dialogs/**: Step editors, Argument editor, Provider dialog, Connect/Publish dialogs
|
- **dialogs/**: Step editors, Argument editor, Provider dialog, Connect/Publish dialogs
|
||||||
|
|
||||||
### Key Paths
|
### Key Paths
|
||||||
|
|
@ -43,6 +44,7 @@ cmdforge
|
||||||
- **Tools storage**: `~/.cmdforge/<toolname>/config.yaml`
|
- **Tools storage**: `~/.cmdforge/<toolname>/config.yaml`
|
||||||
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
|
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
|
||||||
- **Provider config**: `~/.cmdforge/providers.yaml`
|
- **Provider config**: `~/.cmdforge/providers.yaml`
|
||||||
|
- **Collections storage**: `~/.cmdforge/collections/<name>.yaml`
|
||||||
|
|
||||||
### Tool Structure
|
### Tool Structure
|
||||||
|
|
||||||
|
|
|
||||||
91
README.md
91
README.md
|
|
@ -146,9 +146,17 @@ cmdforge registry status # Check moderation status
|
||||||
cmdforge registry my-tools # List your published tools
|
cmdforge registry my-tools # List your published tools
|
||||||
|
|
||||||
# Collections (curated tool bundles)
|
# Collections (curated tool bundles)
|
||||||
cmdforge collections list # List available collections
|
cmdforge collections list # List registry collections
|
||||||
|
cmdforge collections list --local # List your local collections
|
||||||
cmdforge collections info starter # View collection details
|
cmdforge collections info starter # View collection details
|
||||||
cmdforge collections install starter # Install all tools in a collection
|
cmdforge collections install starter # Install all tools in a collection
|
||||||
|
cmdforge collections create my-tools # Create local collection
|
||||||
|
cmdforge collections show my-tools # View local collection
|
||||||
|
cmdforge collections add my-tools summarize # Add tool to collection
|
||||||
|
cmdforge collections remove my-tools summarize # Remove tool
|
||||||
|
cmdforge collections delete my-tools # Delete local collection
|
||||||
|
cmdforge collections publish my-tools # Publish to registry
|
||||||
|
cmdforge collections status my-tools # Check tool statuses
|
||||||
|
|
||||||
# Project Dependencies
|
# Project Dependencies
|
||||||
cmdforge init # Create cmdforge.yaml manifest
|
cmdforge init # Create cmdforge.yaml manifest
|
||||||
|
|
@ -444,10 +452,14 @@ This ensures all required tools are available before running your project's scri
|
||||||
|
|
||||||
## Collections
|
## Collections
|
||||||
|
|
||||||
Collections are curated bundles of related tools. Install an entire workflow with one command:
|
Collections are curated bundles of related tools. Install an entire workflow with one command.
|
||||||
|
|
||||||
|
### Registry Collections
|
||||||
|
|
||||||
|
Browse and install collections from the CmdForge registry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List available collections
|
# List available collections from registry
|
||||||
cmdforge collections list
|
cmdforge collections list
|
||||||
|
|
||||||
# View what's in a collection
|
# View what's in a collection
|
||||||
|
|
@ -461,7 +473,59 @@ cmdforge collections info starter
|
||||||
cmdforge collections install starter
|
cmdforge collections install starter
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Collections
|
### Local Collections
|
||||||
|
|
||||||
|
Create and manage your own collections locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List your local collections
|
||||||
|
cmdforge collections list --local
|
||||||
|
|
||||||
|
# Create a new local collection
|
||||||
|
cmdforge collections create my-toolkit
|
||||||
|
|
||||||
|
# View collection details
|
||||||
|
cmdforge collections show my-toolkit
|
||||||
|
|
||||||
|
# Add tools to the collection
|
||||||
|
cmdforge collections add my-toolkit summarize
|
||||||
|
cmdforge collections add my-toolkit official/translate
|
||||||
|
|
||||||
|
# Remove tools from the collection
|
||||||
|
cmdforge collections remove my-toolkit summarize
|
||||||
|
|
||||||
|
# Delete a collection
|
||||||
|
cmdforge collections delete my-toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publishing Collections
|
||||||
|
|
||||||
|
Share your collections with the community:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check collection status before publishing
|
||||||
|
cmdforge collections status my-toolkit
|
||||||
|
# Shows: tool visibility, approval status, any issues
|
||||||
|
|
||||||
|
# Publish to registry (requires account)
|
||||||
|
cmdforge collections publish my-toolkit
|
||||||
|
|
||||||
|
# Dry run to see what would be published
|
||||||
|
cmdforge collections publish my-toolkit --dry-run
|
||||||
|
|
||||||
|
# Force publish even with warnings
|
||||||
|
cmdforge collections publish my-toolkit --force
|
||||||
|
|
||||||
|
# Continue publishing after fixing issues
|
||||||
|
cmdforge collections publish my-toolkit --continue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Publishing requirements:**
|
||||||
|
- All tools must be public (not private visibility)
|
||||||
|
- All tools must have an approved version in the registry
|
||||||
|
- Local tools will be prefixed with your username (e.g., `summarize` → `yourname/summarize`)
|
||||||
|
|
||||||
|
### Available Registry Collections
|
||||||
|
|
||||||
| Collection | Description | Tools |
|
| Collection | Description | Tools |
|
||||||
|------------|-------------|-------|
|
|------------|-------------|-------|
|
||||||
|
|
@ -567,6 +631,20 @@ The graphical interface provides a modern desktop experience:
|
||||||
- Create new tools with the built-in Tool Builder
|
- Create new tools with the built-in Tool Builder
|
||||||
- Connect to the registry to publish your tools
|
- Connect to the registry to publish your tools
|
||||||
|
|
||||||
|
### Collections Page
|
||||||
|
- **Local Collections**: Create and manage your own tool bundles
|
||||||
|
- Create new collections with display name and description
|
||||||
|
- Add/remove tools from collections
|
||||||
|
- View collection details and tool lists
|
||||||
|
- **Registry Collections**: Browse collections from the registry
|
||||||
|
- Search and filter available collections
|
||||||
|
- View collection contents before installing
|
||||||
|
- One-click install for all tools in a collection
|
||||||
|
- **Publish Collections**: Share your collections with the community
|
||||||
|
- Automatic tool resolution (local names → registry references)
|
||||||
|
- Visibility and approval status checks
|
||||||
|
- Options to publish tools first, skip unpublished, or cancel
|
||||||
|
|
||||||
### Tool Builder
|
### Tool Builder
|
||||||
- Visual form for creating and editing tools
|
- Visual form for creating and editing tools
|
||||||
- Add arguments with flags and default values
|
- Add arguments with flags and default values
|
||||||
|
|
@ -609,8 +687,9 @@ The graphical interface provides a modern desktop experience:
|
||||||
| `Ctrl+R` | Refresh current page |
|
| `Ctrl+R` | Refresh current page |
|
||||||
| `Ctrl+1` | Go to My Tools |
|
| `Ctrl+1` | Go to My Tools |
|
||||||
| `Ctrl+2` | Go to Registry |
|
| `Ctrl+2` | Go to Registry |
|
||||||
| `Ctrl+3` | Go to Providers |
|
| `Ctrl+3` | Go to Collections |
|
||||||
| `Ctrl+4` | Go to Profiles |
|
| `Ctrl+4` | Go to Providers |
|
||||||
|
| `Ctrl+5` | Go to Profiles |
|
||||||
| `Escape` | Close tool builder |
|
| `Escape` | Close tool builder |
|
||||||
| `Ctrl+Q` | Quit application |
|
| `Ctrl+Q` | Quit application |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,21 +219,64 @@ def main():
|
||||||
|
|
||||||
# collections list
|
# collections list
|
||||||
p_coll_list = collections_sub.add_parser("list", help="List available collections")
|
p_coll_list = collections_sub.add_parser("list", help="List available collections")
|
||||||
|
p_coll_list.add_argument("--local", action="store_true", help="Show local collections only")
|
||||||
p_coll_list.add_argument("--json", action="store_true", help="Output as JSON")
|
p_coll_list.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
p_coll_list.set_defaults(func=cmd_collections)
|
p_coll_list.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
# collections info
|
# collections info
|
||||||
p_coll_info = collections_sub.add_parser("info", help="Show collection details")
|
p_coll_info = collections_sub.add_parser("info", help="Show registry collection details")
|
||||||
p_coll_info.add_argument("name", help="Collection name")
|
p_coll_info.add_argument("name", help="Collection name")
|
||||||
p_coll_info.add_argument("--json", action="store_true", help="Output as JSON")
|
p_coll_info.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
p_coll_info.set_defaults(func=cmd_collections)
|
p_coll_info.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
# collections install
|
# collections install
|
||||||
p_coll_install = collections_sub.add_parser("install", help="Install all tools in a collection")
|
p_coll_install = collections_sub.add_parser("install", help="Install all tools in a registry collection")
|
||||||
p_coll_install.add_argument("name", help="Collection name")
|
p_coll_install.add_argument("name", help="Collection name")
|
||||||
p_coll_install.add_argument("--pinned", action="store_true", help="Use pinned versions from collection")
|
p_coll_install.add_argument("--pinned", action="store_true", help="Use pinned versions from collection")
|
||||||
p_coll_install.set_defaults(func=cmd_collections)
|
p_coll_install.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
|
# collections create (local)
|
||||||
|
p_coll_create = collections_sub.add_parser("create", help="Create a new local collection")
|
||||||
|
p_coll_create.add_argument("name", help="Collection name (slug)")
|
||||||
|
p_coll_create.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
|
# collections show (local)
|
||||||
|
p_coll_show = collections_sub.add_parser("show", help="Show local collection details")
|
||||||
|
p_coll_show.add_argument("name", help="Collection name")
|
||||||
|
p_coll_show.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
|
# collections add (local)
|
||||||
|
p_coll_add = collections_sub.add_parser("add", help="Add tool to collection")
|
||||||
|
p_coll_add.add_argument("collection", help="Collection name")
|
||||||
|
p_coll_add.add_argument("tool", help="Tool reference (name or owner/name)")
|
||||||
|
p_coll_add.add_argument("--version", "-v", help="Pin to specific version")
|
||||||
|
p_coll_add.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
|
# collections remove (local)
|
||||||
|
p_coll_remove = collections_sub.add_parser("remove", help="Remove tool from collection")
|
||||||
|
p_coll_remove.add_argument("collection", help="Collection name")
|
||||||
|
p_coll_remove.add_argument("tool", help="Tool reference")
|
||||||
|
p_coll_remove.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
|
# collections delete (local)
|
||||||
|
p_coll_delete = collections_sub.add_parser("delete", help="Delete local collection")
|
||||||
|
p_coll_delete.add_argument("name", help="Collection name")
|
||||||
|
p_coll_delete.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
||||||
|
p_coll_delete.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
|
# collections publish
|
||||||
|
p_coll_publish = collections_sub.add_parser("publish", help="Publish collection to registry")
|
||||||
|
p_coll_publish.add_argument("name", help="Collection name")
|
||||||
|
p_coll_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing")
|
||||||
|
p_coll_publish.add_argument("--continue", dest="resume", action="store_true",
|
||||||
|
help="Resume pending publish after tools approved")
|
||||||
|
p_coll_publish.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
|
# collections status
|
||||||
|
p_coll_status = collections_sub.add_parser("status", help="Check pending collection status")
|
||||||
|
p_coll_status.add_argument("name", help="Collection name")
|
||||||
|
p_coll_status.set_defaults(func=cmd_collections)
|
||||||
|
|
||||||
# Default for collections with no subcommand
|
# Default for collections with no subcommand
|
||||||
p_collections.set_defaults(func=lambda args: cmd_collections(args) if args.collections_cmd else (setattr(args, 'collections_cmd', None) or cmd_collections(args)))
|
p_collections.set_defaults(func=lambda args: cmd_collections(args) if args.collections_cmd else (setattr(args, 'collections_cmd', None) or cmd_collections(args)))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Collections commands for CmdForge CLI."""
|
"""Collections commands for CmdForge CLI."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from ..resolver import install_from_registry
|
from ..resolver import install_from_registry
|
||||||
|
|
@ -8,23 +9,88 @@ from ..resolver import install_from_registry
|
||||||
|
|
||||||
def cmd_collections(args):
|
def cmd_collections(args):
|
||||||
"""Handle collections subcommands."""
|
"""Handle collections subcommands."""
|
||||||
if args.collections_cmd == "list":
|
cmd = args.collections_cmd
|
||||||
|
|
||||||
|
# Existing commands (with --local flag support added)
|
||||||
|
if cmd == "list":
|
||||||
return _cmd_collections_list(args)
|
return _cmd_collections_list(args)
|
||||||
elif args.collections_cmd == "info":
|
elif cmd == "info":
|
||||||
return _cmd_collections_info(args)
|
return _cmd_collections_info(args)
|
||||||
elif args.collections_cmd == "install":
|
elif cmd == "install":
|
||||||
return _cmd_collections_install(args)
|
return _cmd_collections_install(args)
|
||||||
|
|
||||||
|
# Local management commands
|
||||||
|
elif cmd == "create":
|
||||||
|
return cmd_collections_create(args)
|
||||||
|
elif cmd == "show":
|
||||||
|
return cmd_collections_show(args)
|
||||||
|
elif cmd == "add":
|
||||||
|
return cmd_collections_add(args)
|
||||||
|
elif cmd == "remove":
|
||||||
|
return cmd_collections_remove(args)
|
||||||
|
elif cmd == "delete":
|
||||||
|
return cmd_collections_delete(args)
|
||||||
|
|
||||||
|
# Publishing commands
|
||||||
|
elif cmd == "publish":
|
||||||
|
return cmd_collections_publish(args)
|
||||||
|
elif cmd == "status":
|
||||||
|
return cmd_collections_status(args)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Default: show help
|
# Default: show help
|
||||||
print("Collections commands:")
|
print("Collections commands:")
|
||||||
print(" list List available collections")
|
print(" list List available collections (registry)")
|
||||||
print(" info <name> Show collection details")
|
print(" info <name> Show registry collection details")
|
||||||
print(" install <name> Install all tools in a collection")
|
print(" install <name> Install all tools in a registry collection")
|
||||||
|
print()
|
||||||
|
print("Local collection management:")
|
||||||
|
print(" create <name> Create a new local collection")
|
||||||
|
print(" show <name> Show local collection details")
|
||||||
|
print(" add <coll> <tool> Add a tool to a collection")
|
||||||
|
print(" remove <coll> <tool> Remove a tool from a collection")
|
||||||
|
print(" delete <name> Delete a local collection")
|
||||||
|
print()
|
||||||
|
print("Publishing:")
|
||||||
|
print(" publish <name> Publish a collection to the registry")
|
||||||
|
print(" status <name> Check status of a pending collection")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _cmd_collections_list(args):
|
def _cmd_collections_list(args):
|
||||||
"""List available collections."""
|
"""List available collections (registry or local)."""
|
||||||
|
import json as json_mod
|
||||||
|
from ..collection import list_collections, get_collection
|
||||||
|
|
||||||
|
# Handle --local flag
|
||||||
|
if getattr(args, 'local', False):
|
||||||
|
local_colls = list_collections()
|
||||||
|
|
||||||
|
if getattr(args, 'json', False):
|
||||||
|
data = []
|
||||||
|
for name in local_colls:
|
||||||
|
coll = get_collection(name)
|
||||||
|
if coll:
|
||||||
|
data.append(coll.to_dict())
|
||||||
|
print(json_mod.dumps({"collections": data}, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not local_colls:
|
||||||
|
print("No local collections.")
|
||||||
|
print("Create one with: cmdforge collections create <name>")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Local collections ({len(local_colls)}):\n")
|
||||||
|
for name in local_colls:
|
||||||
|
coll = get_collection(name)
|
||||||
|
if coll:
|
||||||
|
status = "pending" if coll.pending_approval else ("published" if coll.published else "local")
|
||||||
|
print(f" {coll.display_name}")
|
||||||
|
print(f" {coll.name} ({len(coll.tools)} tools) [{status}]")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Registry listing
|
||||||
from ..registry_client import RegistryError, get_client
|
from ..registry_client import RegistryError, get_client
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -45,8 +111,9 @@ def _cmd_collections_list(args):
|
||||||
name = coll.get("name", "")
|
name = coll.get("name", "")
|
||||||
display_name = coll.get("display_name", name)
|
display_name = coll.get("display_name", name)
|
||||||
description = coll.get("description", "")
|
description = coll.get("description", "")
|
||||||
|
# Use tool_count if available, fall back to counting tools list
|
||||||
tools = coll.get("tools", [])
|
tools = coll.get("tools", [])
|
||||||
tool_count = len(tools) if isinstance(tools, list) else 0
|
tool_count = coll.get("tool_count", len(tools) if isinstance(tools, list) else 0)
|
||||||
|
|
||||||
print(f" {name}")
|
print(f" {name}")
|
||||||
print(f" {display_name}")
|
print(f" {display_name}")
|
||||||
|
|
@ -206,3 +273,558 @@ def _cmd_collections_install(args):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local Collection Management Commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_collections_create(args):
|
||||||
|
"""Create a new local collection."""
|
||||||
|
from ..collection import Collection, COLLECTIONS_DIR, list_collections
|
||||||
|
|
||||||
|
name = args.name.strip().lower()
|
||||||
|
|
||||||
|
# Validate name
|
||||||
|
if not name or not re.match(r'^[a-z0-9-]+$', name):
|
||||||
|
print("Error: Collection name must be lowercase alphanumeric with hyphens only")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check if exists
|
||||||
|
if name in list_collections():
|
||||||
|
print(f"Error: Collection '{name}' already exists")
|
||||||
|
print(f"Use: cmdforge collections show {name}")
|
||||||
|
print(f"Or delete it first: cmdforge collections delete {name}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Interactive prompts
|
||||||
|
display_name = input(f"Display name [{name.replace('-', ' ').title()}]: ").strip()
|
||||||
|
if not display_name:
|
||||||
|
display_name = name.replace("-", " ").title()
|
||||||
|
|
||||||
|
description = input("Description: ").strip()
|
||||||
|
tags_input = input("Tags (comma-separated): ").strip()
|
||||||
|
tags = [t.strip() for t in tags_input.split(",") if t.strip()]
|
||||||
|
|
||||||
|
collection = Collection(
|
||||||
|
name=name,
|
||||||
|
display_name=display_name,
|
||||||
|
description=description,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
collection.save()
|
||||||
|
|
||||||
|
print(f"\nCreated collection: {name}")
|
||||||
|
print(f" Location: {COLLECTIONS_DIR / f'{name}.yaml'}")
|
||||||
|
print(f"\nAdd tools with: cmdforge collections add {name} <tool>")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_collections_add(args):
|
||||||
|
"""Add a tool to a collection."""
|
||||||
|
from ..collection import get_collection
|
||||||
|
from ..tool import list_tools
|
||||||
|
|
||||||
|
collection = get_collection(args.collection)
|
||||||
|
if not collection:
|
||||||
|
print(f"Error: Collection '{args.collection}' not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tool_ref = args.tool
|
||||||
|
|
||||||
|
# Validate tool exists (local or suggest registry format)
|
||||||
|
if "/" not in tool_ref:
|
||||||
|
# Local tool reference
|
||||||
|
if tool_ref not in list_tools():
|
||||||
|
print(f"Warning: Local tool '{tool_ref}' not found")
|
||||||
|
confirm = input("Add anyway? [y/N]: ").strip().lower()
|
||||||
|
if confirm != "y":
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if tool_ref in collection.tools:
|
||||||
|
print(f"Tool '{tool_ref}' already in collection")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
collection.tools.append(tool_ref)
|
||||||
|
|
||||||
|
# Optional: set pinned version
|
||||||
|
if hasattr(args, 'version') and args.version:
|
||||||
|
collection.pinned[tool_ref] = args.version
|
||||||
|
|
||||||
|
collection.save()
|
||||||
|
print(f"Added '{tool_ref}' to collection '{collection.name}'")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_collections_remove(args):
|
||||||
|
"""Remove a tool from a collection."""
|
||||||
|
from ..collection import get_collection
|
||||||
|
|
||||||
|
collection = get_collection(args.collection)
|
||||||
|
if not collection:
|
||||||
|
print(f"Error: Collection '{args.collection}' not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tool_ref = args.tool
|
||||||
|
|
||||||
|
if tool_ref not in collection.tools:
|
||||||
|
print(f"Tool '{tool_ref}' not in collection")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
collection.tools.remove(tool_ref)
|
||||||
|
collection.pinned.pop(tool_ref, None)
|
||||||
|
collection.save()
|
||||||
|
|
||||||
|
print(f"Removed '{tool_ref}' from collection '{collection.name}'")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_collections_show(args):
|
||||||
|
"""Show local collection details."""
|
||||||
|
from ..collection import get_collection, classify_tool_reference
|
||||||
|
from ..tool import list_tools
|
||||||
|
|
||||||
|
collection = get_collection(args.name)
|
||||||
|
if not collection:
|
||||||
|
print(f"Error: Collection '{args.name}' not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
local_tools = set(list_tools())
|
||||||
|
|
||||||
|
print(f"\n{collection.display_name}")
|
||||||
|
print("=" * 50)
|
||||||
|
if collection.description:
|
||||||
|
print(f"\n{collection.description}\n")
|
||||||
|
|
||||||
|
if collection.tags:
|
||||||
|
print(f"Tags: {', '.join(collection.tags)}")
|
||||||
|
|
||||||
|
# Publishing status
|
||||||
|
if collection.pending_approval:
|
||||||
|
print(f"\nStatus: Pending (waiting for tool approvals)")
|
||||||
|
elif collection.published:
|
||||||
|
print(f"\nStatus: Published as '{collection.registry_name}'")
|
||||||
|
else:
|
||||||
|
print(f"\nStatus: Local only (not published)")
|
||||||
|
|
||||||
|
print(f"\nTools ({len(collection.tools)}):")
|
||||||
|
for tool_ref in collection.tools:
|
||||||
|
owner, name, is_local = classify_tool_reference(tool_ref)
|
||||||
|
pinned = collection.pinned.get(tool_ref, "")
|
||||||
|
version_str = f" @ {pinned}" if pinned else ""
|
||||||
|
|
||||||
|
if is_local:
|
||||||
|
exists = name in local_tools
|
||||||
|
status = "" if exists else " (not found)"
|
||||||
|
print(f" - {name}{version_str} [local]{status}")
|
||||||
|
else:
|
||||||
|
print(f" - {tool_ref}{version_str} [registry]")
|
||||||
|
|
||||||
|
if collection.pending_tools:
|
||||||
|
print(f"\nPending tool approvals:")
|
||||||
|
for tool in collection.pending_tools:
|
||||||
|
print(f" - {tool}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_collections_delete(args):
|
||||||
|
"""Delete a local collection."""
|
||||||
|
from ..collection import get_collection
|
||||||
|
|
||||||
|
collection = get_collection(args.name)
|
||||||
|
if not collection:
|
||||||
|
print(f"Error: Collection '{args.name}' not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not getattr(args, 'force', False):
|
||||||
|
confirm = input(f"Delete collection '{args.name}'? [y/N]: ").strip().lower()
|
||||||
|
if confirm != "y":
|
||||||
|
print("Cancelled")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
collection.delete()
|
||||||
|
print(f"Deleted collection '{args.name}'")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Publishing Commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_collections_publish(args):
|
||||||
|
"""Publish a collection to the registry."""
|
||||||
|
from ..collection import get_collection, resolve_tool_references
|
||||||
|
from ..registry_client import get_client, RegistryError
|
||||||
|
|
||||||
|
collection = get_collection(args.name)
|
||||||
|
if not collection:
|
||||||
|
print(f"Error: Collection '{args.name}' not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not collection.tools:
|
||||||
|
print("Error: Collection has no tools")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
client = get_client()
|
||||||
|
is_valid, error = client.validate_token()
|
||||||
|
if not is_valid:
|
||||||
|
print(f"Error: Not authenticated. Run: cmdforge config connect")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Get user's registry slug
|
||||||
|
try:
|
||||||
|
user_info = client.get_me()
|
||||||
|
user_slug = user_info.get("slug", "")
|
||||||
|
except RegistryError as e:
|
||||||
|
print(f"Error getting user info: {e.message}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not user_slug:
|
||||||
|
print("Error: Could not determine your registry username")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check if resuming a pending publish
|
||||||
|
if getattr(args, 'resume', False) and collection.pending_approval:
|
||||||
|
return _resume_collection_publish(collection, client, user_slug)
|
||||||
|
|
||||||
|
# Resolve tool references (uses authenticated endpoint for user's own tools)
|
||||||
|
print(f"Analyzing collection '{collection.display_name}'...")
|
||||||
|
result = resolve_tool_references(
|
||||||
|
collection.tools, collection.pinned, user_slug, client
|
||||||
|
)
|
||||||
|
registry_refs = result.registry_refs
|
||||||
|
transformed_pinned = result.transformed_pinned
|
||||||
|
unpublished = result.local_unpublished
|
||||||
|
published = result.local_published # List of (name, status, has_approved) tuples
|
||||||
|
visibility_issues = result.visibility_issues
|
||||||
|
registry_tool_issues = result.registry_tool_issues
|
||||||
|
|
||||||
|
# Check for tools where latest version is pending
|
||||||
|
pending_latest = [(name, status) for name, status, has_approved in published if status != "approved"]
|
||||||
|
|
||||||
|
# Dry run mode
|
||||||
|
if getattr(args, 'dry_run', False):
|
||||||
|
print(f"\n[DRY RUN] Would publish collection: {collection.name}")
|
||||||
|
print(f" Display name: {collection.display_name}")
|
||||||
|
print(f" Tools: {len(collection.tools)}")
|
||||||
|
if visibility_issues:
|
||||||
|
print(f"\n BLOCKING: Non-public local tools ({len(visibility_issues)}):")
|
||||||
|
for name, visibility in visibility_issues:
|
||||||
|
print(f" - {name} (visibility: {visibility})")
|
||||||
|
if registry_tool_issues:
|
||||||
|
print(f"\n BLOCKING: Registry tool issues ({len(registry_tool_issues)}):")
|
||||||
|
for ref, reason in registry_tool_issues:
|
||||||
|
print(f" - {ref}: {reason}")
|
||||||
|
if unpublished:
|
||||||
|
print(f"\n Unpublished local tools ({len(unpublished)}):")
|
||||||
|
for tool in unpublished:
|
||||||
|
print(f" - {tool} -> {user_slug}/{tool}")
|
||||||
|
if pending_latest:
|
||||||
|
print(f"\n Pending approval ({len(pending_latest)}):")
|
||||||
|
for name, status in pending_latest:
|
||||||
|
print(f" - {user_slug}/{name} ({status})")
|
||||||
|
if transformed_pinned:
|
||||||
|
print(f"\n Version pins (transformed):")
|
||||||
|
for ref, version in transformed_pinned.items():
|
||||||
|
print(f" - {ref}: {version}")
|
||||||
|
print(f"\n Final tool references:")
|
||||||
|
for ref in registry_refs:
|
||||||
|
print(f" - {ref}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Handle visibility issues FIRST (local tools that aren't public)
|
||||||
|
if visibility_issues:
|
||||||
|
print(f"\n{len(visibility_issues)} local tool(s) are not public:")
|
||||||
|
for name, visibility in visibility_issues:
|
||||||
|
print(f" - {name} (visibility: {visibility})")
|
||||||
|
|
||||||
|
print(f"\nCollections can only include PUBLIC tools.")
|
||||||
|
print(f"Update these tools to be public before including in a collection:")
|
||||||
|
print(f" Edit the tool's config.yaml and set: visibility: public")
|
||||||
|
print(f" Then republish with: cmdforge registry publish <toolname>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Handle registry tool issues (external tools that aren't public/approved)
|
||||||
|
if registry_tool_issues:
|
||||||
|
print(f"\n{len(registry_tool_issues)} registry tool(s) cannot be included:")
|
||||||
|
for ref, reason in registry_tool_issues:
|
||||||
|
print(f" - {ref}: {reason}")
|
||||||
|
|
||||||
|
print(f"\nCollections can only include PUBLIC, APPROVED tools.")
|
||||||
|
print(f"Remove these tools from the collection or wait for them to be approved.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Handle tools where latest version is pending (warn but don't block)
|
||||||
|
if pending_latest:
|
||||||
|
print(f"\n{len(pending_latest)} tool(s) have pending latest versions:")
|
||||||
|
for name, status in pending_latest:
|
||||||
|
print(f" - {user_slug}/{name} ({status})")
|
||||||
|
print(f"\n (An older approved version may still be usable - will attempt publish)")
|
||||||
|
|
||||||
|
# Handle unpublished tools
|
||||||
|
if unpublished:
|
||||||
|
print(f"\nThis collection contains {len(unpublished)} unpublished local tool(s):")
|
||||||
|
for tool in unpublished:
|
||||||
|
print(f" - {tool}")
|
||||||
|
|
||||||
|
print(f"\nThese must be published as '{user_slug}/<name>' before the collection.")
|
||||||
|
print("\nNOTE: Only PUBLIC, APPROVED tools can be included in collections.")
|
||||||
|
print("\nOptions:")
|
||||||
|
print(" [1] Publish all tools now, then collection (recommended)")
|
||||||
|
print(" [2] Remove unpublished tools and publish remaining")
|
||||||
|
print(" [3] Cancel")
|
||||||
|
|
||||||
|
choice = input("\nChoice [1/2/3]: ").strip()
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
# Publish tools first
|
||||||
|
pending_tools = []
|
||||||
|
for tool_name in unpublished:
|
||||||
|
print(f"\nPublishing tool: {tool_name}...")
|
||||||
|
result = _publish_single_tool(tool_name, client)
|
||||||
|
if result.get("pending"):
|
||||||
|
pending_tools.append(tool_name)
|
||||||
|
print(f" -> Submitted for review (pending approval)")
|
||||||
|
elif result.get("success"):
|
||||||
|
print(f" -> Published as {user_slug}/{tool_name}")
|
||||||
|
else:
|
||||||
|
print(f" -> Failed: {result.get('error')}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if pending_tools:
|
||||||
|
# Save pending state
|
||||||
|
collection.pending_approval = True
|
||||||
|
collection.pending_tools = pending_tools
|
||||||
|
collection.maintainer = user_slug
|
||||||
|
collection.save()
|
||||||
|
|
||||||
|
print(f"\nSome tools are pending approval:")
|
||||||
|
for tool in pending_tools:
|
||||||
|
print(f" - {user_slug}/{tool}")
|
||||||
|
print(f"\nCollection will be published once tools are approved.")
|
||||||
|
print(f"Check status with: cmdforge collections status {collection.name}")
|
||||||
|
print(f"Resume with: cmdforge collections publish {collection.name} --continue")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# All tools approved immediately (or auto-approved)
|
||||||
|
# Fall through to publish collection
|
||||||
|
|
||||||
|
elif choice == "2":
|
||||||
|
# Remove unpublished tools from refs and pinned
|
||||||
|
unpublished_full_refs = {f"{user_slug}/{u}" for u in unpublished}
|
||||||
|
registry_refs = [ref for ref in registry_refs if ref not in unpublished_full_refs]
|
||||||
|
# Also remove from transformed_pinned to avoid orphaned keys
|
||||||
|
for u in unpublished:
|
||||||
|
full_ref = f"{user_slug}/{u}"
|
||||||
|
transformed_pinned.pop(full_ref, None)
|
||||||
|
|
||||||
|
if not registry_refs:
|
||||||
|
print("Error: No tools remaining after removal")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Update local collection to match what we're publishing
|
||||||
|
collection.tools = [t for t in collection.tools if t not in unpublished]
|
||||||
|
for u in unpublished:
|
||||||
|
collection.pinned.pop(u, None)
|
||||||
|
collection.save()
|
||||||
|
|
||||||
|
print(f"\nProceeding with {len(registry_refs)} tool(s)")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Cancelled")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Publish collection (using transformed pinned keys)
|
||||||
|
return _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_single_tool(tool_name: str, client) -> dict:
|
||||||
|
"""
|
||||||
|
Publish a single tool. Returns status dict.
|
||||||
|
|
||||||
|
Updates local tool config with registry_hash and registry_status
|
||||||
|
to maintain consistency with direct `cmdforge registry publish` flow.
|
||||||
|
"""
|
||||||
|
from ..tool import load_tool, get_tools_dir
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
tool = load_tool(tool_name)
|
||||||
|
if not tool:
|
||||||
|
return {"success": False, "error": f"Tool '{tool_name}' not found"}
|
||||||
|
|
||||||
|
# Load README if exists
|
||||||
|
readme_path = get_tools_dir() / tool_name / "README.md"
|
||||||
|
readme = readme_path.read_text() if readme_path.exists() else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_yaml = yaml.safe_dump(tool.to_dict(), sort_keys=False)
|
||||||
|
result = client.publish_tool(config_yaml, readme=readme, dry_run=False)
|
||||||
|
|
||||||
|
# Update local tool config with registry metadata
|
||||||
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||||
|
config_hash = result.get("config_hash")
|
||||||
|
moderation_status = result.get("status", "pending")
|
||||||
|
|
||||||
|
if config_hash and config_path.exists():
|
||||||
|
try:
|
||||||
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||||
|
config_data["registry_hash"] = config_hash
|
||||||
|
config_data["registry_status"] = moderation_status
|
||||||
|
# Clear any old feedback when republishing
|
||||||
|
if "registry_feedback" in config_data:
|
||||||
|
del config_data["registry_feedback"]
|
||||||
|
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical
|
||||||
|
|
||||||
|
# Check if pending moderation
|
||||||
|
is_pending = moderation_status == "pending" or result.get("pr_url")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"pending": is_pending,
|
||||||
|
"status": moderation_status,
|
||||||
|
"config_hash": config_hash,
|
||||||
|
"pr_url": result.get("pr_url")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _resume_collection_publish(collection, client, user_slug) -> int:
|
||||||
|
"""Resume publishing a collection after tools are approved."""
|
||||||
|
from ..registry_client import RegistryError
|
||||||
|
|
||||||
|
print(f"Checking status of pending tools...")
|
||||||
|
|
||||||
|
still_pending = []
|
||||||
|
for tool_name in collection.pending_tools:
|
||||||
|
try:
|
||||||
|
# Use authenticated endpoint to check user's own tools
|
||||||
|
status_info = client.get_my_tool_status(tool_name)
|
||||||
|
status = status_info.get("status", "pending")
|
||||||
|
if status != "approved":
|
||||||
|
still_pending.append(tool_name)
|
||||||
|
print(f" {user_slug}/{tool_name}: {status}")
|
||||||
|
else:
|
||||||
|
print(f" {user_slug}/{tool_name}: approved")
|
||||||
|
except RegistryError:
|
||||||
|
still_pending.append(tool_name)
|
||||||
|
print(f" {user_slug}/{tool_name}: not found")
|
||||||
|
|
||||||
|
if still_pending:
|
||||||
|
collection.pending_tools = still_pending
|
||||||
|
collection.save()
|
||||||
|
print(f"\nStill waiting for {len(still_pending)} tool(s) to be approved")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# All approved - publish collection
|
||||||
|
print(f"\nAll tools approved! Publishing collection...")
|
||||||
|
|
||||||
|
# Rebuild registry refs AND transform pinned keys
|
||||||
|
registry_refs = []
|
||||||
|
transformed_pinned = {}
|
||||||
|
for tool_ref in collection.tools:
|
||||||
|
if "/" in tool_ref:
|
||||||
|
registry_refs.append(tool_ref)
|
||||||
|
# Copy pinned if exists
|
||||||
|
if tool_ref in collection.pinned:
|
||||||
|
transformed_pinned[tool_ref] = collection.pinned[tool_ref]
|
||||||
|
else:
|
||||||
|
full_ref = f"{user_slug}/{tool_ref}"
|
||||||
|
registry_refs.append(full_ref)
|
||||||
|
# Transform pinned key from local name to owner/name
|
||||||
|
if tool_ref in collection.pinned:
|
||||||
|
transformed_pinned[full_ref] = collection.pinned[tool_ref]
|
||||||
|
|
||||||
|
return _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug) -> int:
|
||||||
|
"""Actually publish the collection to registry."""
|
||||||
|
print(f"\nPublishing collection '{collection.display_name}'...")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": collection.name,
|
||||||
|
"display_name": collection.display_name,
|
||||||
|
"description": collection.description,
|
||||||
|
"maintainer": user_slug,
|
||||||
|
"tools": registry_refs,
|
||||||
|
"pinned": transformed_pinned,
|
||||||
|
"tags": collection.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = client.publish_collection(payload)
|
||||||
|
|
||||||
|
# Update local state
|
||||||
|
collection.published = True
|
||||||
|
collection.registry_name = collection.name
|
||||||
|
collection.pending_approval = False
|
||||||
|
collection.pending_tools = []
|
||||||
|
collection.maintainer = user_slug
|
||||||
|
collection.save()
|
||||||
|
|
||||||
|
print(f"\nCollection published: {collection.name}")
|
||||||
|
print(f" View at: {collection.get_registry_url()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError publishing collection: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_collections_status(args):
|
||||||
|
"""Check status of a pending collection publish."""
|
||||||
|
from ..collection import get_collection
|
||||||
|
from ..registry_client import get_client, RegistryError
|
||||||
|
|
||||||
|
collection = get_collection(args.name)
|
||||||
|
if not collection:
|
||||||
|
print(f"Error: Collection '{args.name}' not found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not collection.pending_approval:
|
||||||
|
if collection.published:
|
||||||
|
print(f"Collection '{collection.name}' is published")
|
||||||
|
else:
|
||||||
|
print(f"Collection '{collection.name}' is local (not published)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Check pending tools using authenticated endpoint
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Validate auth first
|
||||||
|
is_valid, error = client.validate_token()
|
||||||
|
if not is_valid:
|
||||||
|
print(f"Error: Not authenticated. Run: cmdforge config connect")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
user_slug = collection.maintainer
|
||||||
|
|
||||||
|
print(f"\nCollection: {collection.display_name}")
|
||||||
|
print(f"Status: Pending tool approvals\n")
|
||||||
|
|
||||||
|
all_approved = True
|
||||||
|
for tool_name in collection.pending_tools:
|
||||||
|
try:
|
||||||
|
# Use authenticated endpoint to see pending/private tools
|
||||||
|
status_info = client.get_my_tool_status(tool_name)
|
||||||
|
status = status_info.get("status", "pending")
|
||||||
|
if status == "approved":
|
||||||
|
print(f" {user_slug}/{tool_name}: approved")
|
||||||
|
else:
|
||||||
|
print(f" {user_slug}/{tool_name}: {status}")
|
||||||
|
all_approved = False
|
||||||
|
except RegistryError as e:
|
||||||
|
print(f" {user_slug}/{tool_name}: {e.message}")
|
||||||
|
all_approved = False
|
||||||
|
|
||||||
|
if all_approved:
|
||||||
|
print(f"\nAll tools approved!")
|
||||||
|
print(f"Run: cmdforge collections publish {collection.name} --continue")
|
||||||
|
else:
|
||||||
|
print(f"\nWaiting for tool approvals...")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""Collection management for CmdForge."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .config import get_registry_url
|
||||||
|
|
||||||
|
COLLECTIONS_DIR = Path.home() / ".cmdforge" / "collections"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Collection:
|
||||||
|
"""A collection of related tools."""
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str = ""
|
||||||
|
maintainer: str = ""
|
||||||
|
tools: List[str] = field(default_factory=list)
|
||||||
|
pinned: Dict[str, str] = field(default_factory=dict)
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Publishing state
|
||||||
|
published: bool = False
|
||||||
|
registry_name: str = ""
|
||||||
|
pending_approval: bool = False
|
||||||
|
pending_tools: List[str] = field(default_factory=list) # Tools awaiting approval
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, name: str) -> Optional["Collection"]:
|
||||||
|
"""Load a collection from disk."""
|
||||||
|
path = COLLECTIONS_DIR / f"{name}.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
return cls(
|
||||||
|
name=data.get("name", name),
|
||||||
|
display_name=data.get("display_name", name),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
maintainer=data.get("maintainer", ""),
|
||||||
|
tools=data.get("tools", []),
|
||||||
|
pinned=data.get("pinned", {}),
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
published=data.get("published", False),
|
||||||
|
registry_name=data.get("registry_name", ""),
|
||||||
|
pending_approval=data.get("pending_approval", False),
|
||||||
|
pending_tools=data.get("pending_tools", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Save collection to disk."""
|
||||||
|
COLLECTIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = COLLECTIONS_DIR / f"{self.name}.yaml"
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.safe_dump(self.to_dict(), f, sort_keys=False)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for YAML serialization."""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"description": self.description,
|
||||||
|
"maintainer": self.maintainer,
|
||||||
|
"tools": self.tools,
|
||||||
|
"pinned": self.pinned,
|
||||||
|
"tags": self.tags,
|
||||||
|
"published": self.published,
|
||||||
|
"registry_name": self.registry_name,
|
||||||
|
"pending_approval": self.pending_approval,
|
||||||
|
"pending_tools": self.pending_tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete collection file."""
|
||||||
|
path = COLLECTIONS_DIR / f"{self.name}.yaml"
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def get_registry_url(self) -> str:
|
||||||
|
"""Get the URL for this collection on the registry."""
|
||||||
|
base_url = get_registry_url()
|
||||||
|
# Remove /api/v1 suffix if present (using proper suffix check, not rstrip)
|
||||||
|
if base_url.endswith("/api/v1"):
|
||||||
|
base_url = base_url[:-7] # len("/api/v1") == 7
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
return f"{base_url}/collections/{self.registry_name or self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def list_collections() -> List[str]:
|
||||||
|
"""List all local collection names."""
|
||||||
|
if not COLLECTIONS_DIR.exists():
|
||||||
|
return []
|
||||||
|
return sorted([p.stem for p in COLLECTIONS_DIR.glob("*.yaml")])
|
||||||
|
|
||||||
|
|
||||||
|
def get_collection(name: str) -> Optional[Collection]:
|
||||||
|
"""Get a collection by name."""
|
||||||
|
return Collection.load(name)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_tool_reference(ref: str) -> tuple[str, str, bool]:
|
||||||
|
"""
|
||||||
|
Classify a tool reference.
|
||||||
|
|
||||||
|
Returns: (owner, name, is_local)
|
||||||
|
- Local tool "summarize" -> ("", "summarize", True)
|
||||||
|
- Registry tool "rob/summarize" -> ("rob", "summarize", False)
|
||||||
|
"""
|
||||||
|
if "/" in ref:
|
||||||
|
owner, name = ref.split("/", 1)
|
||||||
|
return (owner, name, False)
|
||||||
|
return ("", ref, True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolResolutionResult:
|
||||||
|
"""Result of resolving tool references for collection publishing."""
|
||||||
|
registry_refs: List[str] # Fully qualified owner/name references
|
||||||
|
transformed_pinned: Dict[str, str] # Pinned dict with transformed keys
|
||||||
|
local_unpublished: List[str] # Local tools not in registry
|
||||||
|
local_published: List[tuple] # (name, status, has_approved_version) tuples
|
||||||
|
visibility_issues: List[tuple] # (name, visibility) - local tools that aren't public
|
||||||
|
registry_tool_issues: List[tuple] # (ref, reason) - registry tools that aren't public/approved
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tool_visibility_from_yaml(tool_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Read visibility directly from tool's config.yaml file.
|
||||||
|
|
||||||
|
NOTE: The Tool dataclass doesn't have a visibility field, so we read
|
||||||
|
the raw YAML to check this. This is a workaround until Tool model is updated.
|
||||||
|
"""
|
||||||
|
from .tool import get_tools_dir
|
||||||
|
|
||||||
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
return "public" # Default if not found
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
config = yaml.safe_load(f) or {}
|
||||||
|
return config.get("visibility", "public")
|
||||||
|
except Exception:
|
||||||
|
return "public"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tool_references(
|
||||||
|
tools: List[str],
|
||||||
|
pinned: Dict[str, str],
|
||||||
|
user_slug: str,
|
||||||
|
client # RegistryClient instance (authenticated)
|
||||||
|
) -> ToolResolutionResult:
|
||||||
|
"""
|
||||||
|
Resolve tool references for publishing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: List of tool references
|
||||||
|
pinned: Dict of tool_ref -> version constraint
|
||||||
|
user_slug: User's registry slug (e.g., "rob")
|
||||||
|
client: Authenticated RegistryClient instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ToolResolutionResult with all resolution data including visibility issues
|
||||||
|
|
||||||
|
NOTE on visibility checking:
|
||||||
|
- Local tools: Read visibility from raw YAML (Tool model doesn't have this field)
|
||||||
|
- Registry tools: Check via has_approved_public_tool() for approved public version
|
||||||
|
"""
|
||||||
|
from .tool import load_tool
|
||||||
|
from .registry_client import RegistryError
|
||||||
|
|
||||||
|
registry_refs = []
|
||||||
|
transformed_pinned = {}
|
||||||
|
local_unpublished = []
|
||||||
|
local_published = [] # List of (name, status, has_approved_version) tuples
|
||||||
|
visibility_issues = [] # List of (name, visibility) tuples - local tools
|
||||||
|
registry_tool_issues = [] # List of (ref, reason) tuples - registry tools
|
||||||
|
|
||||||
|
for ref in tools:
|
||||||
|
owner, name, is_local = classify_tool_reference(ref)
|
||||||
|
|
||||||
|
if not is_local:
|
||||||
|
# Already a registry reference - validate it exists and is accessible
|
||||||
|
registry_refs.append(ref)
|
||||||
|
# Copy pinned version if exists
|
||||||
|
if ref in pinned:
|
||||||
|
transformed_pinned[ref] = pinned[ref]
|
||||||
|
|
||||||
|
# Check if this registry tool has an approved public version
|
||||||
|
try:
|
||||||
|
ref_owner, ref_name = ref.split("/", 1)
|
||||||
|
ok = client.has_approved_public_tool(ref_owner, ref_name)
|
||||||
|
if not ok:
|
||||||
|
registry_tool_issues.append((ref, "no approved public version"))
|
||||||
|
except RegistryError as e:
|
||||||
|
if e.code == "TOOL_NOT_FOUND":
|
||||||
|
registry_tool_issues.append((ref, "not found"))
|
||||||
|
else:
|
||||||
|
registry_tool_issues.append((ref, f"not accessible ({e.message})"))
|
||||||
|
else:
|
||||||
|
# Local tool - check visibility from raw YAML config
|
||||||
|
local_visibility = _get_tool_visibility_from_yaml(name)
|
||||||
|
if local_visibility != 'public':
|
||||||
|
visibility_issues.append((name, local_visibility))
|
||||||
|
|
||||||
|
# Check if published using authenticated endpoint
|
||||||
|
full_ref = f"{user_slug}/{name}"
|
||||||
|
try:
|
||||||
|
# Use authenticated endpoint to see pending/private tools too
|
||||||
|
status_info = client.get_my_tool_status(name)
|
||||||
|
status = status_info.get("status", "pending")
|
||||||
|
|
||||||
|
# Check if there's an approved version
|
||||||
|
has_approved = (status == "approved")
|
||||||
|
|
||||||
|
registry_refs.append(full_ref)
|
||||||
|
local_published.append((name, status, has_approved))
|
||||||
|
except RegistryError:
|
||||||
|
# Not in registry at all
|
||||||
|
local_unpublished.append(name)
|
||||||
|
registry_refs.append(full_ref) # Will be this after publish
|
||||||
|
|
||||||
|
# Transform pinned key from local name to owner/name
|
||||||
|
if name in pinned:
|
||||||
|
transformed_pinned[full_ref] = pinned[name]
|
||||||
|
|
||||||
|
return ToolResolutionResult(
|
||||||
|
registry_refs=registry_refs,
|
||||||
|
transformed_pinned=transformed_pinned,
|
||||||
|
local_unpublished=local_unpublished,
|
||||||
|
local_published=local_published,
|
||||||
|
visibility_issues=visibility_issues,
|
||||||
|
registry_tool_issues=registry_tool_issues
|
||||||
|
)
|
||||||
|
|
@ -104,9 +104,10 @@ class MainWindow(QMainWindow):
|
||||||
items = [
|
items = [
|
||||||
("CmdForge", "Welcome page - Quick actions and getting started (Ctrl+1)"),
|
("CmdForge", "Welcome page - Quick actions and getting started (Ctrl+1)"),
|
||||||
("Tools", "View, edit, delete, and test your local tools (Ctrl+2)"),
|
("Tools", "View, edit, delete, and test your local tools (Ctrl+2)"),
|
||||||
("Registry", "Browse and install tools from the community (Ctrl+3)"),
|
("Collections", "Manage and publish tool collections (Ctrl+3)"),
|
||||||
("Providers", "Configure AI backends like Claude, GPT, etc. (Ctrl+4)"),
|
("Registry", "Browse and install tools from the community (Ctrl+4)"),
|
||||||
("Profiles", "AI persona profiles for customizing tool behavior"),
|
("Providers", "Configure AI backends like Claude, GPT, etc. (Ctrl+5)"),
|
||||||
|
("Profiles", "AI persona profiles for customizing tool behavior (Ctrl+6)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
font = QFont()
|
font = QFont()
|
||||||
|
|
@ -129,18 +130,21 @@ class MainWindow(QMainWindow):
|
||||||
# Import pages here to avoid circular imports
|
# Import pages here to avoid circular imports
|
||||||
from .pages.welcome_page import WelcomePage
|
from .pages.welcome_page import WelcomePage
|
||||||
from .pages.tools_page import ToolsPage
|
from .pages.tools_page import ToolsPage
|
||||||
|
from .pages.collections_page import CollectionsPage
|
||||||
from .pages.registry_page import RegistryPage
|
from .pages.registry_page import RegistryPage
|
||||||
from .pages.providers_page import ProvidersPage
|
from .pages.providers_page import ProvidersPage
|
||||||
from .pages.profiles_page import ProfilesPage
|
from .pages.profiles_page import ProfilesPage
|
||||||
|
|
||||||
self.welcome_page = WelcomePage(self)
|
self.welcome_page = WelcomePage(self)
|
||||||
self.tools_page = ToolsPage(self)
|
self.tools_page = ToolsPage(self)
|
||||||
|
self.collections_page = CollectionsPage(self)
|
||||||
self.registry_page = RegistryPage(self)
|
self.registry_page = RegistryPage(self)
|
||||||
self.providers_page = ProvidersPage(self)
|
self.providers_page = ProvidersPage(self)
|
||||||
self.profiles_page = ProfilesPage(self)
|
self.profiles_page = ProfilesPage(self)
|
||||||
|
|
||||||
self.pages.addWidget(self.welcome_page)
|
self.pages.addWidget(self.welcome_page)
|
||||||
self.pages.addWidget(self.tools_page)
|
self.pages.addWidget(self.tools_page)
|
||||||
|
self.pages.addWidget(self.collections_page)
|
||||||
self.pages.addWidget(self.registry_page)
|
self.pages.addWidget(self.registry_page)
|
||||||
self.pages.addWidget(self.providers_page)
|
self.pages.addWidget(self.providers_page)
|
||||||
self.pages.addWidget(self.profiles_page)
|
self.pages.addWidget(self.profiles_page)
|
||||||
|
|
@ -164,9 +168,10 @@ class MainWindow(QMainWindow):
|
||||||
"welcome": 0,
|
"welcome": 0,
|
||||||
"cmdforge": 0,
|
"cmdforge": 0,
|
||||||
"tools": 1,
|
"tools": 1,
|
||||||
"registry": 2,
|
"collections": 2,
|
||||||
"providers": 3,
|
"registry": 3,
|
||||||
"profiles": 4,
|
"providers": 4,
|
||||||
|
"profiles": 5,
|
||||||
}
|
}
|
||||||
if page_name.lower() in page_map:
|
if page_name.lower() in page_map:
|
||||||
self.sidebar.setCurrentRow(page_map[page_name.lower()])
|
self.sidebar.setCurrentRow(page_map[page_name.lower()])
|
||||||
|
|
@ -211,7 +216,7 @@ class MainWindow(QMainWindow):
|
||||||
shortcut_escape = QShortcut(QKeySequence("Escape"), self)
|
shortcut_escape = QShortcut(QKeySequence("Escape"), self)
|
||||||
shortcut_escape.activated.connect(self._shortcut_escape)
|
shortcut_escape.activated.connect(self._shortcut_escape)
|
||||||
|
|
||||||
# Ctrl+1/2/3/4: Navigate pages
|
# Ctrl+1/2/3/4/5/6: Navigate pages
|
||||||
shortcut_page1 = QShortcut(QKeySequence("Ctrl+1"), self)
|
shortcut_page1 = QShortcut(QKeySequence("Ctrl+1"), self)
|
||||||
shortcut_page1.activated.connect(lambda: self.sidebar.setCurrentRow(0))
|
shortcut_page1.activated.connect(lambda: self.sidebar.setCurrentRow(0))
|
||||||
|
|
||||||
|
|
@ -224,6 +229,12 @@ class MainWindow(QMainWindow):
|
||||||
shortcut_page4 = QShortcut(QKeySequence("Ctrl+4"), self)
|
shortcut_page4 = QShortcut(QKeySequence("Ctrl+4"), self)
|
||||||
shortcut_page4.activated.connect(lambda: self.sidebar.setCurrentRow(3))
|
shortcut_page4.activated.connect(lambda: self.sidebar.setCurrentRow(3))
|
||||||
|
|
||||||
|
shortcut_page5 = QShortcut(QKeySequence("Ctrl+5"), self)
|
||||||
|
shortcut_page5.activated.connect(lambda: self.sidebar.setCurrentRow(4))
|
||||||
|
|
||||||
|
shortcut_page6 = QShortcut(QKeySequence("Ctrl+6"), self)
|
||||||
|
shortcut_page6.activated.connect(lambda: self.sidebar.setCurrentRow(5))
|
||||||
|
|
||||||
# Ctrl+R: Refresh current page
|
# Ctrl+R: Refresh current page
|
||||||
shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self)
|
shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self)
|
||||||
shortcut_refresh.activated.connect(self._shortcut_refresh)
|
shortcut_refresh.activated.connect(self._shortcut_refresh)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,932 @@
|
||||||
|
"""Collections page - manage local collections and browse registry collections."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||||
|
QListWidget, QListWidgetItem, QTextEdit, QLabel,
|
||||||
|
QPushButton, QGroupBox, QMessageBox, QFrame,
|
||||||
|
QLineEdit, QTabWidget, QDialog, QFormLayout,
|
||||||
|
QDialogButtonBox, QComboBox, QCheckBox
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
|
from ...collection import (
|
||||||
|
Collection, list_collections, get_collection, COLLECTIONS_DIR,
|
||||||
|
classify_tool_reference, resolve_tool_references
|
||||||
|
)
|
||||||
|
from ...tool import list_tools
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionCreateDialog(QDialog):
|
||||||
|
"""Dialog for creating a new collection."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Create Collection")
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Form
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.name_edit = QLineEdit()
|
||||||
|
self.name_edit.setPlaceholderText("my-collection (lowercase, hyphens ok)")
|
||||||
|
form.addRow("Name:", self.name_edit)
|
||||||
|
|
||||||
|
self.display_name_edit = QLineEdit()
|
||||||
|
self.display_name_edit.setPlaceholderText("My Collection")
|
||||||
|
form.addRow("Display Name:", self.display_name_edit)
|
||||||
|
|
||||||
|
self.description_edit = QTextEdit()
|
||||||
|
self.description_edit.setMaximumHeight(80)
|
||||||
|
self.description_edit.setPlaceholderText("Description of your collection...")
|
||||||
|
form.addRow("Description:", self.description_edit)
|
||||||
|
|
||||||
|
self.tags_edit = QLineEdit()
|
||||||
|
self.tags_edit.setPlaceholderText("tag1, tag2, tag3")
|
||||||
|
form.addRow("Tags:", self.tags_edit)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
||||||
|
)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
# Auto-fill display name from name
|
||||||
|
self.name_edit.textChanged.connect(self._update_display_name)
|
||||||
|
|
||||||
|
def _update_display_name(self, name: str):
|
||||||
|
if not self.display_name_edit.text():
|
||||||
|
display = name.replace("-", " ").title()
|
||||||
|
self.display_name_edit.setPlaceholderText(display or "My Collection")
|
||||||
|
|
||||||
|
def get_data(self) -> dict:
|
||||||
|
name = self.name_edit.text().strip().lower()
|
||||||
|
display_name = self.display_name_edit.text().strip()
|
||||||
|
if not display_name:
|
||||||
|
display_name = name.replace("-", " ").title()
|
||||||
|
|
||||||
|
tags_text = self.tags_edit.text().strip()
|
||||||
|
tags = [t.strip() for t in tags_text.split(",") if t.strip()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"description": self.description_edit.toPlainText().strip(),
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AddToolDialog(QDialog):
|
||||||
|
"""Dialog for adding a tool to a collection."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Add Tool")
|
||||||
|
self.setMinimumWidth(350)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Tool selection
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.tool_combo = QComboBox()
|
||||||
|
self.tool_combo.setEditable(True)
|
||||||
|
self.tool_combo.setPlaceholderText("Select or enter tool name...")
|
||||||
|
|
||||||
|
# Populate with local tools
|
||||||
|
local_tools = list_tools()
|
||||||
|
self.tool_combo.addItems(local_tools)
|
||||||
|
|
||||||
|
form.addRow("Tool:", self.tool_combo)
|
||||||
|
|
||||||
|
self.version_edit = QLineEdit()
|
||||||
|
self.version_edit.setPlaceholderText("Optional: ^1.0.0 or specific version")
|
||||||
|
form.addRow("Pin Version:", self.version_edit)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
# Hint
|
||||||
|
hint = QLabel("For registry tools, use format: owner/name")
|
||||||
|
hint.setStyleSheet("color: gray; font-size: 11px;")
|
||||||
|
layout.addWidget(hint)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
||||||
|
)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def get_data(self) -> dict:
|
||||||
|
return {
|
||||||
|
"tool": self.tool_combo.currentText().strip(),
|
||||||
|
"version": self.version_edit.text().strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InstallWorker(QThread):
|
||||||
|
"""Background worker for installing registry collections."""
|
||||||
|
finished = Signal(bool, str, int, int) # success, message, installed, failed
|
||||||
|
progress = Signal(str) # status message
|
||||||
|
|
||||||
|
def __init__(self, collection_name: str):
|
||||||
|
super().__init__()
|
||||||
|
self.collection_name = collection_name
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
from ...registry_client import get_client, RegistryError
|
||||||
|
from ...resolver import install_from_registry
|
||||||
|
|
||||||
|
installed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
self.progress.emit(f"Fetching collection '{self.collection_name}'...")
|
||||||
|
coll = client.get_collection(self.collection_name)
|
||||||
|
|
||||||
|
tools = coll.get("tools", [])
|
||||||
|
if not tools:
|
||||||
|
self.finished.emit(True, "Collection has no tools", 0, 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(tools)
|
||||||
|
for i, tool in enumerate(tools, 1):
|
||||||
|
if isinstance(tool, dict):
|
||||||
|
tool_ref = f"{tool['owner']}/{tool['name']}"
|
||||||
|
else:
|
||||||
|
tool_ref = tool
|
||||||
|
|
||||||
|
self.progress.emit(f"Installing {tool_ref} ({i}/{total})...")
|
||||||
|
try:
|
||||||
|
install_from_registry(tool_ref)
|
||||||
|
installed += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
self.finished.emit(False, f"Installed {installed} tools, {failed} failed", installed, failed)
|
||||||
|
else:
|
||||||
|
self.finished.emit(True, f"Installed {installed} tools from '{self.collection_name}'", installed, failed)
|
||||||
|
|
||||||
|
except RegistryError as e:
|
||||||
|
self.finished.emit(False, f"Registry error: {e.message}", installed, failed)
|
||||||
|
except Exception as e:
|
||||||
|
self.finished.emit(False, f"Error: {str(e)}", installed, failed)
|
||||||
|
|
||||||
|
|
||||||
|
class PublishAnalysisWorker(QThread):
|
||||||
|
"""Background worker to analyze collection before publishing."""
|
||||||
|
finished = Signal(dict) # result dict with analysis
|
||||||
|
|
||||||
|
def __init__(self, collection: Collection):
|
||||||
|
super().__init__()
|
||||||
|
self.collection = collection
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
from ...registry_client import get_client, RegistryError
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"error": None,
|
||||||
|
"user_slug": None,
|
||||||
|
"resolution": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Validate token
|
||||||
|
is_valid, error = client.validate_token()
|
||||||
|
if not is_valid:
|
||||||
|
result["error"] = f"Not authenticated: {error}"
|
||||||
|
self.finished.emit(result)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user slug
|
||||||
|
user_info = client.get_me()
|
||||||
|
user_slug = user_info.get("slug", "")
|
||||||
|
|
||||||
|
if not user_slug:
|
||||||
|
result["error"] = "Could not determine registry username"
|
||||||
|
self.finished.emit(result)
|
||||||
|
return
|
||||||
|
|
||||||
|
result["user_slug"] = user_slug
|
||||||
|
|
||||||
|
# Resolve tool references
|
||||||
|
resolution = resolve_tool_references(
|
||||||
|
self.collection.tools,
|
||||||
|
self.collection.pinned,
|
||||||
|
user_slug,
|
||||||
|
client
|
||||||
|
)
|
||||||
|
|
||||||
|
result["resolution"] = resolution
|
||||||
|
result["success"] = True
|
||||||
|
self.finished.emit(result)
|
||||||
|
|
||||||
|
except RegistryError as e:
|
||||||
|
result["error"] = f"Registry error: {e.message}"
|
||||||
|
self.finished.emit(result)
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error: {str(e)}"
|
||||||
|
self.finished.emit(result)
|
||||||
|
|
||||||
|
|
||||||
|
class PublishWorker(QThread):
|
||||||
|
"""Background worker for publishing collections."""
|
||||||
|
finished = Signal(bool, str) # success, message
|
||||||
|
progress = Signal(str) # status message
|
||||||
|
|
||||||
|
def __init__(self, collection: Collection, user_slug: str, registry_refs: list,
|
||||||
|
transformed_pinned: dict, skip_unpublished: list = None,
|
||||||
|
publish_unpublished: list = None):
|
||||||
|
super().__init__()
|
||||||
|
self.collection = collection
|
||||||
|
self.user_slug = user_slug
|
||||||
|
self.registry_refs = registry_refs
|
||||||
|
self.transformed_pinned = transformed_pinned
|
||||||
|
self.skip_unpublished = skip_unpublished or []
|
||||||
|
self.publish_unpublished = publish_unpublished or []
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
from ...registry_client import get_client, RegistryError
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Publish unpublished local tools first if requested
|
||||||
|
if self.publish_unpublished:
|
||||||
|
from ...cli.collections_commands import _publish_single_tool
|
||||||
|
|
||||||
|
pending_tools = []
|
||||||
|
for tool_name in self.publish_unpublished:
|
||||||
|
self.progress.emit(f"Publishing tool: {tool_name}...")
|
||||||
|
result = _publish_single_tool(tool_name, client)
|
||||||
|
if not result.get("success"):
|
||||||
|
self.finished.emit(False, f"Failed to publish {tool_name}: {result.get('error')}")
|
||||||
|
return
|
||||||
|
if result.get("pending"):
|
||||||
|
pending_tools.append(tool_name)
|
||||||
|
|
||||||
|
if pending_tools:
|
||||||
|
# Save pending state and exit without publishing collection yet
|
||||||
|
self.collection.pending_approval = True
|
||||||
|
self.collection.pending_tools = pending_tools
|
||||||
|
self.collection.maintainer = self.user_slug
|
||||||
|
self.collection.save()
|
||||||
|
self.finished.emit(True,
|
||||||
|
"Tools submitted for review. Collection will publish once approved.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter out skipped tools
|
||||||
|
if self.skip_unpublished:
|
||||||
|
unpublished_full_refs = {f"{self.user_slug}/{u}" for u in self.skip_unpublished}
|
||||||
|
self.registry_refs = [ref for ref in self.registry_refs if ref not in unpublished_full_refs]
|
||||||
|
for ref in unpublished_full_refs:
|
||||||
|
self.transformed_pinned.pop(ref, None)
|
||||||
|
|
||||||
|
if not self.registry_refs:
|
||||||
|
self.finished.emit(False, "No tools remaining after filtering")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Publish collection
|
||||||
|
self.progress.emit("Publishing collection...")
|
||||||
|
payload = {
|
||||||
|
"name": self.collection.name,
|
||||||
|
"display_name": self.collection.display_name,
|
||||||
|
"description": self.collection.description,
|
||||||
|
"maintainer": self.user_slug,
|
||||||
|
"tools": self.registry_refs,
|
||||||
|
"pinned": self.transformed_pinned,
|
||||||
|
"tags": self.collection.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
client.publish_collection(payload)
|
||||||
|
|
||||||
|
# Update local state
|
||||||
|
self.collection.published = True
|
||||||
|
self.collection.registry_name = self.collection.name
|
||||||
|
self.collection.maintainer = self.user_slug
|
||||||
|
self.collection.pending_approval = False
|
||||||
|
self.collection.pending_tools = []
|
||||||
|
|
||||||
|
# If we skipped tools, update the local collection to match
|
||||||
|
if self.skip_unpublished:
|
||||||
|
self.collection.tools = [t for t in self.collection.tools if t not in self.skip_unpublished]
|
||||||
|
for u in self.skip_unpublished:
|
||||||
|
self.collection.pinned.pop(u, None)
|
||||||
|
|
||||||
|
self.collection.save()
|
||||||
|
|
||||||
|
self.finished.emit(True, f"Collection published: {self.collection.name}")
|
||||||
|
|
||||||
|
except RegistryError as e:
|
||||||
|
self.finished.emit(False, f"Registry error: {e.message}")
|
||||||
|
except Exception as e:
|
||||||
|
self.finished.emit(False, f"Error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionsPage(QWidget):
|
||||||
|
"""Page for managing collections."""
|
||||||
|
|
||||||
|
def __init__(self, main_window):
|
||||||
|
super().__init__()
|
||||||
|
self.main_window = main_window
|
||||||
|
self._setup_ui()
|
||||||
|
self._publish_worker = None
|
||||||
|
self._analysis_worker = None
|
||||||
|
self._install_worker = None
|
||||||
|
self._pending_analysis = None # Stores analysis result for publish flow
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QHBoxLayout()
|
||||||
|
title = QLabel("Collections")
|
||||||
|
title.setFont(QFont("", 18, QFont.Bold))
|
||||||
|
header.addWidget(title)
|
||||||
|
header.addStretch()
|
||||||
|
|
||||||
|
self.btn_create = QPushButton("New Collection")
|
||||||
|
self.btn_create.clicked.connect(self._create_collection)
|
||||||
|
header.addWidget(self.btn_create)
|
||||||
|
|
||||||
|
self.btn_refresh = QPushButton("Refresh")
|
||||||
|
self.btn_refresh.clicked.connect(self.refresh)
|
||||||
|
header.addWidget(self.btn_refresh)
|
||||||
|
|
||||||
|
layout.addLayout(header)
|
||||||
|
|
||||||
|
# Tabs for local vs registry
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
|
||||||
|
# Local collections tab
|
||||||
|
local_widget = QWidget()
|
||||||
|
local_layout = QHBoxLayout(local_widget)
|
||||||
|
|
||||||
|
# Splitter for list and details
|
||||||
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
|
||||||
|
# Collection list
|
||||||
|
list_frame = QFrame()
|
||||||
|
list_layout = QVBoxLayout(list_frame)
|
||||||
|
list_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
self.collection_list = QListWidget()
|
||||||
|
self.collection_list.currentItemChanged.connect(self._on_selection_changed)
|
||||||
|
list_layout.addWidget(self.collection_list)
|
||||||
|
|
||||||
|
splitter.addWidget(list_frame)
|
||||||
|
|
||||||
|
# Details panel
|
||||||
|
details_frame = QFrame()
|
||||||
|
details_layout = QVBoxLayout(details_frame)
|
||||||
|
|
||||||
|
self.details_title = QLabel("Select a collection")
|
||||||
|
self.details_title.setFont(QFont("", 14, QFont.Bold))
|
||||||
|
details_layout.addWidget(self.details_title)
|
||||||
|
|
||||||
|
self.details_description = QLabel("")
|
||||||
|
self.details_description.setWordWrap(True)
|
||||||
|
details_layout.addWidget(self.details_description)
|
||||||
|
|
||||||
|
self.details_status = QLabel("")
|
||||||
|
details_layout.addWidget(self.details_status)
|
||||||
|
|
||||||
|
# Tools list
|
||||||
|
tools_group = QGroupBox("Tools")
|
||||||
|
tools_layout = QVBoxLayout(tools_group)
|
||||||
|
|
||||||
|
self.tools_list = QListWidget()
|
||||||
|
tools_layout.addWidget(self.tools_list)
|
||||||
|
|
||||||
|
# Tool action buttons
|
||||||
|
tool_buttons = QHBoxLayout()
|
||||||
|
self.btn_add_tool = QPushButton("Add Tool")
|
||||||
|
self.btn_add_tool.clicked.connect(self._add_tool)
|
||||||
|
self.btn_add_tool.setEnabled(False)
|
||||||
|
tool_buttons.addWidget(self.btn_add_tool)
|
||||||
|
|
||||||
|
self.btn_remove_tool = QPushButton("Remove Tool")
|
||||||
|
self.btn_remove_tool.clicked.connect(self._remove_tool)
|
||||||
|
self.btn_remove_tool.setEnabled(False)
|
||||||
|
tool_buttons.addWidget(self.btn_remove_tool)
|
||||||
|
|
||||||
|
tool_buttons.addStretch()
|
||||||
|
tools_layout.addLayout(tool_buttons)
|
||||||
|
|
||||||
|
details_layout.addWidget(tools_group)
|
||||||
|
|
||||||
|
# Collection action buttons
|
||||||
|
action_buttons = QHBoxLayout()
|
||||||
|
|
||||||
|
self.btn_publish = QPushButton("Publish")
|
||||||
|
self.btn_publish.clicked.connect(self._publish_collection)
|
||||||
|
self.btn_publish.setEnabled(False)
|
||||||
|
action_buttons.addWidget(self.btn_publish)
|
||||||
|
|
||||||
|
self.btn_delete = QPushButton("Delete")
|
||||||
|
self.btn_delete.clicked.connect(self._delete_collection)
|
||||||
|
self.btn_delete.setEnabled(False)
|
||||||
|
action_buttons.addWidget(self.btn_delete)
|
||||||
|
|
||||||
|
action_buttons.addStretch()
|
||||||
|
details_layout.addLayout(action_buttons)
|
||||||
|
|
||||||
|
details_layout.addStretch()
|
||||||
|
|
||||||
|
splitter.addWidget(details_frame)
|
||||||
|
splitter.setSizes([250, 450])
|
||||||
|
|
||||||
|
local_layout.addWidget(splitter)
|
||||||
|
|
||||||
|
self.tabs.addTab(local_widget, "Local Collections")
|
||||||
|
|
||||||
|
# Registry collections tab
|
||||||
|
registry_widget = QWidget()
|
||||||
|
registry_layout = QVBoxLayout(registry_widget)
|
||||||
|
|
||||||
|
self.registry_list = QListWidget()
|
||||||
|
self.registry_list.itemDoubleClicked.connect(self._install_registry_collection)
|
||||||
|
registry_layout.addWidget(self.registry_list)
|
||||||
|
|
||||||
|
registry_hint = QLabel("Double-click to install a collection")
|
||||||
|
registry_hint.setStyleSheet("color: gray;")
|
||||||
|
registry_layout.addWidget(registry_hint)
|
||||||
|
|
||||||
|
self.tabs.addTab(registry_widget, "Registry Collections")
|
||||||
|
|
||||||
|
layout.addWidget(self.tabs)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Refresh both local and registry collections."""
|
||||||
|
self._load_local_collections()
|
||||||
|
self._load_registry_collections()
|
||||||
|
|
||||||
|
def _load_local_collections(self):
|
||||||
|
"""Load local collections into the list."""
|
||||||
|
self.collection_list.clear()
|
||||||
|
|
||||||
|
for name in list_collections():
|
||||||
|
coll = get_collection(name)
|
||||||
|
if coll:
|
||||||
|
item = QListWidgetItem(coll.display_name)
|
||||||
|
item.setData(Qt.UserRole, name)
|
||||||
|
|
||||||
|
# Show status indicator
|
||||||
|
if coll.pending_approval:
|
||||||
|
item.setText(f"{coll.display_name} (pending)")
|
||||||
|
elif coll.published:
|
||||||
|
item.setText(f"{coll.display_name} (published)")
|
||||||
|
|
||||||
|
self.collection_list.addItem(item)
|
||||||
|
|
||||||
|
if self.collection_list.count() == 0:
|
||||||
|
item = QListWidgetItem("No local collections")
|
||||||
|
item.setFlags(Qt.NoItemFlags)
|
||||||
|
self.collection_list.addItem(item)
|
||||||
|
|
||||||
|
def _load_registry_collections(self):
|
||||||
|
"""Load registry collections in background."""
|
||||||
|
self.registry_list.clear()
|
||||||
|
self.registry_list.addItem("Loading...")
|
||||||
|
|
||||||
|
# Load in background to avoid blocking UI
|
||||||
|
QTimer.singleShot(100, self._fetch_registry_collections)
|
||||||
|
|
||||||
|
def _fetch_registry_collections(self):
|
||||||
|
"""Actually fetch registry collections."""
|
||||||
|
from ...registry_client import get_client, RegistryError
|
||||||
|
|
||||||
|
self.registry_list.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
collections = client.get_collections()
|
||||||
|
|
||||||
|
if not collections:
|
||||||
|
self.registry_list.addItem("No registry collections available")
|
||||||
|
return
|
||||||
|
|
||||||
|
for coll in collections:
|
||||||
|
name = coll.get("name", "")
|
||||||
|
display_name = coll.get("display_name", name)
|
||||||
|
tool_count = coll.get("tool_count", 0)
|
||||||
|
maintainer = coll.get("maintainer", "")
|
||||||
|
|
||||||
|
item = QListWidgetItem(f"{display_name} ({tool_count} tools) - {maintainer}")
|
||||||
|
item.setData(Qt.UserRole, name)
|
||||||
|
self.registry_list.addItem(item)
|
||||||
|
|
||||||
|
except RegistryError as e:
|
||||||
|
self.registry_list.addItem(f"Error: {e.message}")
|
||||||
|
except Exception as e:
|
||||||
|
self.registry_list.addItem(f"Error: {str(e)}")
|
||||||
|
|
||||||
|
def _on_selection_changed(self, current, previous):
|
||||||
|
"""Handle collection selection change."""
|
||||||
|
if not current:
|
||||||
|
self._clear_details()
|
||||||
|
return
|
||||||
|
|
||||||
|
name = current.data(Qt.UserRole)
|
||||||
|
if not name:
|
||||||
|
self._clear_details()
|
||||||
|
return
|
||||||
|
|
||||||
|
coll = get_collection(name)
|
||||||
|
if not coll:
|
||||||
|
self._clear_details()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._show_collection_details(coll)
|
||||||
|
|
||||||
|
def _clear_details(self):
|
||||||
|
"""Clear the details panel."""
|
||||||
|
self.details_title.setText("Select a collection")
|
||||||
|
self.details_description.setText("")
|
||||||
|
self.details_status.setText("")
|
||||||
|
self.tools_list.clear()
|
||||||
|
self.btn_add_tool.setEnabled(False)
|
||||||
|
self.btn_remove_tool.setEnabled(False)
|
||||||
|
self.btn_publish.setEnabled(False)
|
||||||
|
self.btn_delete.setEnabled(False)
|
||||||
|
|
||||||
|
def _show_collection_details(self, coll: Collection):
|
||||||
|
"""Show collection details in the panel."""
|
||||||
|
self.details_title.setText(coll.display_name)
|
||||||
|
self.details_description.setText(coll.description or "(No description)")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
if coll.pending_approval:
|
||||||
|
self.details_status.setText("Status: Pending tool approvals")
|
||||||
|
self.details_status.setStyleSheet("color: orange;")
|
||||||
|
elif coll.published:
|
||||||
|
self.details_status.setText(f"Status: Published as '{coll.registry_name}'")
|
||||||
|
self.details_status.setStyleSheet("color: green;")
|
||||||
|
else:
|
||||||
|
self.details_status.setText("Status: Local only")
|
||||||
|
self.details_status.setStyleSheet("color: gray;")
|
||||||
|
|
||||||
|
# Tools list
|
||||||
|
self.tools_list.clear()
|
||||||
|
local_tools = set(list_tools())
|
||||||
|
|
||||||
|
for tool_ref in coll.tools:
|
||||||
|
owner, name, is_local = classify_tool_reference(tool_ref)
|
||||||
|
pinned = coll.pinned.get(tool_ref, "")
|
||||||
|
version_str = f" @ {pinned}" if pinned else ""
|
||||||
|
|
||||||
|
if is_local:
|
||||||
|
exists = name in local_tools
|
||||||
|
status = "" if exists else " (not found)"
|
||||||
|
item = QListWidgetItem(f"{name}{version_str} [local]{status}")
|
||||||
|
else:
|
||||||
|
item = QListWidgetItem(f"{tool_ref}{version_str} [registry]")
|
||||||
|
|
||||||
|
item.setData(Qt.UserRole, tool_ref)
|
||||||
|
self.tools_list.addItem(item)
|
||||||
|
|
||||||
|
# Enable buttons
|
||||||
|
self.btn_add_tool.setEnabled(True)
|
||||||
|
self.btn_remove_tool.setEnabled(True)
|
||||||
|
self.btn_publish.setEnabled(not coll.published)
|
||||||
|
self.btn_delete.setEnabled(True)
|
||||||
|
|
||||||
|
def _create_collection(self):
|
||||||
|
"""Create a new collection."""
|
||||||
|
dialog = CollectionCreateDialog(self)
|
||||||
|
if dialog.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = dialog.get_data()
|
||||||
|
name = data["name"]
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
QMessageBox.warning(self, "Error", "Collection name is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate name format
|
||||||
|
import re
|
||||||
|
if not re.match(r'^[a-z0-9-]+$', name):
|
||||||
|
QMessageBox.warning(self, "Error",
|
||||||
|
"Collection name must be lowercase alphanumeric with hyphens only")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if exists
|
||||||
|
if name in list_collections():
|
||||||
|
QMessageBox.warning(self, "Error", f"Collection '{name}' already exists")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create collection
|
||||||
|
coll = Collection(
|
||||||
|
name=name,
|
||||||
|
display_name=data["display_name"],
|
||||||
|
description=data["description"],
|
||||||
|
tags=data["tags"],
|
||||||
|
)
|
||||||
|
coll.save()
|
||||||
|
|
||||||
|
self._load_local_collections()
|
||||||
|
self.main_window.show_status(f"Created collection: {name}")
|
||||||
|
|
||||||
|
# Select the new collection
|
||||||
|
for i in range(self.collection_list.count()):
|
||||||
|
item = self.collection_list.item(i)
|
||||||
|
if item.data(Qt.UserRole) == name:
|
||||||
|
self.collection_list.setCurrentItem(item)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _add_tool(self):
|
||||||
|
"""Add a tool to the selected collection."""
|
||||||
|
current = self.collection_list.currentItem()
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
|
||||||
|
name = current.data(Qt.UserRole)
|
||||||
|
coll = get_collection(name)
|
||||||
|
if not coll:
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = AddToolDialog(self)
|
||||||
|
if dialog.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = dialog.get_data()
|
||||||
|
tool_ref = data["tool"]
|
||||||
|
|
||||||
|
if not tool_ref:
|
||||||
|
QMessageBox.warning(self, "Error", "Tool name is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
if tool_ref in coll.tools:
|
||||||
|
QMessageBox.information(self, "Info", f"Tool '{tool_ref}' already in collection")
|
||||||
|
return
|
||||||
|
|
||||||
|
coll.tools.append(tool_ref)
|
||||||
|
if data["version"]:
|
||||||
|
coll.pinned[tool_ref] = data["version"]
|
||||||
|
coll.save()
|
||||||
|
|
||||||
|
self._show_collection_details(coll)
|
||||||
|
self.main_window.show_status(f"Added '{tool_ref}' to collection")
|
||||||
|
|
||||||
|
def _remove_tool(self):
|
||||||
|
"""Remove selected tool from collection."""
|
||||||
|
current = self.collection_list.currentItem()
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
|
||||||
|
name = current.data(Qt.UserRole)
|
||||||
|
coll = get_collection(name)
|
||||||
|
if not coll:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_item = self.tools_list.currentItem()
|
||||||
|
if not tool_item:
|
||||||
|
QMessageBox.warning(self, "Error", "Select a tool to remove")
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_ref = tool_item.data(Qt.UserRole)
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Remove Tool",
|
||||||
|
f"Remove '{tool_ref}' from collection?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
coll.tools.remove(tool_ref)
|
||||||
|
coll.pinned.pop(tool_ref, None)
|
||||||
|
coll.save()
|
||||||
|
|
||||||
|
self._show_collection_details(coll)
|
||||||
|
self.main_window.show_status(f"Removed '{tool_ref}' from collection")
|
||||||
|
|
||||||
|
def _publish_collection(self):
|
||||||
|
"""Publish the selected collection."""
|
||||||
|
current = self.collection_list.currentItem()
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
|
||||||
|
name = current.data(Qt.UserRole)
|
||||||
|
coll = get_collection(name)
|
||||||
|
if not coll:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not coll.tools:
|
||||||
|
QMessageBox.warning(self, "Error", "Collection has no tools")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start analysis first
|
||||||
|
self.btn_publish.setEnabled(False)
|
||||||
|
self.btn_publish.setText("Analyzing...")
|
||||||
|
self.main_window.show_status("Analyzing collection...")
|
||||||
|
|
||||||
|
self._analysis_worker = PublishAnalysisWorker(coll)
|
||||||
|
self._analysis_worker.finished.connect(self._on_analysis_finished)
|
||||||
|
self._analysis_worker.start()
|
||||||
|
|
||||||
|
def _on_analysis_finished(self, result: dict):
|
||||||
|
"""Handle publish analysis completion."""
|
||||||
|
self.btn_publish.setText("Publish")
|
||||||
|
self.btn_publish.setEnabled(True)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
QMessageBox.warning(self, "Analysis Failed", result["error"])
|
||||||
|
return
|
||||||
|
|
||||||
|
resolution = result["resolution"]
|
||||||
|
user_slug = result["user_slug"]
|
||||||
|
|
||||||
|
# Get current collection again
|
||||||
|
current = self.collection_list.currentItem()
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
coll = get_collection(current.data(Qt.UserRole))
|
||||||
|
if not coll:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for blocking visibility issues
|
||||||
|
if resolution.visibility_issues:
|
||||||
|
tools = ", ".join(t[0] for t in resolution.visibility_issues)
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Cannot Publish",
|
||||||
|
f"The following tools are not public:\n\n{tools}\n\n"
|
||||||
|
"Collections can only include PUBLIC tools.\n"
|
||||||
|
"Update these tools' visibility to 'public' and republish them first."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for registry tool issues
|
||||||
|
if resolution.registry_tool_issues:
|
||||||
|
issues = "\n".join(f" - {t[0]}: {t[1]}" for t in resolution.registry_tool_issues)
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Cannot Publish",
|
||||||
|
f"The following registry tools have issues:\n\n{issues}\n\n"
|
||||||
|
"Collections can only include approved public tools."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle unpublished local tools
|
||||||
|
publish_list = []
|
||||||
|
skip_list = []
|
||||||
|
if resolution.local_unpublished:
|
||||||
|
tools_list = "\n".join(f" - {t}" for t in resolution.local_unpublished)
|
||||||
|
|
||||||
|
dialog = QMessageBox(self)
|
||||||
|
dialog.setIcon(QMessageBox.Warning)
|
||||||
|
dialog.setWindowTitle("Unpublished Tools")
|
||||||
|
dialog.setText(
|
||||||
|
"Some local tools are not published yet.\n\n"
|
||||||
|
f"{tools_list}\n\n"
|
||||||
|
"Choose how to proceed:"
|
||||||
|
)
|
||||||
|
|
||||||
|
btn_publish = dialog.addButton("Publish Tools First", QMessageBox.AcceptRole)
|
||||||
|
btn_skip = dialog.addButton("Skip Unpublished", QMessageBox.DestructiveRole)
|
||||||
|
btn_cancel = dialog.addButton("Cancel", QMessageBox.RejectRole)
|
||||||
|
dialog.setDefaultButton(btn_publish)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
clicked = dialog.clickedButton()
|
||||||
|
if clicked == btn_publish:
|
||||||
|
publish_list = resolution.local_unpublished
|
||||||
|
elif clicked == btn_skip:
|
||||||
|
skip_list = resolution.local_unpublished
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm publish
|
||||||
|
remaining = len(resolution.registry_refs) - len(skip_list)
|
||||||
|
if remaining == 0:
|
||||||
|
QMessageBox.warning(self, "Error", "No tools remaining after filtering")
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Confirm Publish",
|
||||||
|
f"Publish '{coll.display_name}' with {remaining} tools?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start actual publish
|
||||||
|
self.btn_publish.setEnabled(False)
|
||||||
|
self.btn_publish.setText("Publishing...")
|
||||||
|
|
||||||
|
self._publish_worker = PublishWorker(
|
||||||
|
coll, user_slug,
|
||||||
|
resolution.registry_refs,
|
||||||
|
resolution.transformed_pinned,
|
||||||
|
skip_unpublished=skip_list,
|
||||||
|
publish_unpublished=publish_list
|
||||||
|
)
|
||||||
|
self._publish_worker.progress.connect(self._on_publish_progress)
|
||||||
|
self._publish_worker.finished.connect(self._on_publish_finished)
|
||||||
|
self._publish_worker.start()
|
||||||
|
|
||||||
|
def _on_publish_progress(self, message: str):
|
||||||
|
"""Handle publish progress updates."""
|
||||||
|
self.main_window.show_status(message)
|
||||||
|
|
||||||
|
def _on_publish_finished(self, success: bool, message: str):
|
||||||
|
"""Handle publish completion."""
|
||||||
|
self.btn_publish.setText("Publish")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
QMessageBox.information(self, "Published", message)
|
||||||
|
self._load_local_collections()
|
||||||
|
else:
|
||||||
|
QMessageBox.warning(self, "Publish Failed", message)
|
||||||
|
self.btn_publish.setEnabled(True)
|
||||||
|
|
||||||
|
self.main_window.show_status(message)
|
||||||
|
|
||||||
|
def _delete_collection(self):
|
||||||
|
"""Delete the selected collection."""
|
||||||
|
current = self.collection_list.currentItem()
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
|
||||||
|
name = current.data(Qt.UserRole)
|
||||||
|
coll = get_collection(name)
|
||||||
|
if not coll:
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Delete Collection",
|
||||||
|
f"Delete collection '{coll.display_name}'?\n\n"
|
||||||
|
"This only removes the local collection file.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
coll.delete()
|
||||||
|
self._load_local_collections()
|
||||||
|
self._clear_details()
|
||||||
|
self.main_window.show_status(f"Deleted collection: {name}")
|
||||||
|
|
||||||
|
def _install_registry_collection(self, item: QListWidgetItem):
|
||||||
|
"""Install a registry collection."""
|
||||||
|
name = item.data(Qt.UserRole)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Install Collection",
|
||||||
|
f"Install all tools from collection '{name}'?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Disable double-click during install
|
||||||
|
self.registry_list.setEnabled(False)
|
||||||
|
self.main_window.show_status(f"Installing collection '{name}'...")
|
||||||
|
|
||||||
|
self._install_worker = InstallWorker(name)
|
||||||
|
self._install_worker.progress.connect(self._on_install_progress)
|
||||||
|
self._install_worker.finished.connect(self._on_install_finished)
|
||||||
|
self._install_worker.start()
|
||||||
|
|
||||||
|
def _on_install_progress(self, message: str):
|
||||||
|
"""Handle install progress updates."""
|
||||||
|
self.main_window.show_status(message)
|
||||||
|
|
||||||
|
def _on_install_finished(self, success: bool, message: str, installed: int, failed: int):
|
||||||
|
"""Handle install completion."""
|
||||||
|
self.registry_list.setEnabled(True)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
if installed == 0 and failed == 0:
|
||||||
|
QMessageBox.information(self, "Info", message)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, "Success", message)
|
||||||
|
else:
|
||||||
|
if installed > 0:
|
||||||
|
QMessageBox.warning(self, "Partial Success", message)
|
||||||
|
else:
|
||||||
|
QMessageBox.warning(self, "Error", message)
|
||||||
|
|
||||||
|
self.main_window.show_status(message)
|
||||||
|
|
@ -1034,6 +1034,26 @@ def create_app() -> Flask:
|
||||||
versions = [row["version"] for row in rows]
|
versions = [row["version"] for row in rows]
|
||||||
return jsonify({"data": {"versions": versions}})
|
return jsonify({"data": {"versions": versions}})
|
||||||
|
|
||||||
|
@app.route("/api/v1/tools/<owner>/<name>/approved", methods=["GET"])
|
||||||
|
def tool_has_approved_public_version(owner: str, name: str) -> Response:
|
||||||
|
"""Return whether any approved public version exists."""
|
||||||
|
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||||
|
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||||
|
|
||||||
|
row = query_one(
|
||||||
|
g.db,
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM tools
|
||||||
|
WHERE owner = ? AND name = ?
|
||||||
|
AND visibility = 'public'
|
||||||
|
AND moderation_status = 'approved'
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
[owner, name],
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"data": {"has_approved_public_version": row is not None}})
|
||||||
|
|
||||||
@app.route("/api/v1/tools/<owner>/<name>/forks", methods=["GET"])
|
@app.route("/api/v1/tools/<owner>/<name>/forks", methods=["GET"])
|
||||||
def list_tool_forks(owner: str, name: str) -> Response:
|
def list_tool_forks(owner: str, name: str) -> Response:
|
||||||
"""List all forks of a tool."""
|
"""List all forks of a tool."""
|
||||||
|
|
@ -1288,6 +1308,142 @@ def create_app() -> Flask:
|
||||||
response.headers["Cache-Control"] = "max-age=3600"
|
response.headers["Cache-Control"] = "max-age=3600"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.route("/api/v1/collections", methods=["POST"])
|
||||||
|
@require_token
|
||||||
|
def publish_collection() -> Response:
|
||||||
|
"""
|
||||||
|
Publish a collection (user endpoint).
|
||||||
|
|
||||||
|
Unlike admin endpoint, this:
|
||||||
|
- Sets maintainer to authenticated user
|
||||||
|
- Validates all referenced tools exist and are accessible
|
||||||
|
- Only allows PUBLIC tools to be included (explicit UX requirement)
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
user_slug = g.current_publisher["slug"]
|
||||||
|
|
||||||
|
name = data.get("name", "")
|
||||||
|
display_name = data.get("display_name", "")
|
||||||
|
description = data.get("description", "")
|
||||||
|
tools = data.get("tools", [])
|
||||||
|
pinned = data.get("pinned", {})
|
||||||
|
tags = data.get("tags", [])
|
||||||
|
|
||||||
|
# Type validation
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return error_response("VALIDATION_ERROR", "name must be a string", 400)
|
||||||
|
if not isinstance(display_name, str):
|
||||||
|
return error_response("VALIDATION_ERROR", "display_name must be a string", 400)
|
||||||
|
if not isinstance(description, str):
|
||||||
|
return error_response("VALIDATION_ERROR", "description must be a string", 400)
|
||||||
|
if not isinstance(tools, list):
|
||||||
|
return error_response("VALIDATION_ERROR", "tools must be a list", 400)
|
||||||
|
if not isinstance(pinned, dict):
|
||||||
|
return error_response("VALIDATION_ERROR", "pinned must be an object", 400)
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
return error_response("VALIDATION_ERROR", "tags must be a list", 400)
|
||||||
|
|
||||||
|
# Normalize strings
|
||||||
|
name = name.strip().lower()
|
||||||
|
display_name = display_name.strip()
|
||||||
|
description = description.strip()
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not name or not re.match(r'^[a-z0-9-]+$', name):
|
||||||
|
return error_response("INVALID_NAME", "Invalid collection name (must be lowercase alphanumeric with hyphens)", 400)
|
||||||
|
|
||||||
|
if not display_name:
|
||||||
|
return error_response("MISSING_FIELD", "Display name required", 400)
|
||||||
|
|
||||||
|
if not tools:
|
||||||
|
return error_response("MISSING_FIELD", "At least one tool required", 400)
|
||||||
|
|
||||||
|
# Validate all tools exist in registry AND are public/approved
|
||||||
|
for tool_ref in tools:
|
||||||
|
if "/" not in tool_ref:
|
||||||
|
return error_response("INVALID_TOOL_REF",
|
||||||
|
f"Tool '{tool_ref}' must be owner/name format", 400)
|
||||||
|
owner, tool_name = tool_ref.split("/", 1)
|
||||||
|
|
||||||
|
# Check for at least one public, approved version of this tool
|
||||||
|
tool = query_one(g.db, """
|
||||||
|
SELECT id, visibility, moderation_status FROM tools
|
||||||
|
WHERE owner = ? AND name = ?
|
||||||
|
AND visibility = 'public'
|
||||||
|
AND moderation_status = 'approved'
|
||||||
|
ORDER BY published_at DESC LIMIT 1
|
||||||
|
""", [owner, tool_name])
|
||||||
|
|
||||||
|
if not tool:
|
||||||
|
# Check why it failed
|
||||||
|
any_version = query_one(g.db, """
|
||||||
|
SELECT visibility, moderation_status FROM tools
|
||||||
|
WHERE owner = ? AND name = ?
|
||||||
|
ORDER BY published_at DESC LIMIT 1
|
||||||
|
""", [owner, tool_name])
|
||||||
|
|
||||||
|
if not any_version:
|
||||||
|
return error_response("TOOL_NOT_FOUND",
|
||||||
|
f"Tool '{tool_ref}' not found in registry", 400)
|
||||||
|
elif any_version["visibility"] != "public":
|
||||||
|
return error_response("TOOL_NOT_PUBLIC",
|
||||||
|
f"Tool '{tool_ref}' is not public (visibility: {any_version['visibility']})", 400)
|
||||||
|
else:
|
||||||
|
return error_response("TOOL_NOT_APPROVED",
|
||||||
|
f"Tool '{tool_ref}' is not approved (status: {any_version['moderation_status']})", 400)
|
||||||
|
|
||||||
|
# Check if collection exists
|
||||||
|
existing = query_one(g.db, "SELECT id, maintainer FROM collections WHERE name = ?", [name])
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Can only update own collections
|
||||||
|
if existing["maintainer"] != user_slug:
|
||||||
|
return error_response("COLLECTION_EXISTS",
|
||||||
|
f"Collection '{name}' already exists with different owner", 409)
|
||||||
|
|
||||||
|
# Update existing
|
||||||
|
with g.db:
|
||||||
|
g.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE collections
|
||||||
|
SET display_name = ?, description = ?, tools = ?, pinned = ?, tags = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE name = ?
|
||||||
|
""",
|
||||||
|
[display_name, description, json.dumps(tools), json.dumps(pinned), json.dumps(tags), name],
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit("update_collection", "collection", name, {"collection": name, "user": user_slug})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"data": {
|
||||||
|
"name": name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"updated": True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Create new
|
||||||
|
with g.db:
|
||||||
|
g.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO collections (name, display_name, description, maintainer, tools, pinned, tags)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[name, display_name, description, user_slug, json.dumps(tools), json.dumps(pinned), json.dumps(tags)],
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit("create_collection", "collection", name, {"collection": name, "user": user_slug})
|
||||||
|
|
||||||
|
response = jsonify({
|
||||||
|
"data": {
|
||||||
|
"name": name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"created": True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
response.status_code = 201
|
||||||
|
return response
|
||||||
|
|
||||||
@app.route("/api/v1/collections/<name>", methods=["GET"])
|
@app.route("/api/v1/collections/<name>", methods=["GET"])
|
||||||
def get_collection(name: str) -> Response:
|
def get_collection(name: str) -> Response:
|
||||||
"""Get collection details with tool information."""
|
"""Get collection details with tool information."""
|
||||||
|
|
@ -2410,6 +2566,12 @@ def create_app() -> Flask:
|
||||||
response.status_code = 201
|
response.status_code = 201
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.route("/api/v1/me", methods=["GET"])
|
||||||
|
@require_token
|
||||||
|
def get_me() -> Response:
|
||||||
|
"""Get current user info."""
|
||||||
|
return jsonify({"data": g.current_publisher})
|
||||||
|
|
||||||
@app.route("/api/v1/me/tools", methods=["GET"])
|
@app.route("/api/v1/me/tools", methods=["GET"])
|
||||||
@require_token
|
@require_token
|
||||||
def my_tools() -> Response:
|
def my_tools() -> Response:
|
||||||
|
|
|
||||||
|
|
@ -195,8 +195,13 @@ def sync_collections(conn, repo_dir: Path) -> None:
|
||||||
pinned_json = json.dumps(pinned) if pinned else None
|
pinned_json = json.dumps(pinned) if pinned else None
|
||||||
tags_json = json.dumps(tags) if tags else None
|
tags_json = json.dumps(tags) if tags else None
|
||||||
|
|
||||||
existing = query_one(conn, "SELECT id FROM collections WHERE name = ?", [name])
|
existing = query_one(conn, "SELECT id, maintainer FROM collections WHERE name = ?", [name])
|
||||||
if existing:
|
if existing:
|
||||||
|
# Skip user-owned collections to avoid overwriting their customizations
|
||||||
|
existing_maintainer = existing.get("maintainer", "")
|
||||||
|
if existing_maintainer not in ("official", "cmdforge", "", None):
|
||||||
|
continue
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE collections
|
UPDATE collections
|
||||||
|
|
|
||||||
|
|
@ -709,6 +709,74 @@ class RegistryClient:
|
||||||
|
|
||||||
return response.json().get("data", {})
|
return response.json().get("data", {})
|
||||||
|
|
||||||
|
def get_me(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get current user info.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with user info including id, slug, email, role, etc.
|
||||||
|
"""
|
||||||
|
response = self._request("GET", "/me", require_auth=True)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
return response.json().get("data", {})
|
||||||
|
|
||||||
|
def has_approved_public_tool(self, owner: str, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if any approved public version of a tool exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Tool owner
|
||||||
|
name: Tool name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one approved public version exists
|
||||||
|
"""
|
||||||
|
response = self._request("GET", f"/tools/{owner}/{name}/approved")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise RegistryError(
|
||||||
|
code="TOOL_NOT_FOUND",
|
||||||
|
message=f"Tool '{owner}/{name}' not found",
|
||||||
|
http_status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
return bool(response.json().get("data", {}).get("has_approved_public_version"))
|
||||||
|
|
||||||
|
def publish_collection(self, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Publish a collection to the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Collection data dict with name, display_name, tools, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Published collection data
|
||||||
|
"""
|
||||||
|
response = self._request(
|
||||||
|
"POST",
|
||||||
|
"/collections",
|
||||||
|
json_data=data,
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 409:
|
||||||
|
raise RegistryError(
|
||||||
|
code="COLLECTION_EXISTS",
|
||||||
|
message="Collection already exists with different owner",
|
||||||
|
http_status=409
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
return response.json().get("data", {})
|
||||||
|
|
||||||
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
||||||
"""
|
"""
|
||||||
Get most popular tools.
|
Get most popular tools.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The test suite covers the core CmdForge modules with **158 unit tests** organized into four main test files:
|
The test suite covers the core CmdForge modules with **205 unit tests** organized into test files:
|
||||||
|
|
||||||
| Test File | Module Tested | Test Count | Description |
|
| Test File | Module Tested | Test Count | Description |
|
||||||
|-----------|---------------|------------|-------------|
|
|-----------|---------------|------------|-------------|
|
||||||
|
|
@ -10,6 +10,8 @@ The test suite covers the core CmdForge modules with **158 unit tests** organize
|
||||||
| `test_runner.py` | `runner.py` | 31 | Variable substitution, step execution |
|
| `test_runner.py` | `runner.py` | 31 | Variable substitution, step execution |
|
||||||
| `test_providers.py` | `providers.py` | 35 | Provider management, AI calls |
|
| `test_providers.py` | `providers.py` | 35 | Provider management, AI calls |
|
||||||
| `test_cli.py` | `cli/` | 20 | CLI commands and arguments |
|
| `test_cli.py` | `cli/` | 20 | CLI commands and arguments |
|
||||||
|
| `test_collection.py` | `collection.py` | 27 | Collection CRUD, tool resolution |
|
||||||
|
| `test_collection_api.py` | Registry API | 20 | Collection API endpoints, client methods |
|
||||||
| `test_registry_integration.py` | Registry | 37 | Registry client, resolution (12 require server) |
|
| `test_registry_integration.py` | Registry | 37 | Registry client, resolution (12 require server) |
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
@ -73,6 +75,30 @@ pytest tests/ --cov=cmdforge --cov-report=html
|
||||||
- `TestRefreshCommand` - Regenerate wrapper scripts
|
- `TestRefreshCommand` - Regenerate wrapper scripts
|
||||||
- `TestDocsCommand` - View/edit documentation
|
- `TestDocsCommand` - View/edit documentation
|
||||||
|
|
||||||
|
**test_collection.py** - Collection management
|
||||||
|
- `TestCollection` - Collection dataclass and CRUD operations
|
||||||
|
- Create, save, load, delete collections
|
||||||
|
- Registry URL generation
|
||||||
|
- YAML serialization roundtrip
|
||||||
|
- `TestListCollections` - List local collections
|
||||||
|
- `TestGetCollection` - Get collection by name
|
||||||
|
- `TestClassifyToolReference` - Classify local vs registry tool refs
|
||||||
|
- `TestResolveToolReferences` - Transform tool refs for publishing
|
||||||
|
- Local tool name expansion (name → user/name)
|
||||||
|
- Visibility issue detection
|
||||||
|
- Registry tool validation
|
||||||
|
- Pinned version transformation
|
||||||
|
- `TestToolResolutionResult` - Resolution result dataclass
|
||||||
|
|
||||||
|
**test_collection_api.py** - Collection API and client
|
||||||
|
- `TestGetMeEndpoint` - GET /api/v1/me endpoint (requires Flask)
|
||||||
|
- `TestToolApprovedEndpoint` - GET /api/v1/tools/.../approved (requires Flask)
|
||||||
|
- `TestPostCollectionsEndpoint` - POST /api/v1/collections (requires Flask)
|
||||||
|
- `TestRegistryClientMethods` - Client methods with mocked requests
|
||||||
|
- `get_me()` - Get authenticated user info
|
||||||
|
- `has_approved_public_tool()` - Check tool approval status
|
||||||
|
- `publish_collection()` - Publish collection to registry
|
||||||
|
|
||||||
### Integration Tests (require running server)
|
### Integration Tests (require running server)
|
||||||
|
|
||||||
**test_registry_integration.py** - Registry API tests (marked with `@pytest.mark.integration`)
|
**test_registry_integration.py** - Registry API tests (marked with `@pytest.mark.integration`)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
"""Tests for collection.py - Collection definitions and management."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from cmdforge.collection import (
|
||||||
|
Collection, list_collections, get_collection, COLLECTIONS_DIR,
|
||||||
|
classify_tool_reference, resolve_tool_references, ToolResolutionResult
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_collections_dir(tmp_path):
|
||||||
|
"""Create a temporary collections directory."""
|
||||||
|
with patch('cmdforge.collection.COLLECTIONS_DIR', tmp_path):
|
||||||
|
yield tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollection:
|
||||||
|
"""Tests for Collection dataclass."""
|
||||||
|
|
||||||
|
def test_create_basic(self):
|
||||||
|
coll = Collection(name="my-tools", display_name="My Tools")
|
||||||
|
assert coll.name == "my-tools"
|
||||||
|
assert coll.display_name == "My Tools"
|
||||||
|
assert coll.description == ""
|
||||||
|
assert coll.tools == []
|
||||||
|
assert coll.pinned == {}
|
||||||
|
assert coll.tags == []
|
||||||
|
assert coll.published is False
|
||||||
|
|
||||||
|
def test_create_full(self):
|
||||||
|
coll = Collection(
|
||||||
|
name="dev-tools",
|
||||||
|
display_name="Developer Tools",
|
||||||
|
description="Essential tools for developers",
|
||||||
|
maintainer="rob",
|
||||||
|
tools=["summarize", "official/code-review"],
|
||||||
|
pinned={"official/code-review": "^1.0.0"},
|
||||||
|
tags=["dev", "productivity"],
|
||||||
|
published=True,
|
||||||
|
registry_name="dev-tools"
|
||||||
|
)
|
||||||
|
assert coll.name == "dev-tools"
|
||||||
|
assert coll.maintainer == "rob"
|
||||||
|
assert len(coll.tools) == 2
|
||||||
|
assert coll.pinned["official/code-review"] == "^1.0.0"
|
||||||
|
assert coll.published is True
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
coll = Collection(
|
||||||
|
name="test-coll",
|
||||||
|
display_name="Test Collection",
|
||||||
|
description="Test",
|
||||||
|
tools=["tool1"],
|
||||||
|
tags=["test"]
|
||||||
|
)
|
||||||
|
d = coll.to_dict()
|
||||||
|
assert d["name"] == "test-coll"
|
||||||
|
assert d["display_name"] == "Test Collection"
|
||||||
|
assert d["tools"] == ["tool1"]
|
||||||
|
assert d["tags"] == ["test"]
|
||||||
|
assert d["published"] is False
|
||||||
|
|
||||||
|
def test_save_and_load(self, temp_collections_dir):
|
||||||
|
coll = Collection(
|
||||||
|
name="my-coll",
|
||||||
|
display_name="My Collection",
|
||||||
|
description="A test collection",
|
||||||
|
tools=["tool1", "owner/tool2"],
|
||||||
|
pinned={"owner/tool2": "1.0.0"},
|
||||||
|
tags=["test"]
|
||||||
|
)
|
||||||
|
coll.save()
|
||||||
|
|
||||||
|
# Verify file exists
|
||||||
|
path = temp_collections_dir / "my-coll.yaml"
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
# Load and verify
|
||||||
|
loaded = Collection.load("my-coll")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.name == "my-coll"
|
||||||
|
assert loaded.display_name == "My Collection"
|
||||||
|
assert loaded.tools == ["tool1", "owner/tool2"]
|
||||||
|
assert loaded.pinned == {"owner/tool2": "1.0.0"}
|
||||||
|
|
||||||
|
def test_load_nonexistent(self, temp_collections_dir):
|
||||||
|
loaded = Collection.load("nonexistent")
|
||||||
|
assert loaded is None
|
||||||
|
|
||||||
|
def test_load_empty_yaml(self, temp_collections_dir):
|
||||||
|
# Create empty YAML file
|
||||||
|
path = temp_collections_dir / "empty.yaml"
|
||||||
|
path.write_text("")
|
||||||
|
|
||||||
|
loaded = Collection.load("empty")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.name == "empty"
|
||||||
|
|
||||||
|
def test_delete(self, temp_collections_dir):
|
||||||
|
coll = Collection(name="to-delete", display_name="To Delete")
|
||||||
|
coll.save()
|
||||||
|
|
||||||
|
path = temp_collections_dir / "to-delete.yaml"
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
coll.delete()
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
def test_get_registry_url(self):
|
||||||
|
with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech/api/v1"):
|
||||||
|
coll = Collection(name="my-coll", display_name="My Coll")
|
||||||
|
url = coll.get_registry_url()
|
||||||
|
assert url == "https://cmdforge.brrd.tech/collections/my-coll"
|
||||||
|
|
||||||
|
def test_get_registry_url_custom_base(self):
|
||||||
|
with patch('cmdforge.collection.get_registry_url', return_value="https://custom.example.com/api/v1"):
|
||||||
|
coll = Collection(name="my-coll", display_name="My Coll")
|
||||||
|
url = coll.get_registry_url()
|
||||||
|
assert url == "https://custom.example.com/collections/my-coll"
|
||||||
|
|
||||||
|
def test_get_registry_url_no_api_suffix(self):
|
||||||
|
with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech"):
|
||||||
|
coll = Collection(name="my-coll", display_name="My Coll")
|
||||||
|
url = coll.get_registry_url()
|
||||||
|
assert url == "https://cmdforge.brrd.tech/collections/my-coll"
|
||||||
|
|
||||||
|
def test_get_registry_url_uses_registry_name(self):
|
||||||
|
with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech/api/v1"):
|
||||||
|
coll = Collection(name="local-name", display_name="My Coll", registry_name="published-name")
|
||||||
|
url = coll.get_registry_url()
|
||||||
|
assert url == "https://cmdforge.brrd.tech/collections/published-name"
|
||||||
|
|
||||||
|
|
||||||
|
class TestListCollections:
|
||||||
|
"""Tests for list_collections function."""
|
||||||
|
|
||||||
|
def test_empty_dir(self, temp_collections_dir):
|
||||||
|
result = list_collections()
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_with_collections(self, temp_collections_dir):
|
||||||
|
# Create some collections
|
||||||
|
Collection(name="alpha", display_name="Alpha").save()
|
||||||
|
Collection(name="beta", display_name="Beta").save()
|
||||||
|
Collection(name="gamma", display_name="Gamma").save()
|
||||||
|
|
||||||
|
result = list_collections()
|
||||||
|
assert result == ["alpha", "beta", "gamma"] # Sorted
|
||||||
|
|
||||||
|
def test_ignores_non_yaml_files(self, temp_collections_dir):
|
||||||
|
Collection(name="valid", display_name="Valid").save()
|
||||||
|
(temp_collections_dir / "not-yaml.txt").write_text("ignored")
|
||||||
|
|
||||||
|
result = list_collections()
|
||||||
|
assert result == ["valid"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCollection:
|
||||||
|
"""Tests for get_collection function."""
|
||||||
|
|
||||||
|
def test_existing(self, temp_collections_dir):
|
||||||
|
Collection(name="test", display_name="Test", description="Desc").save()
|
||||||
|
coll = get_collection("test")
|
||||||
|
assert coll is not None
|
||||||
|
assert coll.name == "test"
|
||||||
|
assert coll.description == "Desc"
|
||||||
|
|
||||||
|
def test_nonexistent(self, temp_collections_dir):
|
||||||
|
coll = get_collection("nonexistent")
|
||||||
|
assert coll is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyToolReference:
|
||||||
|
"""Tests for classify_tool_reference function."""
|
||||||
|
|
||||||
|
def test_local_tool(self):
|
||||||
|
owner, name, is_local = classify_tool_reference("summarize")
|
||||||
|
assert owner == ""
|
||||||
|
assert name == "summarize"
|
||||||
|
assert is_local is True
|
||||||
|
|
||||||
|
def test_registry_tool(self):
|
||||||
|
owner, name, is_local = classify_tool_reference("official/summarize")
|
||||||
|
assert owner == "official"
|
||||||
|
assert name == "summarize"
|
||||||
|
assert is_local is False
|
||||||
|
|
||||||
|
def test_registry_tool_nested_name(self):
|
||||||
|
owner, name, is_local = classify_tool_reference("user/my-tool")
|
||||||
|
assert owner == "user"
|
||||||
|
assert name == "my-tool"
|
||||||
|
assert is_local is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveToolReferences:
|
||||||
|
"""Tests for resolve_tool_references function."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_my_tool_status.side_effect = lambda name: {"status": "approved"}
|
||||||
|
client.has_approved_public_tool.return_value = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
def test_all_registry_tools(self, mock_client):
|
||||||
|
tools = ["official/tool1", "user/tool2"]
|
||||||
|
pinned = {"official/tool1": "^1.0.0"}
|
||||||
|
|
||||||
|
result = resolve_tool_references(tools, pinned, "myuser", mock_client)
|
||||||
|
|
||||||
|
assert result.registry_refs == ["official/tool1", "user/tool2"]
|
||||||
|
assert result.transformed_pinned == {"official/tool1": "^1.0.0"}
|
||||||
|
assert result.local_unpublished == []
|
||||||
|
|
||||||
|
def test_local_tool_not_published(self, mock_client):
|
||||||
|
from cmdforge.registry_client import RegistryError
|
||||||
|
mock_client.get_my_tool_status.side_effect = RegistryError(
|
||||||
|
code="TOOL_NOT_FOUND", message="Not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'):
|
||||||
|
tools = ["my-local-tool"]
|
||||||
|
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||||
|
|
||||||
|
assert "myuser/my-local-tool" in result.registry_refs
|
||||||
|
assert "my-local-tool" in result.local_unpublished
|
||||||
|
|
||||||
|
def test_local_tool_published_approved(self, mock_client):
|
||||||
|
mock_client.get_my_tool_status.return_value = {"status": "approved"}
|
||||||
|
|
||||||
|
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'):
|
||||||
|
tools = ["my-tool"]
|
||||||
|
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||||
|
|
||||||
|
assert "myuser/my-tool" in result.registry_refs
|
||||||
|
assert result.local_unpublished == []
|
||||||
|
assert ("my-tool", "approved", True) in result.local_published
|
||||||
|
|
||||||
|
def test_transforms_pinned_for_local_tools(self, mock_client):
|
||||||
|
mock_client.get_my_tool_status.return_value = {"status": "approved"}
|
||||||
|
|
||||||
|
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'):
|
||||||
|
tools = ["my-tool"]
|
||||||
|
pinned = {"my-tool": "1.0.0"}
|
||||||
|
result = resolve_tool_references(tools, pinned, "myuser", mock_client)
|
||||||
|
|
||||||
|
assert "myuser/my-tool" in result.transformed_pinned
|
||||||
|
assert result.transformed_pinned["myuser/my-tool"] == "1.0.0"
|
||||||
|
|
||||||
|
def test_visibility_issues_detected(self, mock_client):
|
||||||
|
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='private'):
|
||||||
|
tools = ["private-tool"]
|
||||||
|
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||||
|
|
||||||
|
assert ("private-tool", "private") in result.visibility_issues
|
||||||
|
|
||||||
|
def test_registry_tool_not_approved(self, mock_client):
|
||||||
|
mock_client.has_approved_public_tool.return_value = False
|
||||||
|
|
||||||
|
tools = ["official/unapproved"]
|
||||||
|
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||||
|
|
||||||
|
assert ("official/unapproved", "no approved public version") in result.registry_tool_issues
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolResolutionResult:
|
||||||
|
"""Tests for ToolResolutionResult dataclass."""
|
||||||
|
|
||||||
|
def test_empty_result(self):
|
||||||
|
result = ToolResolutionResult(
|
||||||
|
registry_refs=[],
|
||||||
|
transformed_pinned={},
|
||||||
|
local_unpublished=[],
|
||||||
|
local_published=[],
|
||||||
|
visibility_issues=[],
|
||||||
|
registry_tool_issues=[]
|
||||||
|
)
|
||||||
|
assert len(result.registry_refs) == 0
|
||||||
|
assert len(result.local_unpublished) == 0
|
||||||
|
|
||||||
|
def test_with_data(self):
|
||||||
|
result = ToolResolutionResult(
|
||||||
|
registry_refs=["user/tool1", "user/tool2"],
|
||||||
|
transformed_pinned={"user/tool1": "^1.0"},
|
||||||
|
local_unpublished=["tool3"],
|
||||||
|
local_published=[("tool1", "approved", True)],
|
||||||
|
visibility_issues=[("tool4", "private")],
|
||||||
|
registry_tool_issues=[("other/tool", "not found")]
|
||||||
|
)
|
||||||
|
assert len(result.registry_refs) == 2
|
||||||
|
assert len(result.local_unpublished) == 1
|
||||||
|
assert len(result.visibility_issues) == 1
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
"""Tests for collection-related API endpoints.
|
||||||
|
|
||||||
|
Tests for:
|
||||||
|
- GET /api/v1/me
|
||||||
|
- GET /api/v1/tools/<owner>/<name>/approved
|
||||||
|
- POST /api/v1/collections
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Check if Flask is available for API tests
|
||||||
|
try:
|
||||||
|
import flask
|
||||||
|
HAS_FLASK = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_FLASK = False
|
||||||
|
|
||||||
|
flask_required = pytest.mark.skipif(not HAS_FLASK, reason="Flask not installed")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""Create Flask test app with in-memory database."""
|
||||||
|
pytest.importorskip("flask", reason="Flask not installed")
|
||||||
|
from cmdforge.registry.app import create_app
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||||
|
db_path = f.name
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"CMDFORGE_REGISTRY_DB": db_path}):
|
||||||
|
app = create_app()
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
|
||||||
|
yield app
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
Path(db_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(app):
|
||||||
|
"""Create auth headers with a valid token."""
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
from cmdforge.registry.db import connect_db
|
||||||
|
|
||||||
|
token = "test-token"
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
conn = connect_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM publishers WHERE slug = ?",
|
||||||
|
["testuser"],
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
publisher_id = row["id"]
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO publishers (email, password_hash, slug, display_name, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
["test@example.com", "x", "testuser", "Test User", "user"],
|
||||||
|
)
|
||||||
|
publisher_id = conn.execute(
|
||||||
|
"SELECT id FROM publishers WHERE slug = ?",
|
||||||
|
["testuser"],
|
||||||
|
).fetchone()["id"]
|
||||||
|
|
||||||
|
# Insert token if missing
|
||||||
|
token_row = conn.execute(
|
||||||
|
"SELECT id FROM api_tokens WHERE token_hash = ?",
|
||||||
|
[token_hash],
|
||||||
|
).fetchone()
|
||||||
|
if not token_row:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO api_tokens (publisher_id, token_hash, name, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[publisher_id, token_hash, "test-token", datetime.utcnow().isoformat()],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert a public approved tool for tests if missing
|
||||||
|
tool_row = conn.execute(
|
||||||
|
"SELECT id FROM tools WHERE owner = ? AND name = ?",
|
||||||
|
["testuser", "tool1"],
|
||||||
|
).fetchone()
|
||||||
|
if not tool_row:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tools (owner, name, version, description, category, tags,
|
||||||
|
config_yaml, readme, publisher_id, visibility, moderation_status, published_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
"testuser", "tool1", "1.0.0", "Test tool", "Other", "[]",
|
||||||
|
"name: tool1\nversion: 1.0.0\n", "", publisher_id,
|
||||||
|
"public", "approved", datetime.utcnow().isoformat(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@flask_required
|
||||||
|
class TestGetMeEndpoint:
|
||||||
|
"""Tests for GET /api/v1/me endpoint."""
|
||||||
|
|
||||||
|
def test_requires_auth(self, client):
|
||||||
|
response = client.get('/api/v1/me')
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_returns_user_info(self, client, auth_headers):
|
||||||
|
response = client.get('/api/v1/me', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["data"]["slug"] == "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
@flask_required
|
||||||
|
class TestToolApprovedEndpoint:
|
||||||
|
"""Tests for GET /api/v1/tools/<owner>/<name>/approved endpoint."""
|
||||||
|
|
||||||
|
def test_invalid_owner_format(self, client):
|
||||||
|
response = client.get('/api/v1/tools/invalid@owner/tool/approved')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_invalid_name_format(self, client):
|
||||||
|
response = client.get('/api/v1/tools/valid/invalid@name/approved')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_tool_not_found(self, client):
|
||||||
|
response = client.get('/api/v1/tools/nonexistent/tool/approved')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
# Should return success but with has_approved_public_version = False
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert data['data']['has_approved_public_version'] is False
|
||||||
|
|
||||||
|
|
||||||
|
@flask_required
|
||||||
|
class TestPostCollectionsEndpoint:
|
||||||
|
"""Tests for POST /api/v1/collections endpoint."""
|
||||||
|
|
||||||
|
def test_requires_auth(self, client):
|
||||||
|
response = client.post('/api/v1/collections', json={
|
||||||
|
"name": "test-coll",
|
||||||
|
"display_name": "Test Collection",
|
||||||
|
"tools": ["official/tool1"]
|
||||||
|
})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_name_format(self, client, auth_headers):
|
||||||
|
response = client.post('/api/v1/collections', headers=auth_headers, json={
|
||||||
|
"name": "Invalid Name",
|
||||||
|
"display_name": "Test Collection",
|
||||||
|
"tools": ["testuser/tool1"]
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_missing_display_name(self, client, auth_headers):
|
||||||
|
response = client.post('/api/v1/collections', headers=auth_headers, json={
|
||||||
|
"name": "test-coll",
|
||||||
|
"tools": ["testuser/tool1"]
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_missing_tools(self, client, auth_headers):
|
||||||
|
response = client.post('/api/v1/collections', headers=auth_headers, json={
|
||||||
|
"name": "test-coll",
|
||||||
|
"display_name": "Test Collection",
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_tools_must_be_list(self, client, auth_headers):
|
||||||
|
response = client.post('/api/v1/collections', headers=auth_headers, json={
|
||||||
|
"name": "test-coll",
|
||||||
|
"display_name": "Test Collection",
|
||||||
|
"tools": "not-a-list"
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_pinned_must_be_dict(self, client, auth_headers):
|
||||||
|
response = client.post('/api/v1/collections', headers=auth_headers, json={
|
||||||
|
"name": "test-coll",
|
||||||
|
"display_name": "Test Collection",
|
||||||
|
"tools": ["testuser/tool1"],
|
||||||
|
"pinned": ["not", "a", "dict"]
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_tags_must_be_list(self, client, auth_headers):
|
||||||
|
response = client.post('/api/v1/collections', headers=auth_headers, json={
|
||||||
|
"name": "test-coll",
|
||||||
|
"display_name": "Test Collection",
|
||||||
|
"tools": ["testuser/tool1"],
|
||||||
|
"tags": "not-a-list"
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistryClientMethods:
|
||||||
|
"""Tests for new RegistryClient methods."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session(self):
|
||||||
|
"""Create a mock requests session."""
|
||||||
|
session = MagicMock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
def test_get_me(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient
|
||||||
|
|
||||||
|
client = RegistryClient(token="test-token")
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"data": {"id": 1, "slug": "testuser", "role": "user"}
|
||||||
|
}
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
result = client.get_me()
|
||||||
|
assert result["slug"] == "testuser"
|
||||||
|
assert result["role"] == "user"
|
||||||
|
|
||||||
|
def test_get_me_unauthorized(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient, RegistryError
|
||||||
|
|
||||||
|
client = RegistryClient(token="bad-token")
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 401
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"error": {"code": "UNAUTHORIZED", "message": "Invalid token"}
|
||||||
|
}
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(RegistryError) as exc_info:
|
||||||
|
client.get_me()
|
||||||
|
assert exc_info.value.code == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
def test_has_approved_public_tool_true(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient
|
||||||
|
|
||||||
|
client = RegistryClient()
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"data": {"has_approved_public_version": True}
|
||||||
|
}
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
result = client.has_approved_public_tool("official", "summarize")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_has_approved_public_tool_false(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient
|
||||||
|
|
||||||
|
client = RegistryClient()
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"data": {"has_approved_public_version": False}
|
||||||
|
}
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
result = client.has_approved_public_tool("official", "pending-tool")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_has_approved_public_tool_not_found(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient, RegistryError
|
||||||
|
|
||||||
|
client = RegistryClient()
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(RegistryError) as exc_info:
|
||||||
|
client.has_approved_public_tool("nonexistent", "tool")
|
||||||
|
assert exc_info.value.code == "TOOL_NOT_FOUND"
|
||||||
|
|
||||||
|
def test_publish_collection_success(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient
|
||||||
|
|
||||||
|
client = RegistryClient(token="test-token")
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"data": {"name": "my-coll", "display_name": "My Collection", "created": True}
|
||||||
|
}
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
result = client.publish_collection({
|
||||||
|
"name": "my-coll",
|
||||||
|
"display_name": "My Collection",
|
||||||
|
"tools": ["official/tool1"]
|
||||||
|
})
|
||||||
|
assert result["name"] == "my-coll"
|
||||||
|
assert result["created"] is True
|
||||||
|
|
||||||
|
def test_publish_collection_conflict(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient, RegistryError
|
||||||
|
|
||||||
|
client = RegistryClient(token="test-token")
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 409
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(RegistryError) as exc_info:
|
||||||
|
client.publish_collection({
|
||||||
|
"name": "existing-coll",
|
||||||
|
"display_name": "Existing",
|
||||||
|
"tools": ["official/tool1"]
|
||||||
|
})
|
||||||
|
assert exc_info.value.code == "COLLECTION_EXISTS"
|
||||||
|
|
||||||
|
def test_publish_collection_update(self, mock_session):
|
||||||
|
from cmdforge.registry_client import RegistryClient
|
||||||
|
|
||||||
|
client = RegistryClient(token="test-token")
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"data": {"name": "my-coll", "display_name": "My Collection Updated", "updated": True}
|
||||||
|
}
|
||||||
|
mock_session.request.return_value = mock_response
|
||||||
|
|
||||||
|
result = client.publish_collection({
|
||||||
|
"name": "my-coll",
|
||||||
|
"display_name": "My Collection Updated",
|
||||||
|
"tools": ["official/tool1", "official/tool2"]
|
||||||
|
})
|
||||||
|
assert result["updated"] is True
|
||||||
Loading…
Reference in New Issue