← back to Designer Wallcoverings
DW-Agents/dw-agents/agent-skills-manager/skills-manager-agent.ts
1847 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 { requireGlobalAuth } from '../shared-global-auth';
import session from 'express-session';
import * as fs from 'fs';
import * as path from 'path';
import { exec, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { chatMiddleware } from '../shared-chat-integration';
// Get __dirname equivalent for ESM modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const execAsync = promisify(exec);
// Minimal zero-dependency .env loader (this codebase doesn't use dotenv).
// Reads KEY=VALUE lines from a gitignored .env next to this file so secrets
// (e.g. SKILLS_AUTH_PASS) stay out of git. Anything already in process.env wins.
(function loadDotenv() {
try {
const envPath = path.join(__dirname, '.env');
if (!fs.existsSync(envPath)) return;
for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key && process.env[key] === undefined) process.env[key] = val;
}
} catch { /* ignore malformed .env */ }
})();
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 — env-first; the password is NEVER hardcoded (it lives in the
// gitignored .env next to this file, or in the host/pm2 env). Username falls
// back to a non-secret default. If SKILLS_AUTH_PASS is unset, login is disabled.
const AUTH_USERNAME = process.env.SKILLS_AUTH_USER || 'admin2025';
const AUTH_PASSWORD = process.env.SKILLS_AUTH_PASS || '';
if (!AUTH_PASSWORD) {
console.warn('[skills-manager] ⚠ SKILLS_AUTH_PASS not set — login DISABLED. Set it in the gitignored .env beside this file (or pm2 env).');
}
// Resolve the skills dir per-machine: Kamatera uses /root/.claude/skills,
// a Mac dev box uses ~/.claude/skills. (The old value '/root/.skills' was a
// bug — it never matched scanSkills() which reads /root/.claude/skills.)
const CLAUDE_SKILLS_DIR = fs.existsSync('/root/.claude/skills')
? '/root/.claude/skills'
: path.join(process.env.HOME || '/root', '.claude', 'skills');
// 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 (AUTH_PASSWORD && 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;
}
.results-btn {
background: #64748b;
color: white;
}
.results-btn:hover {
background: #475569;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
/* Inline run-output panel rendered directly below a skill row */
.output-row > td {
padding: 0 1rem 1rem 1rem;
background: #f7fafc;
border-bottom: 1px solid #f0f0f0;
}
.run-panel {
background: #0f172a;
border-radius: 10px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06);
}
.run-panel-head {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
background: #1e293b;
color: #cbd5e1;
font-size: 0.8rem;
}
.run-panel-head .run-entry {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #93c5fd;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-status-pill {
font-weight: 600;
font-size: 0.78rem;
white-space: nowrap;
}
.run-panel-head .panel-x {
cursor: pointer;
color: #94a3b8;
border: none;
background: transparent;
font-size: 1rem;
line-height: 1;
}
.run-panel-head .panel-x:hover { color: #e2e8f0; }
.run-output {
margin: 0;
padding: 0.85rem 0.9rem;
max-height: 360px;
overflow: auto;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.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';
// Run-execution state (client side)
let liveStatus = {}; // { [skillId]: 'running'|'done'|'error'|'killed'|'idle' }
let openPanels = new Set(); // skillIds whose output panel is expanded
let pollers = {}; // { [skillId]: intervalId }
// Load skills data
async function loadSkills() {
try {
const response = await fetch('/api/skills');
allSkills = await response.json();
// Seed live run-state so buttons restore correctly after a refresh
try {
const runningResp = await fetch('/api/skills/running');
const runningData = await runningResp.json();
(runningData.running || []).forEach(id => { liveStatus[id] = 'running'; });
} catch (e) { /* non-fatal */ }
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>\`
: '';
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 id="action-\${skill.id}">
\${actionBtnHtml(skill.id, liveStatus[skill.id] || 'idle')}
</td>
</tr>
<tr class="output-row" id="outrow-\${skill.id}" style="display:none;">
<td colspan="5">
<div class="run-panel">
<div class="run-panel-head">
<span class="run-status-pill" id="runstatus-\${skill.id}">idle</span>
<span class="run-entry" id="runentry-\${skill.id}"></span>
<button class="panel-x" title="Hide output" onclick="hidePanel('\${skill.id}')">✕</button>
</div>
<pre class="run-output" id="output-\${skill.id}"></pre>
</div>
</td>
</tr>
\`;
}).join('');
// Re-apply any panels the user had open, and re-attach pollers for
// runs still in flight (renderSkills rebuilds the whole tbody).
openPanels.forEach(id => {
const row = document.getElementById(\`outrow-\${id}\`);
if (row) { row.style.display = ''; fetchOnce(id); }
});
Object.keys(liveStatus).forEach(id => {
if (liveStatus[id] === 'running' && !pollers[id]) startPolling(id);
});
}
// 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';
}
}
// ── Run / Stop / inline output ──────────────────────────────────────
// Build the Actions cell markup for a given run status
function actionBtnHtml(id, status) {
const running = status === 'running';
const runBtn = running
? \`<button id="btn-\${id}" class="action-btn stop-btn" onclick="stopSkill('\${id}')" style="padding:0.25rem 0.75rem;border-radius:4px;border:none;cursor:pointer;font-size:0.875rem;font-weight:500;transition:all 0.2s;">⏸️ Stop</button>\`
: \`<button id="btn-\${id}" class="action-btn start-btn" onclick="runSkill('\${id}')" style="padding:0.25rem 0.75rem;border-radius:4px;border:none;cursor:pointer;font-size:0.875rem;font-weight:500;transition:all 0.2s;">▶️ Run</button>\`;
const resultsBtn = \`<button class="action-btn results-btn" onclick="togglePanel('\${id}')" style="padding:0.25rem 0.75rem;border-radius:4px;border:none;cursor:pointer;font-size:0.875rem;font-weight:500;transition:all 0.2s;">📄 Results</button>\`;
return \`<div style="display:flex;gap:0.5rem;justify-content:center;">\${runBtn}\${resultsBtn}</div>\`;
}
function updateActionButton(id) {
const cell = document.getElementById(\`action-\${id}\`);
if (cell) cell.innerHTML = actionBtnHtml(id, liveStatus[id] || 'idle');
}
function showPanel(id) {
const row = document.getElementById(\`outrow-\${id}\`);
if (row) row.style.display = '';
openPanels.add(id);
}
function hidePanel(id) {
const row = document.getElementById(\`outrow-\${id}\`);
if (row) row.style.display = 'none';
openPanels.delete(id);
}
function togglePanel(id) {
const row = document.getElementById(\`outrow-\${id}\`);
if (!row) return;
if (row.style.display === 'none') { showPanel(id); fetchOnce(id); }
else hidePanel(id);
}
function setPanelText(id, text) {
const pre = document.getElementById(\`output-\${id}\`);
if (pre) pre.textContent = text;
}
function setPanelOutput(id, j) {
const pre = document.getElementById(\`output-\${id}\`);
if (pre) {
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
pre.textContent = j.output || '';
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
const pill = document.getElementById(\`runstatus-\${id}\`);
if (pill) {
let label = j.status, color = '#94a3b8';
if (j.status === 'running') { label = '● running'; color = '#60a5fa'; }
else if (j.status === 'done') { label = '✓ done (exit 0)'; color = '#34d399'; }
else if (j.status === 'error') { label = \`✗ error\${j.exitCode != null ? ' (exit ' + j.exitCode + ')' : ''}\`; color = '#f87171'; }
else if (j.status === 'killed') { label = '■ stopped'; color = '#fbbf24'; }
else if (j.status === 'idle') { label = 'idle'; color = '#94a3b8'; }
pill.textContent = label;
pill.style.color = color;
}
const entryEl = document.getElementById(\`runentry-\${id}\`);
if (entryEl && j.entry) entryEl.textContent = j.entry;
}
async function fetchOnce(id) {
try {
const r = await fetch(\`/api/skills/\${id}/output\`);
const j = await r.json();
setPanelOutput(id, j);
} catch (e) { /* ignore */ }
}
function startPolling(id) {
if (pollers[id]) clearInterval(pollers[id]);
const tick = async () => {
try {
const r = await fetch(\`/api/skills/\${id}/output\`);
const j = await r.json();
liveStatus[id] = j.status;
setPanelOutput(id, j);
updateActionButton(id);
if (j.status !== 'running') {
clearInterval(pollers[id]);
delete pollers[id];
}
} catch (e) { /* transient */ }
};
tick();
pollers[id] = setInterval(tick, 1500);
}
async function runSkill(id) {
liveStatus[id] = 'running';
openPanels.add(id);
showPanel(id);
setPanelText(id, 'Starting…');
updateActionButton(id);
try {
const r = await fetch(\`/api/skills/\${id}/run\`, { method: 'POST' });
const j = await r.json();
if (!r.ok) {
liveStatus[id] = 'error';
setPanelText(id, '✗ ' + (j.error || 'Failed to start'));
const pill = document.getElementById(\`runstatus-\${id}\`);
if (pill) { pill.textContent = '✗ error'; pill.style.color = '#f87171'; }
updateActionButton(id);
return;
}
const entryEl = document.getElementById(\`runentry-\${id}\`);
if (entryEl && j.entry) entryEl.textContent = j.entry;
} catch (e) {
liveStatus[id] = 'error';
setPanelText(id, '✗ ' + e.message);
updateActionButton(id);
return;
}
updateActionButton(id);
startPolling(id);
}
async function stopSkill(id) {
try {
await fetch(\`/api/skills/\${id}/kill\`, { method: 'POST' });
} catch (e) { /* poller will reflect final state */ }
// Force an immediate refresh of state/output
if (pollers[id]) { clearInterval(pollers[id]); delete pollers[id]; }
startPolling(id);
}
// 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' });
}
});
// Webhook endpoints for external triggers
app.post('/webhook/skill/start/:id', 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);
}
console.log(`[WEBHOOK] Skill "${skillConfig.name}" started via webhook`);
res.json({
success: true,
message: `Skill "${skillConfig.name}" started via webhook`,
skillId,
status: 'active',
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[WEBHOOK] Error starting skill:', error);
res.status(500).json({ error: 'Failed to start skill via webhook' });
}
});
// Webhook to start all skills
app.post('/webhook/skills/start-all', async (req: Request, res: Response) => {
try {
const records = loadRecords();
const results = [];
for (const skill of records.skills) {
try {
const skillPath = path.join(CLAUDE_SKILLS_DIR, skill.id);
const skillJsonPath = path.join(skillPath, 'skill.json');
if (fs.existsSync(skillJsonPath)) {
const skillConfig = JSON.parse(fs.readFileSync(skillJsonPath, 'utf8'));
skillConfig.status = 'active';
fs.writeFileSync(skillJsonPath, JSON.stringify(skillConfig, null, 2));
skill.status = 'active';
skill.lastActivated = new Date().toISOString();
results.push({
id: skill.id,
name: skillConfig.name,
status: 'started'
});
}
} catch (err) {
results.push({
id: skill.id,
status: 'failed',
error: err.message
});
}
}
saveRecords(records.skills);
console.log(`[WEBHOOK] Started ${results.filter(r => r.status === 'started').length} skills`);
res.json({
success: true,
message: 'Bulk start completed',
results,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[WEBHOOK] Error in bulk start:', error);
res.status(500).json({ error: 'Failed to start all skills' });
}
});
// Webhook to stop all skills
app.post('/webhook/skills/stop-all', async (req: Request, res: Response) => {
try {
const records = loadRecords();
const results = [];
for (const skill of records.skills) {
try {
const skillPath = path.join(CLAUDE_SKILLS_DIR, skill.id);
const skillJsonPath = path.join(skillPath, 'skill.json');
if (fs.existsSync(skillJsonPath)) {
const skillConfig = JSON.parse(fs.readFileSync(skillJsonPath, 'utf8'));
skillConfig.status = 'inactive';
fs.writeFileSync(skillJsonPath, JSON.stringify(skillConfig, null, 2));
skill.status = 'inactive';
results.push({
id: skill.id,
name: skillConfig.name,
status: 'stopped'
});
}
} catch (err) {
results.push({
id: skill.id,
status: 'failed',
error: err.message
});
}
}
saveRecords(records.skills);
console.log(`[WEBHOOK] Stopped ${results.filter(r => r.status === 'stopped').length} skills`);
res.json({
success: true,
message: 'Bulk stop completed',
results,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[WEBHOOK] Error in bulk stop:', error);
res.status(500).json({ error: 'Failed to stop all skills' });
}
});
// Webhook to get all available skills
app.get('/webhook/skills/list', async (req: Request, res: Response) => {
try {
const records = loadRecords();
const skills = records.skills.map((s: any) => ({
id: s.id,
name: s.name,
status: s.status,
lastActivated: s.lastActivated
}));
res.json({
success: true,
skills,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[WEBHOOK] Error listing skills:', error);
res.status(500).json({ error: 'Failed to list skills' });
}
});
// ───────────────────────────────────────────────────────────────────────────
// Skill EXECUTION (run / stop / live output)
//
// The /start and /stop endpoints above only flip a status flag. These actually
// spawn the skill's entry script as a child process, capture its combined
// stdout+stderr, let Stop kill it, and expose the live output so the UI can
// render it directly below the skill row.
// ───────────────────────────────────────────────────────────────────────────
interface RunState {
proc: ChildProcess | null;
output: string;
status: 'running' | 'done' | 'error' | 'killed';
exitCode: number | null;
startedAt: string;
entry: string;
}
const runs = new Map<string, RunState>();
const MAX_OUTPUT = 200_000; // keep at most the last ~200 KB of output in memory
// Pick a runner (bash/node/python3) for a concrete file path, or null.
function runnerFor(p: string): { cmd: string; args: string[]; display: string } | null {
if (!fs.existsSync(p)) return null;
const base = path.basename(p);
if (p.endsWith('.sh')) return { cmd: 'bash', args: [p], display: `bash ${base}` };
if (p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs')) return { cmd: 'node', args: [p], display: `node ${base}` };
if (p.endsWith('.py')) return { cmd: 'python3', args: [p], display: `python3 ${base}` };
return null;
}
// Figure out what to run for a skill: skill.json `command`/`entry`, then a list
// of conventional entry-point files.
function detectEntry(skillPath: string): { cmd: string; args: string[]; display: string } | null {
const skillJsonPath = path.join(skillPath, 'skill.json');
if (fs.existsSync(skillJsonPath)) {
try {
const cfg = JSON.parse(fs.readFileSync(skillJsonPath, 'utf8'));
if (cfg.command && typeof cfg.command === 'string') {
return { cmd: 'bash', args: ['-lc', cfg.command], display: cfg.command };
}
if (cfg.entry && typeof cfg.entry === 'string') {
const r = runnerFor(path.join(skillPath, cfg.entry));
if (r) return r;
}
} catch { /* ignore malformed skill.json */ }
}
const candidates = [
'run.sh', 'scripts/run.sh', 'run',
'cli.js', 'scripts/cli.js', 'index.js', 'scripts/index.js',
'main.py', 'scripts/main.py', 'run.py', 'scripts/run.py',
];
for (const c of candidates) {
const r = runnerFor(path.join(skillPath, c));
if (r) return r;
}
return null;
}
// Run a skill's entry script
app.post('/api/skills/:id/run', (req: Request, res: Response) => {
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' });
}
const existing = runs.get(skillId);
if (existing && existing.status === 'running') {
return res.status(409).json({ error: 'Skill is already running' });
}
const entry = detectEntry(skillPath);
if (!entry) {
return res.status(400).json({
error: 'No executable entry point found. Looked for skill.json command/entry, then run.sh, scripts/run.sh, cli.js, index.js, main.py, run.py.'
});
}
const state: RunState = {
proc: null,
output: `$ ${entry.display}\n`,
status: 'running',
exitCode: null,
startedAt: new Date().toISOString(),
entry: entry.display,
};
runs.set(skillId, state);
const append = (chunk: Buffer | string) => {
state.output += chunk.toString();
if (state.output.length > MAX_OUTPUT) {
state.output = '…[earlier output truncated]…\n' + state.output.slice(-MAX_OUTPUT);
}
};
try {
const proc = spawn(entry.cmd, entry.args, { cwd: skillPath, env: process.env });
state.proc = proc;
proc.stdout?.on('data', append);
proc.stderr?.on('data', append);
proc.on('error', (err: Error) => {
append(`\n[spawn error] ${err.message}\n`);
if (state.status === 'running') state.status = 'error';
state.proc = null;
});
proc.on('close', (code: number | null) => {
state.exitCode = code;
if (state.status === 'running') state.status = code === 0 ? 'done' : 'error';
state.proc = null;
});
// Stamp lastActivated so the dashboard reflects the manual run
const records = loadRecords();
const rec = records.skills.find((s: any) => s.id === skillId);
if (rec) {
rec.lastActivated = new Date().toISOString();
rec.status = 'active';
saveRecords(records.skills);
}
console.log(`[RUN] ${skillId}: ${entry.display}`);
res.json({ success: true, message: `Running: ${entry.display}`, skillId, entry: entry.display });
} catch (err: any) {
state.status = 'error';
state.output += `\n[error] ${err.message}\n`;
res.status(500).json({ error: err.message });
}
});
// Stop (kill) a running skill
app.post('/api/skills/:id/kill', (req: Request, res: Response) => {
const skillId = req.params.id;
const state = runs.get(skillId);
if (!state || state.status !== 'running' || !state.proc) {
return res.status(409).json({ error: 'Skill is not running' });
}
const proc = state.proc;
state.status = 'killed';
state.output += '\n[stopped by user]\n';
try {
proc.kill('SIGTERM');
// Escalate to SIGKILL if it ignores the polite request
setTimeout(() => {
if (proc && !proc.killed) {
try { proc.kill('SIGKILL'); } catch { /* already gone */ }
}
}, 3000);
} catch (err: any) {
return res.status(500).json({ error: err.message });
}
console.log(`[STOP] ${skillId}`);
res.json({ success: true, message: 'Stopped', skillId });
});
// Live output for a skill's current/last run
app.get('/api/skills/:id/output', (req: Request, res: Response) => {
const state = runs.get(req.params.id);
if (!state) {
return res.json({ status: 'idle', output: '', exitCode: null, entry: null, startedAt: null });
}
res.json({
status: state.status,
output: state.output,
exitCode: state.exitCode,
entry: state.entry,
startedAt: state.startedAt,
});
});
// List currently-running skill ids (so the UI can restore button state on load)
app.get('/api/skills/running', (_req: Request, res: Response) => {
const running = [...runs.entries()]
.filter(([, s]) => s.status === 'running')
.map(([id]) => id);
res.json({ running });
});
// 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`);
console.log(`\n📡 Webhook endpoints:`);
console.log(` POST http://45.61.58.125:${PORT}/webhook/skill/start/:id - Start specific skill`);
console.log(` POST http://45.61.58.125:${PORT}/webhook/skills/start-all - Start all skills`);
console.log(` POST http://45.61.58.125:${PORT}/webhook/skills/stop-all - Stop all skills`);
console.log(` GET http://45.61.58.125:${PORT}/webhook/skills/list - List all skills`);
});