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 <[email protected]>
This commit is contained in:
rob
2026-01-17 03:43:58 -04:00
co-authored by Claude Opus 4.5
parent 0d91eb7fa0
commit 2c2707679f
8 changed files with 879 additions and 0 deletions
+64
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,
)
@@ -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 %}
@@ -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 %}
@@ -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">