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('/');