mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +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('/');
|
||||
|
||||
@@ -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': '规则',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
118
ccw/src/types/skill-types.ts
Normal file
118
ccw/src/types/skill-types.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user