mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
121
ccw/README.md
Normal 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
10
ccw/bin/ccw.js
Normal 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
1914
ccw/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
ccw/package.json
Normal file
47
ccw/package.json
Normal 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
82
ccw/src/cli.js
Normal 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
309
ccw/src/commands/install.js
Normal 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
37
ccw/src/commands/list.js
Normal 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('');
|
||||
}
|
||||
238
ccw/src/commands/uninstall.js
Normal file
238
ccw/src/commands/uninstall.js
Normal 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
132
ccw/src/commands/view.js
Normal 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`));
|
||||
}
|
||||
}
|
||||
577
ccw/src/core/dashboard-generator.js
Normal file
577
ccw/src/core/dashboard-generator.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
288
ccw/src/core/data-aggregator.js
Normal file
288
ccw/src/core/data-aggregator.js
Normal 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
201
ccw/src/core/manifest.js
Normal 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;
|
||||
}
|
||||
159
ccw/src/core/session-scanner.js
Normal file
159
ccw/src/core/session-scanner.js
Normal 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
9
ccw/src/index.js
Normal 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';
|
||||
2816
ccw/src/templates/review-cycle-dashboard.html
Normal file
2816
ccw/src/templates/review-cycle-dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
664
ccw/src/templates/workflow-dashboard.html
Normal file
664
ccw/src/templates/workflow-dashboard.html
Normal 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>
|
||||
49
ccw/src/utils/browser-launcher.js
Normal file
49
ccw/src/utils/browser-launcher.js
Normal 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
|
||||
);
|
||||
}
|
||||
48
ccw/src/utils/file-utils.js
Normal file
48
ccw/src/utils/file-utils.js
Normal 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);
|
||||
}
|
||||
195
ccw/src/utils/path-resolver.js
Normal file
195
ccw/src/utils/path-resolver.js
Normal 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
148
ccw/src/utils/ui.js
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user