diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index 7ef3570..ae63690 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -11,6 +11,7 @@ from .tool_commands import ( ) from .provider_commands import cmd_providers from .registry_commands import cmd_registry +from .collections_commands import cmd_collections from .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init from .config_commands import cmd_config @@ -209,6 +210,32 @@ def main(): # Default for registry with no subcommand p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args))) + # ------------------------------------------------------------------------- + # Collections Commands + # ------------------------------------------------------------------------- + p_collections = subparsers.add_parser("collections", help="Manage tool collections") + collections_sub = p_collections.add_subparsers(dest="collections_cmd", help="Collections commands") + + # collections list + p_coll_list = collections_sub.add_parser("list", help="List available collections") + 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.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.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) + + # 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))) + # ------------------------------------------------------------------------- # Project Commands # ------------------------------------------------------------------------- diff --git a/src/cmdforge/cli/collections_commands.py b/src/cmdforge/cli/collections_commands.py new file mode 100644 index 0000000..fb2e3d0 --- /dev/null +++ b/src/cmdforge/cli/collections_commands.py @@ -0,0 +1,194 @@ +"""Collections commands for CmdForge CLI.""" + +import json +import sys + +from ..resolver import install_from_registry + + +def cmd_collections(args): + """Handle collections subcommands.""" + if args.collections_cmd == "list": + return _cmd_collections_list(args) + elif args.collections_cmd == "info": + return _cmd_collections_info(args) + elif args.collections_cmd == "install": + return _cmd_collections_install(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") + return 0 + + +def _cmd_collections_list(args): + """List available collections.""" + 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", "") + tools = coll.get("tools", []) + 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_ref in tools: + 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_ref in tools: + version = pinned.get(tool_ref) + 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 diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 86639fe..94a0977 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -1352,6 +1352,203 @@ def create_app() -> Flask: response.headers["Cache-Control"] = "max-age=3600" return response + # ------------------------------------------------------------------------- + # Admin Collections Management + # ------------------------------------------------------------------------- + + @app.route("/api/v1/admin/collections", methods=["GET"]) + @require_auth + def admin_list_collections() -> Response: + """List all collections with full details (admin).""" + user = get_current_user() + if not user or user.get("role") not in ("admin", "moderator"): + return error_response("FORBIDDEN", "Admin access required", 403) + + rows = query_all( + g.db, + "SELECT * FROM collections ORDER BY name", + ) + data = [] + for row in rows: + tools = json.loads(row["tools"]) if row["tools"] else [] + pinned = json.loads(row["pinned"]) if row["pinned"] else {} + tags = json.loads(row["tags"]) if row["tags"] else [] + data.append({ + "id": row["id"], + "name": row["name"], + "display_name": row["display_name"], + "description": row["description"], + "icon": row["icon"], + "maintainer": row["maintainer"], + "tools": tools, + "pinned": pinned, + "tags": tags, + "created_at": row["created_at"], + "updated_at": row["updated_at"], + }) + return jsonify({"data": data}) + + @app.route("/api/v1/admin/collections", methods=["POST"]) + @require_auth + def admin_create_collection() -> Response: + """Create a new collection (admin).""" + user = get_current_user() + if not user or user.get("role") != "admin": + return error_response("FORBIDDEN", "Admin access required", 403) + + data = request.get_json() or {} + name = data.get("name", "").strip().lower() + display_name = data.get("display_name", "").strip() + description = data.get("description", "").strip() + icon = data.get("icon", "").strip() + maintainer = data.get("maintainer", user.get("username", "")).strip() + tools = data.get("tools", []) + pinned = data.get("pinned", {}) + tags = data.get("tags", []) + + # Validation + if not name: + return error_response("INVALID_INPUT", "Collection name is required", 400) + if not display_name: + display_name = name.replace("-", " ").title() + if not isinstance(tools, list): + return error_response("INVALID_INPUT", "Tools must be a list", 400) + + # Check for duplicate + existing = query_one(g.db, "SELECT id FROM collections WHERE name = ?", [name]) + if existing: + return error_response("DUPLICATE", f"Collection '{name}' already exists", 409) + + # Validate tool references + validated_tools = [] + for tool_ref in tools: + if isinstance(tool_ref, str) and "/" in tool_ref: + validated_tools.append(tool_ref) + + try: + g.db.execute( + """ + INSERT INTO collections (name, display_name, description, icon, maintainer, tools, pinned, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + name, display_name, description, icon, maintainer, + json.dumps(validated_tools), + json.dumps(pinned) if pinned else None, + json.dumps(tags) if tags else None, + ], + ) + g.db.commit() + + # Audit log + log_admin_action(g.db, user["id"], "create_collection", {"collection": name}) + + return jsonify({ + "success": True, + "message": f"Collection '{name}' created", + "name": name, + }), 201 + + except Exception as e: + return error_response("SERVER_ERROR", str(e), 500) + + @app.route("/api/v1/admin/collections/", methods=["PUT"]) + @require_auth + def admin_update_collection(name: str) -> Response: + """Update a collection (admin).""" + user = get_current_user() + if not user or user.get("role") != "admin": + return error_response("FORBIDDEN", "Admin access required", 403) + + existing = query_one(g.db, "SELECT * FROM collections WHERE name = ?", [name]) + if not existing: + return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404) + + data = request.get_json() or {} + + # Get updated values (keep existing if not provided) + display_name = data.get("display_name", existing["display_name"]) + description = data.get("description", existing["description"]) + icon = data.get("icon", existing["icon"]) + maintainer = data.get("maintainer", existing["maintainer"]) + + # Handle tools - parse existing if not provided in request + if "tools" in data: + tools = data["tools"] + if not isinstance(tools, list): + return error_response("INVALID_INPUT", "Tools must be a list", 400) + validated_tools = [t for t in tools if isinstance(t, str) and "/" in t] + else: + validated_tools = json.loads(existing["tools"]) if existing["tools"] else [] + + # Handle pinned versions + if "pinned" in data: + pinned = data["pinned"] if isinstance(data["pinned"], dict) else {} + else: + pinned = json.loads(existing["pinned"]) if existing["pinned"] else {} + + # Handle tags + if "tags" in data: + tags = data["tags"] if isinstance(data["tags"], list) else [] + else: + tags = json.loads(existing["tags"]) if existing["tags"] else [] + + try: + g.db.execute( + """ + UPDATE collections + SET display_name = ?, description = ?, icon = ?, maintainer = ?, + tools = ?, pinned = ?, tags = ?, updated_at = CURRENT_TIMESTAMP + WHERE name = ? + """, + [ + display_name, description, icon, maintainer, + json.dumps(validated_tools), + json.dumps(pinned) if pinned else None, + json.dumps(tags) if tags else None, + name, + ], + ) + g.db.commit() + + # Audit log + log_admin_action(g.db, user["id"], "update_collection", {"collection": name}) + + return jsonify({ + "success": True, + "message": f"Collection '{name}' updated", + }) + + except Exception as e: + return error_response("SERVER_ERROR", str(e), 500) + + @app.route("/api/v1/admin/collections/", methods=["DELETE"]) + @require_auth + def admin_delete_collection(name: str) -> Response: + """Delete a collection (admin).""" + user = get_current_user() + if not user or user.get("role") != "admin": + return error_response("FORBIDDEN", "Admin access required", 403) + + existing = query_one(g.db, "SELECT id FROM collections WHERE name = ?", [name]) + if not existing: + return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404) + + try: + g.db.execute("DELETE FROM collections WHERE name = ?", [name]) + g.db.commit() + + # Audit log + log_admin_action(g.db, user["id"], "delete_collection", {"collection": name}) + + return jsonify({ + "success": True, + "message": f"Collection '{name}' deleted", + }) + + except Exception as e: + return error_response("SERVER_ERROR", str(e), 500) + @app.route("/api/v1/stats/popular", methods=["GET"]) def popular_tools() -> Response: limit = min(int(request.args.get("limit", 10)), 50) diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py index 07876d8..506b752 100644 --- a/src/cmdforge/registry_client.py +++ b/src/cmdforge/registry_client.py @@ -563,6 +563,44 @@ class RegistryClient: return response.json().get("data", []) + def get_collections(self) -> List[Dict[str, Any]]: + """ + Get list of tool collections. + + Returns: + List of collection dicts with name, display_name, description, tools + """ + response = self._request("GET", "/collections") + + if response.status_code != 200: + self._handle_error_response(response) + + return response.json().get("data", []) + + def get_collection(self, name: str) -> Dict[str, Any]: + """ + Get detailed information about a collection. + + Args: + name: Collection name + + Returns: + Collection dict with tools and pinned versions + """ + response = self._request("GET", f"/collections/{name}") + + if response.status_code == 404: + raise RegistryError( + code="COLLECTION_NOT_FOUND", + message=f"Collection '{name}' not found", + http_status=404 + ) + + if response.status_code != 200: + self._handle_error_response(response) + + return response.json().get("data", {}) + def publish_tool( self, config_yaml: str, diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index 0a2e947..cd7ccb8 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -1323,3 +1323,67 @@ def admin_scrutiny(): scrutiny_filter=scrutiny_filter, moderation_filter=moderation_filter, ) + + +@web_bp.route("/dashboard/admin/collections", endpoint="admin_collections") +def admin_collections(): + """Manage tool collections.""" + forbidden = _require_admin_role() + if forbidden: + return forbidden + + user = _load_current_publisher() + token = session.get("auth_token") + + status, payload = _api_get("/api/v1/admin/collections", token=token) + collections = payload.get("data", []) if status == 200 else [] + + return render_template( + "admin/collections.html", + user=user, + active_page="admin_collections", + collections=collections, + ) + + +@web_bp.route("/dashboard/admin/collections/new", endpoint="admin_collection_new") +def admin_collection_new(): + """Create a new collection form.""" + forbidden = _require_admin_role() + if forbidden: + return forbidden + + user = _load_current_publisher() + return render_template( + "admin/collection_form.html", + user=user, + active_page="admin_collections", + collection=None, + is_new=True, + ) + + +@web_bp.route("/dashboard/admin/collections//edit", endpoint="admin_collection_edit") +def admin_collection_edit(name): + """Edit a collection form.""" + forbidden = _require_admin_role() + if forbidden: + return forbidden + + user = _load_current_publisher() + token = session.get("auth_token") + + status, payload = _api_get(f"/api/v1/collections/{name}", token=token) + if status != 200: + flash(f"Collection not found: {name}", "error") + return redirect(url_for("web.admin_collections")) + + collection = payload.get("data", {}) + + return render_template( + "admin/collection_form.html", + user=user, + active_page="admin_collections", + collection=collection, + is_new=False, + ) diff --git a/src/cmdforge/web/templates/admin/collection_form.html b/src/cmdforge/web/templates/admin/collection_form.html new file mode 100644 index 0000000..5cafa5a --- /dev/null +++ b/src/cmdforge/web/templates/admin/collection_form.html @@ -0,0 +1,222 @@ +{% extends "dashboard/base.html" %} + +{% block dashboard_header %} +
+
+

{{ 'New Collection' if is_new else 'Edit Collection' }}

+

{{ 'Create a new tool collection' if is_new else 'Update collection details' }}

+
+ ← Back to Collections +
+{% endblock %} + +{% block dashboard_content %} +
+
+ +
+
+ + +

Lowercase letters, numbers, and hyphens only

+
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

Comma-separated list of tags

+
+ + +
+
+ + +
+
+ {% if collection and collection.tools %} + {% for tool in collection.tools %} +
+ + + +
+ {% endfor %} + {% else %} +
+ + + +
+ {% endif %} +
+

Enter tool references as owner/name. Version is optional (leave blank for latest).

+
+ + +
+ Cancel + +
+
+
+ + +{% endblock %} diff --git a/src/cmdforge/web/templates/admin/collections.html b/src/cmdforge/web/templates/admin/collections.html new file mode 100644 index 0000000..8292641 --- /dev/null +++ b/src/cmdforge/web/templates/admin/collections.html @@ -0,0 +1,131 @@ +{% extends "dashboard/base.html" %} + +{% block dashboard_header %} +
+
+

Collections

+

Manage tool collections

+
+ +
+{% endblock %} + +{% block dashboard_content %} +
+ {% if collections %} +
+ + + + + + + + + + + + {% for coll in collections %} + + + + + + + + {% endfor %} + +
CollectionMaintainerToolsTagsActions
+
+
{{ coll.display_name }}
+
{{ coll.name }}
+ {% if coll.description %} +
{{ coll.description[:80] }}{% if coll.description|length > 80 %}...{% endif %}
+ {% endif %} +
+
+ {{ coll.maintainer }} + + {{ coll.tools|length }} + + {% for tag in coll.tags[:3] %} + {{ tag }} + {% endfor %} + {% if coll.tags|length > 3 %} + +{{ coll.tags|length - 3 }} + {% endif %} + + Edit + +
+
+ {% else %} +
+

No collections

+

Get started by creating a new collection.

+ +
+ {% endif %} +
+ + + + + +{% endblock %} diff --git a/src/cmdforge/web/templates/admin/index.html b/src/cmdforge/web/templates/admin/index.html index df977d7..39cdecd 100644 --- a/src/cmdforge/web/templates/admin/index.html +++ b/src/cmdforge/web/templates/admin/index.html @@ -97,6 +97,12 @@ Registry settings + + + + + Manage collections + {% endif %}