mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat: Add Notifications Component with WebSocket and Auto Refresh
- Implemented a Notifications component for real-time updates using WebSocket. - Added silent refresh functionality to update data without notification bubbles. - Introduced auto-refresh mechanism to periodically check for changes in workflow data. - Enhanced data handling with session and task updates, ensuring UI reflects the latest state. feat: Create Hook Manager View for Managing Hooks - Developed a Hook Manager view to manage project and global hooks. - Added functionality to create, edit, and delete hooks with a user-friendly interface. - Implemented quick install templates for common hooks to streamline user experience. - Included environment variables reference for hooks to assist users in configuration. feat: Implement MCP Manager View for Server Management - Created an MCP Manager view for managing MCP servers within projects. - Enabled adding and removing servers from projects with a clear UI. - Displayed available servers from other projects for easy access and management. - Provided an overview of all projects and their associated MCP servers. feat: Add Version Fetcher Utility for GitHub Releases - Implemented a version fetcher utility to retrieve release information from GitHub. - Added functions to fetch the latest release, recent releases, and latest commit details. - Included functionality to download and extract repository zip files. - Ensured cleanup of temporary directories after downloads to maintain system hygiene.
This commit is contained in:
@@ -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 <mode>', 'Installation mode: Global or Path')
|
||||
.option('-p, --path <path>', 'Installation path (for Path mode)')
|
||||
.option('-v, --version <version>', 'Version type: local, stable, latest, or branch', 'local')
|
||||
.option('-t, --tag <tag>', 'Specific release tag (e.g., v3.2.0) for stable version')
|
||||
.option('-b, --branch <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 <tag>', 'Upgrade to specific release tag (e.g., v3.2.0)')
|
||||
.option('-b, --branch <branch>', 'Upgrade to specific branch')
|
||||
.option('-s, --select', 'Force interactive version selection')
|
||||
.action(upgradeCommand);
|
||||
|
||||
// List command
|
||||
program
|
||||
.command('list')
|
||||
|
||||
@@ -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<Object|null>} - 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<Object|null>} - 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<string>} - Selected mode
|
||||
|
||||
443
ccw/src/commands/upgrade.js
Normal file
443
ccw/src/commands/upgrade.js
Normal file
@@ -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<Object|null>} - 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<Object>} - 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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
349
ccw/src/templates/dashboard-js/components/carousel.js
Normal file
349
ccw/src/templates/dashboard-js/components/carousel.js
Normal file
@@ -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) => `
|
||||
<button class="carousel-dot w-2 h-2 rounded-full transition-all duration-200 ${index === carouselIndex ? 'bg-primary w-4' : 'bg-muted-foreground/40 hover:bg-muted-foreground/60'}"
|
||||
onclick="carouselGoToIndex(${index})" title="Session ${index + 1}"></button>
|
||||
`).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 = `
|
||||
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🎯</div>
|
||||
<p class="text-sm">No active sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="carousel-slide ${animClass} h-full">
|
||||
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
|
||||
onclick="showSessionDetailPage('${sessionKey}')">
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="flex gap-4 h-full">
|
||||
|
||||
<!-- Left Column: Session Info -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Session Header -->
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">${sessionType}</span>
|
||||
${inProgress > 0 ? `<span class="inline-flex items-center gap-1 text-xs text-warning"><span class="w-2 h-2 rounded-full bg-warning animate-pulse"></span>${inProgress} running</span>` : ''}
|
||||
</div>
|
||||
<h4 class="font-semibold text-foreground text-sm line-clamp-1 mb-2" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted-foreground">Progress</span>
|
||||
<span class="text-foreground font-medium">${completed}/${taskCount}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500 ${progress === 100 ? 'bg-success' : 'bg-primary'}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Status Summary -->
|
||||
<div class="flex items-center gap-3 text-xs mb-2">
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success"></span>${completed}</span>
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-warning ${inProgress > 0 ? 'animate-pulse' : ''}"></span>${inProgress}</span>
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-muted-foreground"></span>${pending}</span>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>📅 ${createdTime}</span>
|
||||
${updatedTime && updatedTime !== createdTime ? `<span>🔄 ${updatedTime}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Task List -->
|
||||
<div class="w-[45%] flex flex-col border-l border-border pl-3">
|
||||
<div class="text-xs font-medium text-muted-foreground mb-1.5">Recent Tasks</div>
|
||||
<div class="task-list flex-1 space-y-1 overflow-hidden">
|
||||
${displayTasks.length > 0 ? displayTasks.map(task => `
|
||||
<div class="flex items-center gap-1.5 text-xs">
|
||||
<span class="shrink-0">${getTaskStatusEmoji(task.status)}</span>
|
||||
<span class="truncate flex-1 ${task.status === 'in_progress' ? 'text-foreground font-medium' : 'text-muted-foreground'}">${escapeHtml(task.title || task.id || 'Task')}</span>
|
||||
</div>
|
||||
`).join('') : `
|
||||
<div class="text-xs text-muted-foreground">No tasks yet</div>
|
||||
`}
|
||||
</div>
|
||||
<!-- Progress percentage -->
|
||||
<div class="mt-auto pt-1 text-right">
|
||||
<span class="text-xl font-bold ${progress === 100 ? 'text-success' : 'text-primary'}">${progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
}
|
||||
} else {
|
||||
startCarouselInterval();
|
||||
// Change to pause icon
|
||||
if (icon) {
|
||||
icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
273
ccw/src/templates/dashboard-js/components/hook-manager.js
Normal file
273
ccw/src/templates/dashboard-js/components/hook-manager.js
Normal file
@@ -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] || '🪝';
|
||||
}
|
||||
285
ccw/src/templates/dashboard-js/components/mcp-manager.js
Normal file
285
ccw/src/templates/dashboard-js/components/mcp-manager.js
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
194
ccw/src/templates/dashboard-js/components/notifications.js
Normal file
194
ccw/src/templates/dashboard-js/components/notifications.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
387
ccw/src/templates/dashboard-js/views/hook-manager.js
Normal file
387
ccw/src/templates/dashboard-js/views/hook-manager.js
Normal file
@@ -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 = `
|
||||
<div class="hook-manager">
|
||||
<!-- Project Hooks -->
|
||||
<div class="hook-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Project Hooks</h3>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-light text-primary">.claude/settings.json</span>
|
||||
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
onclick="openHookCreateModal()">
|
||||
<span>+</span> New Hook
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${projectHookCount} hooks configured</span>
|
||||
</div>
|
||||
|
||||
${projectHookCount === 0 ? `
|
||||
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-3xl mb-3">🪝</div>
|
||||
<p class="text-muted-foreground">No hooks configured for this project</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Create a hook to automate actions on tool usage</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="hook-grid grid gap-3">
|
||||
${renderHooksByEvent(projectHooks, 'project')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Global Hooks -->
|
||||
<div class="hook-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Global Hooks</h3>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-muted text-muted-foreground">~/.claude/settings.json</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${globalHookCount} hooks configured</span>
|
||||
</div>
|
||||
|
||||
${globalHookCount === 0 ? `
|
||||
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<p class="text-muted-foreground">No global hooks configured</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Global hooks apply to all Claude Code sessions</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="hook-grid grid gap-3">
|
||||
${renderHooksByEvent(globalHooks, 'global')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Quick Install Templates -->
|
||||
<div class="hook-section">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Quick Install Templates</h3>
|
||||
<span class="text-sm text-muted-foreground">One-click hook installation</span>
|
||||
</div>
|
||||
|
||||
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
${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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hook Environment Variables Reference -->
|
||||
<div class="hook-section mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Environment Variables Reference</h3>
|
||||
</div>
|
||||
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_FILE_PATHS</code>
|
||||
<span class="text-muted-foreground">Space-separated file paths affected</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_NAME</code>
|
||||
<span class="text-muted-foreground">Name of the tool being executed</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_INPUT</code>
|
||||
<span class="text-muted-foreground">JSON input passed to the tool</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_SESSION_ID</code>
|
||||
<span class="text-muted-foreground">Current Claude session ID</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_PROJECT_DIR</code>
|
||||
<span class="text-muted-foreground">Current project directory path</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_WORKING_DIR</code>
|
||||
<span class="text-muted-foreground">Current working directory</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 `
|
||||
<div class="hook-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">${getHookEventIcon(event)}</span>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">${event}</h4>
|
||||
<p class="text-xs text-muted-foreground">${getHookEventDescription(event)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded transition-colors"
|
||||
data-scope="${scope}"
|
||||
data-event="${event}"
|
||||
data-index="${index}"
|
||||
data-action="edit"
|
||||
title="Edit hook">
|
||||
✏️
|
||||
</button>
|
||||
<button class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
data-scope="${scope}"
|
||||
data-event="${event}"
|
||||
data-index="${index}"
|
||||
data-action="delete"
|
||||
title="Delete hook">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hook-details text-sm space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">matcher</span>
|
||||
<span class="text-muted-foreground">${escapeHtml(matcher)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">command</span>
|
||||
<span class="font-mono text-xs text-foreground">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
${args.length > 0 ? `
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
|
||||
<span class="font-mono text-xs text-muted-foreground truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderQuickInstallCard(templateId, title, description, event, matcher) {
|
||||
const isInstalled = isHookTemplateInstalled(templateId);
|
||||
|
||||
return `
|
||||
<div class="hook-template-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isInstalled ? 'border-success bg-success-light/30' : ''}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">${isInstalled ? '✅' : '🪝'}</span>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(title)}</h4>
|
||||
<p class="text-xs text-muted-foreground">${escapeHtml(description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hook-template-meta text-xs text-muted-foreground mb-3 flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-mono bg-muted px-1 py-0.5 rounded">${event}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
Matches: <span class="font-medium">${matcher}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
${isInstalled ? `
|
||||
<button class="flex-1 px-3 py-1.5 text-sm bg-destructive/10 text-destructive rounded hover:bg-destructive/20 transition-colors"
|
||||
data-template="${templateId}"
|
||||
data-action="uninstall">
|
||||
Uninstall
|
||||
</button>
|
||||
` : `
|
||||
<button class="flex-1 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-template="${templateId}"
|
||||
data-action="install-project">
|
||||
Install (Project)
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-sm bg-muted text-foreground rounded hover:bg-hover transition-colors"
|
||||
data-template="${templateId}"
|
||||
data-action="install-global">
|
||||
Global
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 = `<div class="tab-error">Failed to load context: ${err.message}</div>`;
|
||||
}
|
||||
|
||||
242
ccw/src/templates/dashboard-js/views/mcp-manager.js
Normal file
242
ccw/src/templates/dashboard-js/views/mcp-manager.js
Normal file
@@ -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 = `
|
||||
<div class="mcp-manager">
|
||||
<!-- Current Project MCP Servers -->
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Current Project MCP Servers</h3>
|
||||
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
onclick="openMcpCreateModal()">
|
||||
<span>+</span> New Server
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${currentProjectServerNames.length} servers configured</span>
|
||||
</div>
|
||||
|
||||
${currentProjectServerNames.length === 0 ? `
|
||||
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-3xl mb-3">🔌</div>
|
||||
<p class="text-muted-foreground">No MCP servers configured for this project</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Add servers from the available list below</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${currentProjectServerNames.map(serverName => {
|
||||
const serverConfig = projectServers[serverName];
|
||||
const isEnabled = !disabledServers.includes(serverName);
|
||||
return renderMcpServerCard(serverName, serverConfig, isEnabled, true);
|
||||
}).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Available MCP Servers from Other Projects -->
|
||||
<div class="mcp-section">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Available from Other Projects</h3>
|
||||
<span class="text-sm text-muted-foreground">${otherAvailableServers.length} servers available</span>
|
||||
</div>
|
||||
|
||||
${otherAvailableServers.length === 0 ? `
|
||||
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<p class="text-muted-foreground">No additional MCP servers found in other projects</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${otherAvailableServers.map(([serverName, serverInfo]) => {
|
||||
return renderAvailableServerCard(serverName, serverInfo);
|
||||
}).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- All Projects Overview -->
|
||||
<div class="mcp-section mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">All Projects</h3>
|
||||
<span class="text-sm text-muted-foreground">${Object.keys(mcpAllProjects).length} projects</span>
|
||||
</div>
|
||||
|
||||
<div class="mcp-projects-list bg-card border border-border rounded-lg overflow-hidden">
|
||||
${Object.entries(mcpAllProjects).map(([path, config]) => {
|
||||
const servers = config.mcpServers || {};
|
||||
const serverCount = Object.keys(servers).length;
|
||||
const isCurrentProject = path === currentPath;
|
||||
return `
|
||||
<div class="mcp-project-item flex items-center justify-between px-4 py-3 border-b border-border last:border-b-0 hover:bg-hover cursor-pointer ${isCurrentProject ? 'bg-primary-light' : ''}"
|
||||
onclick="switchToProject('${escapeHtml(path)}')"
|
||||
data-project-path="${escapeHtml(path)}">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="text-lg">${isCurrentProject ? '📍' : '📁'}</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-foreground truncate" title="${escapeHtml(path)}">${escapeHtml(path.split('\\').pop() || path)}</div>
|
||||
<div class="text-xs text-muted-foreground truncate">${escapeHtml(path)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full ${serverCount > 0 ? 'bg-success-light text-success' : 'bg-hover text-muted-foreground'}">${serverCount} MCP</span>
|
||||
${isCurrentProject ? '<span class="text-xs text-primary font-medium">Current</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 `
|
||||
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isEnabled ? '' : 'opacity-60'}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">${isEnabled ? '🟢' : '🔴'}</span>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
|
||||
</div>
|
||||
<label class="mcp-toggle relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" class="sr-only peer"
|
||||
${isEnabled ? 'checked' : ''}
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-action="toggle">
|
||||
<div class="w-9 h-5 bg-hover peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-success"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mcp-server-details text-sm space-y-1">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
|
||||
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
${args.length > 0 ? `
|
||||
<div class="flex items-start gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
|
||||
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${hasEnv ? `
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">env</span>
|
||||
<span class="text-xs">${Object.keys(serverConfig.env).length} variables</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${isInCurrentProject ? `
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-action="remove">
|
||||
Remove from project
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvailableServerCard(serverName, serverInfo) {
|
||||
const serverConfig = serverInfo.config;
|
||||
const usedIn = serverInfo.usedIn || [];
|
||||
const command = serverConfig.command || 'N/A';
|
||||
|
||||
return `
|
||||
<div class="mcp-server-card mcp-server-available bg-card border border-border border-dashed rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">⚪</span>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
|
||||
</div>
|
||||
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "'")}'
|
||||
data-action="add">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mcp-server-details text-sm space-y-1">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
|
||||
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="text-xs">Used in ${usedIn.length} project${usedIn.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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, '\\'));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +310,36 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- MCP Servers Section -->
|
||||
<div class="mb-2" id="mcpServersNav">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span class="mr-2">🔌</span>
|
||||
<span class="nav-section-title">MCP Servers</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="mcp-manager" data-tooltip="MCP Server Management">
|
||||
<span>⚙️</span>
|
||||
<span class="nav-text flex-1">Manage</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeMcpServers">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Hooks Section -->
|
||||
<div class="mb-2" id="hooksNav">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span class="mr-2">🪝</span>
|
||||
<span class="nav-section-title">Hooks</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="hook-manager" data-tooltip="Hook Management">
|
||||
<span>⚙️</span>
|
||||
<span class="nav-text flex-1">Manage</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeHooks">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Footer -->
|
||||
@@ -323,27 +353,67 @@
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="flex-1 p-6 overflow-y-auto min-w-0">
|
||||
<!-- Stats Grid -->
|
||||
<section class="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 mb-6">
|
||||
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
|
||||
<div class="text-2xl mb-2">📊</div>
|
||||
<div class="text-3xl font-bold text-foreground" id="statTotalSessions">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Total Sessions</div>
|
||||
<!-- Stats Section: Left Metrics + Right Carousel -->
|
||||
<section id="statsGrid" class="stats-section flex gap-4 mb-6">
|
||||
<!-- Left: 4 Metrics Grid -->
|
||||
<div class="stats-metrics grid grid-cols-2 gap-3 shrink-0">
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">📊</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statTotalSessions">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Total Sessions</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">🟢</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statActiveSessions">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Active Sessions</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">📋</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statTotalTasks">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Total Tasks</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">✅</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statCompletedTasks">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Completed Tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
|
||||
<div class="text-2xl mb-2">🟢</div>
|
||||
<div class="text-3xl font-bold text-foreground" id="statActiveSessions">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Active Sessions</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
|
||||
<div class="text-2xl mb-2">📋</div>
|
||||
<div class="text-3xl font-bold text-foreground" id="statTotalTasks">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Total Tasks</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
|
||||
<div class="text-2xl mb-2">✅</div>
|
||||
<div class="text-3xl font-bold text-foreground" id="statCompletedTasks">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Completed Tasks</div>
|
||||
|
||||
<!-- Right: Active Session Carousel (Image-style with dots) -->
|
||||
<div class="stats-carousel flex-1 bg-card border border-border rounded-lg overflow-hidden min-h-[180px] flex flex-col relative">
|
||||
<!-- Carousel Content (Full height) -->
|
||||
<div class="carousel-content flex-1 relative overflow-hidden" id="carouselContent">
|
||||
<!-- Dynamic carousel slides -->
|
||||
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🎯</div>
|
||||
<p class="text-sm">No active sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Dots Indicator & Controls -->
|
||||
<div class="carousel-footer flex items-center justify-center gap-3 py-2 border-t border-border bg-muted/20">
|
||||
<!-- Previous Button -->
|
||||
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselPrev" title="Previous">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Dots Indicator -->
|
||||
<div class="carousel-dots flex items-center gap-1.5" id="carouselDots">
|
||||
<!-- Dots will be rendered dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselNext" title="Next">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Pause Button -->
|
||||
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground ml-1" id="carouselPause" title="Pause auto-play">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="carouselPauseIcon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -404,6 +474,120 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Server Create Modal -->
|
||||
<div id="mcpCreateModal" class="mcp-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div class="mcp-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMcpCreateModal()"></div>
|
||||
<div class="mcp-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col">
|
||||
<div class="mcp-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground">Create MCP Server</h3>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeMcpCreateModal()">×</button>
|
||||
</div>
|
||||
<div class="mcp-modal-body p-4 space-y-4">
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Server Name <span class="text-destructive">*</span></label>
|
||||
<input type="text" id="mcpServerName" placeholder="e.g., my-mcp-server"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
|
||||
<input type="text" id="mcpServerCommand" placeholder="e.g., npx, uvx, node, python"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
|
||||
<textarea id="mcpServerArgs" placeholder="e.g., -y @smithery/cli@latest run exa" rows="4"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Environment Variables (KEY=VALUE per line)</label>
|
||||
<textarea id="mcpServerEnv" placeholder="e.g., API_KEY=your-api-key DEBUG=true" rows="3"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mcp-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeMcpCreateModal()">Cancel</button>
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitMcpCreate()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hook Create Modal -->
|
||||
<div id="hookCreateModal" class="hook-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div class="hook-modal-backdrop absolute inset-0 bg-black/60" onclick="closeHookCreateModal()"></div>
|
||||
<div class="hook-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col max-h-[90vh]">
|
||||
<div class="hook-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground" id="hookModalTitle">Create Hook</h3>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeHookCreateModal()">×</button>
|
||||
</div>
|
||||
<div class="hook-modal-body p-4 space-y-4 overflow-y-auto">
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Hook Event <span class="text-destructive">*</span></label>
|
||||
<select id="hookEvent" class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
<option value="">Select an event...</option>
|
||||
<option value="PreToolUse">PreToolUse - Before a tool is executed</option>
|
||||
<option value="PostToolUse">PostToolUse - After a tool completes</option>
|
||||
<option value="Notification">Notification - On notifications</option>
|
||||
<option value="Stop">Stop - When agent stops</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Matcher (optional)</label>
|
||||
<input type="text" id="hookMatcher" placeholder="e.g., Write, Edit, Bash (leave empty for all)"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
<p class="text-xs text-muted-foreground mt-1">Tool name to match. Leave empty to match all tools.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
|
||||
<input type="text" id="hookCommand" placeholder="e.g., curl, bash, node"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
|
||||
<textarea id="hookArgs" placeholder="e.g., -X POST http://localhost:3456/api/hook" rows="4"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Scope</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="hookScope" value="project" checked class="text-primary focus:ring-primary">
|
||||
<span class="text-sm text-foreground">Project (.claude/settings.json)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="hookScope" value="global" class="text-primary focus:ring-primary">
|
||||
<span class="text-sm text-foreground">Global (~/.claude/settings.json)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Quick Templates</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('ccw-notify')">
|
||||
<span class="font-medium">CCW Notify</span>
|
||||
<span class="block text-muted-foreground">Notify dashboard on Write</span>
|
||||
</button>
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('log-tool')">
|
||||
<span class="font-medium">Log Tool Usage</span>
|
||||
<span class="block text-muted-foreground">Log all tool executions</span>
|
||||
</button>
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('lint-check')">
|
||||
<span class="font-medium">Lint Check</span>
|
||||
<span class="block text-muted-foreground">Run eslint on file changes</span>
|
||||
</button>
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('git-add')">
|
||||
<span class="font-medium">Git Add</span>
|
||||
<span class="block text-muted-foreground">Auto stage written files</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hook-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeHookCreateModal()">Cancel</button>
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitHookCreate()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D3.js for Flowchart -->
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<!-- Marked.js for Markdown rendering -->
|
||||
|
||||
252
ccw/src/utils/version-fetcher.js
Normal file
252
ccw/src/utils/version-fetcher.js
Normal file
@@ -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<Object>}
|
||||
*/
|
||||
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<Array<{tag: string, date: string}>>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<string>} - 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user