← back to Designer Wallcoverings
DW-Agents/dw-agents/agent-skills-manager/skills-manager-agent.ts.backup-oauth-20251112
1255 lines
import cookieParser from "cookie-parser";
/**
* DW-Agents: Skills Manager
*
* Central dashboard for managing and monitoring Claude Code skills:
* - Lists all available skills from /root/.claude/skills/
* - Shows skill metadata (name, description, capabilities)
* - Displays activation status (On/Off based on cron jobs)
* - Shows last activation time from logs
* - Sortable by name, status, or last activation date
* - Real-time monitoring of skill usage
*
* Port: 9894
*/
import express, { Request, Response } from 'express';
import session from 'express-session';
import * as fs from 'fs';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { chatMiddleware } from '../shared-chat-integration';
const execAsync = promisify(exec);
const app = express();
// No authentication required for Skills Manager
const PORT = 9894;
app.use(cookieParser());
// Add inter-agent chat widget
app.use(chatMiddleware({
agentId: 'agent-skills-manager',
agentName: 'Skills Manager',
port: 9894,
category: 'agent'
}));
// Authentication
const AUTH_USERNAME = 'admin2025';
const AUTH_PASSWORD = 'Otis';
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session configuration
declare module 'express-session' {
interface SessionData {
authenticated: boolean;
}
}
app.use(
session({
secret: 'dw-agents-skills-manager-2025',
resave: true,
saveUninitialized: false,
rolling: true,
cookie: {
secure: false,
httpOnly: true,
maxAge: 4 * 60 * 60 * 1000, // 4 hours
sameSite: 'lax'
},
})
);
// Authentication middleware
const requireAuth = (req: Request, res: Response, next: any) => {
if (req.session.authenticated) {
return next();
}
res.redirect('/login');
};
// Skill interface
interface Skill {
id: string;
name: string;
description: string;
version?: string;
capabilities?: string[];
triggers?: string[];
status: 'active' | 'inactive' | 'scheduled';
lastActivated?: Date | null;
cronSchedules?: Array<{
name: string;
schedule: string;
description: string;
}>;
location: string;
hasDocumentation: boolean;
}
// Scan skills directory
// Load previous records
function loadRecords(): any {
const recordsPath = path.join(__dirname, 'skills-records.json');
try {
if (fs.existsSync(recordsPath)) {
return JSON.parse(fs.readFileSync(recordsPath, 'utf8'));
}
} catch (error) {
console.error('Error loading records:', error);
}
return { lastUpdated: new Date().toISOString(), skills: [] };
}
// Save records
function saveRecords(skills: Skill[]) {
const recordsPath = path.join(__dirname, 'skills-records.json');
try {
const records = {
lastUpdated: new Date().toISOString(),
skills: skills.map(s => ({
id: s.id,
name: s.name,
status: s.status,
lastActivated: s.lastActivated,
lastSeen: new Date().toISOString()
}))
};
fs.writeFileSync(recordsPath, JSON.stringify(records, null, 2));
} catch (error) {
console.error('Error saving records:', error);
}
}
async function scanSkills(): Promise<Skill[]> {
const skillsDir = '/root/.claude/skills';
const skills: Skill[] = [];
const previousRecords = loadRecords();
try {
const directories = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
for (const dir of directories) {
const skillPath = path.join(skillsDir, dir);
const skill: Skill = {
id: dir,
name: dir,
description: 'No description available',
status: 'inactive',
location: skillPath,
hasDocumentation: false,
lastActivated: null
};
// Try to read skill.json
const skillJsonPath = path.join(skillPath, 'skill.json');
if (fs.existsSync(skillJsonPath)) {
try {
const skillJson = JSON.parse(fs.readFileSync(skillJsonPath, 'utf8'));
skill.name = skillJson.name || dir;
skill.description = skillJson.description || skill.description;
skill.version = skillJson.version;
skill.capabilities = skillJson.capabilities;
skill.triggers = skillJson.triggers;
skill.cronSchedules = skillJson.cron_schedules;
} catch (error) {
console.error(`Error reading skill.json for ${dir}:`, error);
}
} else {
// Try to read SKILL.md for description
const skillMdPath = path.join(skillPath, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
try {
const skillMd = fs.readFileSync(skillMdPath, 'utf8');
// Parse YAML frontmatter
const yamlMatch = skillMd.match(/^---\n([\s\S]*?)\n---/);
if (yamlMatch) {
const yaml = yamlMatch[1];
const nameMatch = yaml.match(/name:\s*(.+)/);
const descMatch = yaml.match(/description:\s*(.+(?:\n(?![\w-]+:).+)*)/);
if (nameMatch) {
skill.name = nameMatch[1].trim();
}
if (descMatch) {
skill.description = descMatch[1].trim().replace(/\n\s*/g, ' ');
}
}
skill.hasDocumentation = true;
} catch (error) {
console.error(`Error reading SKILL.md for ${dir}:`, error);
}
}
}
// Check if skill has cron jobs (scheduled = active)
if (skill.cronSchedules && skill.cronSchedules.length > 0) {
skill.status = 'scheduled';
// Skip syslog check - causes timeouts on large log files
}
// Check documentation files
const docFiles = ['SKILL.md', 'README.md', 'skill.json'];
skill.hasDocumentation = docFiles.some(file =>
fs.existsSync(path.join(skillPath, file))
);
// Restore previous data if available
const previousRecord = previousRecords.skills.find((r: any) => r.id === dir);
if (previousRecord) {
if (previousRecord.lastActivated && !skill.lastActivated) {
skill.lastActivated = new Date(previousRecord.lastActivated);
}
if (previousRecord.status && skill.status === 'inactive') {
skill.status = previousRecord.status;
}
}
skills.push(skill);
}
// Save records for persistence
saveRecords(skills);
} catch (error) {
console.error('Error scanning skills:', error);
}
return skills;
}
// Login page
app.get('/login', (req: Request, res: Response) => {
if (req.session.authenticated) {
return res.redirect('/');
}
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skills Manager - Login</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
padding: 3rem;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
margin-bottom: 2rem;
}
.logo-icon {
font-size: 4rem;
margin-bottom: 0.5rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
text-align: center;
margin-bottom: 0.5rem;
}
.subtitle {
color: #718096;
text-align: center;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
color: #4a5568;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 1rem;
transition: all 0.3s;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
.error {
background: #fed7d7;
color: #c53030;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
text-align: center;
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<div class="logo-icon">🎯</div>
<h1>Skills Manager</h1>
<p class="subtitle">Claude Code Skills Dashboard</p>
</div>
${req.query.error ? '<div class="error">Invalid username or password</div>' : ''}
<form method="POST" action="/login">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required autocomplete="current-password">
</div>
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
`);
});
// Login handler
app.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body;
if (username === AUTH_USERNAME && password === AUTH_PASSWORD) {
req.session.authenticated = true;
res.redirect('/');
} else {
res.redirect('/login?error=1');
}
});
// Logout handler
app.post('/logout', (req: Request, res: Response) => {
req.session.destroy((err) => {
res.redirect('/login');
});
});
// Main dashboard
app.get('/', async (req: Request, res: Response) => {
// Disable caching
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skills Manager - DW-Agents</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
color: #36454f;
}
.header {
background: linear-gradient(135deg, #36454f 0%, #708090 100%);
color: white;
padding: 2rem;
box-shadow: 0 2px 4px rgba(54, 69, 79, 0.15);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.logo {
font-size: 2.5rem;
}
.header-title h1 {
font-size: 1.75rem;
margin-bottom: 0.25rem;
}
.header-subtitle {
opacity: 0.9;
font-size: 0.9rem;
}
.logout-btn {
background: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.3);
padding: 0.5rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.logout-btn:hover {
background: rgba(255,255,255,0.3);
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
font-size: 2.5rem;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
}
.stat-icon.purple { background: #e8e8e8; }
.stat-icon.green { background: #d3d3d3; }
.stat-icon.blue { background: #e8e8e8; }
.stat-icon.orange { background: #d3d3d3; }
.stat-content h3 {
font-size: 2rem;
color: #36454f;
margin-bottom: 0.25rem;
}
.stat-content p {
color: #708090;
font-size: 0.9rem;
}
.controls {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 2rem;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 300px;
}
.search-box input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
}
.search-box input:focus {
outline: none;
border-color: #667eea;
}
.filter-buttons {
display: flex;
gap: 0.5rem;
}
.filter-btn {
padding: 0.5rem 1rem;
border: 2px solid #e2e8f0;
background: white;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.filter-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.filter-btn:hover {
border-color: #667eea;
}
.refresh-btn {
padding: 0.5rem 1.5rem;
background: #48bb78;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.refresh-btn:hover {
background: #38a169;
}
.skills-table {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: #f7fafc;
}
th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
cursor: pointer;
user-select: none;
}
th:hover {
background: #edf2f7;
}
th .sort-indicator {
margin-left: 0.5rem;
opacity: 0.5;
}
td {
padding: 1rem;
border-bottom: 1px solid #f0f0f0;
}
tr:hover {
background: #f7fafc;
}
.skill-name {
font-weight: 600;
color: #2d3748;
display: flex;
align-items: center;
gap: 0.5rem;
}
.skill-icon {
font-size: 1.5rem;
}
.skill-description {
color: #718096;
font-size: 0.9rem;
max-width: 500px;
line-height: 1.4;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
display: inline-block;
}
.status-badge.active {
background: #d1fae5;
color: #047857;
}
.status-badge.scheduled {
background: #dbeafe;
color: #1e40af;
}
.status-badge.inactive {
background: #f3f4f6;
color: #6b7280;
}
.last-activated {
color: #718096;
font-size: 0.9rem;
}
.capabilities-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.capability-tag {
background: #f3f0ff;
color: #667eea;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.cron-badge {
background: #dbeafe;
color: #1e40af;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
}
.start-btn {
background: #10b981;
color: white;
}
.start-btn:hover {
background: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.stop-btn {
background: #ef4444;
color: white;
}
.stop-btn:hover {
background: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 3rem;
color: #718096;
}
.spinner {
border: 3px solid #f3f4f6;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 3rem;
color: #718096;
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 1rem;
}
.controls {
flex-direction: column;
}
.search-box {
width: 100%;
}
table {
font-size: 0.9rem;
}
td, th {
padding: 0.75rem 0.5rem;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div class="header-left">
<div class="logo">🎯</div>
<div class="header-title">
<h1>Skills Manager</h1>
<p class="header-subtitle">Claude Code Skills Dashboard • Manual refresh only (auto-refresh disabled)</p>
</div>
</div>
<form method="POST" action="/logout">
<button type="submit" class="logout-btn">Logout</button>
</form>
</div>
</div>
<div class="container">
<div class="stats-grid" id="stats">
<div class="stat-card">
<div class="stat-icon purple">📦</div>
<div class="stat-content">
<h3 id="total-skills">-</h3>
<p>Total Skills</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green">✅</div>
<div class="stat-content">
<h3 id="scheduled-skills">-</h3>
<p>Scheduled</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon blue">📋</div>
<div class="stat-content">
<h3 id="documented-skills">-</h3>
<p>Documented</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange">🕐</div>
<div class="stat-content">
<h3 id="inactive-skills">-</h3>
<p>Inactive</p>
</div>
</div>
</div>
<div class="resources-section" style="margin: 30px 0; padding: 25px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 15px; box-shadow: 0 10px 30px rgba(0,0,0,0.2);">
<h2 style="color: white; margin: 0 0 20px 0; font-size: 1.8em; display: flex; align-items: center; gap: 10px;">
<span>🎨</span> Useful Resources
</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">
<a href="https://21st.dev/community/components" target="_blank" style="display: block; padding: 20px; background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border-radius: 12px; text-decoration: none; color: white; transition: all 0.3s ease; border: 1px solid rgba(255,255,255,0.2);" onmouseover="this.style.background='rgba(255,255,255,0.25)'; this.style.transform='translateY(-5px)'; this.style.boxShadow='0 15px 40px rgba(0,0,0,0.3)';" onmouseout="this.style.background='rgba(255,255,255,0.15)'; this.style.transform='translateY(0)'; this.style.boxShadow='none';">
<div style="font-size: 2em; margin-bottom: 10px;">🧩</div>
<h3 style="margin: 0 0 10px 0; font-size: 1.3em;">21st.dev Components</h3>
<p style="margin: 0; opacity: 0.9; font-size: 0.95em; line-height: 1.5;">Modern UI component library with React, Tailwind, and shadcn/ui examples. Perfect for building beautiful interfaces.</p>
</a>
</div>
</div>
<div class="controls">
<div class="search-box">
<input type="text" id="search" placeholder="🔍 Search skills by name or description...">
</div>
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="scheduled">Scheduled</button>
<button class="filter-btn" data-filter="inactive">Inactive</button>
</div>
<button class="refresh-btn" onclick="loadSkills()">🔄 Refresh</button>
</div>
<div class="skills-table">
<table>
<thead>
<tr>
<th onclick="sortTable('name')">Skill Name <span class="sort-indicator">↕</span></th>
<th onclick="sortTable('description')">Description</th>
<th onclick="sortTable('status')">Status <span class="sort-indicator">↕</span></th>
<th onclick="sortTable('lastActivated')">Last Activated <span class="sort-indicator">↕</span></th>
<th>Actions</th>
</tr>
</thead>
<tbody id="skills-body">
<tr>
<td colspan="5">
<div class="loading">
<div class="spinner"></div>
<p>Loading skills...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
let allSkills = [];
let currentFilter = 'all';
let sortColumn = 'name';
let sortDirection = 'asc';
// Load skills data
async function loadSkills() {
try {
const response = await fetch('/api/skills');
allSkills = await response.json();
updateStats();
renderSkills();
} catch (error) {
console.error('Error loading skills:', error);
document.getElementById('skills-body').innerHTML = \`
<tr>
<td colspan="4">
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<p>Error loading skills. Please try again.</p>
</div>
</td>
</tr>
\`;
}
}
// Update statistics
function updateStats() {
const total = allSkills.length;
const scheduled = allSkills.filter(s => s.status === 'scheduled').length;
const documented = allSkills.filter(s => s.hasDocumentation).length;
const inactive = allSkills.filter(s => s.status === 'inactive').length;
document.getElementById('total-skills').textContent = total;
document.getElementById('scheduled-skills').textContent = scheduled;
document.getElementById('documented-skills').textContent = documented;
document.getElementById('inactive-skills').textContent = inactive;
}
// Render skills table
function renderSkills() {
const searchTerm = document.getElementById('search').value.toLowerCase();
let filtered = allSkills.filter(skill => {
const matchesSearch = skill.name.toLowerCase().includes(searchTerm) ||
skill.description.toLowerCase().includes(searchTerm);
const matchesFilter = currentFilter === 'all' || skill.status === currentFilter;
return matchesSearch && matchesFilter;
});
// Sort: Recently used first, then alphabetically
filtered.sort((a, b) => {
// If sorting by a specific column, use that
if (sortColumn !== 'name' && sortColumn !== 'lastActivated') {
let aVal = a[sortColumn];
let bVal = b[sortColumn];
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
}
// Default sort: Recently used first, then alphabetically
const aTime = a.lastActivated ? new Date(a.lastActivated).getTime() : 0;
const bTime = b.lastActivated ? new Date(b.lastActivated).getTime() : 0;
// If one has lastActivated and the other doesn't, prioritize the one with it
if (aTime > 0 && bTime === 0) return -1;
if (aTime === 0 && bTime > 0) return 1;
// If both have lastActivated, sort by most recent first
if (aTime > 0 && bTime > 0) {
return bTime - aTime; // Descending (most recent first)
}
// If neither has lastActivated (or equal times), sort alphabetically
return a.name.localeCompare(b.name);
});
const tbody = document.getElementById('skills-body');
if (filtered.length === 0) {
tbody.innerHTML = \`
<tr>
<td colspan="5">
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<p>No skills found matching your criteria.</p>
</div>
</td>
</tr>
\`;
return;
}
tbody.innerHTML = filtered.map(skill => {
let lastActivated = '<span style="color: #cbd5e0;">Never</span>';
let usageDetails = '';
if (skill.lastActivated) {
const timeAgo = formatDate(new Date(skill.lastActivated));
lastActivated = \`<a href="#" onclick="showUsageDetails('\${skill.id}'); return false;" style="color: #36454f; text-decoration: underline;">\${timeAgo}</a>\`;
if (skill.lastUsage) {
usageDetails = \`
<div id="usage-\${skill.id}" style="display: none; margin-top: 0.5rem; padding: 0.75rem; background: #f5f5f5; border-radius: 6px; font-size: 0.85rem;">
<div style="margin-bottom: 0.5rem;"><strong>📅 Time:</strong> \${skill.lastUsage.readableTime || new Date(skill.lastActivated).toLocaleString()}</div>
<div style="margin-bottom: 0.5rem;"><strong>📝 Usage:</strong> \${skill.lastUsage.description || 'Skill executed'}</div>
<div><strong>📍 Where:</strong> \${skill.lastUsage.location || 'Manual execution'}</div>
</div>
\`;
}
}
const capabilities = skill.capabilities
? \`<div class="capabilities-list">\${skill.capabilities.slice(0, 3).map(cap =>
\`<span class="capability-tag">\${cap}</span>\`
).join('')}\${skill.capabilities.length > 3 ? \`<span class="capability-tag">+\${skill.capabilities.length - 3} more</span>\` : ''}</div>\`
: '';
const cronBadges = skill.cronSchedules
? \`<div class="capabilities-list" style="margin-top: 0.5rem;">\${skill.cronSchedules.map(cron =>
\`<span class="cron-badge" title="\${cron.description}">\${cron.schedule}</span>\`
).join('')}</div>\`
: '';
const isRunning = skill.status === 'scheduled' || skill.status === 'active';
const actionButtons = \`
<div style="display: flex; gap: 0.5rem; justify-content: center;">
<button
class="action-btn \${isRunning ? 'stop-btn' : 'start-btn'}"
onclick="toggleSkill('\${skill.id}', \${isRunning})"
style="padding: 0.25rem 0.75rem; border-radius: 4px; border: none; cursor: pointer; font-size: 0.875rem; font-weight: 500; transition: all 0.2s;">
\${isRunning ? '⏸️ Stop' : '▶️ Start'}
</button>
</div>
\`;
return \`
<tr>
<td>
<div class="skill-name">
<span class="skill-icon">🎯</span>
<div>
<div>\${skill.name}</div>
\${skill.version ? \`<small style="color: #a0aec0;">v\${skill.version}</small>\` : ''}
</div>
</div>
</td>
<td>
<div class="skill-description">\${skill.description}</div>
\${capabilities}
\${cronBadges}
</td>
<td>
<span class="status-badge \${skill.status}">\${skill.status}</span>
</td>
<td>
<div class="last-activated">\${lastActivated}</div>
\${usageDetails}
</td>
<td>
\${actionButtons}
</td>
</tr>
\`;
}).join('');
}
// Sort table
function sortTable(column) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
renderSkills();
}
// Format date
function formatDate(date) {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60));
return \`\${minutes} minute\${minutes !== 1 ? 's' : ''} ago\`;
}
return \`\${hours} hour\${hours !== 1 ? 's' : ''} ago\`;
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return \`\${days} days ago\`;
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
}
// Search handler
document.getElementById('search').addEventListener('input', renderSkills);
// Filter handlers
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
currentFilter = e.target.dataset.filter;
renderSkills();
});
});
// Show/hide usage details
function showUsageDetails(skillId) {
const detailsEl = document.getElementById(\`usage-\${skillId}\`);
if (detailsEl) {
detailsEl.style.display = detailsEl.style.display === 'none' ? 'block' : 'none';
}
}
// Toggle skill (start/stop)
async function toggleSkill(skillId, isRunning) {
try {
const action = isRunning ? 'stop' : 'start';
const response = await fetch(\`/api/skills/\${skillId}/\${action}\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const result = await response.json();
alert(\`Skill \${action}ed successfully: \${result.message}\`);
loadSkills(); // Reload to show updated status
} else {
const error = await response.json();
alert(\`Failed to \${action} skill: \${error.message}\`);
}
} catch (error) {
console.error('Error toggling skill:', error);
alert('Failed to toggle skill. Please try again.');
}
}
// Initial load
loadSkills();
// Auto-refresh DISABLED to prevent interference with product-import-from-url
// (was causing issues when updating skills records)
</script>
</body>
</html>
`);
});
// API endpoint to get skills data
app.get('/api/skills', async (req: Request, res: Response) => {
try {
const skills = await scanSkills();
res.json(skills);
} catch (error) {
console.error('Error getting skills:', error);
res.status(500).json({ error: 'Failed to load skills' });
}
});
// API endpoint to start a skill
app.post('/api/skills/:id/start', async (req: Request, res: Response) => {
try {
const skillId = req.params.id;
const skillPath = path.join(CLAUDE_SKILLS_DIR, skillId);
if (!fs.existsSync(skillPath)) {
return res.status(404).json({ error: 'Skill not found' });
}
// Load skill configuration
const skillJsonPath = path.join(skillPath, 'skill.json');
if (!fs.existsSync(skillJsonPath)) {
return res.status(400).json({ error: 'Skill configuration not found' });
}
const skillConfig = JSON.parse(fs.readFileSync(skillJsonPath, 'utf8'));
// Update status to active
skillConfig.status = 'active';
fs.writeFileSync(skillJsonPath, JSON.stringify(skillConfig, null, 2));
// Update records
const records = loadRecords();
const skillRecord = records.skills.find((s: any) => s.id === skillId);
if (skillRecord) {
skillRecord.status = 'active';
skillRecord.lastActivated = new Date().toISOString();
saveRecords(records.skills);
}
res.json({
success: true,
message: `Skill "${skillConfig.name}" started successfully`,
skillId,
status: 'active'
});
} catch (error) {
console.error('Error starting skill:', error);
res.status(500).json({ error: 'Failed to start skill' });
}
});
// API endpoint to stop a skill
app.post('/api/skills/:id/stop', async (req: Request, res: Response) => {
try {
const skillId = req.params.id;
const skillPath = path.join(CLAUDE_SKILLS_DIR, skillId);
if (!fs.existsSync(skillPath)) {
return res.status(404).json({ error: 'Skill not found' });
}
// Load skill configuration
const skillJsonPath = path.join(skillPath, 'skill.json');
if (!fs.existsSync(skillJsonPath)) {
return res.status(400).json({ error: 'Skill configuration not found' });
}
const skillConfig = JSON.parse(fs.readFileSync(skillJsonPath, 'utf8'));
// Update status to inactive
skillConfig.status = 'inactive';
fs.writeFileSync(skillJsonPath, JSON.stringify(skillConfig, null, 2));
// Update records
const records = loadRecords();
const skillRecord = records.skills.find((s: any) => s.id === skillId);
if (skillRecord) {
skillRecord.status = 'inactive';
saveRecords(records.skills);
}
res.json({
success: true,
message: `Skill "${skillConfig.name}" stopped successfully`,
skillId,
status: 'inactive'
});
} catch (error) {
console.error('Error stopping skill:', error);
res.status(500).json({ error: 'Failed to stop skill' });
}
});
// Start server
app.listen(PORT, () => {
console.log(`🎯 Skills Manager running on port ${PORT}`);
console.log(`📊 Dashboard: http://45.61.58.125:${PORT}`);
console.log(`🔐 Login: admin2025 / Otis`);
});