From 29869ef59fa046958501d468cb0f42919718003c Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 26 Jan 2026 16:50:19 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 40 + CLAUDE.md | 6 +- README.md | 91 +- src/cmdforge/cli/__init__.py | 47 +- src/cmdforge/cli/collections_commands.py | 638 +++++++++++++- src/cmdforge/collection.py | 237 ++++++ src/cmdforge/gui/main_window.py | 25 +- src/cmdforge/gui/pages/collections_page.py | 932 +++++++++++++++++++++ src/cmdforge/registry/app.py | 162 ++++ src/cmdforge/registry/sync.py | 7 +- src/cmdforge/registry_client.py | 68 ++ tests/README.md | 28 +- tests/test_collection.py | 298 +++++++ tests/test_collection_api.py | 365 ++++++++ 14 files changed, 2917 insertions(+), 27 deletions(-) create mode 100644 src/cmdforge/collection.py create mode 100644 src/cmdforge/gui/pages/collections_page.py create mode 100644 tests/test_collection.py create mode 100644 tests/test_collection_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b8d77c..707a26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ All notable changes to CmdForge will be documented in this file. ### 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 ` - Create new local collection + - `cmdforge collections show ` - View collection details + - `cmdforge collections add ` - Add tool to collection + - `cmdforge collections remove ` - Remove tool from collection + - `cmdforge collections delete ` - Delete local collection + - `cmdforge collections publish ` - Publish collection to registry + - `cmdforge collections status ` - 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///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 - **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 diff --git a/CLAUDE.md b/CLAUDE.md index 29fa978..038c9ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,13 +29,14 @@ 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 +- **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()` - **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml` - **gui/**: PySide6 desktop GUI - **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 ### Key Paths @@ -43,6 +44,7 @@ cmdforge - **Tools storage**: `~/.cmdforge//config.yaml` - **Wrapper scripts**: `~/.local/bin/` (auto-generated bash scripts) - **Provider config**: `~/.cmdforge/providers.yaml` +- **Collections storage**: `~/.cmdforge/collections/.yaml` ### Tool Structure diff --git a/README.md b/README.md index 8318ddd..dbd0f9f 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,17 @@ cmdforge registry status # Check moderation status cmdforge registry my-tools # List your published tools # 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 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 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 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 -# List available collections +# List available collections from registry cmdforge collections list # View what's in a collection @@ -461,7 +473,59 @@ cmdforge collections info 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 | |------------|-------------|-------| @@ -567,6 +631,20 @@ The graphical interface provides a modern desktop experience: - Create new tools with the built-in Tool Builder - 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 - Visual form for creating and editing tools - 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+1` | Go to My Tools | | `Ctrl+2` | Go to Registry | -| `Ctrl+3` | Go to Providers | -| `Ctrl+4` | Go to Profiles | +| `Ctrl+3` | Go to Collections | +| `Ctrl+4` | Go to Providers | +| `Ctrl+5` | Go to Profiles | | `Escape` | Close tool builder | | `Ctrl+Q` | Quit application | diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index cab3164..0f1af93 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -219,21 +219,64 @@ def main(): # collections list 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.set_defaults(func=cmd_collections) # 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("--json", action="store_true", help="Output as JSON") p_coll_info.set_defaults(func=cmd_collections) # 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("--pinned", action="store_true", help="Use pinned versions from collection") 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 p_collections.set_defaults(func=lambda args: cmd_collections(args) if args.collections_cmd else (setattr(args, 'collections_cmd', None) or cmd_collections(args))) diff --git a/src/cmdforge/cli/collections_commands.py b/src/cmdforge/cli/collections_commands.py index be11dd3..9b44255 100644 --- a/src/cmdforge/cli/collections_commands.py +++ b/src/cmdforge/cli/collections_commands.py @@ -1,6 +1,7 @@ """Collections commands for CmdForge CLI.""" import json +import re import sys from ..resolver import install_from_registry @@ -8,23 +9,88 @@ from ..resolver import install_from_registry def cmd_collections(args): """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) - elif args.collections_cmd == "info": + elif cmd == "info": return _cmd_collections_info(args) - elif args.collections_cmd == "install": + elif cmd == "install": 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: # Default: show help print("Collections commands:") - print(" list List available collections") - print(" info Show collection details") - print(" install Install all tools in a collection") + print(" list List available collections (registry)") + print(" info Show registry collection details") + print(" install Install all tools in a registry collection") + print() + print("Local collection management:") + print(" create Create a new local collection") + print(" show Show local collection details") + print(" add Add a tool to a collection") + print(" remove Remove a tool from a collection") + print(" delete Delete a local collection") + print() + print("Publishing:") + print(" publish Publish a collection to the registry") + print(" status Check status of a pending collection") return 0 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 ") + 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 try: @@ -45,8 +111,9 @@ def _cmd_collections_list(args): name = coll.get("name", "") display_name = coll.get("display_name", name) description = coll.get("description", "") + # Use tool_count if available, fall back to counting tools list 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" {display_name}") @@ -206,3 +273,558 @@ def _cmd_collections_install(args): return 1 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} ") + 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 ") + 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}/' 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 diff --git a/src/cmdforge/collection.py b/src/cmdforge/collection.py new file mode 100644 index 0000000..490b0bb --- /dev/null +++ b/src/cmdforge/collection.py @@ -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 + ) diff --git a/src/cmdforge/gui/main_window.py b/src/cmdforge/gui/main_window.py index c8475bf..19ba941 100644 --- a/src/cmdforge/gui/main_window.py +++ b/src/cmdforge/gui/main_window.py @@ -104,9 +104,10 @@ class MainWindow(QMainWindow): items = [ ("CmdForge", "Welcome page - Quick actions and getting started (Ctrl+1)"), ("Tools", "View, edit, delete, and test your local tools (Ctrl+2)"), - ("Registry", "Browse and install tools from the community (Ctrl+3)"), - ("Providers", "Configure AI backends like Claude, GPT, etc. (Ctrl+4)"), - ("Profiles", "AI persona profiles for customizing tool behavior"), + ("Collections", "Manage and publish tool collections (Ctrl+3)"), + ("Registry", "Browse and install tools from the community (Ctrl+4)"), + ("Providers", "Configure AI backends like Claude, GPT, etc. (Ctrl+5)"), + ("Profiles", "AI persona profiles for customizing tool behavior (Ctrl+6)"), ] font = QFont() @@ -129,18 +130,21 @@ class MainWindow(QMainWindow): # Import pages here to avoid circular imports from .pages.welcome_page import WelcomePage from .pages.tools_page import ToolsPage + from .pages.collections_page import CollectionsPage from .pages.registry_page import RegistryPage from .pages.providers_page import ProvidersPage from .pages.profiles_page import ProfilesPage self.welcome_page = WelcomePage(self) self.tools_page = ToolsPage(self) + self.collections_page = CollectionsPage(self) self.registry_page = RegistryPage(self) self.providers_page = ProvidersPage(self) self.profiles_page = ProfilesPage(self) self.pages.addWidget(self.welcome_page) self.pages.addWidget(self.tools_page) + self.pages.addWidget(self.collections_page) self.pages.addWidget(self.registry_page) self.pages.addWidget(self.providers_page) self.pages.addWidget(self.profiles_page) @@ -164,9 +168,10 @@ class MainWindow(QMainWindow): "welcome": 0, "cmdforge": 0, "tools": 1, - "registry": 2, - "providers": 3, - "profiles": 4, + "collections": 2, + "registry": 3, + "providers": 4, + "profiles": 5, } if page_name.lower() in page_map: self.sidebar.setCurrentRow(page_map[page_name.lower()]) @@ -211,7 +216,7 @@ class MainWindow(QMainWindow): shortcut_escape = QShortcut(QKeySequence("Escape"), self) 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.activated.connect(lambda: self.sidebar.setCurrentRow(0)) @@ -224,6 +229,12 @@ class MainWindow(QMainWindow): shortcut_page4 = QShortcut(QKeySequence("Ctrl+4"), self) 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 shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self) shortcut_refresh.activated.connect(self._shortcut_refresh) diff --git a/src/cmdforge/gui/pages/collections_page.py b/src/cmdforge/gui/pages/collections_page.py new file mode 100644 index 0000000..6fdc8d7 --- /dev/null +++ b/src/cmdforge/gui/pages/collections_page.py @@ -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) diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index d9acf25..514a28f 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -1034,6 +1034,26 @@ def create_app() -> Flask: versions = [row["version"] for row in rows] return jsonify({"data": {"versions": versions}}) + @app.route("/api/v1/tools///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///forks", methods=["GET"]) def list_tool_forks(owner: str, name: str) -> Response: """List all forks of a tool.""" @@ -1288,6 +1308,142 @@ def create_app() -> Flask: response.headers["Cache-Control"] = "max-age=3600" 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/", methods=["GET"]) def get_collection(name: str) -> Response: """Get collection details with tool information.""" @@ -2410,6 +2566,12 @@ def create_app() -> Flask: response.status_code = 201 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"]) @require_token def my_tools() -> Response: diff --git a/src/cmdforge/registry/sync.py b/src/cmdforge/registry/sync.py index da76af7..8f739c2 100644 --- a/src/cmdforge/registry/sync.py +++ b/src/cmdforge/registry/sync.py @@ -195,8 +195,13 @@ def sync_collections(conn, repo_dir: Path) -> None: pinned_json = json.dumps(pinned) if pinned else None tags_json = json.dumps(tags) if tags else None - existing = query_one(conn, "SELECT id FROM collections WHERE name = ?", [name]) + existing = query_one(conn, "SELECT id, maintainer FROM collections WHERE name = ?", [name]) 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( """ UPDATE collections diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py index 506b752..f1d9221 100644 --- a/src/cmdforge/registry_client.py +++ b/src/cmdforge/registry_client.py @@ -709,6 +709,74 @@ class RegistryClient: 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]: """ Get most popular tools. diff --git a/tests/README.md b/tests/README.md index 7abe579..6c192dd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,7 +2,7 @@ ## 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 | |-----------|---------------|------------|-------------| @@ -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_providers.py` | `providers.py` | 35 | Provider management, AI calls | | `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) | ## Running Tests @@ -73,6 +75,30 @@ pytest tests/ --cov=cmdforge --cov-report=html - `TestRefreshCommand` - Regenerate wrapper scripts - `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) **test_registry_integration.py** - Registry API tests (marked with `@pytest.mark.integration`) diff --git a/tests/test_collection.py b/tests/test_collection.py new file mode 100644 index 0000000..1b969d5 --- /dev/null +++ b/tests/test_collection.py @@ -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 diff --git a/tests/test_collection_api.py b/tests/test_collection_api.py new file mode 100644 index 0000000..0555e3b --- /dev/null +++ b/tests/test_collection_api.py @@ -0,0 +1,365 @@ +"""Tests for collection-related API endpoints. + +Tests for: +- GET /api/v1/me +- GET /api/v1/tools///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///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