diff --git a/ccw/src/cli.js b/ccw/src/cli.js index 989a0b51..679106e7 100644 --- a/ccw/src/cli.js +++ b/ccw/src/cli.js @@ -3,6 +3,7 @@ import { viewCommand } from './commands/view.js'; import { serveCommand } from './commands/serve.js'; import { installCommand } from './commands/install.js'; import { uninstallCommand } from './commands/uninstall.js'; +import { upgradeCommand } from './commands/upgrade.js'; import { listCommand } from './commands/list.js'; import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; @@ -73,6 +74,9 @@ export function run(argv) { .description('Install Claude Code Workflow to your system') .option('-m, --mode ', 'Installation mode: Global or Path') .option('-p, --path ', 'Installation path (for Path mode)') + .option('-v, --version ', 'Version type: local, stable, latest, or branch', 'local') + .option('-t, --tag ', 'Specific release tag (e.g., v3.2.0) for stable version') + .option('-b, --branch ', 'Branch name for branch version type', 'main') .option('-f, --force', 'Force installation without prompts') .action(installCommand); @@ -82,6 +86,17 @@ export function run(argv) { .description('Uninstall Claude Code Workflow') .action(uninstallCommand); + // Upgrade command + program + .command('upgrade') + .description('Upgrade Claude Code Workflow installations to latest version') + .option('-a, --all', 'Upgrade all installations without prompting') + .option('-l, --latest', 'Upgrade to latest development version (main branch)') + .option('-t, --tag ', 'Upgrade to specific release tag (e.g., v3.2.0)') + .option('-b, --branch ', 'Upgrade to specific branch') + .option('-s, --select', 'Force interactive version selection') + .action(upgradeCommand); + // List command program .command('list') diff --git a/ccw/src/commands/install.js b/ccw/src/commands/install.js index eca00f67..ba505d6a 100644 --- a/ccw/src/commands/install.js +++ b/ccw/src/commands/install.js @@ -7,7 +7,7 @@ import chalk from 'chalk'; import { showHeader, showBanner, createSpinner, success, info, warning, error, summaryBox, step, divider } from '../utils/ui.js'; import { createManifest, addFileEntry, addDirectoryEntry, saveManifest, findManifest, getAllManifests } from '../core/manifest.js'; import { validatePath } from '../utils/path-resolver.js'; -import { fetchLatestRelease, fetchLatestCommit, downloadAndExtract, REPO_URL } from '../utils/version-fetcher.js'; +import { fetchLatestRelease, fetchLatestCommit, fetchRecentReleases, downloadAndExtract, cleanupTemp, REPO_URL } from '../utils/version-fetcher.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -59,6 +59,29 @@ export async function installCommand(options) { } } + // Determine source directory based on version option + let sourceDir; + let tempDir = null; + let versionInfo = { version: getVersion(), branch: 'local', commit: '' }; + + if (options.version && options.version !== 'local') { + // Remote installation - download from GitHub + const downloadResult = await selectAndDownloadVersion(options); + if (!downloadResult) { + return; // User cancelled or error occurred + } + sourceDir = downloadResult.repoDir; + tempDir = downloadResult.tempDir; + versionInfo = { + version: downloadResult.version, + branch: downloadResult.branch, + commit: downloadResult.commit + }; + } else { + // Local installation from package source + sourceDir = getSourceDir(); + } + // Interactive mode selection const mode = options.mode || await selectMode(); @@ -73,6 +96,7 @@ export async function installCommand(options) { const pathValidation = validatePath(inputPath, { mustExist: true }); if (!pathValidation.valid) { error(`Invalid installation path: ${pathValidation.error}`); + if (tempDir) cleanupTemp(tempDir); process.exit(1); } @@ -81,12 +105,12 @@ export async function installCommand(options) { } // Validate source directories exist - const sourceDir = getSourceDir(); const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir))); if (availableDirs.length === 0) { error('No source directories found to install.'); error(`Expected directories in: ${sourceDir}`); + if (tempDir) cleanupTemp(tempDir); process.exit(1); } @@ -135,13 +159,15 @@ export async function installCommand(options) { // Create version.json const versionPath = join(installPath, '.claude', 'version.json'); if (existsSync(dirname(versionPath))) { - const versionInfo = { - version: version, + const versionData = { + version: versionInfo.version, + branch: versionInfo.branch, + commit: versionInfo.commit, installedAt: new Date().toISOString(), mode: mode, installer: 'ccw' }; - writeFileSync(versionPath, JSON.stringify(versionInfo, null, 2), 'utf8'); + writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8'); addFileEntry(manifest, versionPath); totalFiles++; } @@ -151,27 +177,46 @@ export async function installCommand(options) { } catch (err) { spinner.fail('Installation failed'); error(err.message); + if (tempDir) cleanupTemp(tempDir); process.exit(1); } + // Cleanup temp directory if used + if (tempDir) { + cleanupTemp(tempDir); + } + // Save manifest const manifestPath = saveManifest(manifest); // Show summary console.log(''); + const summaryLines = [ + chalk.green.bold('✓ Installation Successful'), + '', + chalk.white(`Mode: ${chalk.cyan(mode)}`), + chalk.white(`Path: ${chalk.cyan(installPath)}`), + chalk.white(`Version: ${chalk.cyan(versionInfo.version)}`), + ]; + + if (versionInfo.branch && versionInfo.branch !== 'local') { + summaryLines.push(chalk.white(`Branch: ${chalk.cyan(versionInfo.branch)}`)); + } + if (versionInfo.commit) { + summaryLines.push(chalk.white(`Commit: ${chalk.cyan(versionInfo.commit)}`)); + } + + summaryLines.push( + '', + chalk.gray(`Files installed: ${totalFiles}`), + chalk.gray(`Directories created: ${totalDirs}`), + '', + chalk.gray(`Manifest: ${basename(manifestPath)}`) + ); + summaryBox({ title: ' Installation Summary ', - lines: [ - chalk.green.bold('✓ Installation Successful'), - '', - chalk.white(`Mode: ${chalk.cyan(mode)}`), - chalk.white(`Path: ${chalk.cyan(installPath)}`), - '', - chalk.gray(`Files installed: ${totalFiles}`), - chalk.gray(`Directories created: ${totalDirs}`), - '', - chalk.gray(`Manifest: ${basename(manifestPath)}`), - ], + lines: summaryLines, borderColor: 'green' }); @@ -184,6 +229,177 @@ export async function installCommand(options) { console.log(''); } +/** + * Select version and download from GitHub + * @param {Object} options - Command options + * @returns {Promise} - Download result or null if cancelled + */ +async function selectAndDownloadVersion(options) { + console.log(''); + divider(); + info('Version Selection'); + divider(); + console.log(''); + + // Fetch version information + const spinner = createSpinner('Fetching version information...').start(); + + let latestRelease = null; + let latestCommit = null; + let recentReleases = []; + + try { + [latestRelease, latestCommit, recentReleases] = await Promise.all([ + fetchLatestRelease().catch(() => null), + fetchLatestCommit('main').catch(() => null), + fetchRecentReleases(5).catch(() => []) + ]); + spinner.succeed('Version information loaded'); + } catch (err) { + spinner.warn('Could not fetch all version info'); + } + + console.log(''); + + // Build version choices + const choices = []; + + // Option 1: Latest Stable + if (latestRelease) { + choices.push({ + name: `${chalk.green('1)')} ${chalk.green.bold('Latest Stable')} ${chalk.cyan(latestRelease.tag)} ${chalk.gray(`(${latestRelease.date})`)} ${chalk.green('← Recommended')}`, + value: { type: 'stable', tag: '' } + }); + } else { + choices.push({ + name: `${chalk.green('1)')} ${chalk.green.bold('Latest Stable')} ${chalk.gray('(auto-detect)')} ${chalk.green('← Recommended')}`, + value: { type: 'stable', tag: '' } + }); + } + + // Option 2: Latest Development + if (latestCommit) { + choices.push({ + name: `${chalk.yellow('2)')} ${chalk.yellow.bold('Latest Development')} ${chalk.gray(`main @ ${latestCommit.shortSha}`)} ${chalk.gray(`(${latestCommit.date})`)}`, + value: { type: 'latest', branch: 'main' } + }); + } else { + choices.push({ + name: `${chalk.yellow('2)')} ${chalk.yellow.bold('Latest Development')} ${chalk.gray('(main branch)')}`, + value: { type: 'latest', branch: 'main' } + }); + } + + // Option 3: Specific Version + choices.push({ + name: `${chalk.cyan('3)')} ${chalk.cyan.bold('Specific Version')} ${chalk.gray('- Choose from available releases')}`, + value: { type: 'specific' } + }); + + // Option 4: Custom Branch + choices.push({ + name: `${chalk.magenta('4)')} ${chalk.magenta.bold('Custom Branch')} ${chalk.gray('- Install from a specific branch')}`, + value: { type: 'branch' } + }); + + // Check if version was specified via CLI + if (options.version === 'stable') { + return await downloadVersion({ type: 'stable', tag: options.tag || '' }); + } else if (options.version === 'latest') { + return await downloadVersion({ type: 'latest', branch: 'main' }); + } else if (options.version === 'branch' && options.branch) { + return await downloadVersion({ type: 'branch', branch: options.branch }); + } + + // Interactive selection + const { versionChoice } = await inquirer.prompt([{ + type: 'list', + name: 'versionChoice', + message: 'Select version to install:', + choices + }]); + + // Handle specific version selection + if (versionChoice.type === 'specific') { + const tagChoices = recentReleases.length > 0 + ? recentReleases.map(r => ({ + name: `${r.tag} ${chalk.gray(`(${r.date})`)}`, + value: r.tag + })) + : [ + { name: 'v3.2.0', value: 'v3.2.0' }, + { name: 'v3.1.0', value: 'v3.1.0' }, + { name: 'v3.0.1', value: 'v3.0.1' } + ]; + + tagChoices.push({ + name: chalk.gray('Enter custom tag...'), + value: 'custom' + }); + + const { selectedTag } = await inquirer.prompt([{ + type: 'list', + name: 'selectedTag', + message: 'Select release version:', + choices: tagChoices + }]); + + let tag = selectedTag; + if (selectedTag === 'custom') { + const { customTag } = await inquirer.prompt([{ + type: 'input', + name: 'customTag', + message: 'Enter version tag (e.g., v3.2.0):', + validate: (input) => input ? true : 'Tag is required' + }]); + tag = customTag; + } + + return await downloadVersion({ type: 'stable', tag }); + } + + // Handle custom branch selection + if (versionChoice.type === 'branch') { + const { branchName } = await inquirer.prompt([{ + type: 'input', + name: 'branchName', + message: 'Enter branch name:', + default: 'main', + validate: (input) => input ? true : 'Branch name is required' + }]); + + return await downloadVersion({ type: 'branch', branch: branchName }); + } + + return await downloadVersion(versionChoice); +} + +/** + * Download specified version + * @param {Object} versionChoice - Version selection + * @returns {Promise} - Download result + */ +async function downloadVersion(versionChoice) { + console.log(''); + const spinner = createSpinner('Downloading from GitHub...').start(); + + try { + const result = await downloadAndExtract(versionChoice); + spinner.succeed(`Downloaded: ${result.version} (${result.branch})`); + return result; + } catch (err) { + spinner.fail('Download failed'); + error(err.message); + console.log(''); + warning('Common causes:'); + console.log(chalk.gray(' • Network connection issues')); + console.log(chalk.gray(' • Invalid version tag or branch name')); + console.log(chalk.gray(' • GitHub API rate limit exceeded')); + console.log(''); + return null; + } +} + /** * Interactive mode selection * @returns {Promise} - Selected mode diff --git a/ccw/src/commands/upgrade.js b/ccw/src/commands/upgrade.js new file mode 100644 index 00000000..ee710655 --- /dev/null +++ b/ccw/src/commands/upgrade.js @@ -0,0 +1,443 @@ +import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync, rmSync } from 'fs'; +import { join, dirname, basename } from 'path'; +import { homedir } from 'os'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import { showBanner, createSpinner, success, info, warning, error, summaryBox, divider } from '../utils/ui.js'; +import { getAllManifests, createManifest, addFileEntry, addDirectoryEntry, saveManifest, deleteManifest } from '../core/manifest.js'; +import { fetchLatestRelease, fetchLatestCommit, fetchRecentReleases, downloadAndExtract, cleanupTemp, REPO_URL } from '../utils/version-fetcher.js'; + +// Source directories to install +const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen']; + +/** + * Upgrade command handler + * @param {Object} options - Command options + */ +export async function upgradeCommand(options) { + showBanner(); + console.log(chalk.cyan.bold(' Upgrade Claude Code Workflow\n')); + + // Get all manifests + const manifests = getAllManifests(); + + if (manifests.length === 0) { + warning('No installations found.'); + info('Run "ccw install" to install first.'); + return; + } + + // Fetch latest version info + const spinner = createSpinner('Checking for updates...').start(); + + let latestRelease = null; + let latestCommit = null; + + try { + [latestRelease, latestCommit] = await Promise.all([ + fetchLatestRelease().catch(() => null), + fetchLatestCommit('main').catch(() => null) + ]); + spinner.succeed('Version information loaded'); + } catch (err) { + spinner.fail('Could not fetch version info'); + error(err.message); + return; + } + + console.log(''); + + // Display current installations with version comparison + console.log(chalk.white.bold(' Current installations:\n')); + + const upgradeTargets = []; + + for (let i = 0; i < manifests.length; i++) { + const m = manifests[i]; + const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow; + + // Read current version + const versionFile = join(m.installation_path, '.claude', 'version.json'); + let currentVersion = 'unknown'; + let currentBranch = ''; + + if (existsSync(versionFile)) { + try { + const versionData = JSON.parse(readFileSync(versionFile, 'utf8')); + currentVersion = versionData.version || 'unknown'; + currentBranch = versionData.branch || ''; + } catch { + // Ignore parse errors + } + } + + // Determine if upgrade is available + let upgradeAvailable = false; + let targetVersion = ''; + + if (latestRelease) { + const latestVer = latestRelease.version; + if (currentVersion !== latestVer && !currentVersion.startsWith('dev-')) { + upgradeAvailable = true; + targetVersion = latestVer; + } + } + + console.log(chalk.white(` ${i + 1}. `) + modeColor.bold(m.installation_mode)); + console.log(chalk.gray(` Path: ${m.installation_path}`)); + console.log(chalk.gray(` Current: ${currentVersion}${currentBranch ? ` (${currentBranch})` : ''}`)); + + if (upgradeAvailable && latestRelease) { + console.log(chalk.green(` Available: ${latestRelease.tag} `) + chalk.green('← Update available')); + upgradeTargets.push({ + manifest: m, + currentVersion, + targetVersion: latestRelease.tag, + index: i + }); + } else if (currentVersion.startsWith('dev-')) { + console.log(chalk.yellow(` Development version - use --latest to update`)); + } else { + console.log(chalk.gray(` Up to date ✓`)); + } + console.log(''); + } + + divider(); + + // Version selection + let versionChoice = { type: 'stable', tag: '' }; + + if (options.latest) { + versionChoice = { type: 'latest', branch: 'main' }; + info('Upgrading to latest development version (main branch)'); + } else if (options.tag) { + versionChoice = { type: 'stable', tag: options.tag }; + info(`Upgrading to specific version: ${options.tag}`); + } else if (options.branch) { + versionChoice = { type: 'branch', branch: options.branch }; + info(`Upgrading to branch: ${options.branch}`); + } else { + // Interactive version selection if no targets or --select specified + if (upgradeTargets.length === 0 || options.select) { + const { selectVersion } = await inquirer.prompt([{ + type: 'confirm', + name: 'selectVersion', + message: 'Select a specific version to install?', + default: false + }]); + + if (selectVersion) { + versionChoice = await selectVersionInteractive(latestRelease, latestCommit); + if (!versionChoice) return; + } else if (upgradeTargets.length === 0) { + info('All installations are up to date.'); + return; + } + } + } + + // Select which installations to upgrade + let selectedManifests = []; + + if (options.all) { + selectedManifests = manifests; + } else if (manifests.length === 1) { + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `Upgrade ${manifests[0].installation_mode} installation at ${manifests[0].installation_path}?`, + default: true + }]); + + if (!confirm) { + info('Upgrade cancelled'); + return; + } + + selectedManifests = [manifests[0]]; + } else { + const choices = manifests.map((m, i) => { + const target = upgradeTargets.find(t => t.index === i); + const label = target + ? `${m.installation_mode} - ${m.installation_path} ${chalk.green('(update available)')}` + : `${m.installation_mode} - ${m.installation_path}`; + return { + name: label, + value: i, + checked: !!target + }; + }); + + const { selections } = await inquirer.prompt([{ + type: 'checkbox', + name: 'selections', + message: 'Select installations to upgrade:', + choices + }]); + + if (selections.length === 0) { + info('No installations selected'); + return; + } + + selectedManifests = selections.map(i => manifests[i]); + } + + // Download new version + console.log(''); + const downloadSpinner = createSpinner('Downloading update...').start(); + + let downloadResult; + try { + downloadResult = await downloadAndExtract(versionChoice); + downloadSpinner.succeed(`Downloaded: ${downloadResult.version} (${downloadResult.branch})`); + } catch (err) { + downloadSpinner.fail('Download failed'); + error(err.message); + return; + } + + // Perform upgrades + console.log(''); + const results = []; + + for (const manifest of selectedManifests) { + const upgradeSpinner = createSpinner(`Upgrading ${manifest.installation_mode} at ${manifest.installation_path}...`).start(); + + try { + const result = await performUpgrade(manifest, downloadResult); + upgradeSpinner.succeed(`Upgraded ${manifest.installation_mode}: ${result.files} files`); + results.push({ manifest, success: true, ...result }); + } catch (err) { + upgradeSpinner.fail(`Failed to upgrade ${manifest.installation_mode}`); + error(err.message); + results.push({ manifest, success: false, error: err.message }); + } + } + + // Cleanup + cleanupTemp(downloadResult.tempDir); + + // Show summary + console.log(''); + + const successCount = results.filter(r => r.success).length; + const failCount = results.filter(r => !r.success).length; + + const summaryLines = [ + successCount === results.length + ? chalk.green.bold('✓ Upgrade Successful') + : chalk.yellow.bold('⚠ Upgrade Completed with Issues'), + '', + chalk.white(`Version: ${chalk.cyan(downloadResult.version)}`), + chalk.white(`Branch: ${chalk.cyan(downloadResult.branch)}`), + '' + ]; + + if (successCount > 0) { + summaryLines.push(chalk.green(`Upgraded: ${successCount} installation(s)`)); + } + if (failCount > 0) { + summaryLines.push(chalk.red(`Failed: ${failCount} installation(s)`)); + } + + summaryBox({ + title: ' Upgrade Summary ', + lines: summaryLines, + borderColor: failCount > 0 ? 'yellow' : 'green' + }); + + // Show next steps + console.log(''); + info('Next steps:'); + console.log(chalk.gray(' 1. Restart Claude Code or your IDE')); + console.log(chalk.gray(' 2. Run: ccw view - to open the workflow dashboard')); + console.log(''); +} + +/** + * Interactive version selection + * @param {Object} latestRelease - Latest release info + * @param {Object} latestCommit - Latest commit info + * @returns {Promise} - Version choice + */ +async function selectVersionInteractive(latestRelease, latestCommit) { + const choices = []; + + // Option 1: Latest Stable + if (latestRelease) { + choices.push({ + name: `${chalk.green.bold('Latest Stable')} ${chalk.cyan(latestRelease.tag)} ${chalk.gray(`(${latestRelease.date})`)}`, + value: { type: 'stable', tag: '' } + }); + } + + // Option 2: Latest Development + if (latestCommit) { + choices.push({ + name: `${chalk.yellow.bold('Latest Development')} ${chalk.gray(`main @ ${latestCommit.shortSha}`)}`, + value: { type: 'latest', branch: 'main' } + }); + } + + // Option 3: Specific Version + choices.push({ + name: `${chalk.cyan.bold('Specific Version')} ${chalk.gray('- Enter a release tag')}`, + value: { type: 'specific' } + }); + + // Option 4: Cancel + choices.push({ + name: chalk.gray('Cancel'), + value: null + }); + + const { versionChoice } = await inquirer.prompt([{ + type: 'list', + name: 'versionChoice', + message: 'Select version to upgrade to:', + choices + }]); + + if (!versionChoice) { + info('Upgrade cancelled'); + return null; + } + + if (versionChoice.type === 'specific') { + const recentReleases = await fetchRecentReleases(5).catch(() => []); + + const tagChoices = recentReleases.length > 0 + ? recentReleases.map(r => ({ + name: `${r.tag} ${chalk.gray(`(${r.date})`)}`, + value: r.tag + })) + : []; + + tagChoices.push({ + name: chalk.gray('Enter custom tag...'), + value: 'custom' + }); + + const { selectedTag } = await inquirer.prompt([{ + type: 'list', + name: 'selectedTag', + message: 'Select release version:', + choices: tagChoices + }]); + + let tag = selectedTag; + if (selectedTag === 'custom') { + const { customTag } = await inquirer.prompt([{ + type: 'input', + name: 'customTag', + message: 'Enter version tag (e.g., v3.2.0):', + validate: (input) => input ? true : 'Tag is required' + }]); + tag = customTag; + } + + return { type: 'stable', tag }; + } + + return versionChoice; +} + +/** + * Perform upgrade for a single installation + * @param {Object} manifest - Installation manifest + * @param {Object} downloadResult - Download result with repoDir + * @returns {Promise} - Upgrade result + */ +async function performUpgrade(manifest, downloadResult) { + const installPath = manifest.installation_path; + const sourceDir = downloadResult.repoDir; + + // Get available source directories + const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir))); + + if (availableDirs.length === 0) { + throw new Error('No source directories found in download'); + } + + // Create new manifest + const newManifest = createManifest(manifest.installation_mode, installPath); + + let totalFiles = 0; + let totalDirs = 0; + + // Copy each directory + for (const dir of availableDirs) { + const srcPath = join(sourceDir, dir); + const destPath = join(installPath, dir); + + const { files, directories } = await copyDirectory(srcPath, destPath, newManifest); + totalFiles += files; + totalDirs += directories; + } + + // Update version.json + const versionPath = join(installPath, '.claude', 'version.json'); + if (existsSync(dirname(versionPath))) { + const versionData = { + version: downloadResult.version, + branch: downloadResult.branch, + commit: downloadResult.commit, + installedAt: new Date().toISOString(), + upgradedAt: new Date().toISOString(), + mode: manifest.installation_mode, + installer: 'ccw' + }; + writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8'); + addFileEntry(newManifest, versionPath); + totalFiles++; + } + + // Delete old manifest and save new one + if (manifest.manifest_file) { + deleteManifest(manifest.manifest_file); + } + saveManifest(newManifest); + + return { files: totalFiles, directories: totalDirs }; +} + +/** + * Copy directory recursively + * @param {string} src - Source directory + * @param {string} dest - Destination directory + * @param {Object} manifest - Manifest to track files + * @returns {Object} - Count of files and directories + */ +async function copyDirectory(src, dest, manifest) { + let files = 0; + let directories = 0; + + // Create destination directory + if (!existsSync(dest)) { + mkdirSync(dest, { recursive: true }); + directories++; + addDirectoryEntry(manifest, dest); + } + + const entries = readdirSync(src); + + for (const entry of entries) { + const srcPath = join(src, entry); + const destPath = join(dest, entry); + const stat = statSync(srcPath); + + if (stat.isDirectory()) { + const result = await copyDirectory(srcPath, destPath, manifest); + files += result.files; + directories += result.directories; + } else { + copyFileSync(srcPath, destPath); + files++; + addFileEntry(manifest, destPath); + } + } + + return { files, directories }; +} diff --git a/ccw/src/core/server.js b/ccw/src/core/server.js index 2f29ef80..1db5fab5 100644 --- a/ccw/src/core/server.js +++ b/ccw/src/core/server.js @@ -1,11 +1,19 @@ import http from 'http'; import { URL } from 'url'; -import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; -import { join } from 'path'; +import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { createHash } from 'crypto'; import { scanSessions } from './session-scanner.js'; import { aggregateData } from './data-aggregator.js'; import { resolvePath, getRecentPaths, trackRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js'; +// Claude config file path +const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json'); + +// WebSocket clients for real-time notifications +const wsClients = new Set(); + const TEMPLATE_PATH = join(import.meta.dirname, '../templates/dashboard.html'); const CSS_FILE = join(import.meta.dirname, '../templates/dashboard.css'); const JS_FILE = join(import.meta.dirname, '../templates/dashboard.js'); @@ -46,6 +54,10 @@ const MODULE_FILES = [ 'components/modals.js', 'components/navigation.js', 'components/sidebar.js', + 'components/carousel.js', + 'components/notifications.js', + 'components/mcp-manager.js', + 'components/hook-manager.js', 'components/tabs-context.js', 'components/tabs-other.js', 'components/task-drawer-core.js', @@ -57,6 +69,8 @@ const MODULE_FILES = [ 'views/review-session.js', 'views/lite-tasks.js', 'views/fix-session.js', + 'views/mcp-manager.js', + 'views/hook-manager.js', 'main.js' ]; /** @@ -163,6 +177,111 @@ export async function startServer(options = {}) { return; } + // API: Get MCP configuration + if (pathname === '/api/mcp-config') { + const mcpData = getMcpConfig(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(mcpData)); + return; + } + + // API: Toggle MCP server enabled/disabled + if (pathname === '/api/mcp-toggle' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, serverName, enable } = body; + if (!projectPath || !serverName) { + return { error: 'projectPath and serverName are required', status: 400 }; + } + return toggleMcpServerEnabled(projectPath, serverName, enable); + }); + return; + } + + // API: Copy MCP server to project + if (pathname === '/api/mcp-copy-server' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, serverName, serverConfig } = body; + if (!projectPath || !serverName || !serverConfig) { + return { error: 'projectPath, serverName, and serverConfig are required', status: 400 }; + } + return addMcpServerToProject(projectPath, serverName, serverConfig); + }); + return; + } + + // API: Remove MCP server from project + if (pathname === '/api/mcp-remove-server' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, serverName } = body; + if (!projectPath || !serverName) { + return { error: 'projectPath and serverName are required', status: 400 }; + } + return removeMcpServerFromProject(projectPath, serverName); + }); + return; + } + + // API: Hook endpoint for Claude Code notifications + if (pathname === '/api/hook' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { type, filePath, sessionId } = body; + + // Determine session ID from file path if not provided + let resolvedSessionId = sessionId; + if (!resolvedSessionId && filePath) { + resolvedSessionId = extractSessionIdFromPath(filePath); + } + + // Broadcast to all connected WebSocket clients + const notification = { + type: type || 'session_updated', + payload: { + sessionId: resolvedSessionId, + filePath: filePath, + timestamp: new Date().toISOString() + } + }; + + broadcastToClients(notification); + + return { success: true, notification }; + }); + return; + } + + // API: Get hooks configuration + if (pathname === '/api/hooks' && req.method === 'GET') { + const projectPathParam = url.searchParams.get('path'); + const hooksData = getHooksConfig(projectPathParam); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(hooksData)); + return; + } + + // API: Save hook + if (pathname === '/api/hooks' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, scope, event, hookData } = body; + if (!scope || !event || !hookData) { + return { error: 'scope, event, and hookData are required', status: 400 }; + } + return saveHookToSettings(projectPath, scope, event, hookData); + }); + return; + } + + // API: Delete hook + if (pathname === '/api/hooks' && req.method === 'DELETE') { + handlePostRequest(req, res, async (body) => { + const { projectPath, scope, event, hookIndex } = body; + if (!scope || !event || hookIndex === undefined) { + return { error: 'scope, event, and hookIndex are required', status: 400 }; + } + return deleteHookFromSettings(projectPath, scope, event, hookIndex); + }); + return; + } + // Serve dashboard HTML if (pathname === '/' || pathname === '/index.html') { const html = generateServerDashboard(initialPath); @@ -182,15 +301,188 @@ export async function startServer(options = {}) { } }); + // Handle WebSocket upgrade requests + server.on('upgrade', (req, socket, head) => { + if (req.url === '/ws') { + handleWebSocketUpgrade(req, socket, head); + } else { + socket.destroy(); + } + }); + return new Promise((resolve, reject) => { server.listen(port, () => { console.log(`Dashboard server running at http://localhost:${port}`); + console.log(`WebSocket endpoint available at ws://localhost:${port}/ws`); + console.log(`Hook endpoint available at POST http://localhost:${port}/api/hook`); resolve(server); }); server.on('error', reject); }); } +// ======================================== +// WebSocket Functions +// ======================================== + +/** + * Handle WebSocket upgrade + */ +function handleWebSocketUpgrade(req, socket, head) { + const key = req.headers['sec-websocket-key']; + const acceptKey = createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + .digest('base64'); + + const responseHeaders = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${acceptKey}`, + '', + '' + ].join('\r\n'); + + socket.write(responseHeaders); + + // Add to clients set + wsClients.add(socket); + console.log(`[WS] Client connected (${wsClients.size} total)`); + + // Handle incoming messages + socket.on('data', (buffer) => { + try { + const message = parseWebSocketFrame(buffer); + if (message) { + console.log('[WS] Received:', message); + } + } catch (e) { + // Ignore parse errors + } + }); + + // Handle disconnect + socket.on('close', () => { + wsClients.delete(socket); + console.log(`[WS] Client disconnected (${wsClients.size} remaining)`); + }); + + socket.on('error', () => { + wsClients.delete(socket); + }); +} + +/** + * Parse WebSocket frame (simplified) + */ +function parseWebSocketFrame(buffer) { + if (buffer.length < 2) return null; + + const secondByte = buffer[1]; + const isMasked = (secondByte & 0x80) !== 0; + let payloadLength = secondByte & 0x7f; + + let offset = 2; + if (payloadLength === 126) { + payloadLength = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLength === 127) { + payloadLength = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + + let mask = null; + if (isMasked) { + mask = buffer.slice(offset, offset + 4); + offset += 4; + } + + const payload = buffer.slice(offset, offset + payloadLength); + + if (isMasked && mask) { + for (let i = 0; i < payload.length; i++) { + payload[i] ^= mask[i % 4]; + } + } + + return payload.toString('utf8'); +} + +/** + * Create WebSocket frame + */ +function createWebSocketFrame(data) { + const payload = Buffer.from(JSON.stringify(data), 'utf8'); + const length = payload.length; + + let frame; + if (length <= 125) { + frame = Buffer.alloc(2 + length); + frame[0] = 0x81; // Text frame, FIN + frame[1] = length; + payload.copy(frame, 2); + } else if (length <= 65535) { + frame = Buffer.alloc(4 + length); + frame[0] = 0x81; + frame[1] = 126; + frame.writeUInt16BE(length, 2); + payload.copy(frame, 4); + } else { + frame = Buffer.alloc(10 + length); + frame[0] = 0x81; + frame[1] = 127; + frame.writeBigUInt64BE(BigInt(length), 2); + payload.copy(frame, 10); + } + + return frame; +} + +/** + * Broadcast message to all connected WebSocket clients + */ +function broadcastToClients(data) { + const frame = createWebSocketFrame(data); + + for (const client of wsClients) { + try { + client.write(frame); + } catch (e) { + wsClients.delete(client); + } + } + + console.log(`[WS] Broadcast to ${wsClients.size} clients:`, data.type); +} + +/** + * Extract session ID from file path + */ +function extractSessionIdFromPath(filePath) { + // Normalize path + const normalized = filePath.replace(/\\/g, '/'); + + // Look for session pattern: WFS-xxx, WRS-xxx, etc. + const sessionMatch = normalized.match(/\/(W[A-Z]S-[^/]+)\//); + if (sessionMatch) { + return sessionMatch[1]; + } + + // Look for .workflow/.sessions/xxx pattern + const sessionsMatch = normalized.match(/\.workflow\/\.sessions\/([^/]+)/); + if (sessionsMatch) { + return sessionsMatch[1]; + } + + // Look for lite-plan/lite-fix pattern + const liteMatch = normalized.match(/\.(lite-plan|lite-fix)\/([^/]+)/); + if (liteMatch) { + return liteMatch[2]; + } + + return null; +} + /** * Get workflow data for a project path * @param {string} projectPath @@ -318,6 +610,62 @@ async function getSessionDetailData(sessionPath, dataType) { } } + // Load explorations for lite tasks (exploration-*.json files) + if (dataType === 'context' || dataType === 'explorations' || dataType === 'all') { + result.explorations = { manifest: null, data: {} }; + + // Look for explorations-manifest.json + const manifestFile = join(normalizedPath, 'explorations-manifest.json'); + if (existsSync(manifestFile)) { + try { + result.explorations.manifest = JSON.parse(readFileSync(manifestFile, 'utf8')); + + // Load each exploration file based on manifest + const explorations = result.explorations.manifest.explorations || []; + for (const exp of explorations) { + const expFile = join(normalizedPath, exp.file); + if (existsSync(expFile)) { + try { + result.explorations.data[exp.angle] = JSON.parse(readFileSync(expFile, 'utf8')); + } catch (e) { + // Skip unreadable exploration files + } + } + } + } catch (e) { + result.explorations.manifest = null; + } + } else { + // Fallback: scan for exploration-*.json files directly + try { + const files = readdirSync(normalizedPath).filter(f => f.startsWith('exploration-') && f.endsWith('.json')); + if (files.length > 0) { + // Create synthetic manifest + result.explorations.manifest = { + exploration_count: files.length, + explorations: files.map((f, i) => ({ + angle: f.replace('exploration-', '').replace('.json', ''), + file: f, + index: i + 1 + })) + }; + + // Load each file + for (const file of files) { + const angle = file.replace('exploration-', '').replace('.json', ''); + try { + result.explorations.data[angle] = JSON.parse(readFileSync(join(normalizedPath, file), 'utf8')); + } catch (e) { + // Skip unreadable files + } + } + } + } catch (e) { + // Directory read failed + } + } + } + // Load IMPL_PLAN.md if (dataType === 'impl-plan' || dataType === 'all') { const implPlanFile = join(normalizedPath, 'IMPL_PLAN.md'); @@ -547,3 +895,383 @@ async function loadRecentPaths() { return html; } + +// ======================================== +// MCP Configuration Functions +// ======================================== + +/** + * Get MCP configuration from .claude.json + * @returns {Object} + */ +function getMcpConfig() { + try { + if (!existsSync(CLAUDE_CONFIG_PATH)) { + return { projects: {} }; + } + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + return { + projects: config.projects || {} + }; + } catch (error) { + console.error('Error reading MCP config:', error); + return { projects: {}, error: error.message }; + } +} + +/** + * Normalize project path for .claude.json (Windows backslash format) + * @param {string} path + * @returns {string} + */ +function normalizeProjectPathForConfig(path) { + // Convert forward slashes to backslashes for Windows .claude.json format + let normalized = path.replace(/\//g, '\\'); + + // Handle /d/path format -> D:\path + if (normalized.match(/^\\[a-zA-Z]\\/)) { + normalized = normalized.charAt(1).toUpperCase() + ':' + normalized.slice(2); + } + + return normalized; +} + +/** + * Toggle MCP server enabled/disabled + * @param {string} projectPath + * @param {string} serverName + * @param {boolean} enable + * @returns {Object} + */ +function toggleMcpServerEnabled(projectPath, serverName, enable) { + try { + if (!existsSync(CLAUDE_CONFIG_PATH)) { + return { error: '.claude.json not found' }; + } + + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + + const normalizedPath = normalizeProjectPathForConfig(projectPath); + + if (!config.projects || !config.projects[normalizedPath]) { + return { error: `Project not found: ${normalizedPath}` }; + } + + const projectConfig = config.projects[normalizedPath]; + + // Ensure disabledMcpServers array exists + if (!projectConfig.disabledMcpServers) { + projectConfig.disabledMcpServers = []; + } + + if (enable) { + // Remove from disabled list + projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); + } else { + // Add to disabled list if not already there + if (!projectConfig.disabledMcpServers.includes(serverName)) { + projectConfig.disabledMcpServers.push(serverName); + } + } + + // Write back to file + writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + return { + success: true, + serverName, + enabled: enable, + disabledMcpServers: projectConfig.disabledMcpServers + }; + } catch (error) { + console.error('Error toggling MCP server:', error); + return { error: error.message }; + } +} + +/** + * Add MCP server to project + * @param {string} projectPath + * @param {string} serverName + * @param {Object} serverConfig + * @returns {Object} + */ +function addMcpServerToProject(projectPath, serverName, serverConfig) { + try { + if (!existsSync(CLAUDE_CONFIG_PATH)) { + return { error: '.claude.json not found' }; + } + + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + + const normalizedPath = normalizeProjectPathForConfig(projectPath); + + // Create project entry if it doesn't exist + if (!config.projects) { + config.projects = {}; + } + + if (!config.projects[normalizedPath]) { + config.projects[normalizedPath] = { + allowedTools: [], + mcpContextUris: [], + mcpServers: {}, + enabledMcpjsonServers: [], + disabledMcpjsonServers: [], + hasTrustDialogAccepted: false, + projectOnboardingSeenCount: 0, + hasClaudeMdExternalIncludesApproved: false, + hasClaudeMdExternalIncludesWarningShown: false + }; + } + + const projectConfig = config.projects[normalizedPath]; + + // Ensure mcpServers exists + if (!projectConfig.mcpServers) { + projectConfig.mcpServers = {}; + } + + // Add the server + projectConfig.mcpServers[serverName] = serverConfig; + + // Write back to file + writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + return { + success: true, + serverName, + serverConfig + }; + } catch (error) { + console.error('Error adding MCP server:', error); + return { error: error.message }; + } +} + +/** + * Remove MCP server from project + * @param {string} projectPath + * @param {string} serverName + * @returns {Object} + */ +function removeMcpServerFromProject(projectPath, serverName) { + try { + if (!existsSync(CLAUDE_CONFIG_PATH)) { + return { error: '.claude.json not found' }; + } + + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + + const normalizedPath = normalizeProjectPathForConfig(projectPath); + + if (!config.projects || !config.projects[normalizedPath]) { + return { error: `Project not found: ${normalizedPath}` }; + } + + const projectConfig = config.projects[normalizedPath]; + + if (!projectConfig.mcpServers || !projectConfig.mcpServers[serverName]) { + return { error: `Server not found: ${serverName}` }; + } + + // Remove the server + delete projectConfig.mcpServers[serverName]; + + // Also remove from disabled list if present + if (projectConfig.disabledMcpServers) { + projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); + } + + // Write back to file + writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + return { + success: true, + serverName, + removed: true + }; + } catch (error) { + console.error('Error removing MCP server:', error); + return { error: error.message }; + } +} + +// ======================================== +// Hook Configuration Functions +// ======================================== + +const GLOBAL_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json'); + +/** + * Get project settings path + * @param {string} projectPath + * @returns {string} + */ +function getProjectSettingsPath(projectPath) { + const normalizedPath = projectPath.replace(/\//g, '\\').replace(/^\\([a-zA-Z])\\/, '$1:\\'); + return join(normalizedPath, '.claude', 'settings.json'); +} + +/** + * Read settings file safely + * @param {string} filePath + * @returns {Object} + */ +function readSettingsFile(filePath) { + try { + if (!existsSync(filePath)) { + return { hooks: {} }; + } + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch (error) { + console.error(`Error reading settings file ${filePath}:`, error); + return { hooks: {} }; + } +} + +/** + * Write settings file safely + * @param {string} filePath + * @param {Object} settings + */ +function writeSettingsFile(filePath, settings) { + const dirPath = dirname(filePath); + // Ensure directory exists + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + } + writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); +} + +/** + * Get hooks configuration from both global and project settings + * @param {string} projectPath + * @returns {Object} + */ +function getHooksConfig(projectPath) { + const globalSettings = readSettingsFile(GLOBAL_SETTINGS_PATH); + const projectSettingsPath = projectPath ? getProjectSettingsPath(projectPath) : null; + const projectSettings = projectSettingsPath ? readSettingsFile(projectSettingsPath) : { hooks: {} }; + + return { + global: { + path: GLOBAL_SETTINGS_PATH, + hooks: globalSettings.hooks || {} + }, + project: { + path: projectSettingsPath, + hooks: projectSettings.hooks || {} + } + }; +} + +/** + * Save a hook to settings file + * @param {string} projectPath + * @param {string} scope - 'global' or 'project' + * @param {string} event - Hook event type + * @param {Object} hookData - Hook configuration + * @returns {Object} + */ +function saveHookToSettings(projectPath, scope, event, hookData) { + try { + const filePath = scope === 'global' ? GLOBAL_SETTINGS_PATH : getProjectSettingsPath(projectPath); + const settings = readSettingsFile(filePath); + + // Ensure hooks object exists + if (!settings.hooks) { + settings.hooks = {}; + } + + // Ensure the event array exists + if (!settings.hooks[event]) { + settings.hooks[event] = []; + } + + // Ensure it's an array + if (!Array.isArray(settings.hooks[event])) { + settings.hooks[event] = [settings.hooks[event]]; + } + + // Check if we're replacing an existing hook + if (hookData.replaceIndex !== undefined) { + const index = hookData.replaceIndex; + delete hookData.replaceIndex; + if (index >= 0 && index < settings.hooks[event].length) { + settings.hooks[event][index] = hookData; + } + } else { + // Add new hook + settings.hooks[event].push(hookData); + } + + // Ensure directory exists and write file + const dirPath = dirname(filePath); + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + } + writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); + + return { + success: true, + event, + hookData + }; + } catch (error) { + console.error('Error saving hook:', error); + return { error: error.message }; + } +} + +/** + * Delete a hook from settings file + * @param {string} projectPath + * @param {string} scope - 'global' or 'project' + * @param {string} event - Hook event type + * @param {number} hookIndex - Index of hook to delete + * @returns {Object} + */ +function deleteHookFromSettings(projectPath, scope, event, hookIndex) { + try { + const filePath = scope === 'global' ? GLOBAL_SETTINGS_PATH : getProjectSettingsPath(projectPath); + const settings = readSettingsFile(filePath); + + if (!settings.hooks || !settings.hooks[event]) { + return { error: 'Hook not found' }; + } + + // Ensure it's an array + if (!Array.isArray(settings.hooks[event])) { + settings.hooks[event] = [settings.hooks[event]]; + } + + if (hookIndex < 0 || hookIndex >= settings.hooks[event].length) { + return { error: 'Invalid hook index' }; + } + + // Remove the hook + settings.hooks[event].splice(hookIndex, 1); + + // Remove empty event arrays + if (settings.hooks[event].length === 0) { + delete settings.hooks[event]; + } + + writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); + + return { + success: true, + event, + hookIndex + }; + } catch (error) { + console.error('Error deleting hook:', error); + return { error: error.message }; + } +} diff --git a/ccw/src/templates/dashboard-js/components/carousel.js b/ccw/src/templates/dashboard-js/components/carousel.js new file mode 100644 index 00000000..a8b27d16 --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/carousel.js @@ -0,0 +1,349 @@ +// ========================================== +// CAROUSEL COMPONENT +// ========================================== +// Active session carousel with detailed task info and smooth transitions + +let carouselIndex = 0; +let carouselSessions = []; +let carouselInterval = null; +let carouselPaused = false; +const CAROUSEL_INTERVAL_MS = 5000; // 5 seconds + +function initCarousel() { + const prevBtn = document.getElementById('carouselPrev'); + const nextBtn = document.getElementById('carouselNext'); + const pauseBtn = document.getElementById('carouselPause'); + + if (prevBtn) { + prevBtn.addEventListener('click', () => { + carouselPrev(); + resetCarouselInterval(); + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', () => { + carouselNext(); + resetCarouselInterval(); + }); + } + + if (pauseBtn) { + pauseBtn.addEventListener('click', toggleCarouselPause); + } +} + +function updateCarousel() { + // Get active sessions from workflowData + const previousSessions = carouselSessions; + const previousIndex = carouselIndex; + const previousSessionId = previousSessions[previousIndex]?.session_id; + + carouselSessions = workflowData.activeSessions || []; + + // Try to preserve current position + if (previousSessionId && carouselSessions.length > 0) { + // Find if the same session still exists + const newIndex = carouselSessions.findIndex(s => s.session_id === previousSessionId); + if (newIndex !== -1) { + carouselIndex = newIndex; + } else if (previousIndex < carouselSessions.length) { + // Keep same index if valid + carouselIndex = previousIndex; + } else { + // Reset to last valid index + carouselIndex = Math.max(0, carouselSessions.length - 1); + } + } else { + carouselIndex = 0; + } + + renderCarouselDots(); + renderCarouselSlide('none'); + startCarouselInterval(); +} + +function renderCarouselDots() { + const dotsContainer = document.getElementById('carouselDots'); + if (!dotsContainer) return; + + if (carouselSessions.length === 0) { + dotsContainer.innerHTML = ''; + return; + } + + dotsContainer.innerHTML = carouselSessions.map((_, index) => ` + + `).join(''); +} + +function updateActiveDot() { + const dots = document.querySelectorAll('.carousel-dot'); + dots.forEach((dot, index) => { + if (index === carouselIndex) { + dot.classList.remove('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2'); + dot.classList.add('bg-primary', 'w-4'); + } else { + dot.classList.remove('bg-primary', 'w-4'); + dot.classList.add('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2'); + } + }); +} + +function carouselGoToIndex(index) { + if (index < 0 || index >= carouselSessions.length) return; + const direction = index > carouselIndex ? 'left' : (index < carouselIndex ? 'right' : 'none'); + carouselIndex = index; + renderCarouselSlide(direction); + updateActiveDot(); + resetCarouselInterval(); +} + +function renderCarouselSlide(direction = 'none') { + const container = document.getElementById('carouselContent'); + + if (!container) return; + + if (carouselSessions.length === 0) { + container.innerHTML = ` + + `; + return; + } + + const session = carouselSessions[carouselIndex]; + const tasks = session.tasks || []; + const completed = tasks.filter(t => t.status === 'completed').length; + const inProgress = tasks.filter(t => t.status === 'in_progress').length; + const pending = tasks.filter(t => t.status === 'pending').length; + const taskCount = session.taskCount || tasks.length; + const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0; + + // Get session type badge + const sessionType = session.type || 'workflow'; + const typeBadgeClass = getSessionTypeBadgeClass(sessionType); + + const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + + // Animation class based on direction + const animClass = direction === 'left' ? 'carousel-slide-left' : + direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in'; + + // Get recent task activity + const recentTasks = getRecentTaskActivity(tasks); + + // Format timestamps + const createdTime = session.created_at ? formatRelativeTime(session.created_at) : ''; + const updatedTime = session.updated_at ? formatRelativeTime(session.updated_at) : ''; + + // Get more tasks for display (up to 4) + const displayTasks = getRecentTaskActivity(tasks, 4); + + container.innerHTML = ` + + `; + + // Store session data for navigation + if (!sessionDataStore[sessionKey]) { + sessionDataStore[sessionKey] = session; + } +} + +function getSessionTypeBadgeClass(type) { + const classes = { + 'tdd': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + 'review': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'test': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + 'docs': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'workflow': 'bg-primary-light text-primary' + }; + return classes[type] || classes['workflow']; +} + +function getRecentTaskActivity(tasks, limit = 4) { + if (!tasks || tasks.length === 0) return []; + + // Get in_progress tasks first, then most recently updated + const sorted = [...tasks].sort((a, b) => { + // in_progress first + if (a.status === 'in_progress' && b.status !== 'in_progress') return -1; + if (b.status === 'in_progress' && a.status !== 'in_progress') return 1; + // Then by updated_at + const timeA = a.updated_at || a.created_at || ''; + const timeB = b.updated_at || b.created_at || ''; + return timeB.localeCompare(timeA); + }); + + // Return top N tasks + return sorted.slice(0, limit); +} + +function getTaskStatusEmoji(status) { + const emojis = { + 'completed': '✅', + 'in_progress': '🔄', + 'pending': '⏸️', + 'blocked': '🚫' + }; + return emojis[status] || '📋'; +} + +function getTaskStatusIcon(status) { + return status === 'in_progress' ? 'animate-spin-slow' : ''; +} + +function formatRelativeTime(dateString) { + if (!dateString) return ''; + + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + // Format as date for older + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } catch (e) { + return dateString; + } +} + +function carouselNext() { + if (carouselSessions.length === 0) return; + carouselIndex = (carouselIndex + 1) % carouselSessions.length; + renderCarouselSlide('left'); + updateActiveDot(); +} + +function carouselPrev() { + if (carouselSessions.length === 0) return; + carouselIndex = (carouselIndex - 1 + carouselSessions.length) % carouselSessions.length; + renderCarouselSlide('right'); + updateActiveDot(); +} + +function startCarouselInterval() { + stopCarouselInterval(); + if (!carouselPaused && carouselSessions.length > 1) { + carouselInterval = setInterval(carouselNext, CAROUSEL_INTERVAL_MS); + } +} + +function stopCarouselInterval() { + if (carouselInterval) { + clearInterval(carouselInterval); + carouselInterval = null; + } +} + +function resetCarouselInterval() { + if (!carouselPaused) { + startCarouselInterval(); + } +} + +function toggleCarouselPause() { + carouselPaused = !carouselPaused; + const icon = document.getElementById('carouselPauseIcon'); + + if (carouselPaused) { + stopCarouselInterval(); + // Change to play icon + if (icon) { + icon.innerHTML = ''; + } + } else { + startCarouselInterval(); + // Change to pause icon + if (icon) { + icon.innerHTML = ''; + } + } +} + +// Jump to specific session in carousel +function carouselGoTo(sessionId) { + const index = carouselSessions.findIndex(s => s.session_id === sessionId); + if (index !== -1) { + carouselIndex = index; + renderCarouselSlide('none'); + updateActiveDot(); + resetCarouselInterval(); + } +} diff --git a/ccw/src/templates/dashboard-js/components/hook-manager.js b/ccw/src/templates/dashboard-js/components/hook-manager.js new file mode 100644 index 00000000..7d64f401 --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/hook-manager.js @@ -0,0 +1,273 @@ +// Hook Manager Component +// Manages Claude Code hooks configuration from settings.json + +// ========== Hook State ========== +let hookConfig = { + global: { hooks: {} }, + project: { hooks: {} } +}; + +// ========== Hook Templates ========== +const HOOK_TEMPLATES = { + 'ccw-notify': { + event: 'PostToolUse', + matcher: 'Write', + command: 'curl', + args: ['-s', '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"type":"summary_written","filePath":"$CLAUDE_FILE_PATHS"}', 'http://localhost:3456/api/hook'] + }, + 'log-tool': { + event: 'PostToolUse', + matcher: '', + command: 'bash', + args: ['-c', 'echo "[$(date)] Tool: $CLAUDE_TOOL_NAME, Files: $CLAUDE_FILE_PATHS" >> ~/.claude/tool-usage.log'] + }, + 'lint-check': { + event: 'PostToolUse', + matcher: 'Write', + command: 'bash', + args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do if [[ "$f" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$f" --fix 2>/dev/null || true; fi; done'] + }, + 'git-add': { + event: 'PostToolUse', + matcher: 'Write', + command: 'bash', + args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do git add "$f" 2>/dev/null || true; done'] + } +}; + +// ========== Initialization ========== +function initHookManager() { + // Initialize Hook navigation + document.querySelectorAll('.nav-item[data-view="hook-manager"]').forEach(item => { + item.addEventListener('click', () => { + setActiveNavItem(item); + currentView = 'hook-manager'; + currentFilter = null; + currentLiteType = null; + currentSessionDetailKey = null; + updateContentTitle(); + renderHookManager(); + }); + }); +} + +// ========== Data Loading ========== +async function loadHookConfig() { + try { + const response = await fetch(`/api/hooks?path=${encodeURIComponent(projectPath)}`); + if (!response.ok) throw new Error('Failed to load hook config'); + const data = await response.json(); + hookConfig = data; + updateHookBadge(); + return data; + } catch (err) { + console.error('Failed to load hook config:', err); + return null; + } +} + +async function saveHook(scope, event, hookData) { + try { + const response = await fetch('/api/hooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectPath: projectPath, + scope: scope, + event: event, + hookData: hookData + }) + }); + + if (!response.ok) throw new Error('Failed to save hook'); + + const result = await response.json(); + if (result.success) { + await loadHookConfig(); + renderHookManager(); + showRefreshToast(`Hook saved successfully`, 'success'); + } + return result; + } catch (err) { + console.error('Failed to save hook:', err); + showRefreshToast(`Failed to save hook: ${err.message}`, 'error'); + return null; + } +} + +async function removeHook(scope, event, hookIndex) { + try { + const response = await fetch('/api/hooks', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectPath: projectPath, + scope: scope, + event: event, + hookIndex: hookIndex + }) + }); + + if (!response.ok) throw new Error('Failed to remove hook'); + + const result = await response.json(); + if (result.success) { + await loadHookConfig(); + renderHookManager(); + showRefreshToast(`Hook removed successfully`, 'success'); + } + return result; + } catch (err) { + console.error('Failed to remove hook:', err); + showRefreshToast(`Failed to remove hook: ${err.message}`, 'error'); + return null; + } +} + +// ========== Badge Update ========== +function updateHookBadge() { + const badge = document.getElementById('badgeHooks'); + if (badge) { + let totalHooks = 0; + + // Count global hooks + if (hookConfig.global?.hooks) { + for (const event of Object.keys(hookConfig.global.hooks)) { + const hooks = hookConfig.global.hooks[event]; + totalHooks += Array.isArray(hooks) ? hooks.length : 1; + } + } + + // Count project hooks + if (hookConfig.project?.hooks) { + for (const event of Object.keys(hookConfig.project.hooks)) { + const hooks = hookConfig.project.hooks[event]; + totalHooks += Array.isArray(hooks) ? hooks.length : 1; + } + } + + badge.textContent = totalHooks; + } +} + +// ========== Hook Modal Functions ========== +let editingHookData = null; + +function openHookCreateModal(editData = null) { + const modal = document.getElementById('hookCreateModal'); + const title = document.getElementById('hookModalTitle'); + + if (modal) { + modal.classList.remove('hidden'); + editingHookData = editData; + + // Set title based on mode + title.textContent = editData ? 'Edit Hook' : 'Create Hook'; + + // Clear or populate form + if (editData) { + document.getElementById('hookEvent').value = editData.event || ''; + document.getElementById('hookMatcher').value = editData.matcher || ''; + document.getElementById('hookCommand').value = editData.command || ''; + document.getElementById('hookArgs').value = (editData.args || []).join('\n'); + + // Set scope radio + const scopeRadio = document.querySelector(`input[name="hookScope"][value="${editData.scope || 'project'}"]`); + if (scopeRadio) scopeRadio.checked = true; + } else { + document.getElementById('hookEvent').value = ''; + document.getElementById('hookMatcher').value = ''; + document.getElementById('hookCommand').value = ''; + document.getElementById('hookArgs').value = ''; + document.querySelector('input[name="hookScope"][value="project"]').checked = true; + } + + // Focus on event select + document.getElementById('hookEvent').focus(); + } +} + +function closeHookCreateModal() { + const modal = document.getElementById('hookCreateModal'); + if (modal) { + modal.classList.add('hidden'); + editingHookData = null; + } +} + +function applyHookTemplate(templateName) { + const template = HOOK_TEMPLATES[templateName]; + if (!template) return; + + document.getElementById('hookEvent').value = template.event; + document.getElementById('hookMatcher').value = template.matcher; + document.getElementById('hookCommand').value = template.command; + document.getElementById('hookArgs').value = template.args.join('\n'); +} + +async function submitHookCreate() { + const event = document.getElementById('hookEvent').value; + const matcher = document.getElementById('hookMatcher').value.trim(); + const command = document.getElementById('hookCommand').value.trim(); + const argsText = document.getElementById('hookArgs').value.trim(); + const scope = document.querySelector('input[name="hookScope"]:checked').value; + + // Validate required fields + if (!event) { + showRefreshToast('Hook event is required', 'error'); + document.getElementById('hookEvent').focus(); + return; + } + + if (!command) { + showRefreshToast('Command is required', 'error'); + document.getElementById('hookCommand').focus(); + return; + } + + // Parse args (one per line) + const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : []; + + // Build hook data + const hookData = { + command: command + }; + + if (args.length > 0) { + hookData.args = args; + } + + if (matcher) { + hookData.matcher = matcher; + } + + // If editing, include original index for replacement + if (editingHookData && editingHookData.index !== undefined) { + hookData.replaceIndex = editingHookData.index; + } + + // Submit to API + await saveHook(scope, event, hookData); + closeHookCreateModal(); +} + +// ========== Helpers ========== +function getHookEventDescription(event) { + const descriptions = { + 'PreToolUse': 'Runs before a tool is executed', + 'PostToolUse': 'Runs after a tool completes', + 'Notification': 'Runs when a notification is triggered', + 'Stop': 'Runs when the agent stops' + }; + return descriptions[event] || event; +} + +function getHookEventIcon(event) { + const icons = { + 'PreToolUse': '⏳', + 'PostToolUse': '✅', + 'Notification': '🔔', + 'Stop': '🛑' + }; + return icons[event] || '🪝'; +} diff --git a/ccw/src/templates/dashboard-js/components/mcp-manager.js b/ccw/src/templates/dashboard-js/components/mcp-manager.js new file mode 100644 index 00000000..1d3db02d --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/mcp-manager.js @@ -0,0 +1,285 @@ +// MCP Manager Component +// Manages MCP server configuration from .claude.json + +// ========== MCP State ========== +let mcpConfig = null; +let mcpAllProjects = {}; +let mcpCurrentProjectServers = {}; + +// ========== Initialization ========== +function initMcpManager() { + // Initialize MCP navigation + document.querySelectorAll('.nav-item[data-view="mcp-manager"]').forEach(item => { + item.addEventListener('click', () => { + setActiveNavItem(item); + currentView = 'mcp-manager'; + currentFilter = null; + currentLiteType = null; + currentSessionDetailKey = null; + updateContentTitle(); + renderMcpManager(); + }); + }); +} + +// ========== Data Loading ========== +async function loadMcpConfig() { + try { + const response = await fetch('/api/mcp-config'); + if (!response.ok) throw new Error('Failed to load MCP config'); + const data = await response.json(); + mcpConfig = data; + mcpAllProjects = data.projects || {}; + + // Get current project servers + const currentPath = projectPath.replace(/\//g, '\\'); + mcpCurrentProjectServers = mcpAllProjects[currentPath]?.mcpServers || {}; + + // Update badge count + updateMcpBadge(); + + return data; + } catch (err) { + console.error('Failed to load MCP config:', err); + return null; + } +} + +async function toggleMcpServer(serverName, enable) { + try { + const response = await fetch('/api/mcp-toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectPath: projectPath, + serverName: serverName, + enable: enable + }) + }); + + if (!response.ok) throw new Error('Failed to toggle MCP server'); + + const result = await response.json(); + if (result.success) { + // Reload config and re-render + await loadMcpConfig(); + renderMcpManager(); + showRefreshToast(`MCP server "${serverName}" ${enable ? 'enabled' : 'disabled'}`, 'success'); + } + return result; + } catch (err) { + console.error('Failed to toggle MCP server:', err); + showRefreshToast(`Failed to toggle MCP server: ${err.message}`, 'error'); + return null; + } +} + +async function copyMcpServerToProject(serverName, serverConfig) { + try { + const response = await fetch('/api/mcp-copy-server', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectPath: projectPath, + serverName: serverName, + serverConfig: serverConfig + }) + }); + + if (!response.ok) throw new Error('Failed to copy MCP server'); + + const result = await response.json(); + if (result.success) { + await loadMcpConfig(); + renderMcpManager(); + showRefreshToast(`MCP server "${serverName}" added to project`, 'success'); + } + return result; + } catch (err) { + console.error('Failed to copy MCP server:', err); + showRefreshToast(`Failed to add MCP server: ${err.message}`, 'error'); + return null; + } +} + +async function removeMcpServerFromProject(serverName) { + try { + const response = await fetch('/api/mcp-remove-server', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectPath: projectPath, + serverName: serverName + }) + }); + + if (!response.ok) throw new Error('Failed to remove MCP server'); + + const result = await response.json(); + if (result.success) { + await loadMcpConfig(); + renderMcpManager(); + showRefreshToast(`MCP server "${serverName}" removed from project`, 'success'); + } + return result; + } catch (err) { + console.error('Failed to remove MCP server:', err); + showRefreshToast(`Failed to remove MCP server: ${err.message}`, 'error'); + return null; + } +} + +// ========== Badge Update ========== +function updateMcpBadge() { + const badge = document.getElementById('badgeMcpServers'); + if (badge) { + const currentPath = projectPath.replace(/\//g, '\\'); + const projectData = mcpAllProjects[currentPath]; + const servers = projectData?.mcpServers || {}; + const disabledServers = projectData?.disabledMcpServers || []; + + const totalServers = Object.keys(servers).length; + const enabledServers = totalServers - disabledServers.length; + + badge.textContent = `${enabledServers}/${totalServers}`; + } +} + +// ========== Helpers ========== +function getAllAvailableMcpServers() { + const allServers = {}; + + // Collect servers from all projects + for (const [path, config] of Object.entries(mcpAllProjects)) { + const servers = config.mcpServers || {}; + for (const [name, serverConfig] of Object.entries(servers)) { + if (!allServers[name]) { + allServers[name] = { + config: serverConfig, + usedIn: [] + }; + } + allServers[name].usedIn.push(path); + } + } + + return allServers; +} + +function isServerEnabledInCurrentProject(serverName) { + const currentPath = projectPath.replace(/\//g, '\\'); + const projectData = mcpAllProjects[currentPath]; + if (!projectData) return false; + + const disabledServers = projectData.disabledMcpServers || []; + return !disabledServers.includes(serverName); +} + +function isServerInCurrentProject(serverName) { + const currentPath = projectPath.replace(/\//g, '\\'); + const projectData = mcpAllProjects[currentPath]; + if (!projectData) return false; + + const servers = projectData.mcpServers || {}; + return serverName in servers; +} + +// ========== MCP Create Modal ========== +function openMcpCreateModal() { + const modal = document.getElementById('mcpCreateModal'); + if (modal) { + modal.classList.remove('hidden'); + // Clear form + document.getElementById('mcpServerName').value = ''; + document.getElementById('mcpServerCommand').value = ''; + document.getElementById('mcpServerArgs').value = ''; + document.getElementById('mcpServerEnv').value = ''; + // Focus on name input + document.getElementById('mcpServerName').focus(); + } +} + +function closeMcpCreateModal() { + const modal = document.getElementById('mcpCreateModal'); + if (modal) { + modal.classList.add('hidden'); + } +} + +async function submitMcpCreate() { + const name = document.getElementById('mcpServerName').value.trim(); + const command = document.getElementById('mcpServerCommand').value.trim(); + const argsText = document.getElementById('mcpServerArgs').value.trim(); + const envText = document.getElementById('mcpServerEnv').value.trim(); + + // Validate required fields + if (!name) { + showRefreshToast('Server name is required', 'error'); + document.getElementById('mcpServerName').focus(); + return; + } + + if (!command) { + showRefreshToast('Command is required', 'error'); + document.getElementById('mcpServerCommand').focus(); + return; + } + + // Parse args (one per line) + const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : []; + + // Parse env vars (KEY=VALUE per line) + const env = {}; + if (envText) { + envText.split('\n').forEach(line => { + const trimmed = line.trim(); + if (trimmed && trimmed.includes('=')) { + const eqIndex = trimmed.indexOf('='); + const key = trimmed.substring(0, eqIndex).trim(); + const value = trimmed.substring(eqIndex + 1).trim(); + if (key) { + env[key] = value; + } + } + }); + } + + // Build server config + const serverConfig = { + command: command, + args: args + }; + + // Only add env if there are values + if (Object.keys(env).length > 0) { + serverConfig.env = env; + } + + // Submit to API + try { + const response = await fetch('/api/mcp-copy-server', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectPath: projectPath, + serverName: name, + serverConfig: serverConfig + }) + }); + + if (!response.ok) throw new Error('Failed to create MCP server'); + + const result = await response.json(); + if (result.success) { + closeMcpCreateModal(); + await loadMcpConfig(); + renderMcpManager(); + showRefreshToast(`MCP server "${name}" created successfully`, 'success'); + } else { + showRefreshToast(result.error || 'Failed to create MCP server', 'error'); + } + } catch (err) { + console.error('Failed to create MCP server:', err); + showRefreshToast(`Failed to create MCP server: ${err.message}`, 'error'); + } +} diff --git a/ccw/src/templates/dashboard-js/components/navigation.js b/ccw/src/templates/dashboard-js/components/navigation.js index 77f3a026..8bb3d265 100644 --- a/ccw/src/templates/dashboard-js/components/navigation.js +++ b/ccw/src/templates/dashboard-js/components/navigation.js @@ -60,7 +60,7 @@ function initNavigation() { }); }); - // Project Overview Navigation + // View Navigation (Project Overview, MCP Manager, etc.) document.querySelectorAll('.nav-item[data-view]').forEach(item => { item.addEventListener('click', () => { setActiveNavItem(item); @@ -69,7 +69,13 @@ function initNavigation() { currentLiteType = null; currentSessionDetailKey = null; updateContentTitle(); - renderProjectOverview(); + + // Route to appropriate view + if (currentView === 'mcp-manager') { + renderMcpManager(); + } else if (currentView === 'project-overview') { + renderProjectOverview(); + } }); }); } @@ -83,6 +89,8 @@ function updateContentTitle() { const titleEl = document.getElementById('contentTitle'); if (currentView === 'project-overview') { titleEl.textContent = 'Project Overview'; + } else if (currentView === 'mcp-manager') { + titleEl.textContent = 'MCP Server Management'; } else if (currentView === 'liteTasks') { const names = { 'lite-plan': 'Lite Plan Sessions', 'lite-fix': 'Lite Fix Sessions' }; titleEl.textContent = names[currentLiteType] || 'Lite Tasks'; diff --git a/ccw/src/templates/dashboard-js/components/notifications.js b/ccw/src/templates/dashboard-js/components/notifications.js new file mode 100644 index 00000000..ed98d025 --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/notifications.js @@ -0,0 +1,194 @@ +// ========================================== +// NOTIFICATIONS COMPONENT +// ========================================== +// Real-time silent refresh (no notification bubbles) + +let wsConnection = null; +let autoRefreshInterval = null; +let lastDataHash = null; +const AUTO_REFRESH_INTERVAL_MS = 30000; // 30 seconds + +// ========== WebSocket Connection ========== +function initWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + try { + wsConnection = new WebSocket(wsUrl); + + wsConnection.onopen = () => { + console.log('[WS] Connected'); + }; + + wsConnection.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleNotification(data); + } catch (e) { + console.error('[WS] Failed to parse message:', e); + } + }; + + wsConnection.onclose = () => { + console.log('[WS] Disconnected, reconnecting in 5s...'); + setTimeout(initWebSocket, 5000); + }; + + wsConnection.onerror = (error) => { + console.error('[WS] Error:', error); + }; + } catch (e) { + console.log('[WS] WebSocket not available, using polling'); + } +} + +// ========== Notification Handler ========== +function handleNotification(data) { + const { type, payload } = data; + + // Silent refresh - no notification bubbles + switch (type) { + case 'session_updated': + case 'summary_written': + case 'task_completed': + case 'new_session': + // Just refresh data silently + refreshIfNeeded(); + // Optionally highlight in carousel if it's the current session + if (payload.sessionId && typeof carouselGoTo === 'function') { + carouselGoTo(payload.sessionId); + } + break; + + default: + console.log('[WS] Unknown notification type:', type); + } +} + +// ========== Auto Refresh ========== +function initAutoRefresh() { + // Calculate initial hash + lastDataHash = calculateDataHash(); + + // Start polling interval + autoRefreshInterval = setInterval(checkForChanges, AUTO_REFRESH_INTERVAL_MS); +} + +function calculateDataHash() { + if (!workflowData) return null; + + // Simple hash based on key data points + const hashData = { + activeSessions: (workflowData.activeSessions || []).length, + archivedSessions: (workflowData.archivedSessions || []).length, + totalTasks: workflowData.statistics?.totalTasks || 0, + completedTasks: workflowData.statistics?.completedTasks || 0, + generatedAt: workflowData.generatedAt + }; + + return JSON.stringify(hashData); +} + +async function checkForChanges() { + if (!window.SERVER_MODE) return; + + try { + const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`); + if (!response.ok) return; + + const newData = await response.json(); + const newHash = JSON.stringify({ + activeSessions: (newData.activeSessions || []).length, + archivedSessions: (newData.archivedSessions || []).length, + totalTasks: newData.statistics?.totalTasks || 0, + completedTasks: newData.statistics?.completedTasks || 0, + generatedAt: newData.generatedAt + }); + + if (newHash !== lastDataHash) { + lastDataHash = newHash; + // Silent refresh - no notification + await refreshWorkspaceData(newData); + } + } catch (e) { + console.error('[AutoRefresh] Check failed:', e); + } +} + +async function refreshIfNeeded() { + if (!window.SERVER_MODE) return; + + try { + const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`); + if (!response.ok) return; + + const newData = await response.json(); + await refreshWorkspaceData(newData); + } catch (e) { + console.error('[Refresh] Failed:', e); + } +} + +async function refreshWorkspaceData(newData) { + // Update global data + window.workflowData = newData; + + // Clear and repopulate stores + Object.keys(sessionDataStore).forEach(k => delete sessionDataStore[k]); + Object.keys(liteTaskDataStore).forEach(k => delete liteTaskDataStore[k]); + + [...(newData.activeSessions || []), ...(newData.archivedSessions || [])].forEach(s => { + const key = `session-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + sessionDataStore[key] = s; + }); + + [...(newData.liteTasks?.litePlan || []), ...(newData.liteTasks?.liteFix || [])].forEach(s => { + const key = `lite-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + liteTaskDataStore[key] = s; + }); + + // Update UI silently + updateStats(); + updateBadges(); + updateCarousel(); + + // Re-render current view if needed + if (currentView === 'sessions') { + renderSessions(); + } else if (currentView === 'liteTasks') { + renderLiteTasks(); + } + + lastDataHash = calculateDataHash(); +} + +// ========== Cleanup ========== +function stopAutoRefresh() { + if (autoRefreshInterval) { + clearInterval(autoRefreshInterval); + autoRefreshInterval = null; + } +} + +function closeWebSocket() { + if (wsConnection) { + wsConnection.close(); + wsConnection = null; + } +} + +// ========== Navigation Helper ========== +function goToSession(sessionId) { + // Find session in carousel and navigate + const sessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-'); + + // Jump to session in carousel if visible + if (typeof carouselGoTo === 'function') { + carouselGoTo(sessionId); + } + + // Navigate to session detail + if (sessionDataStore[sessionKey]) { + showSessionDetailPage(sessionKey); + } +} diff --git a/ccw/src/templates/dashboard-js/main.js b/ccw/src/templates/dashboard-js/main.js index 4d80b739..48a2277c 100644 --- a/ccw/src/templates/dashboard-js/main.js +++ b/ccw/src/templates/dashboard-js/main.js @@ -9,6 +9,13 @@ document.addEventListener('DOMContentLoaded', async () => { try { initNavigation(); } catch (e) { console.error('Navigation init failed:', e); } try { initSearch(); } catch (e) { console.error('Search init failed:', e); } try { initRefreshButton(); } catch (e) { console.error('Refresh button init failed:', e); } + try { initCarousel(); } catch (e) { console.error('Carousel init failed:', e); } + try { initMcpManager(); } catch (e) { console.error('MCP Manager init failed:', e); } + try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); } + + // Initialize real-time features (WebSocket + auto-refresh) + try { initWebSocket(); } catch (e) { console.log('WebSocket not available:', e.message); } + try { initAutoRefresh(); } catch (e) { console.error('Auto-refresh init failed:', e); } // Server mode: load data from API try { @@ -35,6 +42,16 @@ document.addEventListener('DOMContentLoaded', async () => { // Close path modal if exists closePathModal(); + + // Close MCP create modal if exists + if (typeof closeMcpCreateModal === 'function') { + closeMcpCreateModal(); + } + + // Close Hook create modal if exists + if (typeof closeHookCreateModal === 'function') { + closeHookCreateModal(); + } } }); }); diff --git a/ccw/src/templates/dashboard-js/views/home.js b/ccw/src/templates/dashboard-js/views/home.js index d666b579..ac0d9ab7 100644 --- a/ccw/src/templates/dashboard-js/views/home.js +++ b/ccw/src/templates/dashboard-js/views/home.js @@ -3,12 +3,23 @@ // ========================================== function renderDashboard() { + // Show stats grid and search (may be hidden by MCP view) + showStatsAndSearch(); + updateStats(); updateBadges(); + updateCarousel(); renderSessions(); document.getElementById('generatedAt').textContent = workflowData.generatedAt || new Date().toISOString(); } +function showStatsAndSearch() { + const statsGrid = document.getElementById('statsGrid'); + const searchInput = document.getElementById('searchInput'); + if (statsGrid) statsGrid.style.display = ''; + if (searchInput) searchInput.parentElement.style.display = ''; +} + function updateStats() { const stats = workflowData.statistics || {}; document.getElementById('statTotalSessions').textContent = stats.totalSessions || 0; @@ -29,6 +40,15 @@ function updateBadges() { const liteTasks = workflowData.liteTasks || {}; document.getElementById('badgeLitePlan').textContent = liteTasks.litePlan?.length || 0; document.getElementById('badgeLiteFix').textContent = liteTasks.liteFix?.length || 0; + + // MCP badge - load async if needed + if (typeof loadMcpConfig === 'function') { + loadMcpConfig().then(() => { + if (typeof updateMcpBadge === 'function') { + updateMcpBadge(); + } + }).catch(e => console.error('MCP badge update failed:', e)); + } } function renderSessions() { diff --git a/ccw/src/templates/dashboard-js/views/hook-manager.js b/ccw/src/templates/dashboard-js/views/hook-manager.js new file mode 100644 index 00000000..4b4dafaa --- /dev/null +++ b/ccw/src/templates/dashboard-js/views/hook-manager.js @@ -0,0 +1,387 @@ +// Hook Manager View +// Renders the Claude Code hooks management interface + +async function renderHookManager() { + const container = document.getElementById('mainContent'); + if (!container) return; + + // Hide stats grid and search for Hook view + const statsGrid = document.getElementById('statsGrid'); + const searchInput = document.getElementById('searchInput'); + if (statsGrid) statsGrid.style.display = 'none'; + if (searchInput) searchInput.parentElement.style.display = 'none'; + + // Load hook config if not already loaded + if (!hookConfig.global.hooks && !hookConfig.project.hooks) { + await loadHookConfig(); + } + + const globalHooks = hookConfig.global?.hooks || {}; + const projectHooks = hookConfig.project?.hooks || {}; + + // Count hooks + const globalHookCount = countHooks(globalHooks); + const projectHookCount = countHooks(projectHooks); + + container.innerHTML = ` +
+ +
+
+
+

Project Hooks

+ .claude/settings.json + +
+ ${projectHookCount} hooks configured +
+ + ${projectHookCount === 0 ? ` +
+
🪝
+

No hooks configured for this project

+

Create a hook to automate actions on tool usage

+
+ ` : ` +
+ ${renderHooksByEvent(projectHooks, 'project')} +
+ `} +
+ + +
+
+
+

Global Hooks

+ ~/.claude/settings.json +
+ ${globalHookCount} hooks configured +
+ + ${globalHookCount === 0 ? ` +
+

No global hooks configured

+

Global hooks apply to all Claude Code sessions

+
+ ` : ` +
+ ${renderHooksByEvent(globalHooks, 'global')} +
+ `} +
+ + +
+
+

Quick Install Templates

+ One-click hook installation +
+ +
+ ${renderQuickInstallCard('ccw-notify', 'CCW Dashboard Notify', 'Notify CCW dashboard when files are written', 'PostToolUse', 'Write')} + ${renderQuickInstallCard('log-tool', 'Tool Usage Logger', 'Log all tool executions to a file', 'PostToolUse', 'All')} + ${renderQuickInstallCard('lint-check', 'Auto Lint Check', 'Run ESLint on JavaScript/TypeScript files after write', 'PostToolUse', 'Write')} + ${renderQuickInstallCard('git-add', 'Auto Git Stage', 'Automatically stage written files to git', 'PostToolUse', 'Write')} +
+
+ + +
+
+

Environment Variables Reference

+
+ +
+
+
+
+ $CLAUDE_FILE_PATHS + Space-separated file paths affected +
+
+ $CLAUDE_TOOL_NAME + Name of the tool being executed +
+
+ $CLAUDE_TOOL_INPUT + JSON input passed to the tool +
+
+
+
+ $CLAUDE_SESSION_ID + Current Claude session ID +
+
+ $CLAUDE_PROJECT_DIR + Current project directory path +
+
+ $CLAUDE_WORKING_DIR + Current working directory +
+
+
+
+
+
+ `; + + // Attach event listeners + attachHookEventListeners(); +} + +function countHooks(hooks) { + let count = 0; + for (const event of Object.keys(hooks)) { + const hookList = hooks[event]; + count += Array.isArray(hookList) ? hookList.length : 1; + } + return count; +} + +function renderHooksByEvent(hooks, scope) { + const events = Object.keys(hooks); + if (events.length === 0) return ''; + + return events.map(event => { + const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]]; + + return hookList.map((hook, index) => { + const matcher = hook.matcher || 'All tools'; + const command = hook.command || 'N/A'; + const args = hook.args || []; + + return ` +
+
+
+ ${getHookEventIcon(event)} +
+

${event}

+

${getHookEventDescription(event)}

+
+
+
+ + +
+
+ +
+
+ matcher + ${escapeHtml(matcher)} +
+
+ command + ${escapeHtml(command)} +
+ ${args.length > 0 ? ` +
+ args + ${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''} +
+ ` : ''} +
+
+ `; + }).join(''); + }).join(''); +} + +function renderQuickInstallCard(templateId, title, description, event, matcher) { + const isInstalled = isHookTemplateInstalled(templateId); + + return ` +
+
+
+ ${isInstalled ? '✅' : '🪝'} +
+

${escapeHtml(title)}

+

${escapeHtml(description)}

+
+
+
+ +
+ + ${event} + + + Matches: ${matcher} + +
+ +
+ ${isInstalled ? ` + + ` : ` + + + `} +
+
+ `; +} + +function isHookTemplateInstalled(templateId) { + const template = HOOK_TEMPLATES[templateId]; + if (!template) return false; + + // Check project hooks + const projectHooks = hookConfig.project?.hooks?.[template.event]; + if (projectHooks) { + const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks]; + if (hookList.some(h => h.command === template.command)) return true; + } + + // Check global hooks + const globalHooks = hookConfig.global?.hooks?.[template.event]; + if (globalHooks) { + const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks]; + if (hookList.some(h => h.command === template.command)) return true; + } + + return false; +} + +async function installHookTemplate(templateId, scope) { + const template = HOOK_TEMPLATES[templateId]; + if (!template) { + showRefreshToast('Template not found', 'error'); + return; + } + + const hookData = { + command: template.command, + args: template.args + }; + + if (template.matcher) { + hookData.matcher = template.matcher; + } + + await saveHook(scope, template.event, hookData); +} + +async function uninstallHookTemplate(templateId) { + const template = HOOK_TEMPLATES[templateId]; + if (!template) return; + + // Find and remove from project hooks + const projectHooks = hookConfig.project?.hooks?.[template.event]; + if (projectHooks) { + const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks]; + const index = hookList.findIndex(h => h.command === template.command); + if (index !== -1) { + await removeHook('project', template.event, index); + return; + } + } + + // Find and remove from global hooks + const globalHooks = hookConfig.global?.hooks?.[template.event]; + if (globalHooks) { + const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks]; + const index = hookList.findIndex(h => h.command === template.command); + if (index !== -1) { + await removeHook('global', template.event, index); + return; + } + } +} + +function attachHookEventListeners() { + // Edit buttons + document.querySelectorAll('.hook-card button[data-action="edit"]').forEach(btn => { + btn.addEventListener('click', (e) => { + const scope = e.target.dataset.scope; + const event = e.target.dataset.event; + const index = parseInt(e.target.dataset.index); + + const hooks = scope === 'global' ? hookConfig.global.hooks : hookConfig.project.hooks; + const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]]; + const hook = hookList[index]; + + if (hook) { + openHookCreateModal({ + scope: scope, + event: event, + index: index, + matcher: hook.matcher || '', + command: hook.command, + args: hook.args || [] + }); + } + }); + }); + + // Delete buttons + document.querySelectorAll('.hook-card button[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const scope = e.target.dataset.scope; + const event = e.target.dataset.event; + const index = parseInt(e.target.dataset.index); + + if (confirm(`Remove this ${event} hook?`)) { + await removeHook(scope, event, index); + } + }); + }); + + // Install project buttons + document.querySelectorAll('button[data-action="install-project"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const templateId = e.target.dataset.template; + await installHookTemplate(templateId, 'project'); + }); + }); + + // Install global buttons + document.querySelectorAll('button[data-action="install-global"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const templateId = e.target.dataset.template; + await installHookTemplate(templateId, 'global'); + }); + }); + + // Uninstall buttons + document.querySelectorAll('button[data-action="uninstall"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const templateId = e.target.dataset.template; + await uninstallHookTemplate(templateId); + }); + }); +} diff --git a/ccw/src/templates/dashboard-js/views/lite-tasks.js b/ccw/src/templates/dashboard-js/views/lite-tasks.js index a71705ad..23e61f68 100644 --- a/ccw/src/templates/dashboard-js/views/lite-tasks.js +++ b/ccw/src/templates/dashboard-js/views/lite-tasks.js @@ -345,12 +345,19 @@ async function loadAndRenderLiteContextTab(session, contentArea) { const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=context`); if (response.ok) { const data = await response.json(); - contentArea.innerHTML = renderLiteContextContent(data.context, session); + contentArea.innerHTML = renderLiteContextContent(data.context, data.explorations, session); + + // Re-initialize collapsible sections for explorations + setTimeout(() => { + document.querySelectorAll('.collapsible-header').forEach(header => { + header.addEventListener('click', () => toggleSection(header)); + }); + }, 50); return; } } // Fallback: show plan context if available - contentArea.innerHTML = renderLiteContextContent(null, session); + contentArea.innerHTML = renderLiteContextContent(null, null, session); } catch (err) { contentArea.innerHTML = `
Failed to load context: ${err.message}
`; } diff --git a/ccw/src/templates/dashboard-js/views/mcp-manager.js b/ccw/src/templates/dashboard-js/views/mcp-manager.js new file mode 100644 index 00000000..d0e97d94 --- /dev/null +++ b/ccw/src/templates/dashboard-js/views/mcp-manager.js @@ -0,0 +1,242 @@ +// MCP Manager View +// Renders the MCP server management interface + +async function renderMcpManager() { + const container = document.getElementById('mainContent'); + if (!container) return; + + // Hide stats grid and search for MCP view + const statsGrid = document.getElementById('statsGrid'); + const searchInput = document.getElementById('searchInput'); + if (statsGrid) statsGrid.style.display = 'none'; + if (searchInput) searchInput.parentElement.style.display = 'none'; + + // Load MCP config if not already loaded + if (!mcpConfig) { + await loadMcpConfig(); + } + + const currentPath = projectPath.replace(/\//g, '\\'); + const projectData = mcpAllProjects[currentPath] || {}; + const projectServers = projectData.mcpServers || {}; + const disabledServers = projectData.disabledMcpServers || []; + + // Get all available servers from all projects + const allAvailableServers = getAllAvailableMcpServers(); + + // Separate current project servers and available servers + const currentProjectServerNames = Object.keys(projectServers); + const otherAvailableServers = Object.entries(allAvailableServers) + .filter(([name]) => !currentProjectServerNames.includes(name)); + + container.innerHTML = ` +
+ +
+
+
+

Current Project MCP Servers

+ +
+ ${currentProjectServerNames.length} servers configured +
+ + ${currentProjectServerNames.length === 0 ? ` +
+
🔌
+

No MCP servers configured for this project

+

Add servers from the available list below

+
+ ` : ` +
+ ${currentProjectServerNames.map(serverName => { + const serverConfig = projectServers[serverName]; + const isEnabled = !disabledServers.includes(serverName); + return renderMcpServerCard(serverName, serverConfig, isEnabled, true); + }).join('')} +
+ `} +
+ + +
+
+

Available from Other Projects

+ ${otherAvailableServers.length} servers available +
+ + ${otherAvailableServers.length === 0 ? ` +
+

No additional MCP servers found in other projects

+
+ ` : ` +
+ ${otherAvailableServers.map(([serverName, serverInfo]) => { + return renderAvailableServerCard(serverName, serverInfo); + }).join('')} +
+ `} +
+ + +
+
+

All Projects

+ ${Object.keys(mcpAllProjects).length} projects +
+ +
+ ${Object.entries(mcpAllProjects).map(([path, config]) => { + const servers = config.mcpServers || {}; + const serverCount = Object.keys(servers).length; + const isCurrentProject = path === currentPath; + return ` +
+
+ ${isCurrentProject ? '📍' : '📁'} +
+
${escapeHtml(path.split('\\').pop() || path)}
+
${escapeHtml(path)}
+
+
+
+ ${serverCount} MCP + ${isCurrentProject ? 'Current' : ''} +
+
+ `; + }).join('')} +
+
+
+ `; + + // Attach event listeners for toggle switches + attachMcpEventListeners(); +} + +function renderMcpServerCard(serverName, serverConfig, isEnabled, isInCurrentProject) { + const command = serverConfig.command || 'N/A'; + const args = serverConfig.args || []; + const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0; + + return ` +
+
+
+ ${isEnabled ? '🟢' : '🔴'} +

${escapeHtml(serverName)}

+
+ +
+ +
+
+ cmd + ${escapeHtml(command)} +
+ ${args.length > 0 ? ` +
+ args + ${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''} +
+ ` : ''} + ${hasEnv ? ` +
+ env + ${Object.keys(serverConfig.env).length} variables +
+ ` : ''} +
+ + ${isInCurrentProject ? ` +
+ +
+ ` : ''} +
+ `; +} + +function renderAvailableServerCard(serverName, serverInfo) { + const serverConfig = serverInfo.config; + const usedIn = serverInfo.usedIn || []; + const command = serverConfig.command || 'N/A'; + + return ` +
+
+
+ +

${escapeHtml(serverName)}

+
+ +
+ +
+
+ cmd + ${escapeHtml(command)} +
+
+ Used in ${usedIn.length} project${usedIn.length !== 1 ? 's' : ''} +
+
+
+ `; +} + +function attachMcpEventListeners() { + // Toggle switches + document.querySelectorAll('.mcp-server-card input[data-action="toggle"]').forEach(input => { + input.addEventListener('change', async (e) => { + const serverName = e.target.dataset.serverName; + const enable = e.target.checked; + await toggleMcpServer(serverName, enable); + }); + }); + + // Add buttons + document.querySelectorAll('.mcp-server-card button[data-action="add"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const serverName = e.target.dataset.serverName; + const serverConfig = JSON.parse(e.target.dataset.serverConfig); + await copyMcpServerToProject(serverName, serverConfig); + }); + }); + + // Remove buttons + document.querySelectorAll('.mcp-server-card button[data-action="remove"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const serverName = e.target.dataset.serverName; + if (confirm(`Remove MCP server "${serverName}" from this project?`)) { + await removeMcpServerFromProject(serverName); + } + }); + }); +} + +function switchToProject(path) { + // Use existing path selection mechanism + selectPath(path.replace(/\\\\/g, '\\')); +} diff --git a/ccw/src/templates/dashboard-js/views/project-overview.js b/ccw/src/templates/dashboard-js/views/project-overview.js index 8ebb311d..6f047664 100644 --- a/ccw/src/templates/dashboard-js/views/project-overview.js +++ b/ccw/src/templates/dashboard-js/views/project-overview.js @@ -3,6 +3,9 @@ // ========================================== function renderProjectOverview() { + // Show stats grid and search (may be hidden by MCP view) + if (typeof showStatsAndSearch === 'function') showStatsAndSearch(); + const container = document.getElementById('mainContent'); const project = workflowData.projectOverview; diff --git a/ccw/src/templates/dashboard.css b/ccw/src/templates/dashboard.css index 8bf72ed4..d30e5a20 100644 --- a/ccw/src/templates/dashboard.css +++ b/ccw/src/templates/dashboard.css @@ -5658,3 +5658,588 @@ code.ctx-meta-chip-value { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +/* ========================================== + MCP MANAGER STYLES + ========================================== */ + +.mcp-manager { + width: 100%; +} + +.mcp-section { + margin-bottom: 2rem; + width: 100%; +} + +.mcp-server-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + width: 100%; +} + +.mcp-server-card { + position: relative; +} + +.mcp-server-card.opacity-60 { + opacity: 0.6; +} + +.mcp-server-available { + border-style: dashed; +} + +.mcp-server-available:hover { + border-style: solid; +} + +/* MCP Toggle Switch */ +.mcp-toggle { + position: relative; + display: inline-flex; + align-items: center; +} + +.mcp-toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.mcp-toggle > div { + width: 36px; + height: 20px; + background: hsl(var(--muted)); + border-radius: 10px; + position: relative; + transition: background 0.2s; +} + +.mcp-toggle > div::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background: white; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.mcp-toggle input:checked + div { + background: hsl(var(--success)); +} + +.mcp-toggle input:checked + div::after { + transform: translateX(16px); +} + +.mcp-toggle input:focus + div { + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} + +/* MCP Projects List */ +.mcp-projects-list { + max-height: 400px; + overflow-y: auto; +} + +.mcp-project-item { + transition: background 0.15s; +} + +.mcp-project-item:hover { + background: hsl(var(--hover)); +} + +.mcp-project-item.bg-primary-light { + background: hsl(var(--primary-light)); +} + +/* MCP Empty State */ +.mcp-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; +} + +/* MCP Server Details */ +.mcp-server-details { + font-size: 0.875rem; +} + +.mcp-server-details .font-mono { + font-family: var(--font-mono); +} + +/* MCP Create Modal */ +.mcp-modal { + animation: fadeIn 0.15s ease-out; +} + +.mcp-modal-backdrop { + animation: fadeIn 0.15s ease-out; +} + +.mcp-modal-content { + animation: slideUp 0.2s ease-out; +} + +.mcp-modal.hidden { + display: none; +} + +.mcp-modal .form-group label { + display: block; + margin-bottom: 0.25rem; +} + +.mcp-modal input, +.mcp-modal textarea { + transition: border-color 0.15s, box-shadow 0.15s; +} + +.mcp-modal input:focus, +.mcp-modal textarea:focus { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ========================================== + HOOK MANAGER STYLES + ========================================== */ + +.hook-manager { + width: 100%; +} + +.hook-section { + margin-bottom: 2rem; + width: 100%; +} + +.hook-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + width: 100%; +} + +.hook-card { + position: relative; +} + +.hook-details { + font-size: 0.875rem; +} + +.hook-details .font-mono { + font-family: var(--font-mono); +} + +.hook-templates-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.hook-template-card { + transition: all 0.2s ease; +} + +.hook-template-card:hover { + box-shadow: 0 4px 12px rgb(0 0 0 / 0.1); +} + +/* Hook Empty State */ +.hook-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; +} + +/* Hook Modal */ +.hook-modal { + animation: fadeIn 0.15s ease-out; +} + +.hook-modal-backdrop { + animation: fadeIn 0.15s ease-out; +} + +.hook-modal-content { + animation: slideUp 0.2s ease-out; +} + +.hook-modal.hidden { + display: none; +} + +.hook-modal .form-group label { + display: block; + margin-bottom: 0.25rem; +} + +.hook-modal input, +.hook-modal textarea, +.hook-modal select { + transition: border-color 0.15s, box-shadow 0.15s; +} + +.hook-modal input:focus, +.hook-modal textarea:focus, +.hook-modal select:focus { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} + +.hook-template-btn { + transition: all 0.15s ease; +} + +.hook-template-btn:hover { + background: hsl(var(--hover)); + border-color: hsl(var(--primary)); +} + +/* ========================================== + STATS SECTION & CAROUSEL + ========================================== */ + +.stats-section { + min-height: 180px; +} + +.stats-metrics { + width: 300px; +} + +.stats-carousel { + position: relative; +} + +.carousel-header { + height: 40px; +} + +.carousel-btn { + transition: all 0.15s; +} + +.carousel-btn:hover { + background: hsl(var(--hover)); +} + +/* Carousel dots indicator */ +.carousel-dots { + display: flex; + align-items: center; + gap: 6px; +} + +.carousel-dot { + cursor: pointer; + border: none; + padding: 0; + transition: all 0.2s ease; +} + +.carousel-dot:focus { + outline: none; + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3); +} + +.carousel-footer { + flex-shrink: 0; +} + +.carousel-content { + position: relative; + overflow: hidden; +} + +.carousel-slide { + position: absolute; + inset: 0; +} + +.carousel-empty { + position: absolute; + inset: 0; +} + +/* Carousel slide animations */ +.carousel-fade-in { + animation: carouselFadeIn 0.3s ease-out forwards; +} + +.carousel-slide-left { + animation: carouselSlideLeft 0.35s ease-out forwards; +} + +.carousel-slide-right { + animation: carouselSlideRight 0.35s ease-out forwards; +} + +@keyframes carouselFadeIn { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes carouselSlideLeft { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes carouselSlideRight { + from { + opacity: 0; + transform: translateX(-100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Task card in carousel */ +.carousel-slide .task-card { + height: 100%; + display: flex; + flex-direction: column; +} + +.carousel-slide .task-timestamps { + flex-grow: 1; +} + +.carousel-slide .task-session-info { + margin-top: auto; +} + +/* Task status badge pulse for in_progress */ +.task-status-badge { + transition: all 0.2s; +} + +.bg-warning-light .task-status-badge { + animation: statusPulse 2s ease-in-out infinite; +} + +@keyframes statusPulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +/* Task highlight animation when navigating from carousel */ +.task-highlight { + animation: taskHighlight 0.5s ease-out 3; +} + +@keyframes taskHighlight { + 0%, 100% { + background: transparent; + box-shadow: none; + } + 50% { + background: hsl(var(--primary-light)); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.3); + } +} + +/* Line clamp utility */ +.line-clamp-1 { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Highlight pulse effect */ +.highlight-pulse { + animation: highlightPulse 0.5s ease-out 2; +} + +@keyframes highlightPulse { + 0%, 100% { + box-shadow: none; + } + 50% { + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.3); + } +} + +/* ========================================== + NOTIFICATION BUBBLES + ========================================== */ + +.notification-bubble { + position: fixed; + top: 70px; + right: 20px; + max-width: 360px; + padding: 12px 16px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.15); + z-index: 1000; + display: flex; + align-items: center; + gap: 8px; + opacity: 0; + transform: translateX(100%); + transition: all 0.3s ease-out; +} + +.notification-bubble.show { + opacity: 1; + transform: translateX(0); +} + +.notification-bubble.fade-out { + opacity: 0; + transform: translateX(100%); +} + +.notification-bubble:nth-child(2) { + top: 130px; +} + +.notification-bubble:nth-child(3) { + top: 190px; +} + +.notification-content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.notification-icon { + font-size: 1.25rem; +} + +.notification-message { + font-size: 0.875rem; + color: hsl(var(--foreground)); + flex: 1; +} + +.notification-action { + padding: 4px 12px; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border: none; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} + +.notification-action:hover { + opacity: 0.9; +} + +.notification-close { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: hsl(var(--muted-foreground)); + font-size: 1.25rem; + cursor: pointer; + border-radius: 4px; + transition: all 0.15s; +} + +.notification-close:hover { + background: hsl(var(--hover)); + color: hsl(var(--foreground)); +} + +/* Notification types */ +.notification-success { + border-left: 3px solid hsl(var(--success)); +} + +.notification-warning { + border-left: 3px solid hsl(var(--warning)); +} + +.notification-error { + border-left: 3px solid hsl(var(--destructive)); +} + +.notification-info { + border-left: 3px solid hsl(var(--primary)); +} + +/* Responsive stats section */ +@media (max-width: 768px) { + .stats-section { + flex-direction: column; + } + + .stats-metrics { + width: 100%; + grid-template-columns: repeat(4, 1fr); + } + + .stats-carousel { + min-height: 160px; + } +} diff --git a/ccw/src/templates/dashboard.html b/ccw/src/templates/dashboard.html index 3543a2c1..98172546 100644 --- a/ccw/src/templates/dashboard.html +++ b/ccw/src/templates/dashboard.html @@ -310,6 +310,36 @@ + + +
+
+ 🔌 + MCP Servers +
+
    + +
+
+ + +
+
+ 🪝 + Hooks +
+
    + +
+
@@ -323,27 +353,67 @@
- -
-
-
📊
-
0
-
Total Sessions
+ +
+ +
+
+
📊
+
0
+
Total Sessions
+
+
+
🟢
+
0
+
Active Sessions
+
+
+
📋
+
0
+
Total Tasks
+
+
+
+
0
+
Completed Tasks
+
-
-
🟢
-
0
-
Active Sessions
-
-
-
📋
-
0
-
Total Tasks
-
-
-
-
0
-
Completed Tasks
+ + +
@@ -404,6 +474,120 @@
+ + + + + + diff --git a/ccw/src/utils/version-fetcher.js b/ccw/src/utils/version-fetcher.js new file mode 100644 index 00000000..a0e46be9 --- /dev/null +++ b/ccw/src/utils/version-fetcher.js @@ -0,0 +1,252 @@ +import https from 'https'; +import { existsSync, mkdirSync, createWriteStream, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { createUnzip } from 'zlib'; +import { pipeline } from 'stream/promises'; + +// GitHub repository URL +export const REPO_URL = 'https://github.com/catlog22/Claude-Code-Workflow'; +const API_BASE = 'https://api.github.com/repos/catlog22/Claude-Code-Workflow'; + +/** + * Make HTTPS request with JSON response + * @param {string} url - URL to fetch + * @param {number} timeout - Timeout in ms (default: 10000) + * @returns {Promise} + */ +function fetchJson(url, timeout = 10000) { + return new Promise((resolve, reject) => { + const req = https.get(url, { + headers: { + 'User-Agent': 'ccw-installer', + 'Accept': 'application/vnd.github.v3+json' + }, + timeout + }, (res) => { + // Handle redirects + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return fetchJson(res.headers.location, timeout).then(resolve).catch(reject); + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(new Error('Invalid JSON response')); + } + }); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + }); +} + +/** + * Fetch latest stable release info + * @returns {Promise<{tag: string, date: string, url: string}>} + */ +export async function fetchLatestRelease() { + const data = await fetchJson(`${API_BASE}/releases/latest`); + + return { + tag: data.tag_name, + version: data.tag_name.replace(/^v/, ''), + date: data.published_at ? new Date(data.published_at).toLocaleDateString() : '', + url: data.zipball_url, + htmlUrl: data.html_url + }; +} + +/** + * Fetch recent releases list + * @param {number} limit - Number of releases to fetch + * @returns {Promise>} + */ +export async function fetchRecentReleases(limit = 5) { + const data = await fetchJson(`${API_BASE}/releases?per_page=${limit}`); + + return data.map(release => ({ + tag: release.tag_name, + version: release.tag_name.replace(/^v/, ''), + date: release.published_at ? new Date(release.published_at).toLocaleDateString() : '', + url: release.zipball_url + })); +} + +/** + * Fetch latest commit from a branch + * @param {string} branch - Branch name (default: main) + * @returns {Promise<{sha: string, shortSha: string, date: string, message: string}>} + */ +export async function fetchLatestCommit(branch = 'main') { + const data = await fetchJson(`${API_BASE}/commits/${branch}`); + + return { + sha: data.sha, + shortSha: data.sha.substring(0, 7), + date: data.commit.committer.date ? new Date(data.commit.committer.date).toLocaleDateString() : '', + message: data.commit.message.split('\n')[0] + }; +} + +/** + * Download file from URL + * @param {string} url - URL to download + * @param {string} destPath - Destination file path + * @returns {Promise} + */ +function downloadFile(url, destPath) { + return new Promise((resolve, reject) => { + const file = createWriteStream(destPath); + + https.get(url, { + headers: { + 'User-Agent': 'ccw-installer', + 'Accept': 'application/octet-stream' + } + }, (res) => { + // Handle redirects + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + file.close(); + return downloadFile(res.headers.location, destPath).then(resolve).catch(reject); + } + + if (res.statusCode !== 200) { + file.close(); + reject(new Error(`Download failed: HTTP ${res.statusCode}`)); + return; + } + + res.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + }).on('error', (err) => { + file.close(); + reject(err); + }); + }); +} + +/** + * Extract zip file using native unzip command or built-in + * @param {string} zipPath - Path to zip file + * @param {string} destDir - Destination directory + * @returns {Promise} - Extracted directory path + */ +async function extractZip(zipPath, destDir) { + const { execSync } = await import('child_process'); + + // Try using native unzip commands + try { + // Try PowerShell Expand-Archive (Windows) + execSync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`, { + stdio: 'pipe' + }); + } catch { + try { + // Try unzip command (Unix/Git Bash) + execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'pipe' }); + } catch { + throw new Error('No unzip utility available. Please install unzip or use PowerShell.'); + } + } + + // Find extracted directory + const { readdirSync } = await import('fs'); + const entries = readdirSync(destDir); + const repoDir = entries.find(e => e.startsWith('Claude-Code-Workflow-') || e.startsWith('catlog22-Claude-Code-Workflow-')); + + if (!repoDir) { + throw new Error('Could not find extracted repository directory'); + } + + return join(destDir, repoDir); +} + +/** + * Download and extract repository + * @param {Object} options + * @param {'stable'|'latest'|'branch'} options.type - Version type + * @param {string} options.tag - Specific tag (for stable) + * @param {string} options.branch - Branch name (for branch type) + * @returns {Promise<{repoDir: string, version: string, branch: string, commit: string}>} + */ +export async function downloadAndExtract(options = {}) { + const { type = 'stable', tag = '', branch = 'main' } = options; + + // Create temp directory + const tempDir = join(tmpdir(), `ccw-install-${Date.now()}`); + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + + let zipUrl; + let versionInfo = { version: '', branch: '', commit: '' }; + + // Determine download URL based on version type + if (type === 'stable') { + if (tag) { + zipUrl = `${REPO_URL}/archive/refs/tags/${tag}.zip`; + versionInfo.version = tag.replace(/^v/, ''); + versionInfo.branch = tag; + } else { + const release = await fetchLatestRelease(); + zipUrl = `${REPO_URL}/archive/refs/tags/${release.tag}.zip`; + versionInfo.version = release.version; + versionInfo.branch = release.tag; + } + } else if (type === 'latest') { + zipUrl = `${REPO_URL}/archive/refs/heads/main.zip`; + const commit = await fetchLatestCommit('main'); + versionInfo.version = `dev-${commit.shortSha}`; + versionInfo.branch = 'main'; + versionInfo.commit = commit.shortSha; + } else { + zipUrl = `${REPO_URL}/archive/refs/heads/${branch}.zip`; + const commit = await fetchLatestCommit(branch); + versionInfo.version = `dev-${commit.shortSha}`; + versionInfo.branch = branch; + versionInfo.commit = commit.shortSha; + } + + // Download zip file + const zipPath = join(tempDir, 'repo.zip'); + await downloadFile(zipUrl, zipPath); + + // Extract zip + const repoDir = await extractZip(zipPath, tempDir); + + return { + repoDir, + tempDir, + ...versionInfo + }; +} + +/** + * Cleanup temporary directory + * @param {string} tempDir - Temp directory to remove + */ +export function cleanupTemp(tempDir) { + if (existsSync(tempDir)) { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +}