feat(skills): implement enable/disable functionality for skills

- Added new API endpoints to enable and disable skills.
- Introduced logic to manage disabled skills, including loading and saving configurations.
- Enhanced skills routes to return lists of disabled skills.
- Updated frontend to display disabled skills and allow toggling their status.
- Added internationalization support for new skill status messages.
- Created JSON schemas for plan verification agent and findings.
- Defined new types for skill management in TypeScript.
This commit is contained in:
catlog22
2026-01-28 00:49:39 +08:00
parent 8d178feaac
commit 7a40f16235
35 changed files with 1123 additions and 2016 deletions

View File

@@ -2,51 +2,26 @@
* Skills Routes Module
* Handles all Skills-related API endpoints
*/
import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, promises as fsPromises } from 'fs';
import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, renameSync, writeFileSync, mkdirSync, cpSync, rmSync, promises as fsPromises } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { executeCliTool } from '../../tools/cli-executor.js';
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
import type { RouteContext } from './types.js';
type SkillLocation = 'project' | 'user';
interface ParsedSkillFrontmatter {
name: string;
description: string;
version: string | null;
allowedTools: string[];
content: string;
}
interface SkillSummary {
name: string;
folderName: string;
description: string;
version: string | null;
allowedTools: string[];
location: SkillLocation;
path: string;
supportingFiles: string[];
}
interface SkillsConfig {
projectSkills: SkillSummary[];
userSkills: SkillSummary[];
}
interface SkillInfo {
name: string;
description: string;
version: string | null;
allowedTools: string[];
supportingFiles: string[];
}
type SkillFolderValidation =
| { valid: true; errors: string[]; skillInfo: SkillInfo }
| { valid: false; errors: string[]; skillInfo: null };
import type {
SkillLocation,
ParsedSkillFrontmatter,
SkillSummary,
SkillsConfig,
SkillInfo,
SkillFolderValidation,
DisabledSkillInfo,
DisabledSkillsConfig,
DisabledSkillSummary,
ExtendedSkillsConfig,
SkillOperationResult
} from '../../types/skill-types.js';
type GenerationType = 'description' | 'template';
@@ -65,6 +40,260 @@ function isRecord(value: unknown): value is Record<string, unknown> {
// ========== Skills Helper Functions ==========
// ========== Disabled Skills Helper Functions ==========
/**
* Get disabled skills directory path
*/
function getDisabledSkillsDir(location: SkillLocation, projectPath: string): string {
if (location === 'project') {
return join(projectPath, '.claude', '.disabled-skills');
}
return join(homedir(), '.claude', '.disabled-skills');
}
/**
* Get disabled skills config file path
*/
function getDisabledSkillsConfigPath(location: SkillLocation, projectPath: string): string {
if (location === 'project') {
return join(projectPath, '.claude', 'disabled-skills.json');
}
return join(homedir(), '.claude', 'disabled-skills.json');
}
/**
* Load disabled skills configuration
*/
function loadDisabledSkillsConfig(location: SkillLocation, projectPath: string): DisabledSkillsConfig {
const configPath = getDisabledSkillsConfigPath(location, projectPath);
try {
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf8');
const config = JSON.parse(content);
return { skills: config.skills || {} };
}
} catch (error) {
console.error(`[Skills] Failed to load disabled skills config: ${error}`);
}
return { skills: {} };
}
/**
* Save disabled skills configuration
*/
function saveDisabledSkillsConfig(location: SkillLocation, projectPath: string, config: DisabledSkillsConfig): void {
const configPath = getDisabledSkillsConfigPath(location, projectPath);
const configDir = join(configPath, '..');
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
}
/**
* Move directory with fallback to copy-delete
*/
function moveDirectory(source: string, target: string): void {
try {
// Try atomic rename first
renameSync(source, target);
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
// If rename fails (cross-filesystem, permission issues), fallback to copy-delete
if (err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EBUSY') {
cpSync(source, target, { recursive: true, force: true });
rmSync(source, { recursive: true, force: true });
} else {
throw error;
}
}
}
/**
* Disable a skill by moving it to disabled directory
*/
async function disableSkill(
skillName: string,
location: SkillLocation,
projectPath: string,
initialPath: string,
reason?: string
): Promise<SkillOperationResult> {
try {
// Validate skill name
if (skillName.includes('/') || skillName.includes('\\') || skillName.includes('..')) {
return { success: false, message: 'Invalid skill name', status: 400 };
}
// Get source directory
let skillsDir: string;
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
skillsDir = join(validatedProjectPath, '.claude', 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
}
} else {
skillsDir = join(homedir(), '.claude', 'skills');
}
const sourceDir = join(skillsDir, skillName);
if (!existsSync(sourceDir)) {
return { success: false, message: 'Skill not found', status: 404 };
}
// Get target directory
const disabledDir = getDisabledSkillsDir(location, projectPath);
if (!existsSync(disabledDir)) {
mkdirSync(disabledDir, { recursive: true });
}
const targetDir = join(disabledDir, skillName);
if (existsSync(targetDir)) {
return { success: false, message: 'Skill already exists in disabled directory', status: 409 };
}
// Move skill to disabled directory
moveDirectory(sourceDir, targetDir);
// Update config
const config = loadDisabledSkillsConfig(location, projectPath);
config.skills[skillName] = {
disabledAt: new Date().toISOString(),
reason
};
saveDisabledSkillsConfig(location, projectPath, config);
return { success: true, message: 'Skill disabled', skillName, location };
} catch (error) {
return { success: false, message: (error as Error).message, status: 500 };
}
}
/**
* Enable a skill by moving it back from disabled directory
*/
async function enableSkill(
skillName: string,
location: SkillLocation,
projectPath: string,
initialPath: string
): Promise<SkillOperationResult> {
try {
// Validate skill name
if (skillName.includes('/') || skillName.includes('\\') || skillName.includes('..')) {
return { success: false, message: 'Invalid skill name', status: 400 };
}
// Get source directory (disabled)
const disabledDir = getDisabledSkillsDir(location, projectPath);
const sourceDir = join(disabledDir, skillName);
if (!existsSync(sourceDir)) {
return { success: false, message: 'Disabled skill not found', status: 404 };
}
// Get target directory (skills)
let skillsDir: string;
if (location === 'project') {
try {
const validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
skillsDir = join(validatedProjectPath, '.claude', 'skills');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: message.includes('Access denied') ? 'Access denied' : 'Invalid path', status: 403 };
}
} else {
skillsDir = join(homedir(), '.claude', 'skills');
}
if (!existsSync(skillsDir)) {
mkdirSync(skillsDir, { recursive: true });
}
const targetDir = join(skillsDir, skillName);
if (existsSync(targetDir)) {
return { success: false, message: 'Skill already exists in skills directory', status: 409 };
}
// Move skill back to skills directory
moveDirectory(sourceDir, targetDir);
// Update config
const config = loadDisabledSkillsConfig(location, projectPath);
delete config.skills[skillName];
saveDisabledSkillsConfig(location, projectPath, config);
return { success: true, message: 'Skill enabled', skillName, location };
} catch (error) {
return { success: false, message: (error as Error).message, status: 500 };
}
}
/**
* Get list of disabled skills
*/
function getDisabledSkillsList(location: SkillLocation, projectPath: string): DisabledSkillSummary[] {
const disabledDir = getDisabledSkillsDir(location, projectPath);
const config = loadDisabledSkillsConfig(location, projectPath);
const result: DisabledSkillSummary[] = [];
if (!existsSync(disabledDir)) {
return result;
}
try {
const skills = readdirSync(disabledDir, { withFileTypes: true });
for (const skill of skills) {
if (skill.isDirectory()) {
const skillMdPath = join(disabledDir, skill.name, 'SKILL.md');
if (existsSync(skillMdPath)) {
const content = readFileSync(skillMdPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
const skillDir = join(disabledDir, skill.name);
const supportingFiles = getSupportingFiles(skillDir);
const disabledInfo = config.skills[skill.name] || { disabledAt: new Date().toISOString() };
result.push({
name: parsed.name || skill.name,
folderName: skill.name,
description: parsed.description,
version: parsed.version,
allowedTools: parsed.allowedTools,
location,
path: skillDir,
supportingFiles,
disabledAt: disabledInfo.disabledAt,
reason: disabledInfo.reason
});
}
}
}
} catch (error) {
console.error(`[Skills] Failed to read disabled skills: ${error}`);
}
return result;
}
/**
* Get extended skills config including disabled skills
*/
function getExtendedSkillsConfig(projectPath: string): ExtendedSkillsConfig {
const baseConfig = getSkillsConfig(projectPath);
return {
...baseConfig,
disabledProjectSkills: getDisabledSkillsList('project', projectPath),
disabledUserSkills: getDisabledSkillsList('user', projectPath)
};
}
// ========== Active Skills Helper Functions ==========
/**
* Parse skill frontmatter (YAML header)
* @param {string} content - Skill file content
@@ -660,15 +889,23 @@ Create a new Claude Code skill with the following specifications:
export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: Get all skills (project and user)
if (pathname === '/api/skills') {
// API: Get all skills (project and user) - with optional extended format
if (pathname === '/api/skills' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const includeDisabled = url.searchParams.get('includeDisabled') === 'true';
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
const skillsData = getSkillsConfig(validatedProjectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(skillsData));
if (includeDisabled) {
const extendedData = getExtendedSkillsConfig(validatedProjectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(extendedData));
} else {
const skillsData = getSkillsConfig(validatedProjectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(skillsData));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
@@ -679,6 +916,73 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Get disabled skills list
if (pathname === '/api/skills/disabled' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path') || initialPath;
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, { mustExist: true, allowedDirectories: [initialPath] });
const disabledProjectSkills = getDisabledSkillsList('project', validatedProjectPath);
const disabledUserSkills = getDisabledSkillsList('user', validatedProjectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ disabledProjectSkills, disabledUserSkills }));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: status === 403 ? 'Access denied' : 'Invalid path', disabledProjectSkills: [], disabledUserSkills: [] }));
}
return true;
}
// API: Disable a skill
if (pathname.match(/^\/api\/skills\/[^/]+\/disable$/) && req.method === 'POST') {
const pathParts = pathname.split('/');
const skillName = decodeURIComponent(pathParts[3]);
handlePostRequest(req, res, async (body) => {
if (!isRecord(body)) {
return { error: 'Invalid request body', status: 400 };
}
const locationValue = body.location;
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const reason = typeof body.reason === 'string' ? body.reason : undefined;
if (locationValue !== 'project' && locationValue !== 'user') {
return { error: 'Location is required (project or user)' };
}
const projectPath = projectPathParam || initialPath;
return disableSkill(skillName, locationValue, projectPath, initialPath, reason);
});
return true;
}
// API: Enable a skill
if (pathname.match(/^\/api\/skills\/[^/]+\/enable$/) && req.method === 'POST') {
const pathParts = pathname.split('/');
const skillName = decodeURIComponent(pathParts[3]);
handlePostRequest(req, res, async (body) => {
if (!isRecord(body)) {
return { error: 'Invalid request body', status: 400 };
}
const locationValue = body.location;
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined;
if (locationValue !== 'project' && locationValue !== 'user') {
return { error: 'Location is required (project or user)' };
}
const projectPath = projectPathParam || initialPath;
return enableSkill(skillName, locationValue, projectPath, initialPath);
});
return true;
}
// API: List skill directory contents
if (pathname.match(/^\/api\/skills\/[^/]+\/dir$/) && req.method === 'GET') {
const pathParts = pathname.split('/');

View File

@@ -1588,6 +1588,18 @@ const i18n = {
'skills.generate': 'Generate',
'skills.cliGenerateInfo': 'AI will generate a complete skill based on your description',
'skills.cliGenerateTimeHint': 'Generation may take a few minutes depending on complexity',
'skills.disable': 'Disable',
'skills.enable': 'Enable',
'skills.disabled': 'Disabled',
'skills.enabled': 'Enabled',
'skills.disabledSkills': 'Disabled Skills',
'skills.disabledAt': 'Disabled at',
'skills.enableConfirm': 'Are you sure you want to enable the skill "{name}"?',
'skills.disableConfirm': 'Are you sure you want to disable the skill "{name}"?',
'skills.noDisabledSkills': 'No disabled skills',
'skills.toggleError': 'Failed to toggle skill status',
'skills.enableSuccess': 'Skill "{name}" enabled successfully',
'skills.disableSuccess': 'Skill "{name}" disabled successfully',
// Rules
'nav.rules': 'Rules',
@@ -4212,6 +4224,18 @@ const i18n = {
'skills.generate': '生成',
'skills.cliGenerateInfo': 'AI 将根据你的描述生成完整的技能',
'skills.cliGenerateTimeHint': '生成时间取决于复杂度,可能需要几分钟',
'skills.disable': '禁用',
'skills.enable': '启用',
'skills.disabled': '已禁用',
'skills.enabled': '已启用',
'skills.disabledSkills': '已禁用的技能',
'skills.disabledAt': '禁用时间',
'skills.enableConfirm': '确定要启用技能 "{name}" 吗?',
'skills.disableConfirm': '确定要禁用技能 "{name}" 吗?',
'skills.noDisabledSkills': '没有已禁用的技能',
'skills.toggleError': '切换技能状态失败',
'skills.enableSuccess': '技能 "{name}" 启用成功',
'skills.disableSuccess': '技能 "{name}" 禁用成功',
// Rules
'nav.rules': '规则',

View File

@@ -4,10 +4,13 @@
// ========== Skills State ==========
var skillsData = {
projectSkills: [],
userSkills: []
userSkills: [],
disabledProjectSkills: [],
disabledUserSkills: []
};
var selectedSkill = null;
var skillsLoading = false;
var showDisabledSkills = false;
// ========== Main Render Function ==========
async function renderSkillsManager() {
@@ -36,18 +39,20 @@ async function renderSkillsManager() {
async function loadSkillsData() {
skillsLoading = true;
try {
const response = await fetch('/api/skills?path=' + encodeURIComponent(projectPath));
const response = await fetch('/api/skills?path=' + encodeURIComponent(projectPath) + '&includeDisabled=true');
if (!response.ok) throw new Error('Failed to load skills');
const data = await response.json();
skillsData = {
projectSkills: data.projectSkills || [],
userSkills: data.userSkills || []
userSkills: data.userSkills || [],
disabledProjectSkills: data.disabledProjectSkills || [],
disabledUserSkills: data.disabledUserSkills || []
};
// Update badge
updateSkillsBadge();
} catch (err) {
console.error('Failed to load skills:', err);
skillsData = { projectSkills: [], userSkills: [] };
skillsData = { projectSkills: [], userSkills: [], disabledProjectSkills: [], disabledUserSkills: [] };
} finally {
skillsLoading = false;
}
@@ -67,6 +72,9 @@ function renderSkillsView() {
const projectSkills = skillsData.projectSkills || [];
const userSkills = skillsData.userSkills || [];
const disabledProjectSkills = skillsData.disabledProjectSkills || [];
const disabledUserSkills = skillsData.disabledUserSkills || [];
const totalDisabled = disabledProjectSkills.length + disabledUserSkills.length;
container.innerHTML = `
<div class="skills-manager">
@@ -109,7 +117,7 @@ function renderSkillsView() {
</div>
` : `
<div class="skills-grid grid gap-3">
${projectSkills.map(skill => renderSkillCard(skill, 'project')).join('')}
${projectSkills.map(skill => renderSkillCard(skill, 'project', false)).join('')}
</div>
`}
</div>
@@ -133,11 +141,48 @@ function renderSkillsView() {
</div>
` : `
<div class="skills-grid grid gap-3">
${userSkills.map(skill => renderSkillCard(skill, 'user')).join('')}
${userSkills.map(skill => renderSkillCard(skill, 'user', false)).join('')}
</div>
`}
</div>
<!-- Disabled Skills Section -->
${totalDisabled > 0 ? `
<div class="skills-section mb-6">
<div class="flex items-center justify-between mb-4 cursor-pointer" onclick="toggleDisabledSkillsSection()">
<div class="flex items-center gap-2">
<i data-lucide="${showDisabledSkills ? 'chevron-down' : 'chevron-right'}" class="w-5 h-5 text-muted-foreground transition-transform"></i>
<i data-lucide="eye-off" class="w-5 h-5 text-muted-foreground"></i>
<h3 class="text-lg font-semibold text-muted-foreground">${t('skills.disabledSkills')}</h3>
</div>
<span class="text-sm text-muted-foreground">${totalDisabled} ${t('skills.skillsCount')}</span>
</div>
${showDisabledSkills ? `
${disabledProjectSkills.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded-full">${t('skills.projectSkills')}</span>
</div>
<div class="skills-grid grid gap-3">
${disabledProjectSkills.map(skill => renderSkillCard(skill, 'project', true)).join('')}
</div>
</div>
` : ''}
${disabledUserSkills.length > 0 ? `
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded-full">${t('skills.userSkills')}</span>
</div>
<div class="skills-grid grid gap-3">
${disabledUserSkills.map(skill => renderSkillCard(skill, 'user', true)).join('')}
</div>
</div>
` : ''}
` : ''}
</div>
` : ''}
<!-- Skill Detail Panel -->
${selectedSkill ? renderSkillDetailPanel(selectedSkill) : ''}
</div>
@@ -147,19 +192,19 @@ function renderSkillsView() {
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function renderSkillCard(skill, location) {
function renderSkillCard(skill, location, isDisabled = false) {
const hasAllowedTools = skill.allowedTools && skill.allowedTools.length > 0;
const hasSupportingFiles = skill.supportingFiles && skill.supportingFiles.length > 0;
const locationIcon = location === 'project' ? 'folder' : 'user';
const locationClass = location === 'project' ? 'text-primary' : 'text-indigo';
const locationBg = location === 'project' ? 'bg-primary/10' : 'bg-indigo/10';
const folderName = skill.folderName || skill.name;
const cardOpacity = isDisabled ? 'opacity-60' : '';
return `
<div class="skill-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all cursor-pointer"
onclick="showSkillDetail('${escapeHtml(folderName)}', '${location}')">
<div class="skill-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${cardOpacity}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 cursor-pointer" onclick="showSkillDetail('${escapeHtml(folderName)}', '${location}')">
<div class="w-10 h-10 ${locationBg} rounded-lg flex items-center justify-center">
<i data-lucide="sparkles" class="w-5 h-5 ${locationClass}"></i>
</div>
@@ -168,27 +213,39 @@ function renderSkillCard(skill, location) {
${skill.version ? `<span class="text-xs text-muted-foreground">v${escapeHtml(skill.version)}</span>` : ''}
</div>
</div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full ${locationBg} ${locationClass}">
<i data-lucide="${locationIcon}" class="w-3 h-3 mr-1"></i>
${location}
</span>
<button class="p-1.5 rounded-lg transition-colors ${isDisabled ? 'text-green-600 hover:bg-green-100' : 'text-amber-600 hover:bg-amber-100'}"
onclick="event.stopPropagation(); toggleSkillEnabled('${escapeHtml(folderName)}', '${location}', ${!isDisabled})"
title="${isDisabled ? t('skills.enable') : t('skills.disable')}">
<i data-lucide="${isDisabled ? 'toggle-left' : 'toggle-right'}" class="w-4 h-4"></i>
</button>
</div>
</div>
<p class="text-sm text-muted-foreground mb-3 line-clamp-2">${escapeHtml(skill.description || t('skills.noDescription'))}</p>
<p class="text-sm text-muted-foreground mb-3 line-clamp-2 cursor-pointer" onclick="showSkillDetail('${escapeHtml(folderName)}', '${location}')">${escapeHtml(skill.description || t('skills.noDescription'))}</p>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
${hasAllowedTools ? `
<span class="flex items-center gap-1">
<i data-lucide="lock" class="w-3 h-3"></i>
${skill.allowedTools.length} ${t('skills.tools')}
</span>
` : ''}
${hasSupportingFiles ? `
<span class="flex items-center gap-1">
<i data-lucide="file-text" class="w-3 h-3"></i>
${skill.supportingFiles.length} ${t('skills.files')}
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-3">
${hasAllowedTools ? `
<span class="flex items-center gap-1">
<i data-lucide="lock" class="w-3 h-3"></i>
${skill.allowedTools.length} ${t('skills.tools')}
</span>
` : ''}
${hasSupportingFiles ? `
<span class="flex items-center gap-1">
<i data-lucide="file-text" class="w-3 h-3"></i>
${skill.supportingFiles.length} ${t('skills.files')}
</span>
` : ''}
</div>
${isDisabled && skill.disabledAt ? `
<span class="text-xs text-muted-foreground/70">
${t('skills.disabledAt')}: ${formatDisabledDate(skill.disabledAt)}
</span>
` : ''}
</div>
@@ -373,6 +430,61 @@ function editSkill(skillName, location) {
}
}
// ========== Enable/Disable Skills Functions ==========
async function toggleSkillEnabled(skillName, location, currentlyEnabled) {
const action = currentlyEnabled ? 'disable' : 'enable';
const confirmMessage = currentlyEnabled
? t('skills.disableConfirm', { name: skillName })
: t('skills.enableConfirm', { name: skillName });
if (!confirm(confirmMessage)) return;
try {
const response = await fetch('/api/skills/' + encodeURIComponent(skillName) + '/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ location, projectPath })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Operation failed');
}
// Close detail panel if open
selectedSkill = null;
// Reload skills data
await loadSkillsData();
renderSkillsView();
if (window.showToast) {
const message = currentlyEnabled ? t('skills.disabled') : t('skills.enabled');
showToast(message, 'success');
}
} catch (err) {
console.error('Failed to toggle skill:', err);
if (window.showToast) {
showToast(err.message || t('skills.toggleError'), 'error');
}
}
}
function toggleDisabledSkillsSection() {
showDisabledSkills = !showDisabledSkills;
renderSkillsView();
}
function formatDisabledDate(isoString) {
try {
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return isoString;
}
}
// ========== Create Skill Modal ==========
var skillCreateState = {
mode: 'import', // 'import' or 'cli-generate'

View File

@@ -280,11 +280,12 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
// Scan directory
const { info: structureInfo, folderType } = scanDirectoryStructure(targetPath);
// Calculate output path
// Calculate output path (relative for display, absolute for CLI prompt)
const outputPath = calculateOutputPath(targetPath, projectName, process.cwd());
const absOutputPath = resolve(process.cwd(), outputPath);
// Ensure output directory exists
mkdirSync(outputPath, { recursive: true });
mkdirSync(absOutputPath, { recursive: true });
// Build prompt based on strategy
let prompt: string;
@@ -304,7 +305,7 @@ Generate documentation files:
- API.md: Code API documentation
- README.md: Module overview and usage
Output directory: ${outputPath}
Output directory: ${absOutputPath}
Template Guidelines:
${templateContent}`;
@@ -318,7 +319,7 @@ Read: @*/API.md @*/README.md
Generate documentation file:
- README.md: Navigation overview of subdirectories
Output directory: ${outputPath}
Output directory: ${absOutputPath}
Template Guidelines:
${templateContent}`;
@@ -327,12 +328,13 @@ ${templateContent}`;
case 'project-readme':
templateContent = loadTemplate('project-readme');
const projectDocsDir = resolve(process.cwd(), '.workflow', 'docs', projectName);
prompt = `Read all module documentation:
@.workflow/docs/${projectName}/**/API.md
@.workflow/docs/${projectName}/**/README.md
Generate project-level documentation:
- README.md in .workflow/docs/${projectName}/
- README.md in ${projectDocsDir}/
Template Guidelines:
${templateContent}`;
@@ -340,6 +342,7 @@ ${templateContent}`;
case 'project-architecture':
templateContent = loadTemplate('project-architecture');
const projectArchDir = resolve(process.cwd(), '.workflow', 'docs', projectName);
prompt = `Read project documentation:
@.workflow/docs/${projectName}/README.md
@.workflow/docs/${projectName}/**/API.md
@@ -348,13 +351,14 @@ Generate:
- ARCHITECTURE.md: System design documentation
- EXAMPLES.md: Usage examples
Output directory: .workflow/docs/${projectName}/
Output directory: ${projectArchDir}/
Template Guidelines:
${templateContent}`;
break;
case 'http-api':
const apiDocsDir = resolve(process.cwd(), '.workflow', 'docs', projectName, 'api');
prompt = `Read API route files:
@**/routes/**/*.ts @**/routes/**/*.js
@**/api/**/*.ts @**/api/**/*.js
@@ -362,7 +366,7 @@ ${templateContent}`;
Generate HTTP API documentation:
- api/README.md: REST API endpoints documentation
Output directory: .workflow/docs/${projectName}/api/`;
Output directory: ${apiDocsDir}/`;
break;
}

View File

@@ -0,0 +1,118 @@
/**
* Skill Types Definition
* Types for skill management including enable/disable functionality
*/
/**
* Skill location type
*/
export type SkillLocation = 'project' | 'user';
/**
* Information about a disabled skill
*/
export interface DisabledSkillInfo {
/** When the skill was disabled */
disabledAt: string;
/** Optional reason for disabling */
reason?: string;
}
/**
* Configuration for disabled skills
* Stored in disabled-skills.json
*/
export interface DisabledSkillsConfig {
/** Map of skill name to disabled info */
skills: Record<string, DisabledSkillInfo>;
}
/**
* Result of a skill operation (enable/disable)
*/
export interface SkillOperationResult {
success: boolean;
message?: string;
skillName?: string;
location?: SkillLocation;
status?: number;
}
/**
* Summary information for an active skill
*/
export interface SkillSummary {
/** Skill name from SKILL.md frontmatter */
name: string;
/** Folder name (actual directory name) */
folderName: string;
/** Skill description */
description: string;
/** Skill version if specified */
version: string | null;
/** Allowed tools list */
allowedTools: string[];
/** Skill location (project or user) */
location: SkillLocation;
/** Full path to skill directory */
path: string;
/** Supporting files in the skill folder */
supportingFiles: string[];
}
/**
* Summary information for a disabled skill
*/
export interface DisabledSkillSummary extends SkillSummary {
/** When the skill was disabled */
disabledAt: string;
/** Optional reason for disabling */
reason?: string;
}
/**
* Skills configuration for active skills only (backward compatible)
*/
export interface SkillsConfig {
projectSkills: SkillSummary[];
userSkills: SkillSummary[];
}
/**
* Extended skills configuration including disabled skills
*/
export interface ExtendedSkillsConfig extends SkillsConfig {
/** Disabled project skills */
disabledProjectSkills: DisabledSkillSummary[];
/** Disabled user skills */
disabledUserSkills: DisabledSkillSummary[];
}
/**
* Parsed skill frontmatter from SKILL.md
*/
export interface ParsedSkillFrontmatter {
name: string;
description: string;
version: string | null;
allowedTools: string[];
content: string;
}
/**
* Skill info extracted from validation
*/
export interface SkillInfo {
name: string;
description: string;
version: string | null;
allowedTools: string[];
supportingFiles: string[];
}
/**
* Skill folder validation result
*/
export type SkillFolderValidation =
| { valid: true; errors: string[]; skillInfo: SkillInfo }
| { valid: false; errors: string[]; skillInfo: null };