Add collections CLI commands and admin management

CLI Commands:
- cmdforge collections list - List available collections
- cmdforge collections info <name> - Show collection details
- cmdforge collections install <name> - Install all tools in a collection

Admin API:
- GET /api/v1/admin/collections - List all collections (admin)
- POST /api/v1/admin/collections - Create collection
- PUT /api/v1/admin/collections/<name> - Update collection
- DELETE /api/v1/admin/collections/<name> - Delete collection

Admin Web UI:
- /dashboard/admin/collections - List and manage collections
- /dashboard/admin/collections/new - Create new collection form
- /dashboard/admin/collections/<name>/edit - Edit collection form
- Added "Manage collections" link to admin dashboard

Registry Client:
- Added get_collections() method
- Added get_collection(name) method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-17 03:43:58 -04:00
parent 0d91eb7fa0
commit 2c2707679f
8 changed files with 879 additions and 0 deletions

View File

@ -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
# -------------------------------------------------------------------------

View File

@ -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 <name> Show collection details")
print(" install <name> 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 <name>")
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

View File

@ -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/<name>", 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/<name>", 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)

View File

@ -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,

View File

@ -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/<name>/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,
)

View File

@ -0,0 +1,222 @@
{% extends "dashboard/base.html" %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ 'New Collection' if is_new else 'Edit Collection' }}</h1>
<p class="mt-1 text-gray-600">{{ 'Create a new tool collection' if is_new else 'Update collection details' }}</p>
</div>
<a href="{{ url_for('web.admin_collections') }}" class="text-sm text-indigo-600 hover:text-indigo-700">&larr; Back to Collections</a>
</div>
{% endblock %}
{% block dashboard_content %}
<div class="bg-white rounded-lg border border-gray-200 p-6">
<form id="collection-form" class="space-y-6">
<!-- Basic Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name (slug)</label>
<input type="text" id="name" name="name"
value="{{ collection.name if collection else '' }}"
{% if not is_new %}readonly{% endif %}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {% if not is_new %}bg-gray-100{% endif %}"
placeholder="e.g., writing-toolkit"
pattern="[a-z0-9-]+"
required>
<p class="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens only</p>
</div>
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" id="display_name" name="display_name"
value="{{ collection.display_name if collection else '' }}"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g., Writing Toolkit"
required>
</div>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
<textarea id="description" name="description" rows="3"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Describe what this collection is for...">{{ collection.description if collection else '' }}</textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="maintainer" class="block text-sm font-medium text-gray-700">Maintainer</label>
<input type="text" id="maintainer" name="maintainer"
value="{{ collection.maintainer if collection else user.username }}"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g., official">
</div>
<div>
<label for="icon" class="block text-sm font-medium text-gray-700">Icon (optional)</label>
<input type="text" id="icon" name="icon"
value="{{ collection.icon if collection else '' }}"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g., pencil, code, data">
</div>
</div>
<div>
<label for="tags" class="block text-sm font-medium text-gray-700">Tags</label>
<input type="text" id="tags" name="tags"
value="{{ collection.tags|join(', ') if collection and collection.tags else '' }}"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g., writing, text, productivity">
<p class="mt-1 text-xs text-gray-500">Comma-separated list of tags</p>
</div>
<!-- Tools Section -->
<div class="border-t border-gray-200 pt-6">
<div class="flex items-center justify-between mb-4">
<label class="block text-sm font-medium text-gray-700">Tools</label>
<button type="button" onclick="addToolRow()" class="text-sm text-indigo-600 hover:text-indigo-700">+ Add Tool</button>
</div>
<div id="tools-container" class="space-y-2">
{% if collection and collection.tools %}
{% for tool in collection.tools %}
<div class="tool-row flex items-center space-x-2">
<input type="text" name="tool" value="{{ tool.owner }}/{{ tool.name }}"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="owner/tool-name">
<input type="text" name="pinned" value="{{ tool.pinned_version or '' }}"
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="version">
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% else %}
<div class="tool-row flex items-center space-x-2">
<input type="text" name="tool"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="owner/tool-name">
<input type="text" name="pinned"
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="version">
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<p class="mt-2 text-xs text-gray-500">Enter tool references as owner/name. Version is optional (leave blank for latest).</p>
</div>
<!-- Submit -->
<div class="flex justify-end space-x-4 pt-4 border-t border-gray-200">
<a href="{{ url_for('web.admin_collections') }}" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</a>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
{{ 'Create Collection' if is_new else 'Save Changes' }}
</button>
</div>
</form>
</div>
<script>
function addToolRow() {
const container = document.getElementById('tools-container');
const row = document.createElement('div');
row.className = 'tool-row flex items-center space-x-2';
row.innerHTML = `
<input type="text" name="tool"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="owner/tool-name">
<input type="text" name="pinned"
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="version">
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(row);
}
function removeToolRow(button) {
const row = button.closest('.tool-row');
const container = document.getElementById('tools-container');
if (container.children.length > 1) {
row.remove();
} else {
// Clear the inputs instead of removing the last row
row.querySelectorAll('input').forEach(input => input.value = '');
}
}
document.getElementById('collection-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const isNew = {{ 'true' if is_new else 'false' }};
const collectionName = document.getElementById('name').value;
// Collect tools and pinned versions
const toolInputs = document.querySelectorAll('input[name="tool"]');
const pinnedInputs = document.querySelectorAll('input[name="pinned"]');
const tools = [];
const pinned = {};
toolInputs.forEach((input, index) => {
const toolRef = input.value.trim();
if (toolRef && toolRef.includes('/')) {
tools.push(toolRef);
const pinnedVersion = pinnedInputs[index].value.trim();
if (pinnedVersion) {
pinned[toolRef] = pinnedVersion;
}
}
});
// Parse tags
const tagsStr = document.getElementById('tags').value;
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t);
const payload = {
name: collectionName,
display_name: document.getElementById('display_name').value,
description: document.getElementById('description').value,
maintainer: document.getElementById('maintainer').value,
icon: document.getElementById('icon').value,
tools: tools,
pinned: pinned,
tags: tags
};
try {
const url = isNew
? '/api/v1/admin/collections'
: `/api/v1/admin/collections/${collectionName}`;
const method = isNew ? 'POST' : 'PUT';
const response = await fetch(url, {
method: method,
headers: {
'Authorization': 'Bearer {{ session.get("auth_token") }}',
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok) {
window.location.href = '{{ url_for("web.admin_collections") }}';
} else {
alert(data.error?.message || 'Failed to save collection');
}
} catch (error) {
alert('Network error: ' + error.message);
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,131 @@
{% extends "dashboard/base.html" %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Collections</h1>
<p class="mt-1 text-gray-600">Manage tool collections</p>
</div>
<div class="flex items-center space-x-4">
<a href="{{ url_for('web.admin_collection_new') }}" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
New Collection
</a>
<a href="{{ url_for('web.admin_dashboard') }}" class="text-sm text-indigo-600 hover:text-indigo-700">&larr; Back to Admin</a>
</div>
</div>
{% endblock %}
{% block dashboard_content %}
<div class="bg-white rounded-lg border border-gray-200">
{% if collections %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Collection</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Maintainer</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tools</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tags</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for coll in collections %}
<tr id="coll-row-{{ coll.name }}" class="hover:bg-gray-50">
<td class="px-6 py-4">
<div>
<div class="text-sm font-medium text-gray-900">{{ coll.display_name }}</div>
<div class="text-sm text-gray-500">{{ coll.name }}</div>
{% if coll.description %}
<div class="text-xs text-gray-400 mt-1">{{ coll.description[:80] }}{% if coll.description|length > 80 %}...{% endif %}</div>
{% endif %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ coll.maintainer }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ coll.tools|length }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% for tag in coll.tags[:3] %}
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 mr-1">{{ tag }}</span>
{% endfor %}
{% if coll.tags|length > 3 %}
<span class="text-xs text-gray-400">+{{ coll.tags|length - 3 }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ url_for('web.admin_collection_edit', name=coll.name) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
<button onclick="deleteCollection('{{ coll.name }}', '{{ coll.display_name }}')" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-12">
<h3 class="text-sm font-medium text-gray-900">No collections</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new collection.</p>
<div class="mt-6">
<a href="{{ url_for('web.admin_collection_new') }}" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
New Collection
</a>
</div>
</div>
{% endif %}
</div>
<!-- Delete Modal -->
<div id="delete-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-red-600 mb-2">Delete Collection</h3>
<p class="text-sm text-gray-600 mb-4">
Are you sure you want to delete <strong id="delete-collection-name"></strong>?
This action cannot be undone.
</p>
<div class="mt-4 flex justify-end space-x-3">
<button onclick="closeDeleteModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</button>
<button onclick="confirmDelete()" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Delete</button>
</div>
</div>
</div>
</div>
<script>
let deleteCollectionName = null;
function deleteCollection(name, displayName) {
deleteCollectionName = name;
document.getElementById('delete-collection-name').textContent = displayName;
document.getElementById('delete-modal').classList.remove('hidden');
}
function closeDeleteModal() {
document.getElementById('delete-modal').classList.add('hidden');
deleteCollectionName = null;
}
async function confirmDelete() {
if (!deleteCollectionName) return;
try {
const response = await fetch(`/api/v1/admin/collections/${deleteCollectionName}`, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer {{ session.get("auth_token") }}'
}
});
if (response.ok) {
location.reload();
} else {
const data = await response.json();
alert(data.error?.message || 'Failed to delete collection');
}
} catch (error) {
alert('Network error');
}
}
</script>
{% endblock %}

View File

@ -97,6 +97,12 @@
</svg>
<span class="text-sm font-medium text-gray-700">Registry settings</span>
</a>
<a href="{{ url_for('web.admin_collections') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<span class="text-sm font-medium text-gray-700">Manage collections</span>
</a>
{% endif %}
<a href="{{ url_for('web.dashboard') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">