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 <[email protected]>
This commit is contained in:
rob
2026-01-26 16:50:19 -04:00
co-authored by Claude Opus 4.5
parent 37e74bf339
commit 29869ef59f
14 changed files with 2917 additions and 27 deletions
+630 -8
View File
@@ -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 <name> Show collection details")
print(" install <name> Install all tools in a collection")
print(" list List available collections (registry)")
print(" info <name> Show registry collection details")
print(" install <name> Install all tools in a registry collection")
print()
print("Local collection management:")
print(" create <name> Create a new local collection")
print(" show <name> Show local collection details")
print(" add <coll> <tool> Add a tool to a collection")
print(" remove <coll> <tool> Remove a tool from a collection")
print(" delete <name> Delete a local collection")
print()
print("Publishing:")
print(" publish <name> Publish a collection to the registry")
print(" status <name> Check status of a pending collection")
return 0
def _cmd_collections_list(args):
"""List available collections."""
"""List available collections (registry or local)."""
import json as json_mod
from ..collection import list_collections, get_collection
# Handle --local flag
if getattr(args, 'local', False):
local_colls = list_collections()
if getattr(args, 'json', False):
data = []
for name in local_colls:
coll = get_collection(name)
if coll:
data.append(coll.to_dict())
print(json_mod.dumps({"collections": data}, indent=2))
return 0
if not local_colls:
print("No local collections.")
print("Create one with: cmdforge collections create <name>")
return 0
print(f"Local collections ({len(local_colls)}):\n")
for name in local_colls:
coll = get_collection(name)
if coll:
status = "pending" if coll.pending_approval else ("published" if coll.published else "local")
print(f" {coll.display_name}")
print(f" {coll.name} ({len(coll.tools)} tools) [{status}]")
print()
return 0
# Registry listing
from ..registry_client import RegistryError, get_client
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} <tool>")
return 0
def cmd_collections_add(args):
"""Add a tool to a collection."""
from ..collection import get_collection
from ..tool import list_tools
collection = get_collection(args.collection)
if not collection:
print(f"Error: Collection '{args.collection}' not found")
return 1
tool_ref = args.tool
# Validate tool exists (local or suggest registry format)
if "/" not in tool_ref:
# Local tool reference
if tool_ref not in list_tools():
print(f"Warning: Local tool '{tool_ref}' not found")
confirm = input("Add anyway? [y/N]: ").strip().lower()
if confirm != "y":
return 1
if tool_ref in collection.tools:
print(f"Tool '{tool_ref}' already in collection")
return 0
collection.tools.append(tool_ref)
# Optional: set pinned version
if hasattr(args, 'version') and args.version:
collection.pinned[tool_ref] = args.version
collection.save()
print(f"Added '{tool_ref}' to collection '{collection.name}'")
return 0
def cmd_collections_remove(args):
"""Remove a tool from a collection."""
from ..collection import get_collection
collection = get_collection(args.collection)
if not collection:
print(f"Error: Collection '{args.collection}' not found")
return 1
tool_ref = args.tool
if tool_ref not in collection.tools:
print(f"Tool '{tool_ref}' not in collection")
return 1
collection.tools.remove(tool_ref)
collection.pinned.pop(tool_ref, None)
collection.save()
print(f"Removed '{tool_ref}' from collection '{collection.name}'")
return 0
def cmd_collections_show(args):
"""Show local collection details."""
from ..collection import get_collection, classify_tool_reference
from ..tool import list_tools
collection = get_collection(args.name)
if not collection:
print(f"Error: Collection '{args.name}' not found")
return 1
local_tools = set(list_tools())
print(f"\n{collection.display_name}")
print("=" * 50)
if collection.description:
print(f"\n{collection.description}\n")
if collection.tags:
print(f"Tags: {', '.join(collection.tags)}")
# Publishing status
if collection.pending_approval:
print(f"\nStatus: Pending (waiting for tool approvals)")
elif collection.published:
print(f"\nStatus: Published as '{collection.registry_name}'")
else:
print(f"\nStatus: Local only (not published)")
print(f"\nTools ({len(collection.tools)}):")
for tool_ref in collection.tools:
owner, name, is_local = classify_tool_reference(tool_ref)
pinned = collection.pinned.get(tool_ref, "")
version_str = f" @ {pinned}" if pinned else ""
if is_local:
exists = name in local_tools
status = "" if exists else " (not found)"
print(f" - {name}{version_str} [local]{status}")
else:
print(f" - {tool_ref}{version_str} [registry]")
if collection.pending_tools:
print(f"\nPending tool approvals:")
for tool in collection.pending_tools:
print(f" - {tool}")
return 0
def cmd_collections_delete(args):
"""Delete a local collection."""
from ..collection import get_collection
collection = get_collection(args.name)
if not collection:
print(f"Error: Collection '{args.name}' not found")
return 1
if not getattr(args, 'force', False):
confirm = input(f"Delete collection '{args.name}'? [y/N]: ").strip().lower()
if confirm != "y":
print("Cancelled")
return 0
collection.delete()
print(f"Deleted collection '{args.name}'")
return 0
# ---------------------------------------------------------------------------
# Publishing Commands
# ---------------------------------------------------------------------------
def cmd_collections_publish(args):
"""Publish a collection to the registry."""
from ..collection import get_collection, resolve_tool_references
from ..registry_client import get_client, RegistryError
collection = get_collection(args.name)
if not collection:
print(f"Error: Collection '{args.name}' not found")
return 1
if not collection.tools:
print("Error: Collection has no tools")
return 1
# Get user info
client = get_client()
is_valid, error = client.validate_token()
if not is_valid:
print(f"Error: Not authenticated. Run: cmdforge config connect")
return 1
# Get user's registry slug
try:
user_info = client.get_me()
user_slug = user_info.get("slug", "")
except RegistryError as e:
print(f"Error getting user info: {e.message}")
return 1
if not user_slug:
print("Error: Could not determine your registry username")
return 1
# Check if resuming a pending publish
if getattr(args, 'resume', False) and collection.pending_approval:
return _resume_collection_publish(collection, client, user_slug)
# Resolve tool references (uses authenticated endpoint for user's own tools)
print(f"Analyzing collection '{collection.display_name}'...")
result = resolve_tool_references(
collection.tools, collection.pinned, user_slug, client
)
registry_refs = result.registry_refs
transformed_pinned = result.transformed_pinned
unpublished = result.local_unpublished
published = result.local_published # List of (name, status, has_approved) tuples
visibility_issues = result.visibility_issues
registry_tool_issues = result.registry_tool_issues
# Check for tools where latest version is pending
pending_latest = [(name, status) for name, status, has_approved in published if status != "approved"]
# Dry run mode
if getattr(args, 'dry_run', False):
print(f"\n[DRY RUN] Would publish collection: {collection.name}")
print(f" Display name: {collection.display_name}")
print(f" Tools: {len(collection.tools)}")
if visibility_issues:
print(f"\n BLOCKING: Non-public local tools ({len(visibility_issues)}):")
for name, visibility in visibility_issues:
print(f" - {name} (visibility: {visibility})")
if registry_tool_issues:
print(f"\n BLOCKING: Registry tool issues ({len(registry_tool_issues)}):")
for ref, reason in registry_tool_issues:
print(f" - {ref}: {reason}")
if unpublished:
print(f"\n Unpublished local tools ({len(unpublished)}):")
for tool in unpublished:
print(f" - {tool} -> {user_slug}/{tool}")
if pending_latest:
print(f"\n Pending approval ({len(pending_latest)}):")
for name, status in pending_latest:
print(f" - {user_slug}/{name} ({status})")
if transformed_pinned:
print(f"\n Version pins (transformed):")
for ref, version in transformed_pinned.items():
print(f" - {ref}: {version}")
print(f"\n Final tool references:")
for ref in registry_refs:
print(f" - {ref}")
return 0
# Handle visibility issues FIRST (local tools that aren't public)
if visibility_issues:
print(f"\n{len(visibility_issues)} local tool(s) are not public:")
for name, visibility in visibility_issues:
print(f" - {name} (visibility: {visibility})")
print(f"\nCollections can only include PUBLIC tools.")
print(f"Update these tools to be public before including in a collection:")
print(f" Edit the tool's config.yaml and set: visibility: public")
print(f" Then republish with: cmdforge registry publish <toolname>")
return 1
# Handle registry tool issues (external tools that aren't public/approved)
if registry_tool_issues:
print(f"\n{len(registry_tool_issues)} registry tool(s) cannot be included:")
for ref, reason in registry_tool_issues:
print(f" - {ref}: {reason}")
print(f"\nCollections can only include PUBLIC, APPROVED tools.")
print(f"Remove these tools from the collection or wait for them to be approved.")
return 1
# Handle tools where latest version is pending (warn but don't block)
if pending_latest:
print(f"\n{len(pending_latest)} tool(s) have pending latest versions:")
for name, status in pending_latest:
print(f" - {user_slug}/{name} ({status})")
print(f"\n (An older approved version may still be usable - will attempt publish)")
# Handle unpublished tools
if unpublished:
print(f"\nThis collection contains {len(unpublished)} unpublished local tool(s):")
for tool in unpublished:
print(f" - {tool}")
print(f"\nThese must be published as '{user_slug}/<name>' before the collection.")
print("\nNOTE: Only PUBLIC, APPROVED tools can be included in collections.")
print("\nOptions:")
print(" [1] Publish all tools now, then collection (recommended)")
print(" [2] Remove unpublished tools and publish remaining")
print(" [3] Cancel")
choice = input("\nChoice [1/2/3]: ").strip()
if choice == "1":
# Publish tools first
pending_tools = []
for tool_name in unpublished:
print(f"\nPublishing tool: {tool_name}...")
result = _publish_single_tool(tool_name, client)
if result.get("pending"):
pending_tools.append(tool_name)
print(f" -> Submitted for review (pending approval)")
elif result.get("success"):
print(f" -> Published as {user_slug}/{tool_name}")
else:
print(f" -> Failed: {result.get('error')}")
return 1
if pending_tools:
# Save pending state
collection.pending_approval = True
collection.pending_tools = pending_tools
collection.maintainer = user_slug
collection.save()
print(f"\nSome tools are pending approval:")
for tool in pending_tools:
print(f" - {user_slug}/{tool}")
print(f"\nCollection will be published once tools are approved.")
print(f"Check status with: cmdforge collections status {collection.name}")
print(f"Resume with: cmdforge collections publish {collection.name} --continue")
return 0
# All tools approved immediately (or auto-approved)
# Fall through to publish collection
elif choice == "2":
# Remove unpublished tools from refs and pinned
unpublished_full_refs = {f"{user_slug}/{u}" for u in unpublished}
registry_refs = [ref for ref in registry_refs if ref not in unpublished_full_refs]
# Also remove from transformed_pinned to avoid orphaned keys
for u in unpublished:
full_ref = f"{user_slug}/{u}"
transformed_pinned.pop(full_ref, None)
if not registry_refs:
print("Error: No tools remaining after removal")
return 1
# Update local collection to match what we're publishing
collection.tools = [t for t in collection.tools if t not in unpublished]
for u in unpublished:
collection.pinned.pop(u, None)
collection.save()
print(f"\nProceeding with {len(registry_refs)} tool(s)")
else:
print("Cancelled")
return 0
# Publish collection (using transformed pinned keys)
return _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug)
def _publish_single_tool(tool_name: str, client) -> dict:
"""
Publish a single tool. Returns status dict.
Updates local tool config with registry_hash and registry_status
to maintain consistency with direct `cmdforge registry publish` flow.
"""
from ..tool import load_tool, get_tools_dir
import yaml
tool = load_tool(tool_name)
if not tool:
return {"success": False, "error": f"Tool '{tool_name}' not found"}
# Load README if exists
readme_path = get_tools_dir() / tool_name / "README.md"
readme = readme_path.read_text() if readme_path.exists() else ""
try:
config_yaml = yaml.safe_dump(tool.to_dict(), sort_keys=False)
result = client.publish_tool(config_yaml, readme=readme, dry_run=False)
# Update local tool config with registry metadata
config_path = get_tools_dir() / tool_name / "config.yaml"
config_hash = result.get("config_hash")
moderation_status = result.get("status", "pending")
if config_hash and config_path.exists():
try:
config_data = yaml.safe_load(config_path.read_text()) or {}
config_data["registry_hash"] = config_hash
config_data["registry_status"] = moderation_status
# Clear any old feedback when republishing
if "registry_feedback" in config_data:
del config_data["registry_feedback"]
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
except Exception:
pass # Non-critical
# Check if pending moderation
is_pending = moderation_status == "pending" or result.get("pr_url")
return {
"success": True,
"pending": is_pending,
"status": moderation_status,
"config_hash": config_hash,
"pr_url": result.get("pr_url")
}
except Exception as e:
return {"success": False, "error": str(e)}
def _resume_collection_publish(collection, client, user_slug) -> int:
"""Resume publishing a collection after tools are approved."""
from ..registry_client import RegistryError
print(f"Checking status of pending tools...")
still_pending = []
for tool_name in collection.pending_tools:
try:
# Use authenticated endpoint to check user's own tools
status_info = client.get_my_tool_status(tool_name)
status = status_info.get("status", "pending")
if status != "approved":
still_pending.append(tool_name)
print(f" {user_slug}/{tool_name}: {status}")
else:
print(f" {user_slug}/{tool_name}: approved")
except RegistryError:
still_pending.append(tool_name)
print(f" {user_slug}/{tool_name}: not found")
if still_pending:
collection.pending_tools = still_pending
collection.save()
print(f"\nStill waiting for {len(still_pending)} tool(s) to be approved")
return 0
# All approved - publish collection
print(f"\nAll tools approved! Publishing collection...")
# Rebuild registry refs AND transform pinned keys
registry_refs = []
transformed_pinned = {}
for tool_ref in collection.tools:
if "/" in tool_ref:
registry_refs.append(tool_ref)
# Copy pinned if exists
if tool_ref in collection.pinned:
transformed_pinned[tool_ref] = collection.pinned[tool_ref]
else:
full_ref = f"{user_slug}/{tool_ref}"
registry_refs.append(full_ref)
# Transform pinned key from local name to owner/name
if tool_ref in collection.pinned:
transformed_pinned[full_ref] = collection.pinned[tool_ref]
return _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug)
def _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug) -> int:
"""Actually publish the collection to registry."""
print(f"\nPublishing collection '{collection.display_name}'...")
payload = {
"name": collection.name,
"display_name": collection.display_name,
"description": collection.description,
"maintainer": user_slug,
"tools": registry_refs,
"pinned": transformed_pinned,
"tags": collection.tags,
}
try:
result = client.publish_collection(payload)
# Update local state
collection.published = True
collection.registry_name = collection.name
collection.pending_approval = False
collection.pending_tools = []
collection.maintainer = user_slug
collection.save()
print(f"\nCollection published: {collection.name}")
print(f" View at: {collection.get_registry_url()}")
return 0
except Exception as e:
print(f"\nError publishing collection: {e}")
return 1
def cmd_collections_status(args):
"""Check status of a pending collection publish."""
from ..collection import get_collection
from ..registry_client import get_client, RegistryError
collection = get_collection(args.name)
if not collection:
print(f"Error: Collection '{args.name}' not found")
return 1
if not collection.pending_approval:
if collection.published:
print(f"Collection '{collection.name}' is published")
else:
print(f"Collection '{collection.name}' is local (not published)")
return 0
# Check pending tools using authenticated endpoint
client = get_client()
# Validate auth first
is_valid, error = client.validate_token()
if not is_valid:
print(f"Error: Not authenticated. Run: cmdforge config connect")
return 1
user_slug = collection.maintainer
print(f"\nCollection: {collection.display_name}")
print(f"Status: Pending tool approvals\n")
all_approved = True
for tool_name in collection.pending_tools:
try:
# Use authenticated endpoint to see pending/private tools
status_info = client.get_my_tool_status(tool_name)
status = status_info.get("status", "pending")
if status == "approved":
print(f" {user_slug}/{tool_name}: approved")
else:
print(f" {user_slug}/{tool_name}: {status}")
all_approved = False
except RegistryError as e:
print(f" {user_slug}/{tool_name}: {e.message}")
all_approved = False
if all_approved:
print(f"\nAll tools approved!")
print(f"Run: cmdforge collections publish {collection.name} --continue")
else:
print(f"\nWaiting for tool approvals...")
return 0