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:
@@ -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">← 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">← 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">
|
||||
|
||||
Reference in New Issue
Block a user