mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
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:
@@ -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('/');
|
||||
|
||||
Reference in New Issue
Block a user