"""Collections commands for CmdForge CLI.""" import json import re import sys from ..resolver import install_from_registry def cmd_collections(args): """Handle collections subcommands.""" cmd = args.collections_cmd # Existing commands (with --local flag support added) if cmd == "list": return _cmd_collections_list(args) elif cmd == "info": return _cmd_collections_info(args) 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 (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 (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: client = get_client() collections = client.get_collections() # JSON output if getattr(args, 'json', False): print(json.dumps({"collections": collections}, indent=2)) return 0 if not collections: print("No collections available.") return 0 print(f"Available collections ({len(collections)}):\n") for coll in collections: 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 = coll.get("tool_count", len(tools) if isinstance(tools, list) else 0) print(f" {name}") print(f" {display_name}") if description: desc_short = description[:60] + ('...' if len(description) > 60 else '') print(f" {desc_short}") print(f" Tools: {tool_count}") print() print("Install a collection with: cmdforge collections install ") except RegistryError as e: if e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 return 0 def _cmd_collections_info(args): """Show collection details.""" from ..registry_client import RegistryError, get_client name = args.name try: client = get_client() coll = client.get_collection(name) # JSON output if getattr(args, 'json', False): print(json.dumps(coll, indent=2)) return 0 display_name = coll.get("display_name", name) description = coll.get("description", "") maintainer = coll.get("maintainer", "") tools = coll.get("tools", []) pinned = coll.get("pinned", {}) tags = coll.get("tags", []) print(f"{display_name}") print("=" * 50) if description: print(f"\n{description}\n") if maintainer: print(f"Maintainer: {maintainer}") if tags: print(f"Tags: {', '.join(tags)}") print(f"\nTools ({len(tools)}):") for tool in tools: # Handle both object format (from API) and string format if isinstance(tool, dict): tool_ref = f"{tool['owner']}/{tool['name']}" version_constraint = tool.get('pinned_version', '') else: tool_ref = tool version_constraint = pinned.get(tool_ref, '') if version_constraint: print(f" - {tool_ref} @ {version_constraint}") else: print(f" - {tool_ref}") print(f"\nInstall all: cmdforge collections install {name}") except RegistryError as e: if e.code == "COLLECTION_NOT_FOUND": print(f"Collection '{name}' not found.", file=sys.stderr) print("List available collections: cmdforge collections list", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 return 0 def _cmd_collections_install(args): """Install all tools in a collection.""" from ..registry_client import RegistryError, get_client name = args.name use_pinned = getattr(args, 'pinned', False) try: client = get_client() coll = client.get_collection(name) tools = coll.get("tools", []) pinned = coll.get("pinned", {}) if use_pinned else {} display_name = coll.get("display_name", name) if not tools: print(f"Collection '{name}' has no tools.") return 0 print(f"Installing collection: {display_name}") print(f"Tools to install: {len(tools)}") if use_pinned and pinned: print(f"Using pinned versions: {len(pinned)} tools") print() installed = 0 failed = 0 for tool in tools: # Handle both object format (from API) and string format if isinstance(tool, dict): tool_ref = f"{tool['owner']}/{tool['name']}" version = tool.get('pinned_version') if use_pinned else None else: tool_ref = tool version = pinned.get(tool_ref) if use_pinned else None try: print(f" Installing {tool_ref}...", end=" ", flush=True) resolved = install_from_registry(tool_ref, version) print(f"v{resolved.version}") installed += 1 except RegistryError as e: print(f"FAILED: {e.message}") failed += 1 except Exception as e: print(f"FAILED: {e}") failed += 1 print() print(f"Installed: {installed}/{len(tools)}") if failed: print(f"Failed: {failed}") return 1 print(f"\nCollection '{name}' installed successfully!") except RegistryError as e: if e.code == "COLLECTION_NOT_FOUND": print(f"Collection '{name}' not found.", file=sys.stderr) print("List available collections: cmdforge collections list", file=sys.stderr) elif e.code == "CONNECTION_ERROR": print("Could not connect to the registry.", file=sys.stderr) else: print(f"Error: {e.message}", file=sys.stderr) return 1 except Exception as e: print(f"Error: {e}", file=sys.stderr) 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 _bump_patch_version(version: str) -> str: """Bump the patch version of a semver string.""" import re match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)?$', version) if match: major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) suffix = match.group(4) or "" return f"{major}.{minor}.{patch + 1}{suffix}" # Fallback: append .1 if not valid semver return f"{version}.1" 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. If the tool was previously rejected, automatically bumps the version to allow resubmission. """ from ..tool import load_tool, get_tools_dir from ..registry_client import RegistryError import yaml # For publishing, prefer qualified (owned) path first, then fall back to unqualified. # This ensures we publish the explicitly-owned version when both exist. tool = None try: me = client.get_me() owner = me.get("slug", "") except Exception: owner = "" if owner: tool = load_tool(f"{owner}/{tool_name}") if not tool: tool = load_tool(tool_name) if not tool: return {"success": False, "error": f"Tool '{tool_name}' not found"} tool_dir = tool.path.parent if tool.path else (get_tools_dir() / tool_name) config_path = tool_dir / "config.yaml" # Check if tool was previously rejected - if so, bump version version_bumped = False try: status_info = client.get_my_tool_status(tool_name) current_status = status_info.get("status", "") if current_status == "rejected": # Bump version to allow resubmission old_version = tool.version new_version = _bump_patch_version(old_version) # Update local config if config_path.exists(): config_data = yaml.safe_load(config_path.read_text()) or {} config_data["version"] = new_version config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False)) # Reload tool from same file (not by name, to preserve path) from ..tool import Tool config_data = yaml.safe_load(config_path.read_text()) or {} tool = Tool.from_dict(config_data) tool.path = config_path version_bumped = True print(f" (Version bumped {old_version} -> {new_version} due to previous rejection)") except RegistryError as e: if e.code != "TOOL_NOT_FOUND": pass # Other errors - continue with publish attempt # Load README/defaults if exists readme_path = tool_dir / "README.md" readme = readme_path.read_text() if readme_path.exists() else "" defaults_path = tool_dir / "defaults.yaml" defaults = defaults_path.read_text() if defaults_path.exists() else "" try: config_yaml = yaml.safe_dump(tool.to_dict(), sort_keys=False) result = client.publish_tool(config_yaml, readme=readme, defaults=defaults, dry_run=False) # Update local tool config with registry metadata (use same config_path from above) 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 registry_owner = result.get("owner") if registry_owner: config_data["registry_owner"] = registry_owner # 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"), "version_bumped": version_bumped, "version": tool.version if tool else None } 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 has_rejected = False has_changes_requested = False rejected_tools = [] changes_tools = [] 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 if status == "rejected": has_rejected = True rejected_tools.append(tool_name) elif status == "changes_requested": has_changes_requested = True changes_tools.append(tool_name) 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") elif has_rejected: print(f"\nSome tools were rejected:") for t in rejected_tools: print(f" - {t}") print(f"\nTo proceed, you need to:") print(f" 1. Fix the issues with rejected tools") print(f" 2. Republish them: cmdforge registry publish ") print(f" 3. Then republish collection: cmdforge collections publish {collection.name}") # Clear pending state since tools were rejected collection.pending_approval = False collection.pending_tools = [] collection.save() print(f"\n(Collection pending state cleared)") elif has_changes_requested: print(f"\nSome tools need changes:") for t in changes_tools: print(f" - {t}") print(f"\nAddress the feedback and republish the tools.") else: print(f"\nWaiting for tool approvals...") return 0