feat: Add phases for document consolidation, assembly, and compliance refinement

- Introduced Phase 2.5: Consolidation Agent to summarize analysis outputs and generate design overviews.
- Added Phase 4: Document Assembly to create index-style documents linking chapter files.
- Implemented Phase 5: Compliance Review & Iterative Refinement for CPCC compliance checks and updates.
- Established CPCC Compliance Requirements document outlining mandatory sections and validation functions.
- Created a base template for analysis agents to ensure consistency and efficiency in execution.
This commit is contained in:
catlog22
2026-01-28 19:57:24 +08:00
parent 4c78f53bcc
commit 0cc5101c0e
34 changed files with 888 additions and 466 deletions

View File

@@ -51,6 +51,17 @@ interface CommandOperationResult {
status?: number;
}
interface GroupDefinition {
name: string;
icon?: string;
color?: string;
}
interface CommandGroupsConfig {
groups: Record<string, GroupDefinition>; // Custom group definitions
assignments: Record<string, string>; // commandName -> groupId mapping
}
// ========== Helper Functions ==========
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -125,19 +136,79 @@ function parseCommandFrontmatter(content: string): CommandMetadata {
}
/**
* Infer group from command path if not specified in frontmatter
* Get command groups config file path
*/
function inferGroupFromPath(relativePath: string, metadata: CommandMetadata): string {
// If group is specified in frontmatter, use it
if (metadata.group && metadata.group !== 'other') {
return metadata.group;
function getGroupsConfigPath(location: CommandLocation, projectPath: string): string {
const baseDir = location === 'project'
? join(projectPath, '.claude')
: join(homedir(), '.claude');
return join(baseDir, 'command-groups.json');
}
/**
* Load command groups configuration
*/
function loadGroupsConfig(location: CommandLocation, projectPath: string): CommandGroupsConfig {
const configPath = getGroupsConfigPath(location, projectPath);
const defaultConfig: CommandGroupsConfig = {
groups: {},
assignments: {}
};
if (!existsSync(configPath)) {
return defaultConfig;
}
// Infer from directory structure
try {
const content = readFileSync(configPath, 'utf8');
const parsed = JSON.parse(content);
return {
groups: isRecord(parsed.groups) ? parsed.groups as Record<string, GroupDefinition> : {},
assignments: isRecord(parsed.assignments) ? parsed.assignments as Record<string, string> : {}
};
} catch (err) {
console.error(`[Commands] Failed to load groups config from ${configPath}:`, err);
return defaultConfig;
}
}
/**
* Save command groups configuration
*/
function saveGroupsConfig(location: CommandLocation, projectPath: string, config: CommandGroupsConfig): void {
const configPath = getGroupsConfigPath(location, projectPath);
const configDir = dirname(configPath);
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
try {
const content = JSON.stringify(config, null, 2);
require('fs').writeFileSync(configPath, content, 'utf8');
} catch (err) {
console.error(`[Commands] Failed to save groups config to ${configPath}:`, err);
}
}
/**
* Get group for a command (from config or inferred from path)
*/
function getCommandGroup(commandName: string, relativePath: string, location: CommandLocation, projectPath: string): string {
// First check custom assignments
const config = loadGroupsConfig(location, projectPath);
if (config.assignments[commandName]) {
return config.assignments[commandName];
}
// Fallback to path-based inference - use full directory path as group
const parts = relativePath.split(/[/\\]/);
if (parts.length > 1) {
// Use first directory as group (e.g., 'workflow', 'issue', 'memory')
return parts[0];
// Use full directory path (excluding filename) as group
// e.g., 'workflow/review/code-review.md' -> 'workflow/review'
return parts.slice(0, -1).join('/');
}
return 'other';
@@ -150,7 +221,8 @@ function scanCommandsRecursive(
baseDir: string,
currentDir: string,
location: CommandLocation,
enabled: boolean
enabled: boolean,
projectPath: string
): CommandInfo[] {
const results: CommandInfo[] = [];
@@ -168,17 +240,20 @@ function scanCommandsRecursive(
if (entry.isDirectory()) {
// Skip _disabled directory when scanning enabled commands
if (entry.name === '_disabled') continue;
// Recursively scan subdirectories
results.push(...scanCommandsRecursive(baseDir, fullPath, location, enabled));
results.push(...scanCommandsRecursive(baseDir, fullPath, location, enabled, projectPath));
} else if (entry.isFile() && entry.name.endsWith('.md')) {
try {
const content = readFileSync(fullPath, 'utf8');
const metadata = parseCommandFrontmatter(content);
const group = inferGroupFromPath(relativePath, metadata);
const commandName = metadata.name || basename(entry.name, '.md');
// Get group from external config (not from frontmatter)
const group = getCommandGroup(commandName, relativePath, location, projectPath);
results.push({
name: metadata.name || basename(entry.name, '.md'),
name: commandName,
description: metadata.description,
group,
enabled,
@@ -217,28 +292,28 @@ function getCommandsConfig(projectPath: string): CommandsConfig {
// Scan project commands
const projectDir = getCommandsDir('project', projectPath);
const projectDisabledDir = getDisabledCommandsDir('project', projectPath);
// Enabled project commands
const enabledProject = scanCommandsRecursive(projectDir, projectDir, 'project', true);
const enabledProject = scanCommandsRecursive(projectDir, projectDir, 'project', true, projectPath);
result.projectCommands.push(...enabledProject);
// Disabled project commands
if (existsSync(projectDisabledDir)) {
const disabledProject = scanCommandsRecursive(projectDisabledDir, projectDisabledDir, 'project', false);
const disabledProject = scanCommandsRecursive(projectDisabledDir, projectDisabledDir, 'project', false, projectPath);
result.projectCommands.push(...disabledProject);
}
// Scan user commands
const userDir = getCommandsDir('user', projectPath);
const userDisabledDir = getDisabledCommandsDir('user', projectPath);
// Enabled user commands
const enabledUser = scanCommandsRecursive(userDir, userDir, 'user', true);
const enabledUser = scanCommandsRecursive(userDir, userDir, 'user', true, projectPath);
result.userCommands.push(...enabledUser);
// Disabled user commands
if (existsSync(userDisabledDir)) {
const disabledUser = scanCommandsRecursive(userDisabledDir, userDisabledDir, 'user', false);
const disabledUser = scanCommandsRecursive(userDisabledDir, userDisabledDir, 'user', false, projectPath);
result.userCommands.push(...disabledUser);
}
@@ -449,8 +524,17 @@ export async function handleCommandsRoutes(ctx: RouteContext): Promise<boolean>
});
const config = getCommandsConfig(validatedProjectPath);
// Include groups config from both project and user
const projectGroupsConfig = loadGroupsConfig('project', validatedProjectPath);
const userGroupsConfig = loadGroupsConfig('user', validatedProjectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
res.end(JSON.stringify({
...config,
projectGroupsConfig,
userGroupsConfig
}));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
@@ -513,5 +597,78 @@ export async function handleCommandsRoutes(ctx: RouteContext): Promise<boolean>
return true;
}
// GET /api/commands/groups - Get groups configuration
if (pathname === '/api/commands/groups' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const location = url.searchParams.get('location') || 'project';
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, {
mustExist: true,
allowedDirectories: [initialPath]
});
if (location !== 'project' && location !== 'user') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid location' }));
return true;
}
const groupsConfig = loadGroupsConfig(location as CommandLocation, validatedProjectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(groupsConfig));
} 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: message }));
}
return true;
}
// PUT /api/commands/groups - Update groups configuration
if (pathname === '/api/commands/groups' && req.method === 'PUT') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const location = url.searchParams.get('location') || 'project';
handlePostRequest(req, res, async (body) => {
try {
const validatedProjectPath = await validateAllowedPath(projectPathParam, {
mustExist: true,
allowedDirectories: [initialPath]
});
if (location !== 'project' && location !== 'user') {
return { error: 'Invalid location', status: 400 };
}
if (!isRecord(body)) {
return { error: 'Invalid request body', status: 400 };
}
// Validate and save groups config
const config: CommandGroupsConfig = {
groups: isRecord(body.groups) ? body.groups as Record<string, GroupDefinition> : {},
assignments: isRecord(body.assignments) ? body.assignments as Record<string, string> : {}
};
saveGroupsConfig(location as CommandLocation, validatedProjectPath, config);
return {
success: true,
message: 'Groups configuration updated',
data: config,
status: 200
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
console.error(`[Commands] Failed to update groups config: ${message}`);
return { error: message, status };
}
});
return true;
}
return false;
}

View File

@@ -2,7 +2,7 @@
* Skills Routes Module
* Handles all Skills-related API endpoints
*/
import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, renameSync, writeFileSync, mkdirSync, cpSync, rmSync, promises as fsPromises } from 'fs';
import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, renameSync, promises as fsPromises } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { executeCliTool } from '../../tools/cli-executor.js';
@@ -16,8 +16,6 @@ import type {
SkillsConfig,
SkillInfo,
SkillFolderValidation,
DisabledSkillInfo,
DisabledSkillsConfig,
DisabledSkillSummary,
ExtendedSkillsConfig,
SkillOperationResult
@@ -40,106 +38,15 @@ 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
* Throws on JSON parse errors to surface config corruption
*/
function loadDisabledSkillsConfig(location: SkillLocation, projectPath: string): DisabledSkillsConfig {
const configPath = getDisabledSkillsConfigPath(location, projectPath);
if (!existsSync(configPath)) {
return { skills: {} };
}
try {
const content = readFileSync(configPath, 'utf8');
const config = JSON.parse(content);
return { skills: config.skills || {} };
} catch (error) {
// Throw on JSON parse errors to surface config corruption
if (error instanceof SyntaxError) {
throw new Error(`Config file corrupted: ${configPath}`);
}
// Log and return empty for other errors (permission, etc.)
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 and rollback on failure
*/
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 });
try {
rmSync(source, { recursive: true, force: true });
} catch (rmError) {
// Rollback: remove the copied target directory to avoid duplicates
try {
rmSync(target, { recursive: true, force: true });
} catch {
// Ignore rollback errors
}
throw new Error(`Failed to remove source directory after copy: ${(rmError as Error).message}`);
}
} else {
throw error;
}
}
}
/**
* Disable a skill by moving it to disabled directory
* Disable a skill by renaming SKILL.md to SKILL.md.disabled
*/
async function disableSkill(
skillName: string,
location: SkillLocation,
projectPath: string,
initialPath: string,
reason?: string
reason?: string // Kept for API compatibility but no longer used
): Promise<SkillOperationResult> {
try {
// Validate skill name
@@ -147,7 +54,7 @@ async function disableSkill(
return { success: false, message: 'Invalid skill name', status: 400 };
}
// Get source directory
// Get skill directory
let skillsDir: string;
if (location === 'project') {
try {
@@ -161,42 +68,23 @@ async function disableSkill(
skillsDir = join(homedir(), '.claude', 'skills');
}
const sourceDir = join(skillsDir, skillName);
if (!existsSync(sourceDir)) {
const skillDir = join(skillsDir, skillName);
if (!existsSync(skillDir)) {
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 skillMdPath = join(skillDir, 'SKILL.md');
if (!existsSync(skillMdPath)) {
return { success: false, message: 'SKILL.md not found', status: 404 };
}
const targetDir = join(disabledDir, skillName);
if (existsSync(targetDir)) {
return { success: false, message: 'Skill already exists in disabled directory', status: 409 };
const disabledPath = join(skillDir, 'SKILL.md.disabled');
if (existsSync(disabledPath)) {
return { success: false, message: 'Skill already disabled', status: 409 };
}
// Move skill to disabled directory
moveDirectory(sourceDir, targetDir);
// Update config with rollback on failure
try {
const config = loadDisabledSkillsConfig(location, projectPath);
config.skills[skillName] = {
disabledAt: new Date().toISOString(),
reason
};
saveDisabledSkillsConfig(location, projectPath, config);
} catch (configError) {
// Rollback: move the skill back to original location
try {
moveDirectory(targetDir, sourceDir);
} catch {
// Ignore rollback errors - skill is in disabled directory but not in config
}
throw new Error(`Failed to update config: ${(configError as Error).message}`);
}
// Rename: SKILL.md → SKILL.md.disabled
renameSync(skillMdPath, disabledPath);
return { success: true, message: 'Skill disabled', skillName, location };
} catch (error) {
@@ -205,7 +93,7 @@ async function disableSkill(
}
/**
* Enable a skill by moving it back from disabled directory
* Enable a skill by renaming SKILL.md.disabled back to SKILL.md
*/
async function enableSkill(
skillName: string,
@@ -219,14 +107,7 @@ async function enableSkill(
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)
// Get skill directory
let skillsDir: string;
if (location === 'project') {
try {
@@ -240,33 +121,24 @@ async function enableSkill(
skillsDir = join(homedir(), '.claude', 'skills');
}
if (!existsSync(skillsDir)) {
mkdirSync(skillsDir, { recursive: true });
const skillDir = join(skillsDir, skillName);
if (!existsSync(skillDir)) {
return { success: false, message: 'Skill not found', status: 404 };
}
const targetDir = join(skillsDir, skillName);
if (existsSync(targetDir)) {
return { success: false, message: 'Skill already exists in skills directory', status: 409 };
const disabledPath = join(skillDir, 'SKILL.md.disabled');
if (!existsSync(disabledPath)) {
return { success: false, message: 'Disabled skill not found', status: 404 };
}
// Move skill back to skills directory
moveDirectory(sourceDir, targetDir);
// Update config with rollback on failure
try {
const config = loadDisabledSkillsConfig(location, projectPath);
delete config.skills[skillName];
saveDisabledSkillsConfig(location, projectPath, config);
} catch (configError) {
// Rollback: move the skill back to disabled directory
try {
moveDirectory(targetDir, sourceDir);
} catch {
// Ignore rollback errors - skill is in skills directory but still in config
}
throw new Error(`Failed to update config: ${(configError as Error).message}`);
const skillMdPath = join(skillDir, 'SKILL.md');
if (existsSync(skillMdPath)) {
return { success: false, message: 'Skill already enabled', status: 409 };
}
// Rename: SKILL.md.disabled → SKILL.md
renameSync(disabledPath, skillMdPath);
return { success: true, message: 'Skill enabled', skillName, location };
} catch (error) {
return { success: false, message: (error as Error).message, status: 500 };
@@ -274,28 +146,33 @@ async function enableSkill(
}
/**
* Get list of disabled skills
* Get list of disabled skills by checking for SKILL.md.disabled files
*/
function getDisabledSkillsList(location: SkillLocation, projectPath: string): DisabledSkillSummary[] {
const disabledDir = getDisabledSkillsDir(location, projectPath);
const config = loadDisabledSkillsConfig(location, projectPath);
const result: DisabledSkillSummary[] = [];
if (!existsSync(disabledDir)) {
// Get skills directory (not a separate disabled directory)
let skillsDir: string;
if (location === 'project') {
skillsDir = join(projectPath, '.claude', 'skills');
} else {
skillsDir = join(homedir(), '.claude', 'skills');
}
if (!existsSync(skillsDir)) {
return result;
}
try {
const skills = readdirSync(disabledDir, { withFileTypes: true });
const skills = readdirSync(skillsDir, { 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 disabledPath = join(skillsDir, skill.name, 'SKILL.md.disabled');
if (existsSync(disabledPath)) {
const content = readFileSync(disabledPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
const skillDir = join(disabledDir, skill.name);
const skillDir = join(skillsDir, skill.name);
const supportingFiles = getSupportingFiles(skillDir);
const disabledInfo = config.skills[skill.name] || { disabledAt: new Date().toISOString() };
result.push({
name: parsed.name || skill.name,
@@ -306,8 +183,8 @@ function getDisabledSkillsList(location: SkillLocation, projectPath: string): Di
location,
path: skillDir,
supportingFiles,
disabledAt: disabledInfo.disabledAt,
reason: disabledInfo.reason
disabledAt: new Date().toISOString(), // Cannot get exact time without config file
reason: undefined // No longer stored
});
}
}
@@ -396,7 +273,8 @@ function getSupportingFiles(skillDir: string): string[] {
try {
const entries = readdirSync(skillDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name !== 'SKILL.md') {
// Exclude SKILL.md and SKILL.md.disabled from supporting files
if (entry.name !== 'SKILL.md' && entry.name !== 'SKILL.md.disabled') {
if (entry.isFile()) {
files.push(entry.name);
} else if (entry.isDirectory()) {

View File

@@ -164,6 +164,7 @@ const MODULE_FILES = [
'views/prompt-history.js',
'views/skills-manager.js',
'views/rules-manager.js',
'views/commands-manager.js',
'views/claude-manager.js',
'views/api-settings.js',
'views/help.js',

View File

@@ -1603,6 +1603,46 @@ const i18n = {
// Rules
'nav.rules': 'Rules',
'nav.commands': 'Commands',
'title.commandsManager': 'Commands Manager',
'commands.title': 'Commands Manager',
'commands.description': 'Manage Claude Code commands - enable, disable, and organize by group',
'commands.totalCommands': 'Total Commands',
'commands.enabledCommands': 'Enabled Commands',
'commands.disabledCommands': 'Disabled Commands',
'commands.showDisabled': 'Show Disabled',
'commands.hideDisabled': 'Hide Disabled',
'commands.noDescription': 'No description',
'commands.disabledAt': 'Disabled at',
'commands.enableConfirm': 'Enable command "{name}"?',
'commands.disableConfirm': 'Disable command "{name}"?',
'commands.enableSuccess': 'Command "{name}" enabled successfully',
'commands.disableSuccess': 'Command "{name}" disabled successfully',
'commands.toggleError': 'Failed to toggle command status',
'commands.enabled': 'enabled',
'commands.disabled': 'disabled',
'commands.name': 'Name',
'commands.description': 'Description',
'commands.scope': 'Scope',
'commands.status': 'Status',
'commands.group.cli': 'CLI',
'commands.group.workflow': 'Workflow',
'commands.group.memory': 'Memory',
'commands.group.task': 'Task',
'commands.group.issue': 'Issue',
'commands.group.other': 'Other',
'commands.enableAll': 'Enable All',
'commands.disableAll': 'Disable All',
'commands.enableGroupConfirm': 'Enable all commands in "{group}" group?',
'commands.disableGroupConfirm': 'Disable all commands in "{group}" group?',
'commands.enableGroupSuccess': 'Group "{group}" enabled successfully',
'commands.disableGroupSuccess': 'Group "{group}" disabled successfully',
'commands.locationProject': 'Project',
'commands.locationUser': 'Global',
'commands.clickToEnableAll': 'Click to enable all commands in this group',
'commands.clickToDisableAll': 'Click to disable all commands in this group',
// Rules
'title.rulesManager': 'Rules Manager',
'rules.title': 'Rules Manager',
'rules.description': 'Manage project and user rules for Claude Code',
@@ -4239,6 +4279,46 @@ const i18n = {
// Rules
'nav.rules': '规则',
'nav.commands': '命令',
'title.commandsManager': '命令管理',
'commands.title': '命令管理',
'commands.description': '管理 Claude Code 命令 - 启用、禁用和按组织分组',
'commands.totalCommands': '总命令数',
'commands.enabledCommands': '已启用命令',
'commands.disabledCommands': '已禁用命令',
'commands.showDisabled': '显示已禁用',
'commands.hideDisabled': '隐藏已禁用',
'commands.noDescription': '无描述',
'commands.disabledAt': '禁用于',
'commands.enableConfirm': '启用命令 "{name}"',
'commands.disableConfirm': '禁用命令 "{name}"',
'commands.enableSuccess': '命令 "{name}" 已成功启用',
'commands.disableSuccess': '命令 "{name}" 已成功禁用',
'commands.toggleError': '切换命令状态失败',
'commands.enabled': '已启用',
'commands.disabled': '已禁用',
'commands.name': '名称',
'commands.description': '描述',
'commands.scope': '作用域',
'commands.status': '状态',
'commands.group.cli': 'CLI',
'commands.group.workflow': '工作流',
'commands.group.memory': '记忆',
'commands.group.task': '任务',
'commands.group.issue': '问题',
'commands.group.other': '其他',
'commands.enableAll': '全部启用',
'commands.disableAll': '全部禁用',
'commands.enableGroupConfirm': '启用 "{group}" 分组中的所有命令?',
'commands.disableGroupConfirm': '禁用 "{group}" 分组中的所有命令?',
'commands.enableGroupSuccess': '分组 "{group}" 已全部启用',
'commands.disableGroupSuccess': '分组 "{group}" 已全部禁用',
'commands.locationProject': '项目',
'commands.locationUser': '全局',
'commands.clickToEnableAll': '点击启用该分组所有命令',
'commands.clickToDisableAll': '点击禁用该分组所有命令',
// Rules
'title.rulesManager': '规则管理',
'rules.title': '规则管理',
'rules.description': '管理 Claude Code 的项目和用户规则',

View File

@@ -4,7 +4,9 @@
// ========== Commands State ==========
var commandsData = {
groups: {}, // Organized by group name: { cli: [...], workflow: [...], memory: [...], task: [...], issue: [...] }
allCommands: []
allCommands: [],
projectGroupsConfig: { groups: {}, assignments: {} },
userGroupsConfig: { groups: {}, assignments: {} }
};
var expandedGroups = {
cli: true,
@@ -15,6 +17,7 @@ var expandedGroups = {
};
var showDisabledCommands = false;
var commandsLoading = false;
var currentLocation = 'project'; // 'project' or 'user'
// ========== Main Render Function ==========
async function renderCommandsManager() {
@@ -47,11 +50,20 @@ async function loadCommandsData() {
if (!response.ok) throw new Error('Failed to load commands');
const data = await response.json();
// Store groups config
commandsData.projectGroupsConfig = data.projectGroupsConfig || { groups: {}, assignments: {} };
commandsData.userGroupsConfig = data.userGroupsConfig || { groups: {}, assignments: {} };
// Filter commands based on currentLocation
const allCommands = currentLocation === 'project'
? (data.projectCommands || [])
: (data.userCommands || []);
// Organize commands by group
commandsData.groups = {};
commandsData.allCommands = data.commands || [];
commandsData.allCommands = allCommands;
data.commands.forEach(cmd => {
allCommands.forEach(cmd => {
const group = cmd.group || 'other';
if (!commandsData.groups[group]) {
commandsData.groups[group] = [];
@@ -63,7 +75,12 @@ async function loadCommandsData() {
updateCommandsBadge();
} catch (err) {
console.error('Failed to load commands:', err);
commandsData = { groups: {}, allCommands: [] };
commandsData = {
groups: {},
allCommands: [],
projectGroupsConfig: { groups: {}, assignments: {} },
userGroupsConfig: { groups: {}, assignments: {} }
};
} finally {
commandsLoading = false;
}
@@ -77,12 +94,47 @@ function updateCommandsBadge() {
}
}
async function switchLocation(location) {
if (location === currentLocation) return;
currentLocation = location;
await loadCommandsData();
renderCommandsView();
}
function renderCommandsView() {
const container = document.getElementById('mainContent');
if (!container) return;
const groups = commandsData.groups || {};
const groupNames = ['cli', 'workflow', 'memory', 'task', 'issue', 'other'];
// Dynamic groups: known groups first, then custom groups hierarchically sorted, 'other' last
const knownOrder = ['cli', 'workflow', 'memory', 'task', 'issue'];
const allGroupNames = Object.keys(groups);
// Separate top-level known groups and nested groups
const topLevelKnown = allGroupNames.filter(g => knownOrder.includes(g));
const nestedAndCustom = allGroupNames.filter(g => g !== 'other' && !knownOrder.includes(g));
// Sort nested/custom groups hierarchically
nestedAndCustom.sort((a, b) => {
// Split by path separator
const aParts = a.split('/');
const bParts = b.split('/');
// Compare level by level
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
if (aParts[i] !== bParts[i]) {
return aParts[i].localeCompare(bParts[i]);
}
}
// If all parts are equal, shorter path comes first
return aParts.length - bParts.length;
});
const groupNames = [...topLevelKnown.filter(g => groups[g] && groups[g].length > 0),
...nestedAndCustom.filter(g => groups[g] && groups[g].length > 0),
'other'].filter(g => groups[g] && groups[g].length > 0);
const totalEnabled = commandsData.allCommands.filter(cmd => cmd.enabled).length;
const totalDisabled = commandsData.allCommands.filter(cmd => !cmd.enabled).length;
@@ -100,11 +152,27 @@ function renderCommandsView() {
<p class="text-sm text-muted-foreground">${t('commands.description') || 'Enable/disable CCW commands'}</p>
</div>
</div>
<button class="px-4 py-2 text-sm ${showDisabledCommands ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'} rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2"
onclick="toggleShowDisabledCommands()">
<i data-lucide="${showDisabledCommands ? 'eye' : 'eye-off'}" class="w-4 h-4"></i>
${showDisabledCommands ? (t('commands.hideDisabled') || 'Hide Disabled') : (t('commands.showDisabled') || 'Show Disabled')} (${totalDisabled})
</button>
<div class="flex items-center gap-2">
<!-- Location Switcher -->
<div class="inline-flex bg-muted rounded-lg p-1">
<button class="px-3 py-1.5 text-sm rounded-md transition-all ${currentLocation === 'project' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick="switchLocation('project')">
<i data-lucide="folder" class="w-3.5 h-3.5 inline mr-1"></i>
${t('commands.locationProject') || 'Project'}
</button>
<button class="px-3 py-1.5 text-sm rounded-md transition-all ${currentLocation === 'user' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick="switchLocation('user')">
<i data-lucide="user" class="w-3.5 h-3.5 inline mr-1"></i>
${t('commands.locationUser') || 'Global'}
</button>
</div>
<!-- Show Disabled Toggle -->
<button class="px-4 py-2 text-sm ${showDisabledCommands ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'} rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2"
onclick="toggleShowDisabledCommands()">
<i data-lucide="${showDisabledCommands ? 'eye' : 'eye-off'}" class="w-4 h-4"></i>
${showDisabledCommands ? (t('commands.hideDisabled') || 'Hide Disabled') : (t('commands.showDisabled') || 'Show Disabled')} (${totalDisabled})
</button>
</div>
</div>
</div>
@@ -128,11 +196,7 @@ function renderCommandsView() {
<!-- Accordion Groups -->
<div class="commands-accordion">
${groupNames.map(groupName => {
const commands = groups[groupName] || [];
if (commands.length === 0) return '';
return renderAccordionGroup(groupName, commands);
}).join('')}
${groupNames.map(groupName => renderAccordionGroup(groupName, groups[groupName])).join('')}
</div>
</div>
`;
@@ -142,6 +206,8 @@ function renderCommandsView() {
}
function renderAccordionGroup(groupName, commands) {
// Default to expanded for new/custom groups
if (expandedGroups[groupName] === undefined) expandedGroups[groupName] = true;
const isExpanded = expandedGroups[groupName];
const enabledCommands = commands.filter(cmd => cmd.enabled);
const disabledCommands = commands.filter(cmd => !cmd.enabled);
@@ -177,26 +243,53 @@ function renderAccordionGroup(groupName, commands) {
return `
<div class="accordion-group mb-4">
<!-- Group Header -->
<div class="accordion-header flex items-center justify-between px-4 py-3 bg-card border border-border rounded-lg cursor-pointer hover:bg-hover transition-colors"
onclick="toggleAccordionGroup('${groupName}')">
<div class="flex items-center gap-3">
<div class="accordion-header flex items-center justify-between px-4 py-3 bg-card border border-border rounded-lg hover:bg-hover transition-colors">
<div class="flex items-center gap-3 flex-1 cursor-pointer" onclick="toggleAccordionGroup('${groupName}')">
<i data-lucide="${isExpanded ? 'chevron-down' : 'chevron-right'}" class="w-5 h-5 text-muted-foreground transition-transform"></i>
<div class="w-8 h-8 ${colorClass} rounded-lg flex items-center justify-center">
<i data-lucide="${icon}" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-base font-semibold text-foreground capitalize">${groupName}</h3>
<p class="text-xs text-muted-foreground">${enabledCommands.length}/${commands.length} enabled</p>
<h3 class="text-base font-semibold text-foreground capitalize">${t('commands.group.' + groupName) || groupName}</h3>
<p class="text-xs text-muted-foreground">${enabledCommands.length}/${commands.length} ${t('commands.enabled') || 'enabled'}</p>
</div>
</div>
<span class="text-xs px-2 py-1 bg-muted rounded-full text-muted-foreground">${commands.length}</span>
<div class="flex items-center gap-3">
<!-- Group Toggle Switch -->
<label class="group-toggle-switch relative inline-flex items-center cursor-pointer" title="${enabledCommands.length === commands.length ? (t('commands.clickToDisableAll') || 'Click to disable all') : (t('commands.clickToEnableAll') || 'Click to enable all')}">
<input type="checkbox"
class="sr-only peer"
${enabledCommands.length === commands.length ? 'checked' : ''}
onchange="toggleGroupEnabled('${groupName}', ${enabledCommands.length === commands.length})">
<div class="w-11 h-6 bg-muted peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-success"></div>
</label>
<span class="text-xs px-2 py-1 bg-muted rounded-full text-muted-foreground">${commands.length}</span>
</div>
</div>
<!-- Group Content (Cards Grid) -->
<!-- Group Content (Compact Table) -->
${isExpanded ? `
<div class="accordion-content mt-3">
<div class="commands-grid grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
${visibleCommands.map(cmd => renderCommandCard(cmd)).join('')}
<div class="bg-card border border-border rounded-lg overflow-hidden">
<table class="w-full commands-table" style="table-layout: fixed;">
<colgroup>
<col style="width: 200px;">
<col style="width: auto;">
<col style="width: 100px;">
<col style="width: 80px;">
</colgroup>
<thead class="bg-muted/30 border-b border-border">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">${t('commands.name') || 'Name'}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">${t('commands.description') || 'Description'}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-muted-foreground uppercase">${t('commands.scope') || 'Scope'}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-muted-foreground uppercase">${t('commands.status') || 'Status'}</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
${visibleCommands.map(cmd => renderCommandRow(cmd)).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
@@ -204,53 +297,40 @@ function renderAccordionGroup(groupName, commands) {
`;
}
function renderCommandCard(command) {
function renderCommandRow(command) {
const isDisabled = !command.enabled;
const cardOpacity = isDisabled ? 'opacity-60' : '';
return `
<div class="command-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-1 min-w-0">
<h4 class="font-semibold text-foreground truncate">${escapeHtml(command.name)}</h4>
<span class="text-xs px-2 py-0.5 rounded-full ${getGroupBadgeClass(command.group)} inline-block mt-1">
${command.group || 'other'}
</span>
</div>
<div class="ml-2 flex-shrink-0">
<label class="command-toggle-switch relative inline-block w-11 h-6 cursor-pointer">
<input type="checkbox"
class="sr-only"
${command.enabled ? 'checked' : ''}
onchange="toggleCommandEnabled('${escapeHtml(command.name)}', ${command.enabled})"
data-command-toggle="${escapeHtml(command.name)}">
<span class="command-toggle-slider absolute inset-0 rounded-full transition-all duration-200 ${command.enabled ? 'bg-success' : 'bg-muted'}"></span>
</label>
</div>
</div>
<p class="text-sm text-muted-foreground mb-3 line-clamp-2">${escapeHtml(command.description || t('commands.noDescription') || 'No description available')}</p>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-2">
<span class="flex items-center gap-1">
<i data-lucide="folder" class="w-3 h-3"></i>
${command.scope || 'project'}
</span>
<tr class="hover:bg-muted/20 transition-colors ${isDisabled ? 'opacity-60' : ''}">
<td class="px-4 py-3 text-sm font-medium text-foreground">
<div class="flex items-center gap-2 flex-wrap">
<span class="break-words">${escapeHtml(command.name)}</span>
${command.triggers && command.triggers.length > 0 ? `
<span class="flex items-center gap-1">
<i data-lucide="zap" class="w-3 h-3"></i>
${command.triggers.length} trigger${command.triggers.length > 1 ? 's' : ''}
<span class="text-xs px-1.5 py-0.5 bg-warning/10 text-warning rounded flex-shrink-0" title="${command.triggers.length} trigger(s)">
<i data-lucide="zap" class="w-3 h-3 inline mr-0.5"></i>${command.triggers.length}
</span>
` : ''}
</div>
${isDisabled && command.disabledAt ? `
<span class="text-xs text-muted-foreground/70">
${t('commands.disabledAt') || 'Disabled'}: ${formatDisabledDate(command.disabledAt)}
</span>
` : ''}
</div>
</div>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
<div class="line-clamp-3 break-words">${escapeHtml(command.description || t('commands.noDescription') || '-')}</div>
</td>
<td class="px-4 py-3 text-center text-xs text-muted-foreground">
<span class="whitespace-nowrap">${command.scope || 'project'}</span>
</td>
<td class="px-4 py-3">
<div class="flex justify-center">
<label class="command-toggle-switch relative inline-flex items-center cursor-pointer">
<input type="checkbox"
class="sr-only peer"
${command.enabled ? 'checked' : ''}
onchange="toggleCommandEnabled('${escapeHtml(command.name)}', ${command.enabled})"
data-command-toggle="${escapeHtml(command.name)}">
<div class="w-11 h-6 bg-muted peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</td>
</tr>
`;
}
@@ -284,20 +364,6 @@ async function toggleCommandEnabled(commandName, currentlyEnabled) {
var loadingKey = commandName;
if (toggleLoadingCommands[loadingKey]) return;
var action = currentlyEnabled ? 'disable' : 'enable';
var confirmMessage = currentlyEnabled
? t('commands.disableConfirm', { name: commandName }) || `Disable command "${commandName}"?`
: t('commands.enableConfirm', { name: commandName }) || `Enable command "${commandName}"?`;
if (!confirm(confirmMessage)) {
// Reset toggle state if user cancels
const toggleInput = document.querySelector(`[data-command-toggle="${commandName}"]`);
if (toggleInput) {
toggleInput.checked = currentlyEnabled;
}
return;
}
// Set loading state
toggleLoadingCommands[loadingKey] = true;
var toggleInput = document.querySelector('[data-command-toggle="' + commandName + '"]');
@@ -306,10 +372,14 @@ async function toggleCommandEnabled(commandName, currentlyEnabled) {
}
try {
var response = await fetch('/api/commands/' + encodeURIComponent(commandName) + '/' + action, {
var response = await fetch('/api/commands/' + encodeURIComponent(commandName) + '/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath: projectPath })
body: JSON.stringify({
projectPath: projectPath,
location: currentLocation,
enable: !currentlyEnabled
})
});
if (!response.ok) {
@@ -352,6 +422,50 @@ async function toggleCommandEnabled(commandName, currentlyEnabled) {
}
}
async function toggleGroupEnabled(groupName, currentlyAllEnabled) {
const enable = !currentlyAllEnabled;
try {
const response = await fetch('/api/commands/group/' + encodeURIComponent(groupName) + '/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
location: currentLocation,
enable: enable
})
});
if (!response.ok) {
var errorMessage = 'Operation failed';
try {
var error = await response.json();
errorMessage = error.message || errorMessage;
} catch (jsonErr) {
errorMessage = response.statusText || errorMessage;
}
throw new Error(errorMessage);
}
// Reload commands data
await loadCommandsData();
renderCommandsView();
if (window.showToast) {
const groupLabel = t('commands.group.' + groupName) || groupName;
const message = enable
? (t('commands.enableGroupSuccess', { group: groupLabel }) || `Group "${groupLabel}" enabled`)
: (t('commands.disableGroupSuccess', { group: groupLabel }) || `Group "${groupLabel}" disabled`);
showToast(message, 'success');
}
} catch (err) {
console.error('Failed to toggle group:', err);
if (window.showToast) {
showToast(err.message || t('commands.toggleError') || 'Failed to toggle group', 'error');
}
}
}
function formatDisabledDate(isoString) {
try {
const date = new Date(isoString);

View File

@@ -8,25 +8,6 @@
*/
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)
*/