Rename project from SmartTools to CmdForge

Major rename operation:
- Rename Python package: smarttools -> cmdforge
- Update CLI entry point: smarttools -> cmdforge
- Update all imports and module references
- Update pyproject.toml with new name, URLs, entry point
- Update all HTML templates with new branding
- Update documentation (CLAUDE.md, README.md, docs/*, wiki/*)
- Update environment variables:
  - SMARTTOOLS_ENV -> CMDFORGE_ENV
  - SMARTTOOLS_REGISTRY_DB -> CMDFORGE_REGISTRY_DB
  - SMARTTOOLS_TOKEN -> CMDFORGE_TOKEN
  - SMARTTOOLS_REGISTRY_* -> CMDFORGE_REGISTRY_*
- Update Dockerfile and docker-compose.yml
- Update tests to use new package name
- Update scripts and examples
- Update package.json and tailwind.config.js

All 158 unit tests pass. The CLI is working correctly with the
new cmdforge command.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-02 22:15:55 -04:00
co-authored by Claude Opus 4.5
parent 2939dd806b
commit 497fe87fc5
113 changed files with 822 additions and 822 deletions
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Dashboard Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-1 text-gray-600">Welcome back, {{ user.display_name }}</p>
{% endblock %}
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Sidebar Navigation -->
<aside>
<nav class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<a href="{{ url_for('web.dashboard') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'overview' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
Overview
</a>
<a href="{{ url_for('web.dashboard_tools') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tools' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
My Tools
</a>
<a href="{{ url_for('web.dashboard_tokens') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'tokens' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
API Tokens
</a>
<a href="{{ url_for('web.dashboard_settings') }}"
class="flex items-center px-4 py-3 text-sm font-medium {{ 'bg-indigo-50 text-indigo-700 border-l-4 border-indigo-600' if active_page == 'settings' else 'text-gray-700 hover:bg-gray-50' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Settings
</a>
</nav>
<!-- Quick Stats (mobile hidden) -->
<div class="hidden lg:block mt-6 bg-white rounded-lg border border-gray-200 p-4">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Quick Stats</h3>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Tools</dt>
<dd class="text-sm font-medium text-gray-900">{{ stats.tools_count if stats else 0 }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-600">Downloads</dt>
<dd class="text-sm font-medium text-gray-900">{{ (stats.total_downloads if stats else 0)|format_number }}</dd>
</div>
</dl>
</div>
</aside>
<!-- Main Content -->
<main class="lg:col-span-3">
{% block dashboard_content %}{% endblock %}
</main>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,135 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'overview' %}
{% block title %}Dashboard - CmdForge{% endblock %}
{% block dashboard_content %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-indigo-100 rounded-lg">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Published Tools</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tools_count }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Downloads</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.total_downloads|format_number }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="flex items-center">
<div class="p-3 bg-cyan-100 rounded-lg">
<svg class="w-6 h-6 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">API Tokens</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.tokens_count }}</p>
</div>
</div>
</div>
</div>
<!-- Recent Tools -->
<div class="bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Your Tools</h2>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
+ Publish New Tool
</a>
</div>
{% if tools %}
<ul class="divide-y divide-gray-200">
{% for tool in tools[:5] %}
<li class="px-6 py-4 flex items-center justify-between">
<div>
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="font-medium text-gray-900 hover:text-indigo-600">
{{ tool.name }}
</a>
<p class="text-sm text-gray-500">v{{ tool.version }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-900">{{ tool.downloads|format_number }} downloads</p>
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
{% if tools|length > 5 %}
<div class="px-6 py-4 border-t border-gray-200">
<a href="{{ url_for('web.dashboard_tools') }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
View all {{ tools|length }} tools
</a>
</div>
{% endif %}
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<p class="mt-4 text-gray-600">You haven't published any tools yet.</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Publish Your First Tool
</a>
</div>
{% endif %}
</div>
<!-- Recent Activity -->
{% if recent_activity %}
<div class="mt-6 bg-white rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Recent Activity</h2>
</div>
<ul class="divide-y divide-gray-200">
{% for activity in recent_activity[:5] %}
<li class="px-6 py-4 flex items-start">
<div class="flex-shrink-0">
{% if activity.type == 'download' %}
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
</div>
{% elif activity.type == 'publish' %}
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
</div>
{% endif %}
</div>
<div class="ml-4">
<p class="text-sm text-gray-900">{{ activity.description }}</p>
<p class="text-xs text-gray-500">{{ activity.timestamp|timeago }}</p>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,254 @@
{% extends "dashboard/base.html" %}
{% from "components/forms.html" import text_input, textarea, button_primary, form_errors, success_alert %}
{% set active_page = 'settings' %}
{% block title %}Settings - CmdForge Dashboard{% endblock %}
{% block dashboard_header %}
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
<p class="mt-1 text-gray-600">Manage your profile and account settings</p>
{% endblock %}
{% block dashboard_content %}
{{ success_alert(success_message) }}
{{ form_errors(errors) }}
<!-- Profile Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Profile</h2>
<p class="text-sm text-gray-600">This information will be displayed on your public profile.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="profile">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">
Display name
</label>
<input type="text"
name="display_name"
id="display_name"
value="{{ user.display_name }}"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<div class="flex">
<span class="inline-flex items-center px-3 text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-lg">
@
</span>
<input type="text"
name="slug"
id="slug"
value="{{ user.slug }}"
disabled
class="flex-1 px-4 py-2 border border-gray-300 rounded-r-lg bg-gray-50 text-gray-500">
</div>
<p class="mt-1 text-xs text-gray-500">Username cannot be changed</p>
</div>
</div>
<div class="mt-6">
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">
Bio
</label>
<textarea name="bio"
id="bio"
rows="3"
placeholder="Tell others a bit about yourself..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y">{{ user.bio or '' }}</textarea>
<p class="mt-1 text-xs text-gray-500">Brief description for your profile. Max 500 characters.</p>
</div>
<div class="mt-6">
<label for="website" class="block text-sm font-medium text-gray-700 mb-1">
Website
</label>
<input type="url"
name="website"
id="website"
value="{{ user.website or '' }}"
placeholder="https://example.com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Save Profile
</button>
</div>
</form>
</div>
<!-- Email Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Email</h2>
<p class="text-sm text-gray-600">Your email address for account notifications.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ user.email }}</p>
{% if user.verified %}
<p class="text-xs text-green-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
Verified
</p>
{% else %}
<p class="text-xs text-amber-600 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Not verified
</p>
{% endif %}
</div>
{% if not user.verified %}
<button type="button"
onclick="resendVerification()"
class="text-sm text-indigo-600 hover:text-indigo-800">
Resend verification
</button>
{% endif %}
</div>
</div>
</div>
<!-- Password Change -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Change Password</h2>
<p class="text-sm text-gray-600">Update your password to keep your account secure.</p>
</div>
<form action="{{ url_for('web.dashboard_settings') }}" method="POST" class="p-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form" value="password">
<div class="space-y-4 max-w-md">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-1">
Current password
</label>
<input type="password"
name="current_password"
id="current_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-1">
New password
</label>
<input type="password"
name="new_password"
id="new_password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<p class="mt-1 text-xs text-gray-500">Minimum 8 characters</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">
Confirm new password
</label>
<input type="password"
name="confirm_password"
id="confirm_password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="mt-6 flex justify-end">
<button type="submit"
class="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Update Password
</button>
</div>
</form>
</div>
<!-- Danger Zone -->
<div class="bg-white rounded-lg border border-red-200 overflow-hidden">
<div class="px-6 py-4 border-b border-red-200 bg-red-50">
<h2 class="text-lg font-semibold text-red-800">Danger Zone</h2>
<p class="text-sm text-red-600">Irreversible actions that affect your account.</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">Delete Account</p>
<p class="text-sm text-gray-500">Permanently delete your account and all associated data.</p>
</div>
<button type="button"
onclick="confirmDeleteAccount()"
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-md hover:bg-red-50">
Delete Account
</button>
</div>
</div>
</div>
<script>
async function resendVerification() {
try {
const response = await fetch('/api/v1/me/resend-verification', {method: 'POST'});
if (response.ok) {
alert('Verification email sent! Please check your inbox.');
} else {
alert('Failed to send verification email. Please try again later.');
}
} catch (err) {
alert('Failed to send verification email. Please try again later.');
}
}
function confirmDeleteAccount() {
const confirmed = confirm(
'Are you absolutely sure you want to delete your account?\n\n' +
'This will permanently delete:\n' +
'- Your profile\n' +
'- All your published tools\n' +
'- All your API tokens\n\n' +
'This action cannot be undone.'
);
if (confirmed) {
const doubleConfirm = prompt('Type "DELETE" to confirm account deletion:');
if (doubleConfirm === 'DELETE') {
deleteAccount();
}
}
}
async function deleteAccount() {
try {
const response = await fetch('/api/v1/me', {method: 'DELETE'});
if (response.ok) {
window.location.href = '/?deleted=1';
} else {
alert('Failed to delete account. Please try again or contact support.');
}
} catch (err) {
alert('Failed to delete account. Please try again or contact support.');
}
}
</script>
{% endblock %}
@@ -0,0 +1,312 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tokens' %}
{% block title %}API Tokens - CmdForge Dashboard{% endblock %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">API Tokens</h1>
<p class="mt-1 text-gray-600">Manage tokens for CLI and API access</p>
</div>
<button type="button"
onclick="openCreateTokenModal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Create Token
</button>
</div>
{% endblock %}
{% block dashboard_content %}
<!-- Info Banner -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-blue-800">About API Tokens</h4>
<p class="mt-1 text-sm text-blue-700">
API tokens are used to authenticate with the CmdForge registry from the CLI.
Use <code class="px-1 bg-blue-100 rounded">cmdforge auth login</code> to authenticate,
or set the <code class="px-1 bg-blue-100 rounded">CMDFORGE_TOKEN</code> environment variable.
</p>
</div>
</div>
</div>
{% if tokens %}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<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">
Name
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Used
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</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 token in tokens %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-10 h-10 bg-cyan-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">{{ token.name }}</p>
<p class="text-xs text-gray-500 font-mono">st_...{{ token.token_suffix }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ token.created_at|date_format }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ token.last_used_at|timeago if token.last_used_at else 'Never' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if token.revoked_at %}
<span class="px-2 py-1 text-xs font-medium text-red-800 bg-red-100 rounded-full">
Revoked
</span>
{% else %}
<span class="px-2 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Active
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{% if not token.revoked_at %}
<button type="button"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
class="text-red-600 hover:text-red-900">
Revoke
</button>
{% else %}
<span class="text-gray-400">Revoked {{ token.revoked_at|timeago }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<!-- Empty State -->
<div class="bg-white rounded-lg border border-gray-200 text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No API tokens</h3>
<p class="mt-2 text-gray-600 max-w-sm mx-auto">
Create an API token to authenticate with the registry from the command line.
</p>
<button type="button"
onclick="openCreateTokenModal()"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Create Your First Token
</button>
</div>
{% endif %}
<!-- Create Token Modal -->
<div id="create-token-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeCreateTokenModal()"></div>
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<form id="create-token-form" onsubmit="createToken(event)">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900">Create API Token</h3>
<p class="mt-2 text-sm text-gray-600">
Give your token a descriptive name so you can identify it later.
</p>
<div class="mt-4">
<label for="token-name" class="block text-sm font-medium text-gray-700 mb-1">
Token name
</label>
<input type="text"
name="name"
id="token-name"
required
placeholder="e.g., Laptop CLI, CI/CD Pipeline"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeCreateTokenModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Create Token
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Token Created Modal (shows the token once) -->
<div id="token-created-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50"></div>
<div class="inline-block w-full max-w-lg my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<div class="p-6">
<div class="flex items-center mb-4">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h3 class="ml-3 text-lg font-semibold text-gray-900">Token Created</h3>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="ml-3 text-sm text-amber-800">
<strong>Copy your token now.</strong> You won't be able to see it again!
</p>
</div>
</div>
<div class="bg-gray-100 rounded-lg p-4">
<div class="flex items-center justify-between">
<code id="new-token-value" class="text-sm font-mono text-gray-800 break-all"></code>
<button type="button"
onclick="copyNewToken()"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 flex-shrink-0">
<svg id="copy-token-icon" 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg id="check-token-icon" class="w-5 h-5 hidden text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
<p class="mt-4 text-sm text-gray-600">
To use this token, run:
</p>
<pre class="mt-2 bg-gray-900 text-gray-100 text-sm p-3 rounded-lg overflow-x-auto"><code>export CMDFORGE_TOKEN="<span id="token-in-export"></span>"</code></pre>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end">
<button type="button"
onclick="closeTokenCreatedModal()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Done
</button>
</div>
</div>
</div>
</div>
<script>
function openCreateTokenModal() {
document.getElementById('create-token-modal').classList.remove('hidden');
document.getElementById('token-name').focus();
}
function closeCreateTokenModal() {
document.getElementById('create-token-modal').classList.add('hidden');
document.getElementById('token-name').value = '';
}
async function createToken(event) {
event.preventDefault();
const form = event.target;
const name = form.querySelector('[name="name"]').value;
try {
const response = await fetch('/dashboard/api/tokens', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (response.ok) {
const result = await response.json();
closeCreateTokenModal();
showNewToken(result.data.token);
} else {
const error = await response.json();
alert(error.error || 'Failed to create token');
}
} catch (err) {
alert('Failed to create token. Please try again.');
}
}
function showNewToken(token) {
document.getElementById('new-token-value').textContent = token;
document.getElementById('token-in-export').textContent = token;
document.getElementById('token-created-modal').classList.remove('hidden');
}
function closeTokenCreatedModal() {
document.getElementById('token-created-modal').classList.add('hidden');
window.location.reload();
}
function copyNewToken() {
const token = document.getElementById('new-token-value').textContent;
navigator.clipboard.writeText(token).then(() => {
document.getElementById('copy-token-icon').classList.add('hidden');
document.getElementById('check-token-icon').classList.remove('hidden');
setTimeout(() => {
document.getElementById('copy-token-icon').classList.remove('hidden');
document.getElementById('check-token-icon').classList.add('hidden');
}, 2000);
});
}
async function revokeToken(tokenId, tokenName) {
if (!confirm(`Are you sure you want to revoke "${tokenName}"? This cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/dashboard/api/tokens/${tokenId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert(error.error || 'Failed to revoke token');
}
} catch (err) {
alert('Failed to revoke token. Please try again.');
}
}
</script>
{% endblock %}
@@ -0,0 +1,259 @@
{% extends "dashboard/base.html" %}
{% set active_page = 'tools' %}
{% block title %}My Tools - CmdForge Dashboard{% endblock %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">My Tools</h1>
<p class="mt-1 text-gray-600">Manage your published tools</p>
</div>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Publish New Tool
</a>
</div>
{% endblock %}
{% block dashboard_content %}
{% if tools %}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<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">
Tool
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Version
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Downloads
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</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 tool in tools %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div class="ml-4">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="text-sm font-medium text-gray-900 hover:text-indigo-600">
{{ tool.name }}
</a>
<p class="text-sm text-gray-500 truncate max-w-xs">{{ tool.description or 'No description' }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-900">v{{ tool.version }}</span>
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ tool.downloads|format_number }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if tool.deprecated %}
<span class="px-2 py-1 text-xs font-medium text-amber-800 bg-amber-100 rounded-full">
Deprecated
</span>
{% else %}
<span class="px-2 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Active
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="text-indigo-600 hover:text-indigo-900">View</a>
<button type="button"
onclick="openDeprecateModal('{{ tool.owner }}', '{{ tool.name }}', {{ 'true' if tool.deprecated else 'false' }})"
class="text-gray-400 hover:text-amber-600">
{% if tool.deprecated %}Restore{% else %}Deprecate{% endif %}
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pending PRs Section -->
{% if pending_prs %}
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Pending Publications</h2>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<ul class="divide-y divide-gray-200">
{% for pr in pending_prs %}
<li class="px-6 py-4 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ pr.owner }}/{{ pr.name }} v{{ pr.version }}</p>
<p class="text-xs text-gray-500">Submitted {{ pr.created_at|timeago }}</p>
</div>
<div class="flex items-center gap-3">
<span class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
{{ pr.status }}
</span>
<a href="{{ pr.pr_url }}"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-indigo-600 hover:text-indigo-800">
View PR
</a>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% else %}
<!-- Empty State -->
<div class="bg-white rounded-lg border border-gray-200 text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No tools yet</h3>
<p class="mt-2 text-gray-600 max-w-sm mx-auto">
You haven't published any tools. Create your first tool and share it with the community.
</p>
<a href="{{ url_for('web.docs', path='publishing') }}"
class="mt-6 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Learn How to Publish
</a>
</div>
{% endif %}
<!-- Deprecate Modal -->
<div id="deprecate-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeDeprecateModal()"></div>
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<form id="deprecate-form" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="owner" id="deprecate-owner">
<input type="hidden" name="name" id="deprecate-name">
<div class="p-6">
<h3 id="deprecate-title" class="text-lg font-semibold text-gray-900">Deprecate Tool</h3>
<p id="deprecate-desc" class="mt-2 text-sm text-gray-600">
Mark this tool as deprecated. Users will see a warning when viewing or installing it.
</p>
<div id="deprecate-fields" class="mt-4 space-y-4">
<div>
<label for="deprecated_message" class="block text-sm font-medium text-gray-700 mb-1">
Deprecation message (optional)
</label>
<textarea name="deprecated_message"
id="deprecated_message"
rows="2"
placeholder="e.g., This tool has been superseded by..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"></textarea>
</div>
<div>
<label for="replacement" class="block text-sm font-medium text-gray-700 mb-1">
Replacement tool (optional)
</label>
<input type="text"
name="replacement"
id="replacement"
placeholder="owner/tool-name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeDeprecateModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="submit"
id="deprecate-submit"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-md hover:bg-amber-700">
Deprecate
</button>
</div>
</form>
</div>
</div>
</div>
<script>
let deprecateAction = '';
function openDeprecateModal(owner, name, isDeprecated) {
document.getElementById('deprecate-owner').value = owner;
document.getElementById('deprecate-name').value = name;
if (isDeprecated) {
document.getElementById('deprecate-title').textContent = 'Restore Tool';
document.getElementById('deprecate-desc').textContent = 'Remove the deprecation notice from this tool.';
document.getElementById('deprecate-fields').classList.add('hidden');
document.getElementById('deprecate-submit').textContent = 'Restore';
document.getElementById('deprecate-submit').classList.remove('bg-amber-600', 'hover:bg-amber-700');
document.getElementById('deprecate-submit').classList.add('bg-green-600', 'hover:bg-green-700');
deprecateAction = `/dashboard/api/tools/${owner}/${name}/undeprecate`;
} else {
document.getElementById('deprecate-title').textContent = 'Deprecate Tool';
document.getElementById('deprecate-desc').textContent = 'Mark this tool as deprecated. Users will see a warning.';
document.getElementById('deprecate-fields').classList.remove('hidden');
document.getElementById('deprecate-submit').textContent = 'Deprecate';
document.getElementById('deprecate-submit').classList.remove('bg-green-600', 'hover:bg-green-700');
document.getElementById('deprecate-submit').classList.add('bg-amber-600', 'hover:bg-amber-700');
deprecateAction = `/dashboard/api/tools/${owner}/${name}/deprecate`;
}
document.getElementById('deprecate-modal').classList.remove('hidden');
}
function closeDeprecateModal() {
document.getElementById('deprecate-modal').classList.add('hidden');
}
document.getElementById('deprecate-form').addEventListener('submit', async function(e) {
e.preventDefault();
const message = document.getElementById('deprecated_message')?.value || '';
const replacement = document.getElementById('replacement')?.value || '';
try {
const response = await fetch(deprecateAction, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({deprecated_message: message, replacement: replacement})
});
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert((error.error && error.error.message) || 'Failed to update tool status');
}
} catch (err) {
alert('Failed to update tool status. Please try again.');
}
});
</script>
{% endblock %}