feat: Add workflow dashboard template and utility functions

- Implemented a new HTML template for the workflow dashboard, featuring a responsive design with dark/light theme support, session statistics, and task management UI.
- Created a browser launcher utility to open HTML files in the default browser across platforms.
- Developed file utility functions for safe reading and writing of JSON and text files.
- Added path resolver utilities to validate and resolve file paths, ensuring security against path traversal attacks.
- Introduced UI utilities for displaying styled messages and banners in the console.
This commit is contained in:
catlog22
2025-12-04 09:40:12 +08:00
parent 0f9adc59f9
commit 35bd0aa8f6
22 changed files with 8272 additions and 24 deletions

View File

@@ -213,7 +213,11 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
```
**Field Descriptions**:
- `id`: Task identifier (format: `IMPL-N`)
- `id`: Task identifier
- Single module format: `IMPL-N` (e.g., IMPL-001, IMPL-002)
- Multi-module format: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1, IMPL-C1)
- Prefix: A, B, C... (assigned by module detection order)
- Sequence: 1, 2, 3... (per-module increment)
- `title`: Descriptive task name summarizing the work
- `status`: Task state - `pending` (not started), `active` (in progress), `completed` (done), `blocked` (waiting on dependencies)
- `context_package_path`: Path to smart context package containing project structure, dependencies, and brainstorming artifacts catalog
@@ -225,7 +229,8 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
"meta": {
"type": "feature|bugfix|refactor|test-gen|test-fix|docs",
"agent": "@code-developer|@action-planning-agent|@test-fix-agent|@universal-executor",
"execution_group": "parallel-abc123|null"
"execution_group": "parallel-abc123|null",
"module": "frontend|backend|shared|null"
}
}
```
@@ -234,6 +239,7 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
- `type`: Task category - `feature` (new functionality), `bugfix` (fix defects), `refactor` (restructure code), `test-gen` (generate tests), `test-fix` (fix failing tests), `docs` (documentation)
- `agent`: Assigned agent for execution
- `execution_group`: Parallelization group ID (tasks with same ID can run concurrently) or `null` for sequential tasks
- `module`: Module identifier for multi-module projects (e.g., `frontend`, `backend`, `shared`) or `null` for single-module
**Test Task Extensions** (for type="test-gen" or type="test-fix"):
@@ -604,10 +610,42 @@ Agent determines CLI tool usage per-step based on user semantics and task nature
- Analysis results (technical approach, architecture decisions)
- Brainstorming artifacts (role analyses, guidance specifications)
**Multi-Module Format** (when modules detected):
When multiple modules are detected (frontend/backend, etc.), organize IMPL_PLAN.md by module:
```markdown
# Implementation Plan
## Module A: Frontend (N tasks)
### IMPL-A1: [Task Title]
[Task details...]
### IMPL-A2: [Task Title]
[Task details...]
## Module B: Backend (N tasks)
### IMPL-B1: [Task Title]
[Task details...]
### IMPL-B2: [Task Title]
[Task details...]
## Cross-Module Dependencies
- IMPL-A1 → IMPL-B1 (Frontend depends on Backend API)
- IMPL-A2 → IMPL-B2 (UI state depends on Backend service)
```
**Cross-Module Dependency Notation**:
- During parallel planning, use `CROSS::{module}::{pattern}` format
- Example: `depends_on: ["CROSS::B::api-endpoint"]`
- Integration phase resolves to actual task IDs: `CROSS::B::api → IMPL-B1`
### 2.3 TODO_LIST.md Structure
Generate at `.workflow/active/{session_id}/TODO_LIST.md`:
**Single Module Format**:
```markdown
# Tasks: {Session Topic}
@@ -621,26 +659,50 @@ Generate at `.workflow/active/{session_id}/TODO_LIST.md`:
- `- [x]` = Completed task
```
**Multi-Module Format** (hierarchical by module):
```markdown
# Tasks: {Session Topic}
## Module A (Frontend)
- [ ] **IMPL-A1**: [Task Title] → [📋](./.task/IMPL-A1.json)
- [ ] **IMPL-A2**: [Task Title] → [📋](./.task/IMPL-A2.json)
## Module B (Backend)
- [ ] **IMPL-B1**: [Task Title] → [📋](./.task/IMPL-B1.json)
- [ ] **IMPL-B2**: [Task Title] → [📋](./.task/IMPL-B2.json)
## Cross-Module Dependencies
- IMPL-A1 → IMPL-B1 (Frontend depends on Backend API)
## Status Legend
- `- [ ]` = Pending task
- `- [x]` = Completed task
```
**Linking Rules**:
- Todo items → task JSON: `[📋](./.task/IMPL-XXX.json)`
- Completed tasks → summaries: `[✅](./.summaries/IMPL-XXX-summary.md)`
- Consistent ID schemes: IMPL-XXX
- Consistent ID schemes: `IMPL-N` (single) or `IMPL-{prefix}{seq}` (multi-module)
### 2.4 Complexity-Based Structure Selection
Use `analysis_results.complexity` or task count to determine structure:
**Simple Tasks** (≤5 tasks):
- Flat structure: IMPL_PLAN.md + TODO_LIST.md + task JSONs
- All tasks at same level
**Single Module Mode**:
- **Simple Tasks** (≤5 tasks): Flat structure
- **Medium Tasks** (6-12 tasks): Flat structure
- **Complex Tasks** (>12 tasks): Re-scope required (maximum 12 tasks hard limit)
**Medium Tasks** (6-12 tasks):
- Flat structure: IMPL_PLAN.md + TODO_LIST.md + task JSONs
- All tasks at same level
**Multi-Module Mode** (N+1 parallel planning):
- **Per-module limit**: ≤9 tasks per module
- **Total limit**: Sum of all module tasks ≤27 (3 modules × 9 tasks)
- **Task ID format**: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1)
- **Structure**: Hierarchical by module in IMPL_PLAN.md and TODO_LIST.md
**Complex Tasks** (>12 tasks):
- **Re-scope required**: Maximum 12 tasks hard limit
- If analysis_results contains >12 tasks, consolidate or request re-scoping
**Multi-Module Detection Triggers**:
- Explicit frontend/backend separation (`src/frontend`, `src/backend`)
- Monorepo structure (`packages/*`, `apps/*`)
- Context-package dependency clustering (2+ distinct module groups)
---
@@ -685,8 +747,10 @@ Use `analysis_results.complexity` or task count to determine structure:
### 3.3 File Organization
- Session naming: `WFS-[topic-slug]`
- Task IDs: IMPL-XXX (flat structure only)
- Directory structure: flat task organization
- Task IDs:
- Single module: `IMPL-N` (e.g., IMPL-001, IMPL-002)
- Multi-module: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1)
- Directory structure: flat task organization (all tasks in `.task/`)
### 3.4 Document Standards

View File

@@ -14,8 +14,8 @@ Generate implementation planning documents (IMPL_PLAN.md, task JSONs, TODO_LIST.
## Core Philosophy
- **Planning Only**: Generate planning documents (IMPL_PLAN.md, task JSONs, TODO_LIST.md) - does NOT implement code
- **Agent-Driven Document Generation**: Delegate plan generation to action-planning-agent
- **N+1 Parallel Planning**: Auto-detect multi-module projects, enable parallel planning (2+1 or 3+1 mode)
- **Progressive Loading**: Load context incrementally (Core → Selective → On-Demand) due to analysis.md file size
- **Two-Phase Flow**: Discovery (context gathering) → Output (planning document generation)
- **Memory-First**: Reuse loaded documents from conversation memory
- **Smart Selection**: Load synthesis_output OR guidance + relevant role analyses, NOT all role analyses
- **MCP-Enhanced**: Use MCP tools for advanced code analysis and research
@@ -28,22 +28,38 @@ Input Parsing:
├─ Parse flags: --session
└─ Validation: session_id REQUIRED
Phase 1: Context Preparation (Command)
Phase 1: Context Preparation & Module Detection (Command)
├─ Assemble session paths (metadata, context package, output dirs)
─ Provide metadata (session_id, execution_mode, mcp_capabilities)
─ Provide metadata (session_id, execution_mode, mcp_capabilities)
├─ Auto-detect modules from context-package + directory structure
└─ Decision:
├─ modules.length == 1 → Single Agent Mode (Phase 2A)
└─ modules.length >= 2 → Parallel Mode (Phase 2B + Phase 3)
Phase 2: Planning Document Generation (Agent)
Phase 2A: Single Agent Planning (Original Flow)
├─ Load context package (progressive loading strategy)
├─ Generate Task JSON Files (.task/IMPL-*.json)
├─ Create IMPL_PLAN.md
└─ Generate TODO_LIST.md
Phase 2B: N Parallel Planning (Multi-Module)
├─ Launch N action-planning-agents simultaneously (one per module)
├─ Each agent generates module-scoped tasks (IMPL-{prefix}{seq}.json)
├─ Task ID format: IMPL-A1, IMPL-A2... / IMPL-B1, IMPL-B2...
└─ Each module limited to ≤9 tasks
Phase 3: Integration (+1 Coordinator, Multi-Module Only)
├─ Collect all module task JSONs
├─ Resolve cross-module dependencies (CROSS::{module}::{pattern} → actual ID)
├─ Generate unified IMPL_PLAN.md (grouped by module)
└─ Generate TODO_LIST.md (hierarchical: module → tasks)
```
## Document Generation Lifecycle
### Phase 1: Context Preparation (Command Responsibility)
### Phase 1: Context Preparation & Module Detection (Command Responsibility)
**Command prepares session paths and metadata for planning document generation.**
**Command prepares session paths, metadata, and detects module structure.**
**Session Path Structure**:
```
@@ -52,8 +68,12 @@ Phase 2: Planning Document Generation (Agent)
├── .process/
│ └── context-package.json # Context package with artifact catalog
├── .task/ # Output: Task JSON files
├── IMPL_PLAN.md # Output: Implementation plan
└── TODO_LIST.md # Output: TODO list
├── IMPL-A1.json # Multi-module: prefixed by module
│ ├── IMPL-A2.json
│ ├── IMPL-B1.json
│ └── ...
├── IMPL_PLAN.md # Output: Implementation plan (grouped by module)
└── TODO_LIST.md # Output: TODO list (hierarchical)
```
**Command Preparation**:
@@ -66,9 +86,40 @@ Phase 2: Planning Document Generation (Agent)
- `session_id`
- `mcp_capabilities` (available MCP tools)
3. **Auto Module Detection** (determines single vs parallel mode):
```javascript
function autoDetectModules(contextPackage, projectRoot) {
// Priority 1: Explicit frontend/backend separation
if (exists('src/frontend') && exists('src/backend')) {
return [
{ name: 'frontend', prefix: 'A', paths: ['src/frontend'] },
{ name: 'backend', prefix: 'B', paths: ['src/backend'] }
];
}
// Priority 2: Monorepo structure
if (exists('packages/*') || exists('apps/*')) {
return detectMonorepoModules(); // Returns 2-3 main packages
}
// Priority 3: Context-package dependency clustering
const modules = clusterByDependencies(contextPackage.dependencies?.internal);
if (modules.length >= 2) return modules.slice(0, 3);
// Default: Single module (original flow)
return [{ name: 'main', prefix: '', paths: ['.'] }];
}
```
**Decision Logic**:
- `modules.length == 1` → Phase 2A (Single Agent, original flow)
- `modules.length >= 2` → Phase 2B + Phase 3 (N+1 Parallel)
**Note**: CLI tool usage is now determined semantically by action-planning-agent based on user's task description, not by flags.
### Phase 2: Planning Document Generation (Agent Responsibility)
### Phase 2A: Single Agent Planning (Original Flow)
**Condition**: `modules.length == 1` (no multi-module detected)
**Purpose**: Generate IMPL_PLAN.md, task JSONs, and TODO_LIST.md - planning documents only, NOT code implementation.
@@ -148,4 +199,93 @@ Hard Constraints:
)
```
### Phase 2B: N Parallel Planning (Multi-Module)
**Condition**: `modules.length >= 2` (multi-module detected)
**Purpose**: Launch N action-planning-agents simultaneously, one per module, for parallel task generation.
**Parallel Agent Invocation**:
```javascript
// Launch N agents in parallel (one per module)
const planningTasks = modules.map(module =>
Task(
subagent_type="action-planning-agent",
description=`Plan ${module.name} module`,
prompt=`
## SCOPE
- Module: ${module.name} (${module.type})
- Focus Paths: ${module.paths.join(', ')}
- Task ID Prefix: IMPL-${module.prefix}
- Task Limit: ≤9 tasks
- Other Modules: ${otherModules.join(', ')}
- Cross-module deps format: "CROSS::{module}::{pattern}"
## SESSION PATHS
Input:
- Context Package: .workflow/active/{session-id}/.process/context-package.json
Output:
- Task Dir: .workflow/active/{session-id}/.task/
## INSTRUCTIONS
- Generate tasks ONLY for ${module.name} module
- Use task ID format: IMPL-${module.prefix}1, IMPL-${module.prefix}2, ...
- For cross-module dependencies, use: depends_on: ["CROSS::B::api-endpoint"]
- Maximum 9 tasks per module
`
)
);
// Execute all in parallel
await Promise.all(planningTasks);
```
**Output Structure** (direct to .task/):
```
.task/
├── IMPL-A1.json # Module A (e.g., frontend)
├── IMPL-A2.json
├── IMPL-B1.json # Module B (e.g., backend)
├── IMPL-B2.json
└── IMPL-C1.json # Module C (e.g., shared)
```
**Task ID Naming**:
- Format: `IMPL-{prefix}{seq}.json`
- Prefix: A, B, C... (assigned by detection order)
- Sequence: 1, 2, 3... (per-module increment)
### Phase 3: Integration (+1 Coordinator, Multi-Module Only)
**Condition**: Only executed when `modules.length >= 2`
**Purpose**: Collect all module tasks, resolve cross-module dependencies, generate unified documents.
**Integration Logic**:
```javascript
// 1. Collect all module task JSONs
const allTasks = glob('.task/IMPL-*.json').map(loadJson);
// 2. Resolve cross-module dependencies
for (const task of allTasks) {
if (task.depends_on) {
task.depends_on = task.depends_on.map(dep => {
if (dep.startsWith('CROSS::')) {
// CROSS::B::api-endpoint → find matching IMPL-B* task
const [, targetModule, pattern] = dep.match(/CROSS::(\w+)::(.+)/);
return findTaskByModuleAndPattern(allTasks, targetModule, pattern);
}
return dep;
});
}
}
// 3. Generate unified IMPL_PLAN.md (grouped by module)
generateIMPL_PLAN(allTasks, modules);
// 4. Generate TODO_LIST.md (hierarchical structure)
generateTODO_LIST(allTasks, modules);
```
**Note**: IMPL_PLAN.md and TODO_LIST.md structure definitions are in `action-planning-agent.md`.

121
ccw/README.md Normal file
View File

@@ -0,0 +1,121 @@
# CCW - Claude Code Workflow CLI
A command-line tool for viewing workflow sessions and code review results from the Claude Code Workflow system.
## Installation
```bash
# Install globally
npm install -g ccw
# Or install from local source
cd path/to/ccw
npm install
npm link
```
## Usage
### View Dashboard
```bash
# Open workflow dashboard in browser
ccw view
# Specify project path
ccw view -p /path/to/project
# Generate dashboard without opening browser
ccw view --no-browser
# Custom output path
ccw view -o report.html
```
## Features
### Workflow Dashboard
- **Active Sessions**: View all active workflow sessions with task progress
- **Archived Sessions**: Browse completed/archived sessions
- **Task Tracking**: See individual task status (pending/in_progress/completed)
- **Progress Bars**: Visual progress indicators for each session
### Review Integration
- **Code Review Findings**: View results from `review-module-cycle`
- **Severity Distribution**: Critical/High/Medium/Low finding counts
- **Dimension Analysis**: Findings by review dimension (Security, Architecture, Quality, etc.)
- **Tabbed Interface**: Switch between Workflow and Reviews tabs
## Dashboard Data Sources
The CLI reads data from the `.workflow/` directory structure:
```
.workflow/
├── active/
│ └── WFS-{session-id}/
│ ├── workflow-session.json # Session metadata
│ ├── .task/
│ │ └── IMPL-*.json # Task definitions
│ └── .review/
│ ├── review-progress.json # Review progress
│ └── dimensions/
│ └── *.json # Dimension findings
└── archives/
└── WFS-{session-id}/ # Archived sessions
```
## Bundled Templates
The CLI includes bundled dashboard templates:
- `workflow-dashboard.html` - Workflow session and task visualization
- `review-cycle-dashboard.html` - Code review findings display
No external template installation required - templates are included in the npm package.
## Requirements
- Node.js >= 16.0.0
- npm or yarn
## Integration with Claude Code Workflow
This CLI is a standalone tool that works with the Claude Code Workflow system:
1. **Install CCW CLI** (via npm)
- `npm install -g ccw`
- Provides `ccw view` command for dashboard viewing
- Templates are bundled - no additional installation required
2. **Optional: Install Claude Code Workflow** (via `Install-Claude.ps1`)
- Provides workflow commands, agents, and automation
- CCW will automatically detect and display workflow sessions
## Options
| Option | Description |
|--------|-------------|
| `-p, --path <path>` | Path to project directory (default: current directory) |
| `--no-browser` | Generate dashboard without opening browser |
| `-o, --output <file>` | Custom output path for HTML file |
| `-V, --version` | Display version number |
| `-h, --help` | Display help information |
## Development
```bash
# Clone and install dependencies
git clone <repo-url>
cd ccw
npm install
# Link for local testing
npm link
# Test the CLI
ccw view -p /path/to/test/project
```
## License
MIT

10
ccw/bin/ccw.js Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env node
/**
* CCW CLI - Claude Code Workflow Dashboard
* Entry point for global CLI installation
*/
import { run } from '../src/cli.js';
run(process.argv);

1914
ccw/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
ccw/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "ccw",
"version": "1.0.0",
"description": "Claude Code Workflow CLI - Dashboard viewer for workflow sessions and reviews",
"type": "module",
"main": "src/index.js",
"bin": {
"ccw": "./bin/ccw.js"
},
"scripts": {
"test": "node --test",
"lint": "eslint src/"
},
"keywords": [
"claude",
"workflow",
"cli",
"dashboard",
"code-review"
],
"author": "Claude Code Workflow",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"commander": "^11.0.0",
"open": "^9.1.0",
"chalk": "^5.3.0",
"glob": "^10.3.0",
"inquirer": "^9.2.0",
"ora": "^7.0.0",
"figlet": "^1.7.0",
"boxen": "^7.1.0",
"gradient-string": "^2.0.2"
},
"files": [
"bin/",
"src/",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://github.com/claude-code-workflow/ccw"
}
}

82
ccw/src/cli.js Normal file
View File

@@ -0,0 +1,82 @@
import { Command } from 'commander';
import { viewCommand } from './commands/view.js';
import { installCommand } from './commands/install.js';
import { uninstallCommand } from './commands/uninstall.js';
import { listCommand } from './commands/list.js';
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Load package.json with error handling
* @returns {Object} - Package info with version
*/
function loadPackageInfo() {
const pkgPath = join(__dirname, '../package.json');
try {
if (!existsSync(pkgPath)) {
console.error('Fatal Error: package.json not found.');
console.error(`Expected location: ${pkgPath}`);
process.exit(1);
}
const content = readFileSync(pkgPath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error instanceof SyntaxError) {
console.error('Fatal Error: package.json contains invalid JSON.');
console.error(`Parse error: ${error.message}`);
} else {
console.error('Fatal Error: Could not read package.json.');
console.error(`Error: ${error.message}`);
}
process.exit(1);
}
}
const pkg = loadPackageInfo();
export function run(argv) {
const program = new Command();
program
.name('ccw')
.description('Claude Code Workflow CLI - Dashboard and workflow tools')
.version(pkg.version);
// View command
program
.command('view')
.description('Open workflow dashboard in browser')
.option('-p, --path <path>', 'Path to project directory', '.')
.option('--no-browser', 'Generate dashboard without opening browser')
.option('-o, --output <file>', 'Output path for generated HTML')
.action(viewCommand);
// Install command
program
.command('install')
.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('-f, --force', 'Force installation without prompts')
.action(installCommand);
// Uninstall command
program
.command('uninstall')
.description('Uninstall Claude Code Workflow')
.action(uninstallCommand);
// List command
program
.command('list')
.description('List all installed Claude Code Workflow instances')
.action(listCommand);
program.parse(argv);
}

309
ccw/src/commands/install.js Normal file
View File

@@ -0,0 +1,309 @@
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
import { join, dirname, basename, relative } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer';
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';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Source directories to install
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'];
// Get package root directory (ccw/src/commands -> ccw)
function getPackageRoot() {
return join(__dirname, '..', '..');
}
// Get source installation directory (parent of ccw)
function getSourceDir() {
return join(getPackageRoot(), '..');
}
/**
* Install command handler
* @param {Object} options - Command options
*/
export async function installCommand(options) {
const version = getVersion();
// Show beautiful header
showHeader(version);
// Check for existing installations
const existingManifests = getAllManifests();
if (existingManifests.length > 0 && !options.force) {
info('Existing installations detected:');
console.log('');
existingManifests.forEach((m, i) => {
console.log(chalk.gray(` ${i + 1}. ${m.installation_mode} - ${m.installation_path}`));
console.log(chalk.gray(` Installed: ${new Date(m.installation_date).toLocaleDateString()}`));
});
console.log('');
const { proceed } = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: 'Continue with new installation?',
default: true
}]);
if (!proceed) {
info('Installation cancelled');
return;
}
}
// Interactive mode selection
const mode = options.mode || await selectMode();
let installPath;
if (mode === 'Global') {
installPath = homedir();
info(`Global installation to: ${installPath}`);
} else {
const inputPath = options.path || await selectPath();
// Validate the installation path
const pathValidation = validatePath(inputPath, { mustExist: true });
if (!pathValidation.valid) {
error(`Invalid installation path: ${pathValidation.error}`);
process.exit(1);
}
installPath = pathValidation.path;
info(`Path installation to: ${installPath}`);
}
// 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}`);
process.exit(1);
}
console.log('');
info(`Found ${availableDirs.length} directories to install: ${availableDirs.join(', ')}`);
divider();
// Check for existing installation at target path
const existingManifest = findManifest(installPath, mode);
if (existingManifest) {
warning('Existing installation found at this location');
const { backup } = await inquirer.prompt([{
type: 'confirm',
name: 'backup',
message: 'Create backup before reinstalling?',
default: true
}]);
if (backup) {
await createBackup(installPath, existingManifest);
}
}
// Create manifest
const manifest = createManifest(mode, installPath);
// Perform installation
console.log('');
const spinner = createSpinner('Installing files...').start();
let totalFiles = 0;
let totalDirs = 0;
try {
for (const dir of availableDirs) {
const srcPath = join(sourceDir, dir);
const destPath = join(installPath, dir);
spinner.text = `Installing ${dir}...`;
const { files, directories } = await copyDirectory(srcPath, destPath, manifest);
totalFiles += files;
totalDirs += directories;
}
// Create version.json
const versionPath = join(installPath, '.claude', 'version.json');
if (existsSync(dirname(versionPath))) {
const versionInfo = {
version: version,
installedAt: new Date().toISOString(),
mode: mode,
installer: 'ccw'
};
writeFileSync(versionPath, JSON.stringify(versionInfo, null, 2), 'utf8');
addFileEntry(manifest, versionPath);
totalFiles++;
}
spinner.succeed('Installation complete!');
} catch (err) {
spinner.fail('Installation failed');
error(err.message);
process.exit(1);
}
// Save manifest
const manifestPath = saveManifest(manifest);
// Show summary
console.log('');
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)}`),
],
borderColor: '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(chalk.gray(' 3. Run: ccw uninstall - to remove this installation'));
console.log('');
}
/**
* Interactive mode selection
* @returns {Promise<string>} - Selected mode
*/
async function selectMode() {
const { mode } = await inquirer.prompt([{
type: 'list',
name: 'mode',
message: 'Select installation mode:',
choices: [
{
name: `${chalk.cyan('Global')} - Install to home directory (recommended)`,
value: 'Global'
},
{
name: `${chalk.yellow('Path')} - Install to specific project path`,
value: 'Path'
}
]
}]);
return mode;
}
/**
* Interactive path selection
* @returns {Promise<string>} - Selected path
*/
async function selectPath() {
const { path } = await inquirer.prompt([{
type: 'input',
name: 'path',
message: 'Enter installation path:',
default: process.cwd(),
validate: (input) => {
if (!input) return 'Path is required';
if (!existsSync(input)) {
return `Path does not exist: ${input}`;
}
return true;
}
}]);
return path;
}
/**
* Create backup of existing installation
* @param {string} installPath - Installation path
* @param {Object} manifest - Existing manifest
*/
async function createBackup(installPath, manifest) {
const spinner = createSpinner('Creating backup...').start();
try {
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const backupDir = join(installPath, `.claude-backup-${timestamp}`);
mkdirSync(backupDir, { recursive: true });
// Copy existing .claude directory
const claudeDir = join(installPath, '.claude');
if (existsSync(claudeDir)) {
await copyDirectory(claudeDir, join(backupDir, '.claude'));
}
spinner.succeed(`Backup created: ${backupDir}`);
} catch (err) {
spinner.warn(`Backup failed: ${err.message}`);
}
}
/**
* Copy directory recursively
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @param {Object} manifest - Manifest to track files (optional)
* @returns {Object} - Count of files and directories
*/
async function copyDirectory(src, dest, manifest = null) {
let files = 0;
let directories = 0;
// Create destination directory
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
directories++;
if (manifest) 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++;
if (manifest) addFileEntry(manifest, destPath);
}
}
return { files, directories };
}
/**
* Get package version
* @returns {string} - Version string
*/
function getVersion() {
try {
const pkgPath = join(getPackageRoot(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
return pkg.version || '1.0.0';
} catch {
return '1.0.0';
}
}

37
ccw/src/commands/list.js Normal file
View File

@@ -0,0 +1,37 @@
import chalk from 'chalk';
import { showBanner, divider, info } from '../utils/ui.js';
import { getAllManifests } from '../core/manifest.js';
/**
* List command handler - shows all installations
*/
export async function listCommand() {
showBanner();
console.log(chalk.cyan.bold(' Installed Claude Code Workflow Instances\n'));
const manifests = getAllManifests();
if (manifests.length === 0) {
info('No installations found.');
console.log('');
console.log(chalk.gray(' Run: ccw install - to install Claude Code Workflow'));
console.log('');
return;
}
manifests.forEach((m, i) => {
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
console.log(chalk.white.bold(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
console.log(chalk.gray(` Path: ${m.installation_path}`));
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
console.log(chalk.gray(` Version: ${m.application_version}`));
console.log(chalk.gray(` Files: ${m.files_count}`));
console.log(chalk.gray(` Dirs: ${m.directories_count}`));
console.log('');
});
divider();
console.log(chalk.gray(' Run: ccw uninstall - to remove an installation'));
console.log('');
}

View File

@@ -0,0 +1,238 @@
import { existsSync, unlinkSync, rmdirSync, readdirSync, statSync } from 'fs';
import { join, dirname, basename } from 'path';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { showBanner, createSpinner, success, info, warning, error, summaryBox, divider } from '../utils/ui.js';
import { getAllManifests, deleteManifest } from '../core/manifest.js';
/**
* Uninstall command handler
* @param {Object} options - Command options
*/
export async function uninstallCommand(options) {
showBanner();
console.log(chalk.cyan.bold(' Uninstall Claude Code Workflow\n'));
// Get all manifests
const manifests = getAllManifests();
if (manifests.length === 0) {
warning('No installations found.');
info('Nothing to uninstall.');
return;
}
// Display installations
console.log(chalk.white.bold(' Found installations:\n'));
manifests.forEach((m, i) => {
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
console.log(chalk.white(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
console.log(chalk.gray(` Path: ${m.installation_path}`));
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
console.log(chalk.gray(` Version: ${m.application_version}`));
console.log(chalk.gray(` Files: ${m.files_count} | Dirs: ${m.directories_count}`));
console.log('');
});
divider();
// Select installation to uninstall
let selectedManifest;
if (manifests.length === 1) {
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Uninstall ${manifests[0].installation_mode} installation at ${manifests[0].installation_path}?`,
default: false
}]);
if (!confirm) {
info('Uninstall cancelled');
return;
}
selectedManifest = manifests[0];
} else {
const choices = manifests.map((m, i) => ({
name: `${m.installation_mode} - ${m.installation_path}`,
value: i
}));
choices.push({ name: chalk.gray('Cancel'), value: -1 });
const { selection } = await inquirer.prompt([{
type: 'list',
name: 'selection',
message: 'Select installation to uninstall:',
choices
}]);
if (selection === -1) {
info('Uninstall cancelled');
return;
}
selectedManifest = manifests[selection];
// Confirm selection
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to uninstall ${selectedManifest.installation_mode} installation?`,
default: false
}]);
if (!confirm) {
info('Uninstall cancelled');
return;
}
}
console.log('');
// Perform uninstallation
const spinner = createSpinner('Removing files...').start();
let removedFiles = 0;
let removedDirs = 0;
let failedFiles = [];
try {
// Remove files first (in reverse order to handle nested files)
const files = [...(selectedManifest.files || [])].reverse();
for (const fileEntry of files) {
const filePath = fileEntry.path;
spinner.text = `Removing: ${basename(filePath)}`;
try {
if (existsSync(filePath)) {
unlinkSync(filePath);
removedFiles++;
}
} catch (err) {
failedFiles.push({ path: filePath, error: err.message });
}
}
// Remove directories (in reverse order to remove nested dirs first)
const directories = [...(selectedManifest.directories || [])].reverse();
// Sort by path length (deepest first)
directories.sort((a, b) => b.path.length - a.path.length);
for (const dirEntry of directories) {
const dirPath = dirEntry.path;
spinner.text = `Removing directory: ${basename(dirPath)}`;
try {
if (existsSync(dirPath)) {
// Only remove if empty
const contents = readdirSync(dirPath);
if (contents.length === 0) {
rmdirSync(dirPath);
removedDirs++;
}
}
} catch (err) {
// Ignore directory removal errors (might not be empty)
}
}
// Try to clean up parent directories if empty
const installPath = selectedManifest.installation_path;
for (const dir of ['.claude', '.codex', '.gemini', '.qwen']) {
const dirPath = join(installPath, dir);
try {
if (existsSync(dirPath)) {
await removeEmptyDirs(dirPath);
}
} catch {
// Ignore
}
}
spinner.succeed('Uninstall complete!');
} catch (err) {
spinner.fail('Uninstall failed');
error(err.message);
return;
}
// Delete manifest
deleteManifest(selectedManifest.manifest_file);
// Show summary
console.log('');
if (failedFiles.length > 0) {
summaryBox({
title: ' Uninstall Summary ',
lines: [
chalk.yellow.bold('⚠ Partially Completed'),
'',
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
chalk.white(`Failed: ${chalk.red(failedFiles.length)}`),
'',
chalk.gray('Some files could not be removed.'),
chalk.gray('They may be in use or require elevated permissions.'),
],
borderColor: 'yellow'
});
if (process.env.DEBUG) {
console.log('');
console.log(chalk.gray('Failed files:'));
failedFiles.forEach(f => {
console.log(chalk.red(` ${f.path}: ${f.error}`));
});
}
} else {
summaryBox({
title: ' Uninstall Summary ',
lines: [
chalk.green.bold('✓ Successfully Uninstalled'),
'',
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
'',
chalk.gray('Manifest removed'),
],
borderColor: 'green'
});
}
console.log('');
}
/**
* Recursively remove empty directories
* @param {string} dirPath - Directory path
*/
async function removeEmptyDirs(dirPath) {
if (!existsSync(dirPath)) return;
const stat = statSync(dirPath);
if (!stat.isDirectory()) return;
let files = readdirSync(dirPath);
// Recursively check subdirectories
for (const file of files) {
const filePath = join(dirPath, file);
if (statSync(filePath).isDirectory()) {
await removeEmptyDirs(filePath);
}
}
// Re-check after processing subdirectories
files = readdirSync(dirPath);
if (files.length === 0) {
rmdirSync(dirPath);
}
}

132
ccw/src/commands/view.js Normal file
View File

@@ -0,0 +1,132 @@
import { scanSessions } from '../core/session-scanner.js';
import { aggregateData } from '../core/data-aggregator.js';
import { generateDashboard } from '../core/dashboard-generator.js';
import { launchBrowser, isHeadlessEnvironment } from '../utils/browser-launcher.js';
import { resolvePath, ensureDir, getWorkflowDir, validatePath, validateOutputPath } from '../utils/path-resolver.js';
import chalk from 'chalk';
import { writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
/**
* View command handler - generates and opens workflow dashboard
* @param {Object} options - Command options
*/
export async function viewCommand(options) {
// Validate project path
const pathValidation = validatePath(options.path, { mustExist: true });
if (!pathValidation.valid) {
console.error(chalk.red(`\n Error: ${pathValidation.error}\n`));
process.exit(1);
}
const workingDir = pathValidation.path;
const workflowDir = join(workingDir, '.workflow');
console.log(chalk.blue.bold('\n CCW Dashboard Generator\n'));
console.log(chalk.gray(` Project: ${workingDir}`));
console.log(chalk.gray(` Workflow: ${workflowDir}\n`));
// Check if .workflow directory exists
if (!existsSync(workflowDir)) {
console.log(chalk.yellow(' No .workflow directory found.'));
console.log(chalk.gray(' This project may not have any workflow sessions yet.\n'));
// Still generate an empty dashboard
const emptyData = {
generatedAt: new Date().toISOString(),
activeSessions: [],
archivedSessions: [],
reviewData: null,
statistics: {
totalSessions: 0,
activeSessions: 0,
totalTasks: 0,
completedTasks: 0,
reviewFindings: 0
}
};
await generateAndOpen(emptyData, workflowDir, options);
return;
}
try {
// Step 1: Scan for sessions
console.log(chalk.cyan(' Scanning sessions...'));
const sessions = await scanSessions(workflowDir);
console.log(chalk.green(` Found ${sessions.active.length} active, ${sessions.archived.length} archived sessions`));
if (sessions.hasReviewData) {
console.log(chalk.magenta(' Review data detected - will include Reviews tab'));
}
// Step 2: Aggregate all data
console.log(chalk.cyan(' Aggregating data...'));
const dashboardData = await aggregateData(sessions, workflowDir);
// Log statistics
const stats = dashboardData.statistics;
console.log(chalk.gray(` Tasks: ${stats.completedTasks}/${stats.totalTasks} completed`));
if (stats.reviewFindings > 0) {
console.log(chalk.gray(` Review findings: ${stats.reviewFindings}`));
}
// Step 3 & 4: Generate and open
await generateAndOpen(dashboardData, workflowDir, options);
} catch (error) {
console.error(chalk.red(`\n Error: ${error.message}\n`));
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
}
/**
* Generate dashboard and optionally open in browser
* @param {Object} data - Dashboard data
* @param {string} workflowDir - Path to .workflow
* @param {Object} options - Command options
*/
async function generateAndOpen(data, workflowDir, options) {
// Step 3: Generate dashboard HTML
console.log(chalk.cyan(' Generating dashboard...'));
const html = await generateDashboard(data);
// Step 4: Validate and write dashboard file
let outputPath;
if (options.output) {
const outputValidation = validateOutputPath(options.output, workflowDir);
if (!outputValidation.valid) {
console.error(chalk.red(`\n Error: ${outputValidation.error}\n`));
process.exit(1);
}
outputPath = outputValidation.path;
} else {
outputPath = join(workflowDir, 'dashboard.html');
}
ensureDir(dirname(outputPath));
writeFileSync(outputPath, html, 'utf8');
console.log(chalk.green(` Dashboard saved: ${outputPath}`));
// Step 5: Open in browser (unless --no-browser or headless environment)
if (options.browser !== false) {
if (isHeadlessEnvironment()) {
console.log(chalk.yellow('\n Running in CI/headless environment - skipping browser launch'));
console.log(chalk.gray(` Open manually: file://${outputPath.replace(/\\/g, '/')}\n`));
} else {
console.log(chalk.cyan(' Opening in browser...'));
try {
await launchBrowser(outputPath);
console.log(chalk.green.bold('\n Dashboard opened in browser!\n'));
} catch (error) {
console.log(chalk.yellow(`\n Could not open browser: ${error.message}`));
console.log(chalk.gray(` Open manually: file://${outputPath.replace(/\\/g, '/')}\n`));
}
}
} else {
console.log(chalk.gray(`\n Open in browser: file://${outputPath.replace(/\\/g, '/')}\n`));
}
}

View File

@@ -0,0 +1,577 @@
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Bundled template paths
const WORKFLOW_TEMPLATE = join(__dirname, '../templates/workflow-dashboard.html');
const REVIEW_TEMPLATE = join(__dirname, '../templates/review-cycle-dashboard.html');
/**
* Generate dashboard HTML from aggregated data
* Uses bundled templates from ccw package
* @param {Object} data - Aggregated dashboard data
* @returns {Promise<string>} - Generated HTML
*/
export async function generateDashboard(data) {
// Use bundled workflow template
if (existsSync(WORKFLOW_TEMPLATE)) {
return generateFromBundledTemplate(data, WORKFLOW_TEMPLATE);
}
// Fallback to inline dashboard if template missing
return generateInlineDashboard(data);
}
/**
* Generate dashboard using bundled template
* @param {Object} data - Dashboard data
* @param {string} templatePath - Path to workflow-dashboard.html
* @returns {string} - Generated HTML
*/
function generateFromBundledTemplate(data, templatePath) {
let html = readFileSync(templatePath, 'utf8');
// Prepare workflow data for injection
const workflowData = {
activeSessions: data.activeSessions,
archivedSessions: data.archivedSessions
};
// Inject workflow data
html = html.replace('{{WORKFLOW_DATA}}', JSON.stringify(workflowData, null, 2));
// If we have review data, add a review tab
if (data.reviewData && data.reviewData.totalFindings > 0) {
html = injectReviewTab(html, data.reviewData);
}
return html;
}
/**
* Inject review tab into existing dashboard
* @param {string} html - Base dashboard HTML
* @param {Object} reviewData - Review data to display
* @returns {string} - Modified HTML with review tab
*/
function injectReviewTab(html, reviewData) {
// Add review tab button in header controls
const tabButtonHtml = `
<button class="btn" data-tab="reviews" id="reviewTabBtn">Reviews (${reviewData.totalFindings})</button>
`;
// Insert after filter-group
html = html.replace(
'</div>\n </div>\n </header>',
`</div>
<div class="filter-group" style="margin-left: auto;">
${tabButtonHtml}
</div>
</div>
</header>`
);
// Add review section before closing container
const reviewSectionHtml = generateReviewSection(reviewData);
html = html.replace(
'</div>\n\n <button class="theme-toggle"',
`</div>
${reviewSectionHtml}
</div>
<button class="theme-toggle"`
);
// Add review tab JavaScript
const reviewScript = generateReviewScript(reviewData);
html = html.replace('</script>', `\n${reviewScript}\n</script>`);
return html;
}
/**
* Generate review section HTML
* @param {Object} reviewData - Review data
* @returns {string} - HTML for review section
*/
function generateReviewSection(reviewData) {
const severityBars = Object.entries(reviewData.severityDistribution)
.map(([severity, count]) => {
const colors = {
critical: '#c53030',
high: '#f56565',
medium: '#ed8936',
low: '#48bb78'
};
const percent = reviewData.totalFindings > 0
? Math.round((count / reviewData.totalFindings) * 100)
: 0;
return `
<div class="severity-bar-item">
<span class="severity-label">${severity}</span>
<div class="severity-bar">
<div class="severity-fill" style="width: ${percent}%; background-color: ${colors[severity]}"></div>
</div>
<span class="severity-count">${count}</span>
</div>
`;
}).join('');
const dimensionCards = Object.entries(reviewData.dimensionSummary)
.map(([name, info]) => `
<div class="dimension-card">
<div class="dimension-name">${name}</div>
<div class="dimension-count">${info.count} findings</div>
</div>
`).join('');
return `
<div class="section" id="reviewSectionContainer" style="display: none;">
<div class="section-header">
<h2 class="section-title">Code Review Findings</h2>
</div>
<div class="review-stats">
<div class="stat-card">
<div class="stat-value" style="color: #c53030;">${reviewData.severityDistribution.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #f56565;">${reviewData.severityDistribution.high}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #ed8936;">${reviewData.severityDistribution.medium}</div>
<div class="stat-label">Medium</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #48bb78;">${reviewData.severityDistribution.low}</div>
<div class="stat-label">Low</div>
</div>
</div>
<div class="severity-distribution">
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">Severity Distribution</h3>
${severityBars}
</div>
<div class="dimensions-grid" style="margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">By Dimension</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px;">
${dimensionCards}
</div>
</div>
<style>
.review-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.severity-distribution {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.severity-bar-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.severity-label {
width: 80px;
text-transform: capitalize;
font-size: 0.9rem;
}
.severity-bar {
flex: 1;
height: 20px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
}
.severity-fill {
height: 100%;
transition: width 0.3s;
}
.severity-count {
width: 40px;
text-align: right;
font-weight: bold;
}
.dimension-card {
background: var(--bg-card);
padding: 15px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.dimension-name {
font-weight: 600;
text-transform: capitalize;
margin-bottom: 5px;
}
.dimension-count {
color: var(--text-secondary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.review-stats {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</div>
`;
}
/**
* Generate JavaScript for review tab functionality
* @param {Object} reviewData - Review data
* @returns {string} - JavaScript code
*/
function generateReviewScript(reviewData) {
return `
// Review tab functionality
const reviewTabBtn = document.getElementById('reviewTabBtn');
const reviewSection = document.getElementById('reviewSectionContainer');
const activeSectionContainer = document.getElementById('activeSectionContainer');
const archivedSectionContainer = document.getElementById('archivedSectionContainer');
if (reviewTabBtn) {
reviewTabBtn.addEventListener('click', () => {
const isActive = reviewTabBtn.classList.contains('active');
// Toggle review section
if (isActive) {
// Hide reviews, show workflow
reviewTabBtn.classList.remove('active');
reviewSection.style.display = 'none';
activeSectionContainer.style.display = 'block';
archivedSectionContainer.style.display = 'block';
} else {
// Show reviews, hide workflow
reviewTabBtn.classList.add('active');
reviewSection.style.display = 'block';
activeSectionContainer.style.display = 'none';
archivedSectionContainer.style.display = 'none';
// Reset filter buttons
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
document.querySelector('[data-filter="all"]').classList.add('active');
}
});
}
`;
}
/**
* Generate inline dashboard HTML (fallback if bundled templates missing)
* @param {Object} data - Dashboard data
* @returns {string}
*/
function generateInlineDashboard(data) {
const stats = data.statistics;
const hasReviews = data.reviewData && data.reviewData.totalFindings > 0;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CCW Dashboard</title>
<style>
:root {
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a202c;
--text-secondary: #718096;
--border-color: #e2e8f0;
--accent-color: #4299e1;
--success-color: #48bb78;
--warning-color: #ed8936;
--danger-color: #f56565;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-primary: #1a202c;
--bg-secondary: #2d3748;
--bg-card: #2d3748;
--text-primary: #f7fafc;
--text-secondary: #a0aec0;
--border-color: #4a5568;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header {
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 30px;
}
h1 { font-size: 2rem; color: var(--accent-color); margin-bottom: 10px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.stat-value { font-size: 2rem; font-weight: bold; color: var(--accent-color); }
.stat-label { color: var(--text-secondary); font-size: 0.9rem; }
.section { margin-bottom: 40px; }
.section-title { font-size: 1.5rem; margin-bottom: 20px; }
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.session-card {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.session-title { font-size: 1.2rem; font-weight: 600; margin-bottom: 10px; }
.session-meta { color: var(--text-secondary); font-size: 0.9rem; }
.progress-bar {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
margin: 15px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), var(--success-color));
}
.task-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
background: var(--bg-primary);
border-radius: 6px;
border-left: 3px solid var(--border-color);
}
.task-item.completed { border-left-color: var(--success-color); opacity: 0.8; }
.task-item.in_progress { border-left-color: var(--warning-color); }
.task-title { flex: 1; font-size: 0.9rem; }
.task-id { font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
.tabs { display: flex; gap: 10px; margin-top: 15px; }
.tab-btn {
padding: 10px 20px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
}
.tab-btn.active { background: var(--accent-color); color: white; border-color: var(--accent-color); }
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--accent-color);
color: white;
border: none;
cursor: pointer;
font-size: 1.5rem;
box-shadow: var(--shadow);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>CCW Dashboard</h1>
<p style="color: var(--text-secondary);">Workflow Sessions and Reviews</p>
<div class="tabs">
<button class="tab-btn active" data-tab="workflow">Workflow</button>
${hasReviews ? '<button class="tab-btn" data-tab="reviews">Reviews</button>' : ''}
</div>
</header>
<div id="workflowTab">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${stats.totalSessions}</div>
<div class="stat-label">Total Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.activeSessions}</div>
<div class="stat-label">Active Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.totalTasks}</div>
<div class="stat-label">Total Tasks</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.completedTasks}</div>
<div class="stat-label">Completed Tasks</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Active Sessions</h2>
<div class="sessions-grid" id="activeSessions">
${data.activeSessions.length === 0
? '<div class="empty-state">No active sessions</div>'
: data.activeSessions.map(s => renderSessionCard(s, true)).join('')}
</div>
</div>
<div class="section">
<h2 class="section-title">Archived Sessions</h2>
<div class="sessions-grid" id="archivedSessions">
${data.archivedSessions.length === 0
? '<div class="empty-state">No archived sessions</div>'
: data.archivedSessions.map(s => renderSessionCard(s, false)).join('')}
</div>
</div>
</div>
${hasReviews ? renderReviewTab(data.reviewData) : ''}
</div>
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
<script>
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
document.querySelector('.theme-toggle').textContent = next === 'dark' ? '☀️' : '🌙';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
document.querySelector('.theme-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.getElementById('workflowTab').style.display = tab === 'workflow' ? 'block' : 'none';
const reviewTab = document.getElementById('reviewsTab');
if (reviewTab) reviewTab.style.display = tab === 'reviews' ? 'block' : 'none';
});
});
</script>
</body>
</html>`;
}
/**
* Render a session card
* @param {Object} session - Session data
* @param {boolean} isActive - Whether session is active
* @returns {string} - HTML string
*/
function renderSessionCard(session, isActive) {
const completedTasks = isActive
? session.tasks.filter(t => t.status === 'completed').length
: session.taskCount;
const totalTasks = isActive ? session.tasks.length : session.taskCount;
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
const tasksHtml = isActive && session.tasks.length > 0
? session.tasks.map(t => `
<div class="task-item ${t.status}">
<div class="task-title">${t.title}</div>
<span class="task-id">${t.task_id}</span>
</div>
`).join('')
: '';
return `
<div class="session-card">
<div class="session-title">${session.session_id}</div>
<div class="session-meta">
${session.project ? `<div>${session.project}</div>` : ''}
<div>${session.created_at} | ${completedTasks}/${totalTasks} tasks</div>
</div>
${totalTasks > 0 ? `
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
` : ''}
${tasksHtml}
</div>
`;
}
/**
* Render review tab HTML
* @param {Object} reviewData - Review data
* @returns {string} - HTML string
*/
function renderReviewTab(reviewData) {
const { severityDistribution, dimensionSummary } = reviewData;
return `
<div id="reviewsTab" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" style="color: #c53030;">${severityDistribution.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #f56565;">${severityDistribution.high}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #ed8936;">${severityDistribution.medium}</div>
<div class="stat-label">Medium</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #48bb78;">${severityDistribution.low}</div>
<div class="stat-label">Low</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Findings by Dimension</h2>
<div class="sessions-grid">
${Object.entries(dimensionSummary).map(([name, info]) => `
<div class="session-card">
<div class="session-title" style="text-transform: capitalize;">${name}</div>
<div class="session-meta">${info.count} findings</div>
</div>
`).join('')}
</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,288 @@
import { glob } from 'glob';
import { readFileSync, existsSync } from 'fs';
import { join, basename } from 'path';
/**
* Aggregate all data for dashboard rendering
* @param {Object} sessions - Scanned sessions from session-scanner
* @param {string} workflowDir - Path to .workflow directory
* @returns {Promise<Object>} - Aggregated dashboard data
*/
export async function aggregateData(sessions, workflowDir) {
const data = {
generatedAt: new Date().toISOString(),
activeSessions: [],
archivedSessions: [],
reviewData: null,
statistics: {
totalSessions: 0,
activeSessions: 0,
totalTasks: 0,
completedTasks: 0,
reviewFindings: 0
}
};
// Process active sessions
for (const session of sessions.active) {
const sessionData = await processSession(session, true);
data.activeSessions.push(sessionData);
data.statistics.totalTasks += sessionData.tasks.length;
data.statistics.completedTasks += sessionData.tasks.filter(t => t.status === 'completed').length;
}
// Process archived sessions
for (const session of sessions.archived) {
const sessionData = await processSession(session, false);
data.archivedSessions.push(sessionData);
data.statistics.totalTasks += sessionData.taskCount || 0;
data.statistics.completedTasks += sessionData.taskCount || 0;
}
// Aggregate review data if present
if (sessions.hasReviewData) {
data.reviewData = await aggregateReviewData(sessions.active);
data.statistics.reviewFindings = data.reviewData.totalFindings;
}
data.statistics.totalSessions = sessions.active.length + sessions.archived.length;
data.statistics.activeSessions = sessions.active.length;
return data;
}
/**
* Process a single session, loading tasks and review info
* @param {Object} session - Session object from scanner
* @param {boolean} isActive - Whether session is active
* @returns {Promise<Object>} - Processed session data
*/
async function processSession(session, isActive) {
const result = {
session_id: session.session_id,
project: session.project || session.session_id,
status: session.status || (isActive ? 'active' : 'archived'),
created_at: formatDate(session.created_at),
archived_at: formatDate(session.archived_at),
path: session.path,
tasks: [],
taskCount: 0,
hasReview: false,
reviewSummary: null
};
// Load tasks for active sessions (full details)
if (isActive) {
const taskDir = join(session.path, '.task');
if (existsSync(taskDir)) {
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
for (const taskFile of taskFiles) {
try {
const taskData = JSON.parse(readFileSync(join(taskDir, taskFile), 'utf8'));
result.tasks.push({
task_id: taskData.id || basename(taskFile, '.json'),
title: taskData.title || 'Untitled Task',
status: taskData.status || 'pending',
type: taskData.meta?.type || 'task'
});
} catch {
// Skip invalid task files
}
}
// Sort tasks by ID
result.tasks.sort((a, b) => sortTaskIds(a.task_id, b.task_id));
}
result.taskCount = result.tasks.length;
// Check for review data
const reviewDir = join(session.path, '.review');
if (existsSync(reviewDir)) {
result.hasReview = true;
result.reviewSummary = loadReviewSummary(reviewDir);
}
} else {
// For archived, just count tasks
const taskDir = join(session.path, '.task');
if (existsSync(taskDir)) {
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
result.taskCount = taskFiles.length;
}
}
return result;
}
/**
* Aggregate review data from all active sessions with reviews
* @param {Array} activeSessions - Active session objects
* @returns {Promise<Object>} - Aggregated review data
*/
async function aggregateReviewData(activeSessions) {
const reviewData = {
totalFindings: 0,
severityDistribution: { critical: 0, high: 0, medium: 0, low: 0 },
dimensionSummary: {},
sessions: []
};
for (const session of activeSessions) {
const reviewDir = join(session.path, '.review');
if (!existsSync(reviewDir)) continue;
const reviewProgress = loadReviewProgress(reviewDir);
const dimensionData = await loadDimensionData(reviewDir);
if (reviewProgress || dimensionData.length > 0) {
const sessionReview = {
session_id: session.session_id,
progress: reviewProgress,
dimensions: dimensionData,
findings: []
};
// Collect and count findings
for (const dim of dimensionData) {
if (dim.findings && Array.isArray(dim.findings)) {
for (const finding of dim.findings) {
const severity = (finding.severity || 'low').toLowerCase();
if (reviewData.severityDistribution.hasOwnProperty(severity)) {
reviewData.severityDistribution[severity]++;
}
reviewData.totalFindings++;
sessionReview.findings.push({
...finding,
dimension: dim.name
});
}
}
// Track dimension summary
if (!reviewData.dimensionSummary[dim.name]) {
reviewData.dimensionSummary[dim.name] = { count: 0, sessions: [] };
}
reviewData.dimensionSummary[dim.name].count += dim.findings?.length || 0;
reviewData.dimensionSummary[dim.name].sessions.push(session.session_id);
}
reviewData.sessions.push(sessionReview);
}
}
return reviewData;
}
/**
* Load review progress from review-progress.json
* @param {string} reviewDir - Path to .review directory
* @returns {Object|null}
*/
function loadReviewProgress(reviewDir) {
const progressFile = join(reviewDir, 'review-progress.json');
if (!existsSync(progressFile)) return null;
try {
return JSON.parse(readFileSync(progressFile, 'utf8'));
} catch {
return null;
}
}
/**
* Load review summary from review-state.json
* @param {string} reviewDir - Path to .review directory
* @returns {Object|null}
*/
function loadReviewSummary(reviewDir) {
const stateFile = join(reviewDir, 'review-state.json');
if (!existsSync(stateFile)) return null;
try {
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
return {
phase: state.phase || 'unknown',
severityDistribution: state.severity_distribution || {},
criticalFiles: (state.critical_files || []).slice(0, 3),
status: state.status || 'in_progress'
};
} catch {
return null;
}
}
/**
* Load dimension data from .review/dimensions/
* @param {string} reviewDir - Path to .review directory
* @returns {Promise<Array>}
*/
async function loadDimensionData(reviewDir) {
const dimensionsDir = join(reviewDir, 'dimensions');
if (!existsSync(dimensionsDir)) return [];
const dimensions = [];
const dimFiles = await safeGlob('*.json', dimensionsDir);
for (const file of dimFiles) {
try {
const data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
dimensions.push({
name: basename(file, '.json'),
findings: Array.isArray(data) ? data : (data.findings || []),
status: data.status || 'completed'
});
} catch {
// Skip invalid dimension files
}
}
return dimensions;
}
/**
* Safe glob wrapper that returns empty array on error
* @param {string} pattern - Glob pattern
* @param {string} cwd - Current working directory
* @returns {Promise<string[]>}
*/
async function safeGlob(pattern, cwd) {
try {
return await glob(pattern, { cwd, absolute: false });
} catch {
return [];
}
}
/**
* Format date for display
* @param {string|null} dateStr - ISO date string
* @returns {string}
*/
function formatDate(dateStr) {
if (!dateStr) return 'N/A';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateStr;
}
}
/**
* Sort task IDs numerically (IMPL-1, IMPL-2, IMPL-1.1, etc.)
* @param {string} a - First task ID
* @param {string} b - Second task ID
* @returns {number}
*/
function sortTaskIds(a, b) {
const parseId = (id) => {
const match = id.match(/IMPL-(\d+)(?:\.(\d+))?/);
if (!match) return [0, 0];
return [parseInt(match[1]), parseInt(match[2] || 0)];
};
const [a1, a2] = parseId(a);
const [b1, b2] = parseId(b);
return a1 - b1 || a2 - b2;
}

201
ccw/src/core/manifest.js Normal file
View File

@@ -0,0 +1,201 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
// Manifest directory location
const MANIFEST_DIR = join(homedir(), '.claude-manifests');
/**
* Ensure manifest directory exists
*/
function ensureManifestDir() {
if (!existsSync(MANIFEST_DIR)) {
mkdirSync(MANIFEST_DIR, { recursive: true });
}
}
/**
* Create a new installation manifest
* @param {string} mode - Installation mode (Global/Path)
* @param {string} installPath - Installation path
* @returns {Object} - New manifest object
*/
export function createManifest(mode, installPath) {
ensureManifestDir();
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const modePrefix = mode === 'Global' ? 'manifest-global' : 'manifest-path';
const manifestId = `${modePrefix}-${timestamp}`;
return {
manifest_id: manifestId,
version: '1.0',
installation_mode: mode,
installation_path: installPath,
installation_date: new Date().toISOString(),
installer_version: '1.0.0',
files: [],
directories: []
};
}
/**
* Add file entry to manifest
* @param {Object} manifest - Manifest object
* @param {string} filePath - File path
*/
export function addFileEntry(manifest, filePath) {
manifest.files.push({
path: filePath,
type: 'File',
timestamp: new Date().toISOString()
});
}
/**
* Add directory entry to manifest
* @param {Object} manifest - Manifest object
* @param {string} dirPath - Directory path
*/
export function addDirectoryEntry(manifest, dirPath) {
manifest.directories.push({
path: dirPath,
type: 'Directory',
timestamp: new Date().toISOString()
});
}
/**
* Save manifest to disk
* @param {Object} manifest - Manifest object
* @returns {string} - Path to saved manifest
*/
export function saveManifest(manifest) {
ensureManifestDir();
// Remove old manifests for same path and mode
removeOldManifests(manifest.installation_path, manifest.installation_mode);
const manifestPath = join(MANIFEST_DIR, `${manifest.manifest_id}.json`);
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
return manifestPath;
}
/**
* Remove old manifests for the same installation path and mode
* @param {string} installPath - Installation path
* @param {string} mode - Installation mode
*/
function removeOldManifests(installPath, mode) {
if (!existsSync(MANIFEST_DIR)) return;
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8'));
const manifestPath = (content.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
const manifestMode = content.installation_mode || 'Global';
if (manifestPath === normalizedPath && manifestMode === mode) {
unlinkSync(filePath);
}
} catch {
// Skip invalid manifest files
}
}
} catch {
// Ignore errors
}
}
/**
* Get all installation manifests
* @returns {Array} - Array of manifest objects
*/
export function getAllManifests() {
if (!existsSync(MANIFEST_DIR)) return [];
const manifests = [];
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8'));
// Try to read version.json for application version
let appVersion = 'unknown';
try {
const versionPath = join(content.installation_path, '.claude', 'version.json');
if (existsSync(versionPath)) {
const versionInfo = JSON.parse(readFileSync(versionPath, 'utf8'));
appVersion = versionInfo.version || 'unknown';
}
} catch {
// Ignore
}
manifests.push({
...content,
manifest_file: filePath,
application_version: appVersion,
files_count: content.files?.length || 0,
directories_count: content.directories?.length || 0
});
} catch {
// Skip invalid manifest files
}
}
// Sort by installation date (newest first)
manifests.sort((a, b) => new Date(b.installation_date) - new Date(a.installation_date));
} catch {
// Ignore errors
}
return manifests;
}
/**
* Find manifest for a specific path and mode
* @param {string} installPath - Installation path
* @param {string} mode - Installation mode
* @returns {Object|null} - Manifest or null
*/
export function findManifest(installPath, mode) {
const manifests = getAllManifests();
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
return manifests.find(m => {
const manifestPath = (m.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
return manifestPath === normalizedPath && m.installation_mode === mode;
}) || null;
}
/**
* Delete a manifest file
* @param {string} manifestFile - Path to manifest file
*/
export function deleteManifest(manifestFile) {
if (existsSync(manifestFile)) {
unlinkSync(manifestFile);
}
}
/**
* Get manifest directory path
* @returns {string}
*/
export function getManifestDir() {
return MANIFEST_DIR;
}

View File

@@ -0,0 +1,159 @@
import { glob } from 'glob';
import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
import { join, basename } from 'path';
/**
* Scan .workflow directory for active and archived sessions
* @param {string} workflowDir - Path to .workflow directory
* @returns {Promise<{active: Array, archived: Array, hasReviewData: boolean}>}
*/
export async function scanSessions(workflowDir) {
const result = {
active: [],
archived: [],
hasReviewData: false
};
if (!existsSync(workflowDir)) {
return result;
}
// Scan active sessions
const activeDir = join(workflowDir, 'active');
if (existsSync(activeDir)) {
const activeSessions = await findWfsSessions(activeDir);
for (const sessionName of activeSessions) {
const sessionPath = join(activeDir, sessionName);
const sessionData = readSessionData(sessionPath);
if (sessionData) {
result.active.push({
...sessionData,
path: sessionPath,
isActive: true
});
// Check for review data
if (existsSync(join(sessionPath, '.review'))) {
result.hasReviewData = true;
}
}
}
}
// Scan archived sessions
const archivesDir = join(workflowDir, 'archives');
if (existsSync(archivesDir)) {
const archivedSessions = await findWfsSessions(archivesDir);
for (const sessionName of archivedSessions) {
const sessionPath = join(archivesDir, sessionName);
const sessionData = readSessionData(sessionPath);
if (sessionData) {
result.archived.push({
...sessionData,
path: sessionPath,
isActive: false
});
}
}
}
// Sort by creation date (newest first)
result.active.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
result.archived.sort((a, b) => new Date(b.archived_at || b.created_at || 0) - new Date(a.archived_at || a.created_at || 0));
return result;
}
/**
* Find WFS-* directories in a given path
* @param {string} dir - Directory to search
* @returns {Promise<string[]>} - Array of session directory names
*/
async function findWfsSessions(dir) {
try {
// Use glob for cross-platform pattern matching
const sessions = await glob('WFS-*', {
cwd: dir,
onlyDirectories: true,
absolute: false
});
return sessions;
} catch {
// Fallback: manual directory listing
try {
const entries = readdirSync(dir, { withFileTypes: true });
return entries
.filter(e => e.isDirectory() && e.name.startsWith('WFS-'))
.map(e => e.name);
} catch {
return [];
}
}
}
/**
* Read session data from workflow-session.json or create minimal from directory
* @param {string} sessionPath - Path to session directory
* @returns {Object|null} - Session data object or null if invalid
*/
function readSessionData(sessionPath) {
const sessionFile = join(sessionPath, 'workflow-session.json');
if (existsSync(sessionFile)) {
try {
const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
return {
session_id: data.session_id || basename(sessionPath),
project: data.project || data.description || '',
status: data.status || 'active',
created_at: data.created_at || data.initialized_at || null,
archived_at: data.archived_at || null,
type: data.type || 'workflow'
};
} catch {
// Fall through to minimal session
}
}
// Fallback: create minimal session from directory info
try {
const stats = statSync(sessionPath);
return {
session_id: basename(sessionPath),
project: '',
status: 'unknown',
created_at: stats.birthtime.toISOString(),
archived_at: null,
type: 'workflow'
};
} catch {
return null;
}
}
/**
* Check if session has review data
* @param {string} sessionPath - Path to session directory
* @returns {boolean}
*/
export function hasReviewData(sessionPath) {
const reviewDir = join(sessionPath, '.review');
return existsSync(reviewDir);
}
/**
* Get list of task files in session
* @param {string} sessionPath - Path to session directory
* @returns {Promise<string[]>}
*/
export async function getTaskFiles(sessionPath) {
const taskDir = join(sessionPath, '.task');
if (!existsSync(taskDir)) {
return [];
}
try {
return await glob('IMPL-*.json', { cwd: taskDir, absolute: false });
} catch {
return [];
}
}

9
ccw/src/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* CCW - Claude Code Workflow CLI
* Main exports for programmatic usage
*/
export { run } from './cli.js';
export { scanSessions } from './core/session-scanner.js';
export { aggregateData } from './core/data-aggregator.js';
export { generateDashboard } from './core/dashboard-generator.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workflow Dashboard - Task Board</title>
<style>
:root {
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a202c;
--text-secondary: #718096;
--border-color: #e2e8f0;
--accent-color: #4299e1;
--success-color: #48bb78;
--warning-color: #ed8936;
--danger-color: #f56565;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] {
--bg-primary: #1a202c;
--bg-secondary: #2d3748;
--bg-card: #2d3748;
--text-primary: #f7fafc;
--text-secondary: #a0aec0;
--border-color: #4a5568;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
transition: background-color 0.3s, color 0.3s;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: var(--bg-secondary);
box-shadow: var(--shadow);
padding: 20px;
margin-bottom: 30px;
border-radius: 8px;
}
h1 {
font-size: 2rem;
margin-bottom: 10px;
color: var(--accent-color);
}
.header-controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
margin-top: 15px;
}
.search-box {
flex: 1;
min-width: 250px;
position: relative;
}
.search-box input {
width: 100%;
padding: 10px 15px;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 0.95rem;
}
.filter-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
background-color: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.btn.active {
background-color: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background-color: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--accent-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 5px;
}
.section {
margin-bottom: 40px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
}
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.session-card {
background-color: var(--bg-card);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 20px;
transition: all 0.3s;
}
.session-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.session-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.session-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 5px;
}
.session-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background-color: #c6f6d5;
color: #22543d;
}
.status-archived {
background-color: #e2e8f0;
color: #4a5568;
}
[data-theme="dark"] .status-active {
background-color: #22543d;
color: #c6f6d5;
}
[data-theme="dark"] .status-archived {
background-color: #4a5568;
color: #e2e8f0;
}
.session-meta {
display: flex;
gap: 15px;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 15px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
margin: 15px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), var(--success-color));
transition: width 0.3s;
}
.tasks-list {
margin-top: 15px;
}
.task-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
background-color: var(--bg-primary);
border-radius: 6px;
border-left: 3px solid var(--border-color);
transition: all 0.2s;
}
.task-item:hover {
transform: translateX(4px);
}
.task-item.completed {
border-left-color: var(--success-color);
opacity: 0.8;
}
.task-item.in_progress {
border-left-color: var(--warning-color);
}
.task-item.pending {
border-left-color: var(--text-secondary);
}
.task-checkbox {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--border-color);
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.task-item.completed .task-checkbox {
background-color: var(--success-color);
border-color: var(--success-color);
}
.task-item.completed .task-checkbox::after {
content: '✓';
color: white;
font-size: 0.8rem;
font-weight: bold;
}
.task-item.in_progress .task-checkbox {
border-color: var(--warning-color);
background-color: var(--warning-color);
}
.task-item.in_progress .task-checkbox::after {
content: '⟳';
color: white;
font-size: 0.9rem;
}
.task-title {
flex: 1;
font-size: 0.9rem;
}
.task-id {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: monospace;
margin-left: 10px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 20px;
opacity: 0.5;
}
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 60px;
height: 60px;
border-radius: 50%;
background-color: var(--accent-color);
color: white;
border: none;
cursor: pointer;
font-size: 1.5rem;
box-shadow: var(--shadow-lg);
transition: all 0.3s;
z-index: 1000;
}
.theme-toggle:hover {
transform: scale(1.1);
}
@media (max-width: 768px) {
.sessions-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
h1 {
font-size: 1.5rem;
}
.header-controls {
flex-direction: column;
align-items: stretch;
}
.search-box {
width: 100%;
}
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
margin-left: 8px;
}
.badge-count {
background-color: var(--accent-color);
color: white;
}
.session-footer {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
font-size: 0.85rem;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🚀 Workflow Dashboard</h1>
<p style="color: var(--text-secondary);">Task Board - Active and Archived Sessions</p>
<div class="header-controls">
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Search tasks or sessions..." />
</div>
<div class="filter-group">
<button class="btn active" data-filter="all">All</button>
<button class="btn" data-filter="active">Active</button>
<button class="btn" data-filter="archived">Archived</button>
</div>
</div>
</header>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="totalSessions">0</div>
<div class="stat-label">Total Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value" id="activeSessions">0</div>
<div class="stat-label">Active Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalTasks">0</div>
<div class="stat-label">Total Tasks</div>
</div>
<div class="stat-card">
<div class="stat-value" id="completedTasks">0</div>
<div class="stat-label">Completed Tasks</div>
</div>
</div>
<div class="section" id="activeSectionContainer">
<div class="section-header">
<h2 class="section-title">📋 Active Sessions</h2>
</div>
<div class="sessions-grid" id="activeSessions"></div>
</div>
<div class="section" id="archivedSectionContainer">
<div class="section-header">
<h2 class="section-title">📦 Archived Sessions</h2>
</div>
<div class="sessions-grid" id="archivedSessions"></div>
</div>
</div>
<button class="theme-toggle" id="themeToggle">🌙</button>
<script>
// Workflow data will be injected here
const workflowData = {{WORKFLOW_DATA}};
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
}
function updateThemeIcon(theme) {
document.getElementById('themeToggle').textContent = theme === 'dark' ? '☀️' : '🌙';
}
// Statistics calculation
function updateStatistics() {
const stats = {
totalSessions: workflowData.activeSessions.length + workflowData.archivedSessions.length,
activeSessions: workflowData.activeSessions.length,
totalTasks: 0,
completedTasks: 0
};
workflowData.activeSessions.forEach(session => {
stats.totalTasks += session.tasks.length;
stats.completedTasks += session.tasks.filter(t => t.status === 'completed').length;
});
workflowData.archivedSessions.forEach(session => {
stats.totalTasks += session.taskCount || 0;
stats.completedTasks += session.taskCount || 0;
});
document.getElementById('totalSessions').textContent = stats.totalSessions;
document.getElementById('activeSessions').textContent = stats.activeSessions;
document.getElementById('totalTasks').textContent = stats.totalTasks;
document.getElementById('completedTasks').textContent = stats.completedTasks;
}
// Render session card
function createSessionCard(session, isActive) {
const card = document.createElement('div');
card.className = 'session-card';
card.dataset.sessionType = isActive ? 'active' : 'archived';
const completedTasks = isActive
? session.tasks.filter(t => t.status === 'completed').length
: (session.taskCount || 0);
const totalTasks = isActive ? session.tasks.length : (session.taskCount || 0);
const progress = totalTasks > 0 ? (completedTasks / totalTasks * 100) : 0;
let tasksHtml = '';
if (isActive && session.tasks.length > 0) {
tasksHtml = `
<div class="tasks-list">
${session.tasks.map(task => `
<div class="task-item ${task.status}">
<div class="task-checkbox"></div>
<div class="task-title">${task.title || 'Untitled Task'}</div>
<span class="task-id">${task.task_id || ''}</span>
</div>
`).join('')}
</div>
`;
}
card.innerHTML = `
<div class="session-header">
<div>
<h3 class="session-title">${session.session_id || 'Unknown Session'}</h3>
<div style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 5px;">
${session.project || ''}
</div>
</div>
<span class="session-status ${isActive ? 'status-active' : 'status-archived'}">
${isActive ? 'Active' : 'Archived'}
</span>
</div>
<div class="session-meta">
<span>📅 ${session.created_at || session.archived_at || 'N/A'}</span>
<span>📊 ${completedTasks}/${totalTasks} tasks</span>
</div>
${totalTasks > 0 ? `
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<div style="text-align: center; font-size: 0.85rem; color: var(--text-secondary);">
${Math.round(progress)}% Complete
</div>
` : ''}
${tasksHtml}
${!isActive && session.archive_path ? `
<div class="session-footer">
📁 Archive: ${session.archive_path}
</div>
` : ''}
`;
return card;
}
// Render all sessions
function renderSessions(filter = 'all') {
const activeContainer = document.getElementById('activeSessions');
const archivedContainer = document.getElementById('archivedSessions');
activeContainer.innerHTML = '';
archivedContainer.innerHTML = '';
if (filter === 'all' || filter === 'active') {
if (workflowData.activeSessions.length === 0) {
activeContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<p>No active sessions</p>
</div>
`;
} else {
workflowData.activeSessions.forEach(session => {
activeContainer.appendChild(createSessionCard(session, true));
});
}
}
if (filter === 'all' || filter === 'archived') {
if (workflowData.archivedSessions.length === 0) {
archivedContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📦</div>
<p>No archived sessions</p>
</div>
`;
} else {
workflowData.archivedSessions.forEach(session => {
archivedContainer.appendChild(createSessionCard(session, false));
});
}
}
// Show/hide sections
document.getElementById('activeSectionContainer').style.display =
(filter === 'all' || filter === 'active') ? 'block' : 'none';
document.getElementById('archivedSectionContainer').style.display =
(filter === 'all' || filter === 'archived') ? 'block' : 'none';
}
// Search functionality
function setupSearch() {
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const cards = document.querySelectorAll('.session-card');
cards.forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(query) ? 'block' : 'none';
});
});
}
// Filter functionality
function setupFilters() {
const filterButtons = document.querySelectorAll('[data-filter]');
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderSessions(btn.dataset.filter);
});
});
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
initTheme();
updateStatistics();
renderSessions();
setupSearch();
setupFilters();
document.getElementById('themeToggle').addEventListener('click', toggleTheme);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
import open from 'open';
import { platform } from 'os';
import { resolve } from 'path';
/**
* Launch a file in the default browser
* Cross-platform compatible (Windows/macOS/Linux)
* @param {string} filePath - Path to HTML file
* @returns {Promise<void>}
*/
export async function launchBrowser(filePath) {
const absolutePath = resolve(filePath);
// Construct file:// URL based on platform
let url;
if (platform() === 'win32') {
// Windows: file:///C:/path/to/file.html
url = `file:///${absolutePath.replace(/\\/g, '/')}`;
} else {
// Unix: file:///path/to/file.html
url = `file://${absolutePath}`;
}
try {
// Use the 'open' package which handles cross-platform browser launching
await open(url);
} catch (error) {
// Fallback: try opening the file path directly
try {
await open(absolutePath);
} catch (fallbackError) {
throw new Error(`Failed to open browser: ${error.message}`);
}
}
}
/**
* Check if we're running in a headless/CI environment
* @returns {boolean}
*/
export function isHeadlessEnvironment() {
return !!(
process.env.CI ||
process.env.CONTINUOUS_INTEGRATION ||
process.env.GITHUB_ACTIONS ||
process.env.GITLAB_CI ||
process.env.JENKINS_URL
);
}

View File

@@ -0,0 +1,48 @@
import { readFileSync, existsSync, writeFileSync } from 'fs';
import { join } from 'path';
/**
* Safely read a JSON file
* @param {string} filePath - Path to JSON file
* @returns {Object|null} - Parsed JSON or null on error
*/
export function readJsonFile(filePath) {
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
/**
* Safely read a text file
* @param {string} filePath - Path to text file
* @returns {string|null} - File contents or null on error
*/
export function readTextFile(filePath) {
if (!existsSync(filePath)) return null;
try {
return readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
/**
* Write content to a file
* @param {string} filePath - Path to file
* @param {string} content - Content to write
*/
export function writeTextFile(filePath, content) {
writeFileSync(filePath, content, 'utf8');
}
/**
* Check if a path exists
* @param {string} filePath - Path to check
* @returns {boolean}
*/
export function pathExists(filePath) {
return existsSync(filePath);
}

View File

@@ -0,0 +1,195 @@
import { resolve, join, relative, isAbsolute } from 'path';
import { existsSync, mkdirSync, realpathSync, statSync } from 'fs';
import { homedir } from 'os';
/**
* Resolve a path, handling ~ for home directory
* @param {string} inputPath - Path to resolve
* @returns {string} - Absolute path
*/
export function resolvePath(inputPath) {
if (!inputPath) return process.cwd();
// Handle ~ for home directory
if (inputPath.startsWith('~')) {
return join(homedir(), inputPath.slice(1));
}
return resolve(inputPath);
}
/**
* Validate and sanitize a user-provided path
* Prevents path traversal attacks and validates path is within allowed boundaries
* @param {string} inputPath - User-provided path
* @param {Object} options - Validation options
* @param {string} options.baseDir - Base directory to restrict paths within (optional)
* @param {boolean} options.mustExist - Whether path must exist (default: false)
* @param {boolean} options.allowHome - Whether to allow home directory paths (default: true)
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
*/
export function validatePath(inputPath, options = {}) {
const { baseDir = null, mustExist = false, allowHome = true } = options;
// Check for empty/null input
if (!inputPath || typeof inputPath !== 'string') {
return { valid: false, path: null, error: 'Path is required' };
}
// Trim whitespace
const trimmedPath = inputPath.trim();
// Check for suspicious patterns (null bytes, control characters)
if (/[\x00-\x1f]/.test(trimmedPath)) {
return { valid: false, path: null, error: 'Path contains invalid characters' };
}
// Resolve the path
let resolvedPath;
try {
resolvedPath = resolvePath(trimmedPath);
} catch (err) {
return { valid: false, path: null, error: `Invalid path: ${err.message}` };
}
// Check if path exists when required
if (mustExist && !existsSync(resolvedPath)) {
return { valid: false, path: null, error: `Path does not exist: ${resolvedPath}` };
}
// Get real path if it exists (resolves symlinks)
let realPath = resolvedPath;
if (existsSync(resolvedPath)) {
try {
realPath = realpathSync(resolvedPath);
} catch (err) {
return { valid: false, path: null, error: `Cannot resolve path: ${err.message}` };
}
}
// Check if within base directory when specified
if (baseDir) {
const resolvedBase = resolvePath(baseDir);
const relativePath = relative(resolvedBase, realPath);
// Path traversal detection: relative path should not start with '..'
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return {
valid: false,
path: null,
error: `Path must be within ${resolvedBase}`
};
}
}
// Check home directory restriction
if (!allowHome) {
const home = homedir();
if (realPath === home || realPath.startsWith(home + '/') || realPath.startsWith(home + '\\')) {
// This is fine, we're just checking if it's explicitly the home dir itself
}
}
return { valid: true, path: realPath, error: null };
}
/**
* Validate output file path for writing
* @param {string} outputPath - Output file path
* @param {string} defaultDir - Default directory if path is relative
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
*/
export function validateOutputPath(outputPath, defaultDir = process.cwd()) {
if (!outputPath || typeof outputPath !== 'string') {
return { valid: false, path: null, error: 'Output path is required' };
}
const trimmedPath = outputPath.trim();
// Check for suspicious patterns
if (/[\x00-\x1f]/.test(trimmedPath)) {
return { valid: false, path: null, error: 'Output path contains invalid characters' };
}
// Resolve the path
let resolvedPath;
try {
resolvedPath = isAbsolute(trimmedPath) ? trimmedPath : join(defaultDir, trimmedPath);
resolvedPath = resolve(resolvedPath);
} catch (err) {
return { valid: false, path: null, error: `Invalid output path: ${err.message}` };
}
// Ensure it's not a directory
if (existsSync(resolvedPath)) {
try {
const stat = statSync(resolvedPath);
if (stat.isDirectory()) {
return { valid: false, path: null, error: 'Output path is a directory, expected a file' };
}
} catch {
// Ignore stat errors
}
}
return { valid: true, path: resolvedPath, error: null };
}
/**
* Get potential template locations
* @returns {string[]} - Array of existing template directories
*/
export function getTemplateLocations() {
const locations = [
join(homedir(), '.claude', 'templates'),
join(process.cwd(), '.claude', 'templates')
];
return locations.filter(loc => existsSync(loc));
}
/**
* Find a template file in known locations
* @param {string} templateName - Name of template file (e.g., 'workflow-dashboard.html')
* @returns {string|null} - Path to template or null if not found
*/
export function findTemplate(templateName) {
const locations = getTemplateLocations();
for (const loc of locations) {
const templatePath = join(loc, templateName);
if (existsSync(templatePath)) {
return templatePath;
}
}
return null;
}
/**
* Ensure directory exists, creating if necessary
* @param {string} dirPath - Directory path to ensure
*/
export function ensureDir(dirPath) {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
}
/**
* Get the .workflow directory path from project path
* @param {string} projectPath - Path to project
* @returns {string} - Path to .workflow directory
*/
export function getWorkflowDir(projectPath) {
return join(resolvePath(projectPath), '.workflow');
}
/**
* Normalize path for display (handle Windows backslashes)
* @param {string} filePath - Path to normalize
* @returns {string}
*/
export function normalizePathForDisplay(filePath) {
return filePath.replace(/\\/g, '/');
}

148
ccw/src/utils/ui.js Normal file
View File

@@ -0,0 +1,148 @@
import chalk from 'chalk';
import figlet from 'figlet';
import boxen from 'boxen';
import gradient from 'gradient-string';
import ora from 'ora';
// Custom gradient colors
const claudeGradient = gradient(['#00d4ff', '#00ff88']);
const codeGradient = gradient(['#00ff88', '#ffff00']);
const workflowGradient = gradient(['#ffff00', '#ff8800']);
/**
* Display ASCII art banner
*/
export function showBanner() {
console.log('');
// CLAUDE in cyan gradient
try {
const claudeText = figlet.textSync('Claude', { font: 'Standard' });
console.log(claudeGradient(claudeText));
} catch {
console.log(chalk.cyan.bold(' Claude'));
}
// CODE in green gradient
try {
const codeText = figlet.textSync('Code', { font: 'Standard' });
console.log(codeGradient(codeText));
} catch {
console.log(chalk.green.bold(' Code'));
}
// WORKFLOW in yellow gradient
try {
const workflowText = figlet.textSync('Workflow', { font: 'Standard' });
console.log(workflowGradient(workflowText));
} catch {
console.log(chalk.yellow.bold(' Workflow'));
}
console.log('');
}
/**
* Display header with version info
* @param {string} version - Version number
* @param {string} mode - Installation mode
*/
export function showHeader(version, mode = '') {
showBanner();
const versionText = version ? `v${version}` : '';
const modeText = mode ? ` (${mode})` : '';
console.log(boxen(
chalk.cyan.bold('Claude Code Workflow System') + '\n' +
chalk.gray(`Installer ${versionText}${modeText}`) + '\n\n' +
chalk.white('Unified workflow system with comprehensive coordination'),
{
padding: 1,
margin: { top: 0, bottom: 1, left: 2, right: 2 },
borderStyle: 'round',
borderColor: 'cyan'
}
));
}
/**
* Create a spinner
* @param {string} text - Spinner text
* @returns {ora.Ora}
*/
export function createSpinner(text) {
return ora({
text,
color: 'cyan',
spinner: 'dots'
});
}
/**
* Display success message
* @param {string} message
*/
export function success(message) {
console.log(chalk.green('✓') + ' ' + chalk.green(message));
}
/**
* Display info message
* @param {string} message
*/
export function info(message) {
console.log(chalk.cyan('') + ' ' + chalk.cyan(message));
}
/**
* Display warning message
* @param {string} message
*/
export function warning(message) {
console.log(chalk.yellow('⚠') + ' ' + chalk.yellow(message));
}
/**
* Display error message
* @param {string} message
*/
export function error(message) {
console.log(chalk.red('✖') + ' ' + chalk.red(message));
}
/**
* Display step message
* @param {number} step - Step number
* @param {number} total - Total steps
* @param {string} message - Step message
*/
export function step(stepNum, total, message) {
console.log(chalk.gray(`[${stepNum}/${total}]`) + ' ' + chalk.white(message));
}
/**
* Display summary box
* @param {Object} options
* @param {string} options.title - Box title
* @param {string[]} options.lines - Content lines
* @param {string} options.borderColor - Border color
*/
export function summaryBox({ title, lines, borderColor = 'green' }) {
const content = lines.join('\n');
console.log(boxen(content, {
title,
titleAlignment: 'center',
padding: 1,
margin: { top: 1, bottom: 1, left: 2, right: 2 },
borderStyle: 'round',
borderColor
}));
}
/**
* Display a divider line
*/
export function divider() {
console.log(chalk.gray('─'.repeat(60)));
}