From 2e6a48b69fc9c13431158abf970aef404d36ad4d Mon Sep 17 00:00:00 2001 From: Doan Bac Tam Date: Tue, 24 Mar 2026 16:23:26 +0700 Subject: [PATCH] Revert "Rebuild as proper Claude Code plugin" --- .claude-plugin/plugin.json | 11 - .factory/library/user-testing.md | 77 -- .../reviews/fix-performance-thresholds.json | 15 - .../enhancements/scrutiny/synthesis.json | 36 - .../validation/tests/scrutiny/synthesis.json | 53 - .gitignore | 2 + CLAUDE.md | 102 +- README.md | 164 ++- hooks/hooks.json | 15 - package.json | 38 + plugin.json | 31 + scripts/export.js | 209 ---- scripts/export.test.js | 520 -------- scripts/flow-validation.test.js | 504 -------- scripts/reset.js | 133 -- scripts/reset.test.js | 617 ---------- scripts/rotate.js | 196 --- scripts/rotate.test.js | 734 ----------- scripts/track.js | 149 --- scripts/track.perf.test.js | 294 ----- scripts/track.sh | 25 - scripts/track.test.js | 1069 ----------------- skills/export/SKILL.md | 60 - skills/pulse/README.md | 60 + skills/pulse/SKILL.md | 95 +- skills/pulse/bin/pulse.sh | 144 +++ skills/pulse/skill.json | 12 + src/handlers.js | 102 ++ src/index.js | 52 + src/periods.js | 56 + src/storage.js | 79 ++ tests/e2e/pulse-cli.test.sh | 324 +++++ 32 files changed, 1068 insertions(+), 4910 deletions(-) delete mode 100644 .claude-plugin/plugin.json delete mode 100644 .factory/library/user-testing.md delete mode 100644 .factory/validation/enhancements/scrutiny/reviews/fix-performance-thresholds.json delete mode 100644 .factory/validation/enhancements/scrutiny/synthesis.json delete mode 100644 .factory/validation/tests/scrutiny/synthesis.json delete mode 100644 hooks/hooks.json create mode 100644 package.json create mode 100644 plugin.json delete mode 100644 scripts/export.js delete mode 100644 scripts/export.test.js delete mode 100644 scripts/flow-validation.test.js delete mode 100644 scripts/reset.js delete mode 100644 scripts/reset.test.js delete mode 100644 scripts/rotate.js delete mode 100644 scripts/rotate.test.js delete mode 100644 scripts/track.js delete mode 100644 scripts/track.perf.test.js delete mode 100644 scripts/track.sh delete mode 100644 scripts/track.test.js delete mode 100644 skills/export/SKILL.md create mode 100644 skills/pulse/README.md create mode 100644 skills/pulse/bin/pulse.sh create mode 100644 skills/pulse/skill.json create mode 100644 src/handlers.js create mode 100644 src/index.js create mode 100644 src/periods.js create mode 100644 src/storage.js create mode 100644 tests/e2e/pulse-cli.test.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index b2aa91a..0000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "skillpulse", - "version": "2.0.0", - "description": "Track which Claude Code skills you actually use", - "author": { - "name": "doanbactam", - "url": "https://github.com/doanbactam/skillpulse" - }, - "repository": "https://github.com/doanbactam/skillpulse", - "license": "MIT" -} diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md deleted file mode 100644 index 8488a66..0000000 --- a/.factory/library/user-testing.md +++ /dev/null @@ -1,77 +0,0 @@ -# User Testing - -Testing surface and validation approach for SkillPulse plugin. - -## What belongs here -Testing surface, validation tools, resource costs. - ---- - -## Validation Surface - -### Surface: CLI Plugin (Lightweight) - -This is a Claude Code plugin with no browser UI. Validation happens via: -1. **File inspection** - Check pulse.jsonl contents -2. **CLI invocation** - Run `claude --plugin-dir .` -3. **Manual skill triggering** - Use skills and verify tracking - -### Required Tools -- Node.js (already available) -- Claude Code CLI -- Text editor for file inspection - -### No Browser Testing Required -This plugin has no web interface. All testing is file-based. - ---- - -## Validation Concurrency - -| Metric | Value | -|--------|-------| -| Max concurrent validators | 5 | -| Memory per validator | ~50-100 MB | -| Resource type | Node.js process | - -**Rationale**: Plugin is lightweight. No server, no browser. Each validation runs a simple Node.js script that reads/writes a small JSONL file. - ---- - -## Manual Validation Flows - -### FLOW-001: Basic Tracking -1. Start Claude Code with plugin: `claude --plugin-dir .` -2. Trigger a skill read (e.g., mention something that loads a skill) -3. Check `${CLAUDE_PLUGIN_DATA}/pulse.jsonl` -4. Verify entry exists with correct fields - -### FLOW-002: Cross-Platform Path Test -1. Test with Windows-style path: `C:\skills\test\SKILL.md` -2. Test with Unix-style path: `/skills/test/SKILL.md` -3. Verify both produce correct skill name - -### FLOW-003: Error Handling -1. Unset `CLAUDE_PLUGIN_DATA` -2. Trigger skill read -3. Verify no error shown, Claude continues normally - -### FLOW-004: Export/Reset -1. Run export command -2. Verify JSON/CSV output -3. Run reset command -4. Verify data cleared - ---- - -## Test Coverage Requirements - -| Area | Min Tests | -|------|-----------| -| SKILL.md detection | 3 (forward slash, backslash, mixed) | -| Skill name extraction | 3 (simple, nested, special chars) | -| Trigger classification | 3 (explicit, auto, edge case) | -| Error handling | 4 (missing env, invalid JSON, write fail, corruption) | -| JSONL format | 2 (valid output, all fields) | - -**Total**: Minimum 15 unit tests required diff --git a/.factory/validation/enhancements/scrutiny/reviews/fix-performance-thresholds.json b/.factory/validation/enhancements/scrutiny/reviews/fix-performance-thresholds.json deleted file mode 100644 index 4febc5e..0000000 --- a/.factory/validation/enhancements/scrutiny/reviews/fix-performance-thresholds.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "featureId": "fix-performance-thresholds", - "reviewedAt": "2026-03-24T07:30:00.000Z", - "commitId": "2fb8805", - "transcriptSkeletonReviewed": true, - "diffReviewed": true, - "status": "pass", - "codeReview": { - "summary": "The fix correctly addresses the threshold inconsistency issue identified in the prior verify-performance review. The original issue was that some tests used 100ms thresholds while others used 200ms, causing intermittent failures on Windows due to process spawn overhead. The fix standardizes thresholds to account for Windows spawn overhead: 100ms→150ms for large file handling, 200ms→300ms for execution time tests, and 50ms→100ms for stdDev consistency. All 165 tests now pass, including all 6 performance tests. The track.js implementation remains unchanged and is correct (O(1) append-only writes).", - "issues": [] - }, - "sharedStateObservations": [], - "addressesFailureFrom": "C:\\Users\\kisde\\OneDrive\\Desktop\\Project\\skillpulse\\.factory\\validation\\enhancements\\scrutiny\\reviews\\verify-performance.json", - "summary": "PASS. The fix-performance-thresholds feature successfully resolves the threshold inconsistency issue from the prior verify-performance review. The worker correctly identified that the original 100ms threshold for the 'append performance does not degrade with file size' test was too tight for Windows process spawn overhead (observed 116.57ms vs 100ms threshold). The fix raises all performance thresholds to account for Windows spawn overhead: 100ms→150ms for large file tests, 200ms→300ms for execution time tests, and 50ms→100ms for stdDev consistency. Clarifying comments were added explaining that Windows process spawn overhead adds 50-100ms per execution while the actual track.js implementation is O(1). All 165 tests now pass. The fix is appropriate for cross-platform compatibility." -} diff --git a/.factory/validation/enhancements/scrutiny/synthesis.json b/.factory/validation/enhancements/scrutiny/synthesis.json deleted file mode 100644 index ca29cfc..0000000 --- a/.factory/validation/enhancements/scrutiny/synthesis.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "milestone": "enhancements", - "round": 2, - "status": "pass", - "validatorsRun": { - "test": { - "passed": true, - "command": "node --test scripts/*.test.js", - "exitCode": 0, - "summary": "165 tests passed, 0 failed, 0 skipped" - }, - "lint": { - "passed": true, - "command": "node --check scripts/track.js", - "exitCode": 0 - }, - "syntax": { - "passed": true, - "command": "node --check scripts/track.js", - "exitCode": 0 - } - }, - "reviewsSummary": { - "total": 6, - "passed": 6, - "failed": 0, - "failedFeatures": [], - "round1Reviews": ["add-data-rotation", "bump-version", "cross-area-validation", "update-readme", "verify-performance"], - "round2Reviews": ["fix-performance-thresholds"] - }, - "blockingIssues": [], - "appliedUpdates": [], - "suggestedGuidanceUpdates": [], - "rejectedObservations": [], - "previousRound": "round 1 - performance tests failing due to Windows spawn overhead thresholds" -} diff --git a/.factory/validation/tests/scrutiny/synthesis.json b/.factory/validation/tests/scrutiny/synthesis.json deleted file mode 100644 index e43399e..0000000 --- a/.factory/validation/tests/scrutiny/synthesis.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "milestone": "tests", - "round": 1, - "status": "pass", - "validatorsRun": { - "test": { - "passed": true, - "command": "node --test scripts/*.test.js", - "exitCode": 0, - "details": "56 tests passed across 8 test suites: SKILL.md detection (7), Skill name extraction (7), Trigger classification (7), JSONL output format (7), Error handling (14), Cross-platform path handling (7), Test isolation and determinism (3), Edge cases (4)" - }, - "syntax": { - "passed": true, - "command": "node --check scripts/track.js", - "exitCode": 0, - "details": "JavaScript syntax validation passed" - }, - "lint": { - "passed": true, - "command": "node --check scripts/track.js", - "exitCode": 0, - "details": "No dedicated lint tool configured; syntax check passed as lint equivalent" - } - }, - "reviewsSummary": { - "total": 1, - "passed": 1, - "failed": 0, - "failedFeatures": [] - }, - "blockingIssues": [], - "appliedUpdates": [], - "suggestedGuidanceUpdates": [], - "rejectedObservations": [], - "previousRound": null, - "reviewDetails": [ - { - "featureId": "write-unit-tests", - "status": "pass", - "fulfills": [ - "VAL-TEST-001", - "VAL-TEST-002", - "VAL-TEST-003", - "VAL-TEST-004", - "VAL-TEST-005", - "VAL-TEST-006", - "VAL-TEST-007", - "VAL-TEST-008" - ], - "summary": "Comprehensive unit test suite for track.js with 56 tests covering all required areas. Tests use Node.js built-in test runner and are isolated/deterministic." - } - ] -} diff --git a/.gitignore b/.gitignore index 3491cb4..390382b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +node_modules/ *.log .DS_Store .env +dist/ diff --git a/CLAUDE.md b/CLAUDE.md index ee9b432..9ea99f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,88 +1,66 @@ # CLAUDE.md -This file provides guidance to Claude Code when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview -SkillPulse is a Claude Code plugin that tracks skill usage analytics passively via hooks. +SkillPulse is a Claude Code plugin that tracks skill usage analytics. It consists of: +- **MCP Server** (`src/index.js`) — Exposes tools for logging and querying skill usage +- **Pulse Skill** (`skills/pulse/`) — CLI interface for viewing usage statistics +- **Analytics Storage** — `~/.claude/skills/pulse.jsonl` (JSONL format: `{"skill":"name","ts":1234567890,"outcome":"success"}`) -**Cross-platform:** Works on Windows, macOS, and Linux. +## Development Commands -## Plugin Structure +```bash +# Run the MCP server (for testing) +npm start +# Run with file watching during development +npm run dev ``` -skillpulse/ -├── .claude-plugin/ -│ └── plugin.json # Plugin manifest -├── hooks/ -│ └── hooks.json # PostToolUse hook for passive tracking -├── scripts/ -│ ├── track.js # Hook script that logs skill usage (Node.js) -│ ├── rotate.js # Data rotation script -│ ├── export.js # Export script (JSON/CSV) -│ └── reset.js # Reset script -├── skills/ -│ └── pulse/ -│ └── SKILL.md # Skill that powers /skillpulse:pulse -└── README.md -``` - -## How It Works - -### Passive Tracking (Hook) -The `hooks/hooks.json` defines a `PostToolUse` hook that fires after every `Read` tool call. +## Architecture -`scripts/track.js` receives: -- `CLAUDE_TOOL_INPUT` — JSON input of the Read tool (contains file path) -- `CLAUDE_HUMAN_TURN` — Last user message (to detect explicit skill invocation) -- `CLAUDE_PLUGIN_DATA` — Plugin's writable data directory +### MCP Server (`src/index.js`) +The server runs on stdio and exposes three tools: -If the read file ends with `SKILL.md`, it logs the usage to `${CLAUDE_PLUGIN_DATA}/pulse.jsonl`. +| Tool | Purpose | +|------|---------| +| `log_pulse` | Log skill usage to analytics file | +| `get_skill_stats` | Query usage stats by period (24h/7d/30d/all) | +| `list_skills` | Enumerate installed skills from `~/.claude/skills/` | -### Analytics File Format - -```json -{"skill":"careful","ts":1711234567,"trigger":"auto"} -{"skill":"freeze","ts":1711234568,"trigger":"explicit"} -``` +**Note:** There's a mismatch in the code: `tools/list` defines `log_pulse` but `tools/call` handles `log_skill_usage`. This should be unified. -- `skill` — Name of the skill (directory name) -- `ts` — Unix timestamp -- `trigger` — "auto" (Claude loaded it) or "explicit" (user invoked via `/skill`) +### Pulse Skill (`skills/pulse/`) +- `SKILL.md` — Skill metadata and documentation +- `skill.json` — Package metadata +- `bin/pulse.sh` — Bash script that reads `pulse.jsonl` and renders ASCII stats -### The Skill: /skillpulse:pulse +### Plugin Manifest (`plugin.json`) +Defines the MCP server and skill components for Claude Code to load. -`skills/pulse/SKILL.md` is a user-invocable skill that: -1. Reads `${CLAUDE_PLUGIN_DATA}/pulse.jsonl` -2. Filters by time period (24h/7d/30d/all) -3. Scans for all installed skills -4. Outputs usage statistics with hot/cold breakdown +## Analytics File Format -## Testing Locally +Stored at `~/.claude/skills/pulse.jsonl`: -```bash -claude --plugin-dir ./skillpulse +```json +{"skill":"careful","ts":1711234567,"outcome":"success","pid":12345} +{"skill":"freeze","ts":1711234568,"outcome":"success","pid":12345} ``` -Then: -1. Trigger a few skills (Claude will read their SKILL.md files) -2. Run `/skillpulse:pulse` to verify data flows +## Key Directories -## Running Tests +| Path | Purpose | +|------|---------| +| `src/` | MCP server implementation | +| `skills/pulse/` | User-facing CLI skill | +| `~/.claude/skills/` | Where Claude Code installs skills | -```bash -node --test scripts/*.test.js -``` +## Skill Integration Pattern -## Install Flow (for users) +Other skills can self-track by appending to `pulse.jsonl`: ```bash -/plugin install github:doanbactam/skillpulse -``` - -Then use: -``` -/skillpulse:pulse -/skillpulse:pulse 30d +echo "{\"skill\":\"$(basename $0)\",\"ts\":$(date +%s)}" >> ~/.claude/skills/pulse.jsonl ``` diff --git a/README.md b/README.md index 5b66c77..01235c8 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,114 @@ # SkillPulse -> Track which Claude Code skills you actually use +> See your Claude Code skills come alive. Track usage, discover patterns, stay lean. -A Claude Code plugin that passively tracks skill usage via hooks. Works with ALL skills — no opt-in required. +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Claude Code](https://img.shields.io/badge/Claude-Code-compatible-blue)](https://claude.com/plugins) -**Cross-platform:** Works on Windows, macOS, and Linux. +--- -## Requirements +## ✨ Features -- **Node.js v18.0.0 or later** +| Feature | Description | +|---------|-------------| +| **📊 Usage Tracking** | See which skills you use most — hourly, daily, weekly, monthly | +| **🔍 Skill Discovery** | Browse all installed skills with descriptions | +| **🧹 Cleanup Helper** | Identify unused skills cluttering your setup | +| **🌐 Universal** | Works with ALL skills — gstack, custom, official | -## Install +--- -```bash -/plugin install github:doanbactam/skillpulse -``` - -## Usage +## 🚀 Quick Start -### View Analytics +### Install from Marketplace (Coming Soon) ``` -/skillpulse:pulse # Last 7 days (default) -/skillpulse:pulse 24h # Today -/skillpulse:pulse 30d # Last month -/skillpulse:pulse all # All time +Search "SkillPulse" at https://claude.com/plugins ``` -### Data Management +### Manual Install ```bash -# Rotate data - remove entries older than retention period -node ${CLAUDE_PLUGIN_ROOT}/scripts/rotate.js 30 # Keep last 30 days (default) -node ${CLAUDE_PLUGIN_ROOT}/scripts/rotate.js 7 # Keep last 7 days - -# Export data to JSON or CSV -node ${CLAUDE_PLUGIN_ROOT}/scripts/export.js json # Export as JSON array -node ${CLAUDE_PLUGIN_ROOT}/scripts/export.js csv # Export as CSV - -# Reset all analytics data (requires --force) -node ${CLAUDE_PLUGIN_ROOT}/scripts/reset.js --force +git clone https://github.com/doanbactam/skillpulse.git ~/.claude/plugins/skillpulse +cd ~/.claude/plugins/skillpulse +npm install ``` -## Output +--- -``` -╭──────────────────────────────────────────╮ -│ skillpulse • Last 7 days │ -├──────────────────────────────────────────┤ -│ 39 skills • 11 used • 28 unused │ -│ │ -│ 🔥 Hot │ -│ /careful 98 calls ████████████████ │ -│ /freeze 18 calls ███ │ -│ /ship 13 calls ██ │ -│ │ -│ ❄️ Cold (28 unused) │ -│ /baseline-ui, /benchmark, /browse... │ -╰──────────────────────────────────────────╯ - -💡 Remove unused: rm -rf ~/.claude/skills/SKILL_NAME -``` +## 💡 Usage -## How It Works +```bash +# See your skill pulse (default: 7 days) +/pulse + +# Time periods +/pulse 24h # Today +/pulse 7d # Week +/pulse 30d # Month +/pulse all # All time +``` -1. **Hook-based tracking** — PostToolUse hook fires when Claude reads any `SKILL.md` -2. **Passive collection** — No skill author opt-in required -3. **Local storage** — Data stored at `${CLAUDE_PLUGIN_DATA}/pulse.jsonl` -4. **Trigger detection** — Distinguishes explicit `/skill` calls from auto-invocations -5. **Cross-platform** — Built with Node.js for Windows, macOS, and Linux +--- -## Plugin Structure +## 📸 Preview ``` -skillpulse/ -├── .claude-plugin/ -│ └── plugin.json # Plugin manifest -├── hooks/ -│ └── hooks.json # PostToolUse hook config -├── scripts/ -│ ├── track.js # Tracking script (Node.js) -│ ├── rotate.js # Data rotation script -│ ├── export.js # Export script (JSON/CSV) -│ └── reset.js # Reset script -├── skills/ -│ └── pulse/ -│ └── SKILL.md # /skillpulse:pulse skill -└── README.md +╭─────────────────────────────────────────────────────╮ +│ SkillPulse • Last 7 days │ +├─────────────────────────────────────────────────────┤ +│ 📊 39 skills • 11 used • 28 unused │ +│ │ +│ 🔥 Hot │ +│ /careful 98 calls ████████████████████ │ +│ /freeze 18 calls ███ │ +│ /ship 13 calls ██ │ +│ │ +│ ❄️ Cold (28 unused) │ +│ /baseline-ui, /benchmark, /browse... │ +╰─────────────────────────────────────────────────────╯ ``` -## Data Format +--- -`pulse.jsonl` (JSONL format): -```json -{"skill":"careful","ts":1711234567,"trigger":"explicit"} -{"skill":"freeze","ts":1711234568,"trigger":"auto"} -``` +## 🔧 How It Works -- `skill` — Skill name (directory name) -- `ts` — Unix timestamp (seconds) -- `trigger` — `explicit` (via `/skill`) or `auto` (Claude invoked) +1. **MCP Server** — Runs silently, logging skill activity +2. **Analytics File** — Stored at `~/.claude/skills/pulse.jsonl` +3. **CLI Tool** — Reads analytics, presents insights -## Development +--- -### Prerequisites +## 📚 For Skill Authors -- Node.js v18+ installed +Add pulse tracking to your skill: -### Test Locally +```markdown +## Analytics ```bash -claude --plugin-dir ./skillpulse +echo "{\"skill\":\"$(basename $0)\",\"ts\":$(date +%s)}" >> ~/.claude/skills/pulse.jsonl +``` ``` -Trigger skills, then run `/skillpulse:pulse` to verify tracking. +--- -### Run Tests +## 📋 Requirements -```bash -node --test scripts/*.test.js -``` +- Node.js 18+ +- Claude Code (latest) +- macOS / Linux / WSL -### Syntax Check +--- -```bash -node --check scripts/track.js -``` +## 📜 License + +MIT © [doanbactam] + +--- + +## 🙏 Acknowledgments -## License +Built for the [Claude Code](https://claude.com) plugin ecosystem. -MIT +Inspired by the need to see which skills actually spark joy. diff --git a/hooks/hooks.json b/hooks/hooks.json deleted file mode 100644 index cca620b..0000000 --- a/hooks/hooks.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/track.js\"" - } - ] - } - ] - } -} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9857fde --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "skillpulse", + "version": "1.0.0", + "description": "Track which Claude Code skills you actually use. Discover your workflow, identify unused skills, and keep your setup lean.", + "type": "module", + "main": "src/index.js", + "bin": { + "skillpulse": "./src/cli.js" + }, + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "test:e2e": "bash tests/e2e/pulse-cli.test.sh" + }, + "keywords": [ + "claude", + "claude-code", + "skills", + "analytics", + "productivity", + "workflow" + ], + "author": { + "name": "Your Name", + "url": "https://github.com/doanbactam" + }, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/doanbactam/skillpulse" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0" + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..f078235 --- /dev/null +++ b/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "SkillPulse", + "id": "skillpulse", + "version": "1.0.0", + "tagline": "See your Claude Code skills come alive", + "description": "Track which skills you actually use, discover your workflow patterns, and keep your setup lean. Works with ALL skills — universal analytics for Claude Code.", + "author": "Your Name", + "homepage": "https://github.com/yourusername/skillpulse", + "license": "MIT", + "categories": ["Developer Tools", "Productivity"], + "tags": ["analytics", "skills", "workflow", "productivity"], + "works_with": ["claude-code"], + "components": { + "mcp_servers": [ + { + "name": "skillpulse", + "command": "node", + "args": ["src/index.js"], + "env": {} + } + ], + "skills": [ + { + "name": "pulse", + "path": "skills/pulse" + } + ] + }, + "install_instructions": "1. Clone: git clone https://github.com/yourusername/skillpulse.git ~/.claude/plugins/skillpulse\n2. Install: cd ~/.claude/plugins/skillpulse && npm install\n3. Reload Claude Code", + "verified": false +} diff --git a/scripts/export.js b/scripts/export.js deleted file mode 100644 index c33bf19..0000000 --- a/scripts/export.js +++ /dev/null @@ -1,209 +0,0 @@ -/** - * SkillPulse - Export data from pulse.jsonl - * - * Exports analytics data in JSON or CSV format. - * - * Usage: node export.js [format] - * - * - format: "json" or "csv" (default: json) - * - * JSON export produces an array of entries. - * CSV export produces output with headers: skill,ts,trigger - * - * Behavior: - * - Reads all entries from pulse.jsonl - * - JSON: outputs valid JSON array (empty array [] if no data) - * - CSV: outputs headers + data rows (headers only if no data) - * - Skips corrupted entries, preserves valid ones - * - Exits with code 0 on all paths - * - * Cross-platform: Uses Node.js path module for path handling. - * Silent operation: No stderr output, outputs to stdout. - * - * Fulfills: - * - VAL-ENH-005: Export - produces valid JSON output - * - VAL-ENH-006: Export - produces valid CSV output - * - VAL-ENH-007: Export - handles empty data - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -// CSV headers -const CSV_HEADERS = 'skill,ts,trigger'; - -/** - * Parse command line arguments to get export format - * @param {string[]} args - Command line arguments - * @returns {string} - "json" or "csv" - */ -function parseFormatArg(args) { - if (!args || args.length === 0) { - return 'json'; - } - - const arg = args[0].toLowerCase(); - - if (arg === 'csv') { - return 'csv'; - } - - // Default to json for any other value - return 'json'; -} - -/** - * Parse a single line of pulse.jsonl - * @param {string} line - A single line from the file - * @returns {object|null} - Parsed entry or null if invalid - */ -function parseLine(line) { - // Skip empty lines - if (!line || line.trim() === '') { - return null; - } - - try { - const entry = JSON.parse(line); - - // Validate entry has required fields - if (typeof entry !== 'object' || entry === null) { - return null; - } - - // Must have skill, ts, and trigger fields - if (typeof entry.skill !== 'string' || - typeof entry.ts !== 'number' || - typeof entry.trigger !== 'string') { - return null; - } - - return entry; - } catch (e) { - // Invalid JSON - skip this line - return null; - } -} - -/** - * Export entries as JSON array - * @param {object[]} entries - Valid entries to export - * @returns {string} - JSON string - */ -function exportAsJson(entries) { - return JSON.stringify(entries, null, 2); -} - -/** - * Export entries as CSV - * @param {object[]} entries - Valid entries to export - * @returns {string} - CSV string with headers - */ -function exportAsCsv(entries) { - // Always include headers - const lines = [CSV_HEADERS]; - - // Add data rows - for (const entry of entries) { - // Simple CSV format - no quoting needed for our data - // (skill names don't contain commas, ts is numeric, trigger is 'auto' or 'explicit') - lines.push(`${entry.skill},${entry.ts},${entry.trigger}`); - } - - return lines.join('\n'); -} - -/** - * Read and parse all entries from pulse.jsonl - * @param {string} pulseFilePath - Path to pulse.jsonl - * @returns {object[]} - Array of valid entries - */ -function readEntries(pulseFilePath) { - // Check if file exists - if (!fs.existsSync(pulseFilePath)) { - return []; - } - - // Read the file - let content; - try { - content = fs.readFileSync(pulseFilePath, 'utf8'); - } catch (readError) { - // Can't read file - return empty - return []; - } - - // Handle empty file - if (!content || content.trim() === '') { - return []; - } - - // Split into lines and process each - const lines = content.split('\n'); - const entries = []; - - for (const line of lines) { - const entry = parseLine(line); - - // Skip invalid/corrupted lines - if (entry !== null) { - entries.push(entry); - } - } - - return entries; -} - -// Main execution -try { - // Get environment variables - const pluginData = process.env.CLAUDE_PLUGIN_DATA; - - // If CLAUDE_PLUGIN_DATA is missing or empty, output empty result - if (pluginData === void 0 || pluginData === '') { - // Parse format and output empty result - const format = parseFormatArg(process.argv.slice(2)); - if (format === 'csv') { - console.log(CSV_HEADERS); - } else { - console.log('[]'); - } - process.exit(0); - } - - // Parse format argument - const format = parseFormatArg(process.argv.slice(2)); - - // Construct path to pulse.jsonl - const pulseFilePath = path.join(pluginData, 'pulse.jsonl'); - - // Read all entries - const entries = readEntries(pulseFilePath); - - // Export based on format - let output; - if (format === 'csv') { - output = exportAsCsv(entries); - } else { - output = exportAsJson(entries); - } - - // Output to stdout - console.log(output); - - // Success - process.exit(0); - -} catch (unexpectedError) { - // Catch any unexpected errors - // Still output valid empty result - const format = parseFormatArg(process.argv.slice(2)); - if (format === 'csv') { - console.log(CSV_HEADERS); - } else { - console.log('[]'); - } - process.exit(0); -} diff --git a/scripts/export.test.js b/scripts/export.test.js deleted file mode 100644 index fd497e6..0000000 --- a/scripts/export.test.js +++ /dev/null @@ -1,520 +0,0 @@ -/** - * Unit tests for export.js - SkillPulse export functionality - * - * Tests cover: - * - JSON export produces valid JSON array - * - CSV export produces valid CSV with correct headers - * - Empty data produces valid empty output ([] for JSON, headers only for CSV) - * - Export reads all entries from pulse.jsonl - * - Error handling (missing CLAUDE_PLUGIN_DATA, corrupted entries) - * - * Uses Node.js built-in test runner (node:test) and assertions (node:assert). - * Tests are isolated and deterministic - no shared state between tests. - * - * Fulfills: - * - VAL-ENH-005: Export - produces valid JSON output - * - VAL-ENH-006: Export - produces valid CSV output - * - VAL-ENH-007: Export - handles empty data - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const os = require('os'); - -// Path to the script under test -const EXPORT_SCRIPT_PATH = path.join(__dirname, 'export.js'); - -/** - * Helper to create a unique temp directory for each test - * This ensures test isolation - */ -function createTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'export-test-')); -} - -/** - * Helper to run export.js with given environment variables and arguments - * Returns { stdout, stderr, exitCode } - */ -function runExport(env, args = []) { - const result = { - stdout: '', - stderr: '', - exitCode: null - }; - - const argsStr = args.length > 0 ? ' ' + args.map(a => `"${a}"`).join(' ') : ''; - - try { - result.stdout = execSync( - `node "${EXPORT_SCRIPT_PATH}"${argsStr}`, - { - env: { ...process.env, ...env }, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 - } - ); - } catch (error) { - result.stdout = error.stdout || ''; - result.stderr = error.stderr || ''; - result.exitCode = error.status; - return result; - } - - result.exitCode = 0; - return result; -} - -/** - * Helper to create a pulse.jsonl file with given entries - */ -function createPulseFile(dir, entries) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - const content = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; - fs.writeFileSync(pulsePath, content, 'utf8'); - return pulsePath; -} - -// ============================================================================ -// JSON EXPORT TESTS (VAL-ENH-005) -// ============================================================================ - -describe('JSON export', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('produces valid JSON array', () => { - createPulseFile(tempDir, [ - { skill: 'careful', ts: 1711234567, trigger: 'auto' }, - { skill: 'freeze', ts: 1711234568, trigger: 'explicit' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - // Should be valid JSON - const parsed = JSON.parse(result.stdout); - assert.ok(Array.isArray(parsed), 'Output should be a JSON array'); - }); - - test('includes all entries from pulse.jsonl', () => { - const entries = [ - { skill: 'careful', ts: 1711234567, trigger: 'auto' }, - { skill: 'freeze', ts: 1711234568, trigger: 'explicit' }, - { skill: 'plugin-developer', ts: 1711234569, trigger: 'auto' } - ]; - createPulseFile(tempDir, entries); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(parsed.length, 3, 'Should have all 3 entries'); - assert.strictEqual(parsed[0].skill, 'careful'); - assert.strictEqual(parsed[1].skill, 'freeze'); - assert.strictEqual(parsed[2].skill, 'plugin-developer'); - }); - - test('preserves entry fields', () => { - createPulseFile(tempDir, [ - { skill: 'my-skill', ts: 1711234567, trigger: 'explicit' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(parsed[0].skill, 'my-skill'); - assert.strictEqual(parsed[0].ts, 1711234567); - assert.strictEqual(parsed[0].trigger, 'explicit'); - assert.strictEqual(Object.keys(parsed[0]).length, 3, 'Should have exactly 3 fields'); - }); - - test('produces empty array for empty pulse.jsonl', () => { - // Create empty pulse.jsonl - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - fs.writeFileSync(pulsePath, '', 'utf8'); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(result.exitCode, 0); - assert.ok(Array.isArray(parsed), 'Should be an array'); - assert.strictEqual(parsed.length, 0, 'Should be empty array'); - }); - - test('produces empty array for non-existent pulse.jsonl', () => { - // Don't create pulse.jsonl - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(result.exitCode, 0); - assert.ok(Array.isArray(parsed)); - assert.strictEqual(parsed.length, 0, 'Should be empty array when file missing'); - }); - - test('skips corrupted entries and preserves valid ones', () => { - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - // Mix valid and invalid entries - const content = `{"skill":"valid1","ts":1711234567,"trigger":"auto"} -{broken json here -{"skill":"valid2","ts":1711234568,"trigger":"explicit"} -{"skill":"valid3","ts":1711234569,"trigger":"auto"}`; - fs.writeFileSync(pulsePath, content, 'utf8'); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(parsed.length, 3, 'Should have 3 valid entries, skipping corrupted'); - assert.strictEqual(parsed[0].skill, 'valid1'); - assert.strictEqual(parsed[1].skill, 'valid2'); - assert.strictEqual(parsed[2].skill, 'valid3'); - }); - - test('handles entries with unicode skill names', () => { - createPulseFile(tempDir, [ - { skill: '技能测试', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(parsed[0].skill, '技能测试'); - }); - - test('defaults to JSON format when no format specified', () => { - createPulseFile(tempDir, [ - { skill: 'careful', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }); - const parsed = JSON.parse(result.stdout); - - assert.ok(Array.isArray(parsed), 'Default should be JSON array'); - assert.strictEqual(parsed.length, 1); - }); -}); - -// ============================================================================ -// CSV EXPORT TESTS (VAL-ENH-006) -// ============================================================================ - -describe('CSV export', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('produces valid CSV with headers', () => { - createPulseFile(tempDir, [ - { skill: 'careful', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(result.exitCode, 0); - assert.ok(lines.length >= 2, 'Should have header + at least one data row'); - assert.strictEqual(lines[0], 'skill,ts,trigger', 'Header should be skill,ts,trigger'); - }); - - test('includes all entries as CSV rows', () => { - createPulseFile(tempDir, [ - { skill: 'careful', ts: 1711234567, trigger: 'auto' }, - { skill: 'freeze', ts: 1711234568, trigger: 'explicit' }, - { skill: 'plugin-developer', ts: 1711234569, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(lines.length, 4, 'Should have header + 3 data rows'); - assert.strictEqual(lines[1], 'careful,1711234567,auto'); - assert.strictEqual(lines[2], 'freeze,1711234568,explicit'); - assert.strictEqual(lines[3], 'plugin-developer,1711234569,auto'); - }); - - test('produces headers only for empty pulse.jsonl', () => { - // Create empty pulse.jsonl - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - fs.writeFileSync(pulsePath, '', 'utf8'); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(lines.length, 1, 'Should have only header'); - assert.strictEqual(lines[0], 'skill,ts,trigger'); - }); - - test('produces headers only for non-existent pulse.jsonl', () => { - // Don't create pulse.jsonl - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(lines.length, 1, 'Should have only header'); - assert.strictEqual(lines[0], 'skill,ts,trigger'); - }); - - test('handles skill names with hyphens', () => { - createPulseFile(tempDir, [ - { skill: 'my-skill-123', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(lines[1], 'my-skill-123,1711234567,auto'); - }); - - test('handles skill names with underscores', () => { - createPulseFile(tempDir, [ - { skill: 'skill_name', ts: 1711234567, trigger: 'explicit' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(lines[1], 'skill_name,1711234567,explicit'); - }); - - test('handles skill names with unicode characters', () => { - createPulseFile(tempDir, [ - { skill: '技能测试', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(lines[1], '技能测试,1711234567,auto'); - }); - - test('skips corrupted entries in CSV export', () => { - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - const content = `{"skill":"valid1","ts":1711234567,"trigger":"auto"} -{broken json -{"skill":"valid2","ts":1711234568,"trigger":"explicit"}`; - fs.writeFileSync(pulsePath, content, 'utf8'); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(lines.length, 3, 'Should have header + 2 valid rows'); - assert.strictEqual(lines[1], 'valid1,1711234567,auto'); - assert.strictEqual(lines[2], 'valid2,1711234568,explicit'); - }); - - test('handles entries with missing fields gracefully', () => { - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - // Entry with missing trigger field - const content = `{"skill":"partial","ts":1711234567} -{"skill":"complete","ts":1711234568,"trigger":"explicit"}`; - fs.writeFileSync(pulsePath, content, 'utf8'); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(result.exitCode, 0); - // Should skip incomplete entry or handle gracefully - assert.ok(lines.length >= 2, 'Should have at least header and one valid entry'); - }); -}); - -// ============================================================================ -// ERROR HANDLING TESTS (VAL-ENH-007) -// ============================================================================ - -describe('Error handling', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('exits with code 0 on success', () => { - createPulseFile(tempDir, [ - { skill: 'test', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - assert.strictEqual(result.exitCode, 0); - }); - - test('handles missing CLAUDE_PLUGIN_DATA gracefully', () => { - const result = runExport({}, ['json']); - - assert.strictEqual(result.exitCode, 0); - const parsed = JSON.parse(result.stdout); - assert.strictEqual(parsed.length, 0, 'Should return empty array'); - }); - - test('handles empty CLAUDE_PLUGIN_DATA gracefully', () => { - const result = runExport({ CLAUDE_PLUGIN_DATA: '' }, ['json']); - - assert.strictEqual(result.exitCode, 0); - const parsed = JSON.parse(result.stdout); - assert.strictEqual(parsed.length, 0); - }); - - test('handles invalid format argument gracefully', () => { - createPulseFile(tempDir, [ - { skill: 'test', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['invalid']); - - // Should default to JSON or handle gracefully - assert.strictEqual(result.exitCode, 0); - }); - - test('handles case-insensitive format argument', () => { - createPulseFile(tempDir, [ - { skill: 'test', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['CSV']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(lines[0], 'skill,ts,trigger'); - }); - - test('produces no stderr on success', () => { - createPulseFile(tempDir, [ - { skill: 'test', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles pulse.jsonl with only empty lines', () => { - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - fs.writeFileSync(pulsePath, '\n\n\n', 'utf8'); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(parsed.length, 0, 'Empty lines should result in empty array'); - }); -}); - -// ============================================================================ -// LARGE FILE HANDLING TESTS -// ============================================================================ - -describe('Large file handling', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles 100 entries correctly', () => { - const entries = []; - for (let i = 0; i < 100; i++) { - entries.push({ skill: `skill-${i}`, ts: 1711234567 + i, trigger: i % 2 === 0 ? 'auto' : 'explicit' }); - } - createPulseFile(tempDir, entries); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - const parsed = JSON.parse(result.stdout); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(parsed.length, 100); - assert.strictEqual(parsed[0].skill, 'skill-0'); - assert.strictEqual(parsed[99].skill, 'skill-99'); - }); - - test('CSV handles 100 entries correctly', () => { - const entries = []; - for (let i = 0; i < 100; i++) { - entries.push({ skill: `skill-${i}`, ts: 1711234567 + i, trigger: 'auto' }); - } - createPulseFile(tempDir, entries); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - const lines = result.stdout.trim().split('\n'); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(lines.length, 101, 'Should have header + 100 data rows'); - }); -}); - -// ============================================================================ -// INTEGRATION WITH PULSE SKILL TESTS -// ============================================================================ - -describe('Integration readiness', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('export output can be piped to file', () => { - createPulseFile(tempDir, [ - { skill: 'careful', ts: 1711234567, trigger: 'auto' }, - { skill: 'freeze', ts: 1711234568, trigger: 'explicit' } - ]); - - const outputPath = path.join(tempDir, 'export.json'); - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['json']); - - // Write output to file - fs.writeFileSync(outputPath, result.stdout, 'utf8'); - - // Verify file is valid JSON - const fileContent = fs.readFileSync(outputPath, 'utf8'); - const parsed = JSON.parse(fileContent); - assert.strictEqual(parsed.length, 2); - }); - - test('CSV output can be imported by standard CSV parsers', () => { - createPulseFile(tempDir, [ - { skill: 'careful', ts: 1711234567, trigger: 'auto' } - ]); - - const result = runExport({ CLAUDE_PLUGIN_DATA: tempDir }, ['csv']); - - // Verify CSV structure: no quotes needed for simple values - assert.ok(!result.stdout.includes('"'), 'Simple values should not be quoted'); - - // Verify comma separation - const lines = result.stdout.trim().split('\n'); - assert.strictEqual(lines[1].split(',').length, 3, 'Each row should have 3 columns'); - }); -}); diff --git a/scripts/flow-validation.test.js b/scripts/flow-validation.test.js deleted file mode 100644 index fa7891b..0000000 --- a/scripts/flow-validation.test.js +++ /dev/null @@ -1,504 +0,0 @@ -/** - * SkillPulse - Cross-Area Flow Validation Tests - * - * Tests FLOW-001 through FLOW-010 from the validation contract. - * These tests verify end-to-end flows across multiple areas. - */ - -'use strict'; - -const { test, describe, before, after, beforeEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execSync, spawn } = require('child_process'); - -// Test directory for isolated testing -const TEST_DIR = path.join(__dirname, '..', '.flow-test-temp'); -const PULSE_FILE = path.join(TEST_DIR, 'pulse.jsonl'); - -// Scripts paths -const TRACK_JS = path.join(__dirname, 'track.js'); -const ROTATE_JS = path.join(__dirname, 'rotate.js'); -const EXPORT_JS = path.join(__dirname, 'export.js'); -const RESET_JS = path.join(__dirname, 'reset.js'); - -// Helper to run track.js with env vars -function runTrack(filePath, humanTurn = '', pluginData = TEST_DIR) { - const input = JSON.stringify({ file_path: filePath }); - const result = execSync( - `node "${TRACK_JS}"`, - { - env: { - ...process.env, - CLAUDE_TOOL_INPUT: input, - CLAUDE_HUMAN_TURN: humanTurn, - CLAUDE_PLUGIN_DATA: pluginData - }, - encoding: 'utf8', - timeout: 5000 - } - ); - return result; -} - -// Helper to run rotate.js -function runRotate(retentionDays, pluginData = TEST_DIR) { - const result = execSync( - `node "${ROTATE_JS}" ${retentionDays}`, - { - env: { - ...process.env, - CLAUDE_PLUGIN_DATA: pluginData - }, - encoding: 'utf8', - timeout: 5000 - } - ); - return result; -} - -// Helper to run export.js -function runExport(format = 'json', pluginData = TEST_DIR) { - const result = execSync( - `node "${EXPORT_JS}" ${format}`, - { - env: { - ...process.env, - CLAUDE_PLUGIN_DATA: pluginData - }, - encoding: 'utf8', - timeout: 5000 - } - ); - return result; -} - -// Helper to run reset.js -function runReset(force = false, pluginData = TEST_DIR) { - const forceFlag = force ? '--force' : ''; - const result = execSync( - `node "${RESET_JS}" ${forceFlag}`, - { - env: { - ...process.env, - CLAUDE_PLUGIN_DATA: pluginData - }, - encoding: 'utf8', - timeout: 5000 - } - ); - return result; -} - -// Helper to read pulse.jsonl entries -function readPulseFile() { - if (!fs.existsSync(PULSE_FILE)) { - return []; - } - const content = fs.readFileSync(PULSE_FILE, 'utf8'); - if (!content.trim()) { - return []; - } - return content.trim().split('\n').map(line => JSON.parse(line)); -} - -// Helper to write entries to pulse.jsonl -function writePulseFile(entries) { - const content = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; - fs.writeFileSync(PULSE_FILE, content, 'utf8'); -} - -// Helper to create a timestamp N days ago -function daysAgo(days) { - return Math.floor(Date.now() / 1000) - (days * 24 * 60 * 60); -} - -// Setup and teardown -before(() => { - // Create test directory - if (!fs.existsSync(TEST_DIR)) { - fs.mkdirSync(TEST_DIR, { recursive: true }); - } -}); - -after(() => { - // Clean up test directory - if (fs.existsSync(TEST_DIR)) { - fs.rmSync(TEST_DIR, { recursive: true, force: true }); - } -}); - -beforeEach(() => { - // Clean pulse file before each test - if (fs.existsSync(PULSE_FILE)) { - fs.unlinkSync(PULSE_FILE); - } -}); - -// ======================================== -// FLOW-001: Track → Verify → Export works end-to-end -// ======================================== -describe('FLOW-001: Track → Verify → Export works end-to-end', () => { - test('complete tracking flow verification', () => { - // Step 1: Start with empty pulse.jsonl (already clean from beforeEach) - assert.ok(!fs.existsSync(PULSE_FILE), 'pulse.jsonl should not exist initially'); - - // Step 2: Trigger 3 different skills (mix of explicit and auto) - // Skill 1: explicit trigger - runTrack('/skills/careful/SKILL.md', '/careful please help'); - - // Skill 2: auto trigger (no /freeze in message) - runTrack('/skills/freeze/SKILL.md', 'please analyze this code'); - - // Skill 3: explicit trigger - runTrack('/skills/planning/SKILL.md', '/planning create a roadmap'); - - // Step 3: Verify pulse.jsonl has 3 entries with correct data - const entries = readPulseFile(); - assert.strictEqual(entries.length, 3, 'Should have 3 entries'); - - assert.strictEqual(entries[0].skill, 'careful'); - assert.strictEqual(entries[0].trigger, 'explicit'); - - assert.strictEqual(entries[1].skill, 'freeze'); - assert.strictEqual(entries[1].trigger, 'auto'); - - assert.strictEqual(entries[2].skill, 'planning'); - assert.strictEqual(entries[2].trigger, 'explicit'); - - // Step 4: Run export to JSON - const jsonExport = runExport('json'); - const exported = JSON.parse(jsonExport); - - // Step 5: Verify exported JSON matches pulse.jsonl content - assert.strictEqual(exported.length, 3, 'Export should have 3 entries'); - assert.strictEqual(exported[0].skill, 'careful'); - assert.strictEqual(exported[1].skill, 'freeze'); - assert.strictEqual(exported[2].skill, 'planning'); - - // Also verify CSV export works - const csvExport = runExport('csv'); - const csvLines = csvExport.trim().split('\n'); - assert.strictEqual(csvLines.length, 4, 'CSV should have header + 3 data rows'); // header + 3 entries - assert.strictEqual(csvLines[0], 'skill,ts,trigger'); - assert.ok(csvLines[1].includes('careful')); - assert.ok(csvLines[2].includes('freeze')); - assert.ok(csvLines[3].includes('planning')); - }); -}); - -// ======================================== -// FLOW-002: Track → Rotate → Verify retention works -// ======================================== -describe('FLOW-002: Track → Rotate → Verify retention works', () => { - test('data lifecycle management', () => { - // Step 1: Create entries with timestamps: 60 days ago, 30 days ago, 1 day ago - const entries = [ - { skill: 'old-skill', ts: daysAgo(60), trigger: 'auto' }, - { skill: 'mid-skill', ts: daysAgo(30), trigger: 'auto' }, - { skill: 'new-skill', ts: daysAgo(1), trigger: 'explicit' } - ]; - writePulseFile(entries); - - // Step 2: Run rotation with 45-day retention - runRotate(45); - - // Step 3: Verify 60-day entry removed, others preserved - const afterRotate = readPulseFile(); - assert.strictEqual(afterRotate.length, 2, 'Should have 2 entries after rotation'); - - const skillNames = afterRotate.map(e => e.skill); - assert.ok(!skillNames.includes('old-skill'), '60-day old entry should be removed'); - assert.ok(skillNames.includes('mid-skill'), '30-day old entry should be preserved'); - assert.ok(skillNames.includes('new-skill'), '1-day old entry should be preserved'); - - // Step 4 & 5: Verify only new entries appear in recent output - // The pulse skill filters by time, but we can verify the data is correct - assert.strictEqual(afterRotate[0].skill, 'mid-skill'); - assert.strictEqual(afterRotate[1].skill, 'new-skill'); - }); -}); - -// ======================================== -// FLOW-003: Cross-platform data compatibility -// ======================================== -describe('FLOW-003: Track on Windows → Read on macOS (Cross-platform data compatibility)', () => { - test('data compatibility across platforms', () => { - // Simulate data written on Windows (backslash paths) - runTrack('C:\\Users\\test\\.claude\\skills\\careful\\SKILL.md', '/careful'); - - // Verify entry is written correctly - const entries = readPulseFile(); - assert.strictEqual(entries.length, 1); - assert.strictEqual(entries[0].skill, 'careful'); - - // The data format is JSON, which is platform-independent - // Verify the JSON can be parsed and has correct structure - const rawContent = fs.readFileSync(PULSE_FILE, 'utf8'); - const parsed = JSON.parse(rawContent.trim()); - assert.strictEqual(typeof parsed.skill, 'string'); - assert.strictEqual(typeof parsed.ts, 'number'); - assert.strictEqual(typeof parsed.trigger, 'string'); - - // Simulate reading this data on macOS - it should parse correctly - // (In reality, this would be on a different machine, but the JSON format - // ensures compatibility) - const exported = runExport('json'); - const exportedData = JSON.parse(exported); - assert.strictEqual(exportedData[0].skill, 'careful'); - }); -}); - -// ======================================== -// FLOW-004: Error recovery → Continue tracking works -// ======================================== -describe('FLOW-004: Error recovery → Continue tracking works', () => { - test('resilience after error conditions', () => { - // Step 1: Make CLAUDE_PLUGIN_DATA temporarily "read-only" by using invalid path - // This simulates write failure - - // Step 2: Trigger skill read with invalid path (silently fails) - const invalidPath = path.join(TEST_DIR, 'nonexistent-deep', 'nested', 'path'); - runTrack('/skills/test1/SKILL.md', '', invalidPath); - - // Step 3: Trigger with valid path - runTrack('/skills/test2/SKILL.md', ''); - - // Step 4: Verify new entry written successfully - const entries = readPulseFile(); - assert.strictEqual(entries.length, 1, 'Should have 1 entry after recovery'); - assert.strictEqual(entries[0].skill, 'test2'); - }); -}); - -// ======================================== -// FLOW-005: Concurrent track → No corruption -// ======================================== -describe('FLOW-005: Concurrent tracking → No corruption', () => { - test('concurrent write safety', (t, done) => { - // Create multiple entries rapidly (simulating concurrent writes) - const numWrites = 10; - const skills = []; - - // Run track.js multiple times in quick succession - for (let i = 0; i < numWrites; i++) { - const skillName = `skill-${i}`; - skills.push(skillName); - runTrack(`/skills/${skillName}/SKILL.md`, ''); - } - - // Wait for all to complete and verify - const entries = readPulseFile(); - - // Step 3 & 4: Verify all entries present, no corruption - assert.strictEqual(entries.length, numWrites, `Should have ${numWrites} entries`); - - // Verify all entries are valid JSON and have correct structure - const skillNames = entries.map(e => { - assert.strictEqual(typeof e.skill, 'string'); - assert.strictEqual(typeof e.ts, 'number'); - assert.strictEqual(typeof e.trigger, 'string'); - return e.skill; - }); - - // Verify all expected skills are present - for (const skill of skills) { - assert.ok(skillNames.includes(skill), `Skill ${skill} should be present`); - } - - done(); - }); -}); - -// ======================================== -// FLOW-006: Full lifecycle -// ======================================== -describe('FLOW-006: Full lifecycle: Fresh install → Track → Analyze → Rotate → Export → Reset', () => { - test('complete user journey', () => { - // Step 1: Fresh plugin install (no pulse.jsonl) - already clean - - // Step 2: Use Claude for a session, triggering 5+ skills - for (let i = 1; i <= 5; i++) { - const trigger = i % 2 === 0 ? 'auto' : 'explicit'; - const humanTurn = trigger === 'explicit' ? `/skill-${i}` : 'some message'; - runTrack(`/skills/skill-${i}/SKILL.md`, humanTurn); - } - - // Step 3: Verify tracking - pulse.jsonl should have 5 entries - let entries = readPulseFile(); - assert.strictEqual(entries.length, 5, 'Should have 5 entries after session'); - - // Step 4: Run rotation with 7-day retention - runRotate(7); - entries = readPulseFile(); - assert.strictEqual(entries.length, 5, 'All recent entries should be preserved'); - - // Step 5: Export to JSON - const jsonExport = runExport('json'); - const exported = JSON.parse(jsonExport); - assert.strictEqual(exported.length, 5, 'Export should have all entries'); - - // Step 6: Reset data with --force - const resetOutput = runReset(true); - assert.ok(resetOutput.includes('reset successfully'), 'Reset should succeed'); - - // Step 7: Verify pulse.jsonl empty or deleted - assert.ok(!fs.existsSync(PULSE_FILE), 'pulse.jsonl should be deleted after reset'); - }); -}); - -// ======================================== -// FLOW-007: Bash → Node.js upgrade preserves data -// ======================================== -describe('FLOW-007: Upgrade from Bash to Node.js preserves data', () => { - test('migration path for existing users', () => { - // Step 1: Have existing pulse.jsonl from "Bash version" - // (The format is the same, so we just create entries) - const oldEntries = [ - { skill: 'old-skill-1', ts: daysAgo(5), trigger: 'auto' }, - { skill: 'old-skill-2', ts: daysAgo(3), trigger: 'explicit' } - ]; - writePulseFile(oldEntries); - - // Step 2: Upgrade plugin to Node.js version (already done - we're using track.js) - - // Step 3: Trigger new skill read - runTrack('/skills/new-skill/SKILL.md', '/new-skill'); - - // Step 4 & 5: Verify old and new entries both visible - const entries = readPulseFile(); - assert.strictEqual(entries.length, 3, 'Should have 3 entries total'); - - const skillNames = entries.map(e => e.skill); - assert.ok(skillNames.includes('old-skill-1'), 'Old entry 1 preserved'); - assert.ok(skillNames.includes('old-skill-2'), 'Old entry 2 preserved'); - assert.ok(skillNames.includes('new-skill'), 'New entry added'); - - // Verify export also works - const exported = JSON.parse(runExport('json')); - assert.strictEqual(exported.length, 3); - }); -}); - -// ======================================== -// FLOW-008: Missing env var → Graceful degradation -// ======================================== -describe('FLOW-008: Missing env var → Graceful degradation', () => { - test('environment error handling', () => { - // Step 1: Unset CLAUDE_PLUGIN_DATA (use empty string) - const input = JSON.stringify({ file_path: '/skills/test/SKILL.md' }); - - // Run with empty CLAUDE_PLUGIN_DATA - const result = execSync( - `node "${TRACK_JS}"`, - { - env: { - ...process.env, - CLAUDE_TOOL_INPUT: input, - CLAUDE_HUMAN_TURN: '/test', - CLAUDE_PLUGIN_DATA: '' // Empty = missing - }, - encoding: 'utf8', - timeout: 5000 - } - ); - - // Step 3: Verify Claude continues normally (exit code 0, no error output) - assert.strictEqual(result, '', 'Should produce no output'); - - // Step 4: Set CLAUDE_PLUGIN_DATA to valid path - // Step 5: Trigger skill read - runTrack('/skills/test/SKILL.md', '/test'); - - // Step 6: Verify tracking resumes - const entries = readPulseFile(); - assert.strictEqual(entries.length, 1, 'Should have 1 entry after recovery'); - assert.strictEqual(entries[0].skill, 'test'); - }); -}); - -// ======================================== -// FLOW-009: Unicode skill names -// ======================================== -describe('FLOW-009: Unicode skill names handled correctly', () => { - test('internationalization support', () => { - // Step 1: Create skill with unicode name - const unicodeSkillPath = '/skills/修复/SKILL.md'; - - // Step 2: Trigger read of that SKILL.md - runTrack(unicodeSkillPath, '/修复'); - - // Step 3: Verify pulse.jsonl entry has correct skill name - const entries = readPulseFile(); - assert.strictEqual(entries.length, 1); - assert.strictEqual(entries[0].skill, '修复'); - assert.strictEqual(entries[0].trigger, 'explicit'); - - // Step 4 & 5: Run export and verify unicode skill name displays correctly - const jsonExport = runExport('json'); - const exported = JSON.parse(jsonExport); - assert.strictEqual(exported[0].skill, '修复'); - - // Also test CSV export - const csvExport = runExport('csv'); - assert.ok(csvExport.includes('修复'), 'CSV should contain unicode skill name'); - - // Test more unicode characters - if (fs.existsSync(PULSE_FILE)) { - fs.unlinkSync(PULSE_FILE); - } - - runTrack('/skills/日本語/SKILL.md', ''); - runTrack('/skills/العربية/SKILL.md', ''); - runTrack('/skills/русский/SKILL.md', ''); - - const moreEntries = readPulseFile(); - assert.strictEqual(moreEntries.length, 3); - assert.strictEqual(moreEntries[0].skill, '日本語'); - assert.strictEqual(moreEntries[1].skill, 'العربية'); - assert.strictEqual(moreEntries[2].skill, 'русский'); - }); -}); - -// ======================================== -// FLOW-010: Rapid successive tracking -// ======================================== -describe('FLOW-010: Rapid successive tracking works', () => { - test('performance under load', () => { - // Step 1: Programmatically trigger 100 skill reads in rapid succession - const numEntries = 100; - const startTime = Date.now(); - - for (let i = 0; i < numEntries; i++) { - runTrack(`/skills/skill-${i}/SKILL.md`, ''); - } - - const duration = Date.now() - startTime; - - // Step 2: Wait for completion (already done - execSync is synchronous) - - // Step 3: Verify pulse.jsonl has 100 valid entries - const entries = readPulseFile(); - assert.strictEqual(entries.length, numEntries, `Should have ${numEntries} entries`); - - // Verify all entries are valid JSON - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - assert.strictEqual(typeof entry.skill, 'string', `Entry ${i} should have string skill`); - assert.strictEqual(typeof entry.ts, 'number', `Entry ${i} should have number ts`); - assert.strictEqual(typeof entry.trigger, 'string', `Entry ${i} should have string trigger`); - assert.ok(entry.skill.startsWith('skill-'), `Entry ${i} should have correct skill name`); - } - - // Step 4 & 5: Run export and verify all skills counted - const jsonExport = runExport('json'); - const exported = JSON.parse(jsonExport); - assert.strictEqual(exported.length, numEntries, 'Export should have all entries'); - - // Performance check - 100 entries should complete reasonably fast - // (This is a soft check - the main requirement is correctness) - console.log(` 100 tracking operations completed in ${duration}ms`); - assert.ok(duration < 30000, '100 operations should complete in under 30 seconds'); - }); -}); diff --git a/scripts/reset.js b/scripts/reset.js deleted file mode 100644 index 2bbdd78..0000000 --- a/scripts/reset.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * SkillPulse - Reset analytics data - * - * Clears all analytics data from pulse.jsonl. - * Requires --force flag to prevent accidental data loss. - * - * Usage: node reset.js --force - * - * Behavior: - * - Without --force: outputs warning message and exits with code 0 - * - With --force: clears pulse.jsonl (deletes file or empties it) - * - Handles missing pulse.jsonl gracefully (no error) - * - Exits with code 0 on all paths - * - * Cross-platform: Uses Node.js path module for path handling. - * - * Fulfills: - * - VAL-ENH-008: Reset - clears all data - * - VAL-ENH-009: Reset - requires confirmation (optional safety) - * - VAL-ENH-010: Reset - handles missing pulse.jsonl - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -/** - * Parse command line arguments to check for --force flag - * @param {string[]} args - Command line arguments - * @returns {boolean} - True if --force flag is present - */ -function hasForceFlag(args) { - if (!args || args.length === 0) { - return false; - } - - return args.includes('--force') || args.includes('-f'); -} - -/** - * Check if pulse.jsonl exists - * @param {string} pulseFilePath - Path to pulse.jsonl - * @returns {boolean} - True if file exists - */ -function fileExists(pulseFilePath) { - try { - return fs.existsSync(pulseFilePath); - } catch (e) { - return false; - } -} - -/** - * Delete or empty the pulse.jsonl file - * @param {string} pulseFilePath - Path to pulse.jsonl - * @returns {boolean} - True if successful - */ -function resetFile(pulseFilePath) { - try { - // Delete the file (simpler than emptying) - fs.unlinkSync(pulseFilePath); - return true; - } catch (e) { - // If file doesn't exist, that's fine - if (e.code === 'ENOENT') { - return true; - } - // Other errors (permission denied, etc.) - return false - return false; - } -} - -// Main execution -try { - // Get environment variables - const pluginData = process.env.CLAUDE_PLUGIN_DATA; - - // Parse arguments - const args = process.argv.slice(2); - const force = hasForceFlag(args); - - // If CLAUDE_PLUGIN_DATA is missing or empty - if (pluginData === void 0 || pluginData === '') { - if (!force) { - console.log('⚠️ Reset requires --force flag to prevent accidental data loss.'); - console.log(' Usage: node reset.js --force'); - } - // Nothing to reset anyway - process.exit(0); - } - - // Construct path to pulse.jsonl - const pulseFilePath = path.join(pluginData, 'pulse.jsonl'); - - // Check if file exists - const exists = fileExists(pulseFilePath); - - if (!force) { - // Safety: require --force flag - console.log('⚠️ Reset requires --force flag to prevent accidental data loss.'); - console.log(' Usage: node reset.js --force'); - if (exists) { - console.log(` File: ${pulseFilePath}`); - } - process.exit(0); - } - - // --force flag provided, proceed with reset - if (!exists) { - // File doesn't exist - nothing to reset, but that's fine - console.log('✓ No analytics data to reset (pulse.jsonl does not exist).'); - process.exit(0); - } - - // Reset the file - const success = resetFile(pulseFilePath); - - if (success) { - console.log('✓ Analytics data reset successfully.'); - } else { - // Couldn't delete (permission denied, etc.) - console.log('⚠️ Could not reset analytics data (permission denied or file in use).'); - } - - // Always exit with code 0 - process.exit(0); - -} catch (unexpectedError) { - // Catch any unexpected errors - // Still exit with code 0 - process.exit(0); -} diff --git a/scripts/reset.test.js b/scripts/reset.test.js deleted file mode 100644 index f37c1a5..0000000 --- a/scripts/reset.test.js +++ /dev/null @@ -1,617 +0,0 @@ -/** - * Unit tests for reset.js - SkillPulse data reset script - * - * Tests cover: - * - Clearing all data with --force flag - * - Requiring --force flag (safety) - * - Handling missing pulse.jsonl gracefully - * - Exit code 0 on all paths - * - Command-line argument parsing - * - * Fulfills validation assertions: - * - VAL-ENH-008: Reset - clears all data - * - VAL-ENH-009: Reset - requires confirmation (optional safety) - * - VAL-ENH-010: Reset - handles missing pulse.jsonl - * - * Uses Node.js built-in test runner (node:test) and assertions (node:assert). - * Tests are isolated and deterministic - no shared state between tests. - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const os = require('os'); - -// Path to the script under test -const RESET_SCRIPT_PATH = path.join(__dirname, 'reset.js'); - -/** - * Helper to create a unique temp directory for each test - * This ensures test isolation - */ -function createTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'reset-test-')); -} - -/** - * Helper to run reset.js with given environment variables and args - * Returns { stdout, stderr, exitCode } - */ -function runReset(env, args = []) { - const result = { - stdout: '', - stderr: '', - exitCode: null - }; - - const argsStr = args.length > 0 ? ' ' + args.map(a => `"${a}"`).join(' ') : ''; - - try { - result.stdout = execSync( - `node "${RESET_SCRIPT_PATH}"${argsStr}`, - { - env: { ...process.env, ...env }, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 - } - ); - } catch (error) { - result.stdout = error.stdout || ''; - result.stderr = error.stderr || ''; - result.exitCode = error.status; - return result; - } - - result.exitCode = 0; - return result; -} - -/** - * Helper to check if pulse.jsonl exists in a directory - */ -function pulseFileExists(dir) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - return fs.existsSync(pulsePath); -} - -/** - * Helper to read pulse.jsonl from a directory - * Returns array of parsed entries (null if file doesn't exist) - */ -function readPulseFile(dir) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - if (!fs.existsSync(pulsePath)) { - return null; - } - const content = fs.readFileSync(pulsePath, 'utf8').trim(); - if (content === '') { - return []; - } - return content.split('\n').map(line => JSON.parse(line)); -} - -/** - * Helper to write entries to pulse.jsonl - */ -function writePulseFile(dir, entries) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - const content = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; - fs.writeFileSync(pulsePath, content, 'utf8'); -} - -/** - * Get current Unix timestamp in seconds - */ -function nowTs() { - return Math.floor(Date.now() / 1000); -} - -// ============================================================================ -// RESET WITH --FORCE FLAG TESTS (VAL-ENH-008) -// ============================================================================ - -describe('Reset with --force flag (VAL-ENH-008)', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('clears all entries from pulse.jsonl with --force', () => { - // Create entries - const entries = [ - { skill: 'skill-1', ts: nowTs() - 100, trigger: 'auto' }, - { skill: 'skill-2', ts: nowTs() - 50, trigger: 'explicit' }, - { skill: 'skill-3', ts: nowTs(), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - // Verify file exists with data - assert.ok(pulseFileExists(tempDir), 'pulse.jsonl should exist before reset'); - - // Run reset with --force - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.ok(!pulseFileExists(tempDir), 'pulse.jsonl should be deleted after reset'); - assert.ok(result.stdout.includes('reset successfully'), 'Should show success message'); - }); - - test('deletes pulse.jsonl file completely', () => { - // Create file with one entry - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - assert.ok(pulseFileExists(tempDir), 'File should exist before reset'); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'File should not exist after reset'); - }); - - test('works with -f short flag', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['-f']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'File should be deleted with -f flag'); - }); - - test('handles file with many entries', () => { - // Create 100 entries - const entries = []; - for (let i = 0; i < 100; i++) { - entries.push({ skill: `skill-${i}`, ts: nowTs() - i, trigger: i % 2 === 0 ? 'auto' : 'explicit' }); - } - writePulseFile(tempDir, entries); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'Large file should be deleted'); - }); -}); - -// ============================================================================ -// SAFETY: REQUIRES --FORCE FLAG (VAL-ENH-009) -// ============================================================================ - -describe('Safety: requires --force flag (VAL-ENH-009)', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('does not reset without --force flag', () => { - // Create file with entries - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - // Run reset WITHOUT --force - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.ok(pulseFileExists(tempDir), 'File should still exist'); - assert.ok(result.stdout.includes('--force'), 'Should mention --force flag'); - }); - - test('shows warning message without --force', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.ok(result.stdout.includes('⚠️'), 'Should show warning emoji'); - assert.ok(result.stdout.includes('prevent accidental'), 'Should mention accidental data loss'); - }); - - test('shows file path in warning when file exists', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.ok(result.stdout.includes(tempDir) || result.stdout.includes('pulse.jsonl'), - 'Should show file path'); - }); - - test('does not show file path when file does not exist', () => { - // Don't create pulse.jsonl - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - // Should still show warning about --force - assert.ok(result.stdout.includes('--force'), 'Should mention --force flag'); - }); -}); - -// ============================================================================ -// MISSING FILE HANDLING (VAL-ENH-010) -// ============================================================================ - -describe('Missing pulse.jsonl handling (VAL-ENH-010)', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles non-existent pulse.jsonl gracefully with --force', () => { - // Don't create pulse.jsonl - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.ok(result.stdout.includes('does not exist') || result.stdout.includes('No analytics'), - 'Should indicate no data to reset'); - }); - - test('handles non-existent pulse.jsonl gracefully without --force', () => { - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - // Should still show the --force warning - assert.ok(result.stdout.includes('--force'), 'Should mention --force flag'); - }); - - test('handles missing CLAUDE_PLUGIN_DATA gracefully', () => { - const result = runReset({}, ['--force']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - }); - - test('handles empty CLAUDE_PLUGIN_DATA gracefully', () => { - const result = runReset({ CLAUDE_PLUGIN_DATA: '' }, ['--force']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - }); - - test('handles non-existent CLAUDE_PLUGIN_DATA directory', () => { - const result = runReset({ CLAUDE_PLUGIN_DATA: '/nonexistent/path/that/does/not/exist' }, ['--force']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - }); -}); - -// ============================================================================ -// EXIT CODE TESTS -// ============================================================================ - -describe('Exit code 0 on all paths', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('exits with code 0 on successful reset', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - }); - - test('exits with code 0 when file does not exist', () => { - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - }); - - test('exits with code 0 when --force is missing', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.strictEqual(result.exitCode, 0); - }); - - test('exits with code 0 when CLAUDE_PLUGIN_DATA is missing', () => { - const result = runReset({}, ['--force']); - - assert.strictEqual(result.exitCode, 0); - }); - - test('exits with code 0 on any error condition', () => { - // Test multiple error conditions - all should exit 0 - - // Missing env - let result = runReset({}, ['--force']); - assert.strictEqual(result.exitCode, 0, 'Missing env should exit 0'); - - // Empty env - result = runReset({ CLAUDE_PLUGIN_DATA: '' }, ['--force']); - assert.strictEqual(result.exitCode, 0, 'Empty env should exit 0'); - - // Non-existent directory - result = runReset({ CLAUDE_PLUGIN_DATA: '/nonexistent' }, ['--force']); - assert.strictEqual(result.exitCode, 0, 'Non-existent dir should exit 0'); - }); -}); - -// ============================================================================ -// COMMAND LINE ARGUMENT TESTS -// ============================================================================ - -describe('Command line argument handling', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('recognizes --force flag', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'File should be deleted with --force'); - }); - - test('recognizes -f short flag', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['-f']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'File should be deleted with -f'); - }); - - test('ignores other arguments', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force', '--other', 'arg']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'File should be deleted even with extra args'); - }); - - test('no arguments defaults to no force', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.strictEqual(result.exitCode, 0); - assert.ok(pulseFileExists(tempDir), 'File should not be deleted without --force'); - }); -}); - -// ============================================================================ -// OUTPUT MESSAGE TESTS -// ============================================================================ - -describe('Output messages', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('shows success message on successful reset', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.ok(result.stdout.includes('✓'), 'Should show checkmark'); - assert.ok(result.stdout.includes('successfully'), 'Should show success message'); - }); - - test('shows appropriate message when file does not exist', () => { - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.ok( - result.stdout.includes('does not exist') || result.stdout.includes('No analytics'), - 'Should indicate file does not exist' - ); - }); - - test('shows usage hint in warning', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.ok(result.stdout.includes('Usage:'), 'Should show usage hint'); - }); -}); - -// ============================================================================ -// CROSS-PLATFORM TESTS -// ============================================================================ - -describe('Cross-platform compatibility', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles Windows-style CLAUDE_PLUGIN_DATA path', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir)); - }); - - test('handles path with spaces', () => { - const dirWithSpaces = path.join(os.tmpdir(), 'reset test dir'); - try { - fs.mkdirSync(dirWithSpaces, { recursive: true }); - - writePulseFile(dirWithSpaces, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: dirWithSpaces }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(dirWithSpaces)); - } finally { - fs.rmSync(dirWithSpaces, { recursive: true, force: true }); - } - }); - - test('handles unicode in path and skill names', () => { - const dirWithUnicode = path.join(os.tmpdir(), 'reset-测试-目录'); - try { - fs.mkdirSync(dirWithUnicode, { recursive: true }); - - writePulseFile(dirWithUnicode, [{ skill: '技能', ts: nowTs(), trigger: 'auto' }]); - - const result = runReset({ CLAUDE_PLUGIN_DATA: dirWithUnicode }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(dirWithUnicode)); - } finally { - fs.rmSync(dirWithUnicode, { recursive: true, force: true }); - } - }); -}); - -// ============================================================================ -// INTEGRATION TESTS -// ============================================================================ - -describe('Integration with track.js format', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('can reset and then append new entries', () => { - // Create initial entries - writePulseFile(tempDir, [ - { skill: 'old-1', ts: nowTs() - 1000, trigger: 'auto' }, - { skill: 'old-2', ts: nowTs() - 500, trigger: 'explicit' } - ]); - - // Reset - runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - // Verify file is gone - assert.ok(!pulseFileExists(tempDir), 'File should be deleted'); - - // Simulate appending a new entry (like track.js would) - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - const newEntry = { skill: 'new-skill', ts: nowTs(), trigger: 'explicit' }; - fs.appendFileSync(pulsePath, JSON.stringify(newEntry) + '\n', 'utf8'); - - // Verify new entry exists - const entries = readPulseFile(tempDir); - assert.strictEqual(entries.length, 1); - assert.strictEqual(entries[0].skill, 'new-skill'); - }); - - test('reset clears entries created by track.js', () => { - // Simulate entries as created by track.js - const entries = [ - { skill: 'careful', ts: nowTs() - 100, trigger: 'explicit' }, - { skill: 'freeze', ts: nowTs() - 50, trigger: 'auto' }, - { skill: 'my-skill-123', ts: nowTs(), trigger: 'explicit' } - ]; - writePulseFile(tempDir, entries); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'All track.js entries should be cleared'); - }); -}); - -// ============================================================================ -// EDGE CASE TESTS -// ============================================================================ - -describe('Edge cases', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles empty pulse.jsonl', () => { - // Create empty file - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - fs.writeFileSync(pulsePath, '', 'utf8'); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'Empty file should be deleted'); - }); - - test('handles file with only whitespace', () => { - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - fs.writeFileSync(pulsePath, ' \n\n ', 'utf8'); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'Whitespace-only file should be deleted'); - }); - - test('handles file with corrupted entries', () => { - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - fs.writeFileSync(pulsePath, '{not valid json\nalso broken\n', 'utf8'); - - const result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - - assert.strictEqual(result.exitCode, 0); - assert.ok(!pulseFileExists(tempDir), 'Corrupted file should be deleted'); - }); - - test('handles multiple consecutive resets', () => { - writePulseFile(tempDir, [{ skill: 'test', ts: nowTs(), trigger: 'auto' }]); - - // First reset - let result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - assert.strictEqual(result.exitCode, 0); - - // Second reset (file already gone) - result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - assert.strictEqual(result.exitCode, 0); - - // Third reset - result = runReset({ CLAUDE_PLUGIN_DATA: tempDir }, ['--force']); - assert.strictEqual(result.exitCode, 0); - }); -}); diff --git a/scripts/rotate.js b/scripts/rotate.js deleted file mode 100644 index d0317d7..0000000 --- a/scripts/rotate.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * SkillPulse - Data rotation script - * - * Removes entries from pulse.jsonl that are older than a specified retention period. - * Default retention is 30 days. - * - * Usage: node rotate.js [retention-days] - * - * - retention-days: Number of days to keep entries (default: 30) - * - * Behavior: - * - Removes entries with ts older than retention period - * - Preserves entries within retention period - * - Handles empty or non-existent pulse.jsonl gracefully - * - Skips corrupted lines, preserves valid ones - * - Exits with code 0 on all paths (silent operation) - * - * Cross-platform: Uses Node.js path module for path handling. - * Silent operation: Exits with code 0 on all paths, no stdout/stderr. - * - * Fulfills: - * - VAL-ENH-001: Data rotation - removes entries older than retention period - * - VAL-ENH-002: Data rotation - preserves recent entries - * - VAL-ENH-003: Data rotation - handles empty pulse.jsonl - * - VAL-ENH-004: Data rotation - handles corrupted entries - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -// Default retention period in days -const DEFAULT_RETENTION_DAYS = 30; - -// Silent error handler - always exits with code 0, never outputs -function exitSilently() { - process.exit(0); -} - -/** - * Parse command line arguments to get retention days - * @param {string[]} args - Command line arguments - * @returns {number|null} - Retention days or null if invalid - */ -function parseRetentionArg(args) { - if (!args || args.length === 0) { - return DEFAULT_RETENTION_DAYS; - } - - const arg = args[0]; - const parsed = parseInt(arg, 10); - - // Check if it's a valid positive number - if (isNaN(parsed) || parsed < 0) { - return null; - } - - return parsed; -} - -/** - * Check if an entry is within the retention period - * @param {object} entry - Parsed JSON entry with ts field - * @param {number} cutoffTs - Unix timestamp cutoff (entries older than this are removed) - * @returns {boolean} - True if entry should be kept - */ -function isEntryValid(entry, cutoffTs) { - // Entry must be an object with a numeric ts field - if (typeof entry !== 'object' || entry === null) { - return false; - } - - const ts = entry.ts; - - // ts must be a number - if (typeof ts !== 'number' || isNaN(ts)) { - return false; - } - - // Entry is valid if ts >= cutoff - return ts >= cutoffTs; -} - -/** - * Parse a single line of pulse.jsonl - * @param {string} line - A single line from the file - * @returns {object|null} - Parsed entry or null if invalid - */ -function parseLine(line) { - // Skip empty lines - if (!line || line.trim() === '') { - return null; - } - - try { - return JSON.parse(line); - } catch (e) { - // Invalid JSON - skip this line - return null; - } -} - -// Wrap all logic in try-catch for silent failure -try { - // Get environment variables - const pluginData = process.env.CLAUDE_PLUGIN_DATA; - - // If CLAUDE_PLUGIN_DATA is missing or empty, exit silently - if (pluginData === void 0 || pluginData === '') { - exitSilently(); - } - - // Parse retention days from command line arguments - const retentionDays = parseRetentionArg(process.argv.slice(2)); - - // If invalid retention argument, exit silently - if (retentionDays === null) { - exitSilently(); - } - - // Calculate cutoff timestamp (now - retention days in seconds) - const nowTs = Math.floor(Date.now() / 1000); - const cutoffTs = nowTs - (retentionDays * 24 * 60 * 60); - - // Construct path to pulse.jsonl - const pulseFilePath = path.join(pluginData, 'pulse.jsonl'); - - // Check if file exists - if (!fs.existsSync(pulseFilePath)) { - // File doesn't exist - nothing to rotate - exitSilently(); - } - - // Read the file - let content; - try { - content = fs.readFileSync(pulseFilePath, 'utf8'); - } catch (readError) { - // Can't read file - exit silently - exitSilently(); - } - - // Handle empty file - if (!content || content.trim() === '') { - // File is empty - nothing to rotate - exitSilently(); - } - - // Split into lines and process each - const lines = content.split('\n'); - const validEntries = []; - - for (const line of lines) { - const entry = parseLine(line); - - // Skip invalid/corrupted lines - if (entry === null) { - continue; - } - - // Check if entry is within retention period - if (isEntryValid(entry, cutoffTs)) { - validEntries.push(entry); - } - } - - // If no valid entries remain, either delete the file or leave it empty - if (validEntries.length === 0) { - try { - // Write empty file (or could delete it) - fs.writeFileSync(pulseFilePath, '', 'utf8'); - } catch (writeError) { - // Can't write - exit silently - } - exitSilently(); - } - - // Write the filtered entries back to the file - const newContent = validEntries.map(e => JSON.stringify(e)).join('\n') + '\n'; - - try { - fs.writeFileSync(pulseFilePath, newContent, 'utf8'); - } catch (writeError) { - // Can't write - exit silently - exitSilently(); - } - - // Success - exit silently - exitSilently(); - -} catch (unexpectedError) { - // Catch any unexpected errors - // Exit silently - never break Claude's workflow - exitSilently(); -} diff --git a/scripts/rotate.test.js b/scripts/rotate.test.js deleted file mode 100644 index 4a32a9c..0000000 --- a/scripts/rotate.test.js +++ /dev/null @@ -1,734 +0,0 @@ -/** - * Unit tests for rotate.js - SkillPulse data rotation script - * - * Tests cover: - * - Removing entries older than retention period - * - Preserving entries within retention period - * - Handling empty or non-existent pulse.jsonl - * - Skipping corrupted lines while preserving valid ones - * - Exit code 0 on all paths - * - Command-line argument parsing - * - * Fulfills validation assertions: - * - VAL-ENH-001: Data rotation - removes entries older than retention period - * - VAL-ENH-002: Data rotation - preserves recent entries - * - VAL-ENH-003: Data rotation - handles empty pulse.jsonl - * - VAL-ENH-004: Data rotation - handles corrupted entries - * - * Uses Node.js built-in test runner (node:test) and assertions (node:assert). - * Tests are isolated and deterministic - no shared state between tests. - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const os = require('os'); - -// Path to the script under test -const ROTATE_SCRIPT_PATH = path.join(__dirname, 'rotate.js'); - -/** - * Helper to create a unique temp directory for each test - * This ensures test isolation - */ -function createTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'rotate-test-')); -} - -/** - * Helper to run rotate.js with given environment variables and args - * Returns { stdout, stderr, exitCode } - */ -function runRotate(env, args = []) { - const result = { - stdout: '', - stderr: '', - exitCode: null - }; - - const argsStr = args.length > 0 ? ' ' + args.map(a => `"${a}"`).join(' ') : ''; - - try { - result.stdout = execSync( - `node "${ROTATE_SCRIPT_PATH}"${argsStr}`, - { - env: { ...process.env, ...env }, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 - } - ); - } catch (error) { - result.stdout = error.stdout || ''; - result.stderr = error.stderr || ''; - result.exitCode = error.status; - return result; - } - - result.exitCode = 0; - return result; -} - -/** - * Helper to read pulse.jsonl from a directory - * Returns array of parsed entries (null if file doesn't exist) - */ -function readPulseFile(dir) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - if (!fs.existsSync(pulsePath)) { - return null; - } - const content = fs.readFileSync(pulsePath, 'utf8').trim(); - if (content === '') { - return []; - } - return content.split('\n').map(line => JSON.parse(line)); -} - -/** - * Helper to write entries to pulse.jsonl - */ -function writePulseFile(dir, entries) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - const content = entries.map(e => JSON.stringify(e)).join('\n'); - fs.writeFileSync(pulsePath, content, 'utf8'); -} - -/** - * Helper to write raw content to pulse.jsonl (for testing corruption) - */ -function writeRawPulseFile(dir, content) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - fs.writeFileSync(pulsePath, content, 'utf8'); -} - -/** - * Get current Unix timestamp in seconds - */ -function nowTs() { - return Math.floor(Date.now() / 1000); -} - -/** - * Get timestamp N days ago - */ -function daysAgoTs(days) { - return nowTs() - (days * 24 * 60 * 60); -} - -// ============================================================================ -// BASIC ROTATION TESTS (VAL-ENH-001, VAL-ENH-002) -// ============================================================================ - -describe('Basic rotation functionality', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('removes entries older than retention period (VAL-ENH-001)', () => { - // Create entries: 60 days ago and 5 days ago - const entries = [ - { skill: 'old-skill', ts: daysAgoTs(60), trigger: 'auto' }, - { skill: 'recent-skill', ts: daysAgoTs(5), trigger: 'explicit' } - ]; - writePulseFile(tempDir, entries); - - // Run rotation with 30-day retention - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.ok(remaining, 'pulse.jsonl should exist'); - assert.strictEqual(remaining.length, 1, 'Should have 1 entry after rotation'); - assert.strictEqual(remaining[0].skill, 'recent-skill', 'Recent entry should be preserved'); - }); - - test('preserves entries within retention period (VAL-ENH-002)', () => { - // Create entries all within 7 days - const entries = [ - { skill: 'skill-1', ts: daysAgoTs(1), trigger: 'auto' }, - { skill: 'skill-2', ts: daysAgoTs(3), trigger: 'explicit' }, - { skill: 'skill-3', ts: daysAgoTs(6), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - // Run rotation with 7-day retention - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['7']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 3, 'All entries within retention should be preserved'); - }); - - test('preserves entries exactly at retention boundary', () => { - const retentionDays = 30; - // Add a small buffer (1 second) to ensure the entry is clearly within retention - // This accounts for time elapsed between calculating the timestamp and running rotation - const boundaryTs = daysAgoTs(retentionDays) + 1; - - // Entry at boundary (with buffer) should be preserved (>= comparison) - const entries = [ - { skill: 'boundary-skill', ts: boundaryTs, trigger: 'auto' }, - { skill: 'old-skill', ts: boundaryTs - 2, trigger: 'auto' } // clearly older - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, [String(retentionDays)]); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1, 'Entry at boundary should be preserved'); - assert.strictEqual(remaining[0].skill, 'boundary-skill'); - }); - - test('removes all entries when all are older than retention', () => { - const entries = [ - { skill: 'old-1', ts: daysAgoTs(100), trigger: 'auto' }, - { skill: 'old-2', ts: daysAgoTs(90), trigger: 'explicit' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 0, 'All old entries should be removed'); - }); - - test('uses default 30-day retention when no argument provided', () => { - // Entry from 60 days ago should be removed with default 30-day retention - const entries = [ - { skill: 'old-skill', ts: daysAgoTs(60), trigger: 'auto' }, - { skill: 'recent-skill', ts: daysAgoTs(15), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1, 'Default retention should work'); - assert.strictEqual(remaining[0].skill, 'recent-skill'); - }); -}); - -// ============================================================================ -// EMPTY/MISSING FILE TESTS (VAL-ENH-003) -// ============================================================================ - -describe('Empty and missing file handling (VAL-ENH-003)', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles non-existent pulse.jsonl gracefully', () => { - // Don't create pulse.jsonl - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles empty pulse.jsonl gracefully', () => { - writeRawPulseFile(tempDir, ''); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - }); - - test('handles pulse.jsonl with only whitespace', () => { - writeRawPulseFile(tempDir, ' \n\n \n '); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - }); - - test('handles missing CLAUDE_PLUGIN_DATA gracefully', () => { - const result = runRotate({}, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles empty CLAUDE_PLUGIN_DATA gracefully', () => { - const result = runRotate({ CLAUDE_PLUGIN_DATA: '' }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - }); - - test('handles non-existent CLAUDE_PLUGIN_DATA directory', () => { - const result = runRotate({ CLAUDE_PLUGIN_DATA: '/nonexistent/path/that/does/not/exist' }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - }); -}); - -// ============================================================================ -// CORRUPTED ENTRIES TESTS (VAL-ENH-004) -// ============================================================================ - -describe('Corrupted entries handling (VAL-ENH-004)', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('skips corrupted lines while preserving valid ones', () => { - // Mix of valid and invalid lines - const content = [ - JSON.stringify({ skill: 'valid-1', ts: daysAgoTs(5), trigger: 'auto' }), - '{not valid json', - JSON.stringify({ skill: 'valid-2', ts: daysAgoTs(3), trigger: 'explicit' }), - 'completely broken line', - JSON.stringify({ skill: 'valid-3', ts: daysAgoTs(1), trigger: 'auto' }) - ].join('\n'); - - writeRawPulseFile(tempDir, content); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 3, 'Valid entries should be preserved'); - assert.strictEqual(remaining[0].skill, 'valid-1'); - assert.strictEqual(remaining[1].skill, 'valid-2'); - assert.strictEqual(remaining[2].skill, 'valid-3'); - }); - - test('skips entries with missing ts field', () => { - const content = [ - JSON.stringify({ skill: 'no-ts', trigger: 'auto' }), // missing ts - JSON.stringify({ skill: 'valid', ts: daysAgoTs(5), trigger: 'auto' }) - ].join('\n'); - - writeRawPulseFile(tempDir, content); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1, 'Entry without ts should be skipped'); - assert.strictEqual(remaining[0].skill, 'valid'); - }); - - test('skips entries with non-numeric ts field', () => { - const content = [ - JSON.stringify({ skill: 'string-ts', ts: 'not-a-number', trigger: 'auto' }), - JSON.stringify({ skill: 'null-ts', ts: null, trigger: 'auto' }), - JSON.stringify({ skill: 'valid', ts: daysAgoTs(5), trigger: 'auto' }) - ].join('\n'); - - writeRawPulseFile(tempDir, content); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1, 'Entries with invalid ts should be skipped'); - }); - - test('handles file with only corrupted entries', () => { - const content = [ - '{broken', - 'also broken', - 'more garbage' - ].join('\n'); - - writeRawPulseFile(tempDir, content); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - // File should be empty or have no valid entries - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 0, 'All corrupted entries should result in empty file'); - }); - - test('handles corrupted old entries mixed with valid old entries', () => { - const content = [ - JSON.stringify({ skill: 'old-valid', ts: daysAgoTs(60), trigger: 'auto' }), - '{corrupted old entry}', - JSON.stringify({ skill: 'recent-valid', ts: daysAgoTs(5), trigger: 'auto' }) - ].join('\n'); - - writeRawPulseFile(tempDir, content); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1, 'Only recent valid entry should remain'); - assert.strictEqual(remaining[0].skill, 'recent-valid'); - }); -}); - -// ============================================================================ -// COMMAND LINE ARGUMENT TESTS -// ============================================================================ - -describe('Command line argument handling', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('accepts retention days as first argument', () => { - const entries = [ - { skill: 'old', ts: daysAgoTs(100), trigger: 'auto' }, - { skill: 'recent', ts: daysAgoTs(10), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - // 50-day retention should keep the 100-day entry? No, 100 > 50, so remove - // Actually 50 days retention: keep entries with ts >= now - 50 days - // 100 days ago < 50 days retention threshold, so remove - // 10 days ago >= 50 days retention threshold, so keep - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['50']); - - assert.strictEqual(result.exitCode, 0); - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1); - assert.strictEqual(remaining[0].skill, 'recent'); - }); - - test('handles zero retention days', () => { - const entries = [ - { skill: 'any-skill', ts: daysAgoTs(1), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - // 0-day retention: only keep entries from today (ts >= today start) - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['0']); - - assert.strictEqual(result.exitCode, 0); - const remaining = readPulseFile(tempDir); - // Entry from 1 day ago should be removed with 0 retention - assert.strictEqual(remaining.length, 0); - }); - - test('handles very large retention days', () => { - const entries = [ - { skill: 'any-skill', ts: daysAgoTs(365), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['1000']); - - assert.strictEqual(result.exitCode, 0); - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1, 'Large retention should preserve old entries'); - }); - - test('handles non-numeric retention argument gracefully', () => { - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['invalid']); - - // Should exit 0 (graceful failure) but may produce no output - assert.strictEqual(result.exitCode, 0); - }); - - test('handles negative retention argument gracefully', () => { - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['-5']); - - assert.strictEqual(result.exitCode, 0); - }); -}); - -// ============================================================================ -// SILENT OPERATION TESTS -// ============================================================================ - -describe('Silent operation', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('produces no stdout on success', () => { - const entries = [ - { skill: 'test', ts: daysAgoTs(5), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - }); - - test('produces no stderr on success', () => { - const entries = [ - { skill: 'test', ts: daysAgoTs(5), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('exits with code 0 on all paths', () => { - // Test various scenarios - all should exit 0 - - // Non-existent file - let result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - assert.strictEqual(result.exitCode, 0, 'Non-existent file should exit 0'); - - // Empty file - writeRawPulseFile(tempDir, ''); - result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - assert.strictEqual(result.exitCode, 0, 'Empty file should exit 0'); - - // Corrupted file - writeRawPulseFile(tempDir, '{broken}'); - result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - assert.strictEqual(result.exitCode, 0, 'Corrupted file should exit 0'); - - // Missing env var - result = runRotate({}, ['30']); - assert.strictEqual(result.exitCode, 0, 'Missing env var should exit 0'); - }); -}); - -// ============================================================================ -// LARGE FILE TESTS -// ============================================================================ - -describe('Large file handling', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles large pulse.jsonl files efficiently', () => { - // Create 1000 entries - mix of old and new - const entries = []; - for (let i = 0; i < 1000; i++) { - entries.push({ - skill: `skill-${i}`, - ts: i < 500 ? daysAgoTs(60) : daysAgoTs(5), // 500 old, 500 new - trigger: i % 2 === 0 ? 'auto' : 'explicit' - }); - } - writePulseFile(tempDir, entries); - - const startTime = Date.now(); - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - const duration = Date.now() - startTime; - - assert.strictEqual(result.exitCode, 0); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 500, 'Should keep 500 recent entries'); - - // Should complete quickly (under 5 seconds for 1000 entries) - assert.ok(duration < 5000, `Rotation should be fast, took ${duration}ms`); - }); - - test('preserves entry order after rotation', () => { - const entries = [ - { skill: 'first-recent', ts: daysAgoTs(5), trigger: 'auto' }, - { skill: 'old', ts: daysAgoTs(60), trigger: 'auto' }, - { skill: 'second-recent', ts: daysAgoTs(3), trigger: 'explicit' }, - { skill: 'older', ts: daysAgoTs(90), trigger: 'auto' }, - { skill: 'third-recent', ts: daysAgoTs(1), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 3); - // Order should be preserved - assert.strictEqual(remaining[0].skill, 'first-recent'); - assert.strictEqual(remaining[1].skill, 'second-recent'); - assert.strictEqual(remaining[2].skill, 'third-recent'); - }); -}); - -// ============================================================================ -// CROSS-PLATFORM TESTS -// ============================================================================ - -describe('Cross-platform compatibility', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles Windows-style CLAUDE_PLUGIN_DATA path', () => { - // Use the tempDir which is already a proper path for the platform - const entries = [ - { skill: 'test', ts: daysAgoTs(5), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0); - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 1); - }); - - test('handles path with spaces', () => { - // Create a temp dir with spaces in the name - const dirWithSpaces = path.join(os.tmpdir(), 'rotate test dir'); - try { - fs.mkdirSync(dirWithSpaces, { recursive: true }); - - const entries = [ - { skill: 'test', ts: daysAgoTs(5), trigger: 'auto' } - ]; - writePulseFile(dirWithSpaces, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: dirWithSpaces }, ['30']); - - assert.strictEqual(result.exitCode, 0); - } finally { - fs.rmSync(dirWithSpaces, { recursive: true, force: true }); - } - }); - - test('handles unicode in path', () => { - // Create a temp dir with unicode characters - const dirWithUnicode = path.join(os.tmpdir(), 'rotate-测试-目录'); - try { - fs.mkdirSync(dirWithUnicode, { recursive: true }); - - const entries = [ - { skill: '技能', ts: daysAgoTs(5), trigger: 'auto' } - ]; - writePulseFile(dirWithUnicode, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: dirWithUnicode }, ['30']); - - assert.strictEqual(result.exitCode, 0); - const remaining = readPulseFile(dirWithUnicode); - assert.strictEqual(remaining.length, 1); - assert.strictEqual(remaining[0].skill, '技能'); - } finally { - fs.rmSync(dirWithUnicode, { recursive: true, force: true }); - } - }); -}); - -// ============================================================================ -// INTEGRATION TESTS -// ============================================================================ - -describe('Integration with track.js format', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('rotates entries created by track.js format', () => { - // Simulate entries as created by track.js - // Use 29 days to ensure entry is clearly within 30-day retention (avoid boundary timing issues) - const entries = [ - { skill: 'careful', ts: daysAgoTs(60), trigger: 'explicit' }, - { skill: 'freeze', ts: daysAgoTs(29), trigger: 'auto' }, - { skill: 'careful', ts: daysAgoTs(5), trigger: 'auto' }, - { skill: 'my-skill-123', ts: daysAgoTs(2), trigger: 'explicit' } - ]; - writePulseFile(tempDir, entries); - - const result = runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - assert.strictEqual(result.exitCode, 0); - - const remaining = readPulseFile(tempDir); - assert.strictEqual(remaining.length, 3, 'Should keep 3 recent entries'); - - // Verify the correct entries remain - const skills = remaining.map(e => e.skill); - assert.ok(!skills.includes('careful') || remaining.filter(e => e.skill === 'careful').length === 1); - assert.ok(skills.includes('freeze')); - assert.ok(skills.includes('my-skill-123')); - }); - - test('output remains compatible with track.js append', () => { - // Create entries, rotate, then verify new entries can be appended - const entries = [ - { skill: 'old', ts: daysAgoTs(60), trigger: 'auto' }, - { skill: 'recent', ts: daysAgoTs(5), trigger: 'auto' } - ]; - writePulseFile(tempDir, entries); - - // Rotate - runRotate({ CLAUDE_PLUGIN_DATA: tempDir }, ['30']); - - // Simulate appending a new entry (like track.js would) - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - const newEntry = { skill: 'new-skill', ts: nowTs(), trigger: 'explicit' }; - fs.appendFileSync(pulsePath, JSON.stringify(newEntry) + '\n', 'utf8'); - - // Read and verify all entries are valid - const allEntries = readPulseFile(tempDir); - assert.strictEqual(allEntries.length, 2); - assert.strictEqual(allEntries[0].skill, 'recent'); - assert.strictEqual(allEntries[1].skill, 'new-skill'); - }); -}); diff --git a/scripts/track.js b/scripts/track.js deleted file mode 100644 index 03aee59..0000000 --- a/scripts/track.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * SkillPulse - Track skill usage when SKILL.md files are read - * - * This hook fires after every Read tool call and logs skill usage - * to pulse.jsonl for analytics. - * - * Cross-platform: Uses Node.js path module for path handling. - * Silent operation: Exits with code 0 on all paths, no stdout/stderr. - * - * Error Handling: - * - Missing CLAUDE_PLUGIN_DATA: exits silently - * - Missing CLAUDE_TOOL_INPUT: exits silently - * - Malformed JSON input: exits silently - * - Write permission denied: exits silently - * - pulse.jsonl corruption: still appends valid entries - * - Very long file paths: handled without crash - * - Concurrent writes: safe via append mode with exclusive lock - * - * Performance: - * - Completes in < 100ms for typical input - * - Append-only: doesn't read entire pulse.jsonl - * - No memory leaks: minimal allocations - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -// Silent error handler - always exits with code 0, never outputs -function exitSilently() { - process.exit(0); -} - -// Wrap all logic in try-catch for silent failure -try { - // Get environment variables - use void 0 to check for undefined - const toolInput = process.env.CLAUDE_TOOL_INPUT; - const humanTurn = process.env.CLAUDE_HUMAN_TURN; - const pluginData = process.env.CLAUDE_PLUGIN_DATA; - - // If any required env var is missing, exit silently - // Check for both undefined and empty string - if (toolInput === void 0 || toolInput === '' || - pluginData === void 0 || pluginData === '') { - exitSilently(); - } - - // Parse the tool input JSON - let parsedInput; - try { - parsedInput = JSON.parse(toolInput); - } catch (parseError) { - // Malformed JSON - exit silently - exitSilently(); - } - - // Validate parsed input is an object - if (typeof parsedInput !== 'object' || parsedInput === null) { - exitSilently(); - } - - // Extract file_path from parsed input - const filePath = parsedInput.file_path; - if (typeof filePath !== 'string' || filePath.length === 0) { - exitSilently(); - } - - // Handle very long file paths - Node.js handles these gracefully - // but we still process them normally. No explicit length check needed - // as Node.js will throw if path is too long, caught by outer try-catch. - - // Normalize path separators for cross-platform detection - // Convert backslashes to forward slashes for consistent pattern matching - // This handles mixed separators as well - const normalizedPath = filePath.replace(/\\/g, '/'); - - // Check if file ends with SKILL.md (case-sensitive) - if (!normalizedPath.endsWith('/SKILL.md')) { - // Not a SKILL.md file - exit silently without logging - exitSilently(); - } - - // Extract skill name: the immediate parent directory of SKILL.md - // For both "/path/to/skills/my-skill/SKILL.md" and "C:\skills\my-skill\SKILL.md" - // after normalization we get "/path/to/skills/my-skill/SKILL.md" - // Remove trailing /SKILL.md, then get the last path segment - const pathWithoutFile = normalizedPath.slice(0, -'/SKILL.md'.length); - const lastSlashIndex = pathWithoutFile.lastIndexOf('/'); - - if (lastSlashIndex === -1) { - // No parent directory found - edge case, exit silently - exitSilently(); - } - - const skillName = pathWithoutFile.slice(lastSlashIndex + 1); - - // Validate skill name is not empty - if (skillName.length === 0) { - exitSilently(); - } - - // Classify trigger type - // Explicit: user message contains /skillname pattern - // Auto: everything else (including undefined/empty CLAUDE_HUMAN_TURN) - const humanTurnStr = (humanTurn === void 0 || humanTurn === null) ? '' : String(humanTurn); - const trigger = humanTurnStr.includes('/' + skillName) ? 'explicit' : 'auto'; - - // Create log entry with minimal allocations - const entry = { - skill: skillName, - ts: Math.floor(Date.now() / 1000), // Unix timestamp in seconds - trigger: trigger - }; - - // Construct path to pulse.jsonl using path module for cross-platform support - const pulseFilePath = path.join(pluginData, 'pulse.jsonl'); - - // Format as JSON line - const jsonLine = JSON.stringify(entry) + '\n'; - - // Append to pulse.jsonl (creates file if it doesn't exist) - // Using writeFileSync with 'a' flag for append mode - // This handles: - // - Concurrent writes: atomic append at OS level - // - Large files: doesn't read entire file, just appends - // - Missing file: creates it - // - pulse.jsonl corruption: appends valid entry regardless of existing content - try { - // Use 'a' flag for append mode - ensures atomic appends - // 'wx' would fail if file exists, 'a' always appends - fs.writeFileSync(pulseFilePath, jsonLine, { - encoding: 'utf8', - flag: 'a' // Append mode - creates file if doesn't exist - }); - } catch (writeError) { - // Write failed (permission denied, disk full, parent dir doesn't exist, etc.) - // Exit silently - never break Claude's workflow - exitSilently(); - } - - // Success - exit silently with code 0 - exitSilently(); - -} catch (unexpectedError) { - // Catch any unexpected errors (including path too long, etc.) - // Exit silently - never break Claude's workflow - exitSilently(); -} diff --git a/scripts/track.perf.test.js b/scripts/track.perf.test.js deleted file mode 100644 index efaea5f..0000000 --- a/scripts/track.perf.test.js +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Performance tests for track.js - * Validates VAL-PERF-001, VAL-PERF-002, VAL-PERF-003 - * - * These tests verify that track.js meets performance requirements: - * - Script execution time < 100ms for typical input - * - No memory leaks over repeated executions - * - Append to large pulse.jsonl (10k entries) completes quickly - */ - -const { test, describe, before, after } = require('node:test'); -const assert = require('node:assert'); -const { execSync } = require('child_process'); -const path = require('path'); -const fs = require('fs'); -const os = require('os'); - -const TRACK_JS = path.join(__dirname, 'track.js'); -const TEST_DIR = path.join(__dirname, '..', 'test-perf-temp'); -const PULSE_FILE = path.join(TEST_DIR, 'pulse.jsonl'); - -// Helper to run track.js with env vars and measure time -function runTrack(filePath, humanTurn, pluginData) { - const env = { - ...process.env, - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: filePath }), - CLAUDE_HUMAN_TURN: humanTurn || '', - CLAUDE_PLUGIN_DATA: pluginData || TEST_DIR - }; - - const start = process.hrtime.bigint(); - execSync(`node "${TRACK_JS}"`, { - env, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] - }); - const end = process.hrtime.bigint(); - - // Return time in milliseconds - return Number(end - start) / 1_000_000; -} - -// Setup and cleanup -before(() => { - if (!fs.existsSync(TEST_DIR)) { - fs.mkdirSync(TEST_DIR, { recursive: true }); - } -}); - -after(() => { - if (fs.existsSync(TEST_DIR)) { - fs.rmSync(TEST_DIR, { recursive: true, force: true }); - } -}); - -describe('VAL-PERF-001: Execution Time', () => { - test('script execution time is under 100ms (typical)', () => { - // Warm up the node process (first run has startup overhead) - // Multiple warmup runs to ensure Node.js is fully "warmed up" - for (let i = 0; i < 3; i++) { - runTrack('C:\\Users\\test\\skills\\warmup\\SKILL.md', '', TEST_DIR); - } - - const times = []; - const iterations = 30; - - for (let i = 0; i < iterations; i++) { - const time = runTrack( - 'C:\\Users\\test\\skills\\my-skill\\SKILL.md', - '', - TEST_DIR - ); - times.push(time); - } - - // Sort and remove outliers (top 5 and bottom 5) for more accurate measurement - times.sort((a, b) => a - b); - const trimmedTimes = times.slice(5, -5); - - const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length; - const maxTime = Math.max(...trimmedTimes); - const minTime = Math.min(...trimmedTimes); - - // Log for debugging - console.log(` Execution times (trimmed): min=${minTime.toFixed(2)}ms, max=${maxTime.toFixed(2)}ms, avg=${avgTime.toFixed(2)}ms`); - console.log(` All times: ${times.map(t => t.toFixed(1)).join(', ')}ms`); - - // Typical execution should be under 100ms - // Note: Test environment has process spawn overhead that production doesn't have - // In production, the hook runs in the same process context, so actual overhead is lower - // On Windows, process spawn overhead can be 50-100ms additional - // We use 300ms as a reasonable threshold for Windows test environment - // The actual script execution (without spawn overhead) is < 10ms - assert.ok(avgTime < 300, `Average execution time ${avgTime.toFixed(2)}ms should be < 300ms (includes Windows spawn overhead)`); - - // Also verify that min time is reasonable - // On Windows, minimum spawn overhead is still significant - assert.ok(minTime < 300, `Min execution time ${minTime.toFixed(2)}ms should be < 300ms (includes Windows spawn overhead)`); - }); - - test('execution time is consistent across multiple runs', () => { - // Warm up - runTrack('C:\\Users\\test\\skills\\warmup\\SKILL.md', '', TEST_DIR); - - const times = []; - const iterations = 30; - - for (let i = 0; i < iterations; i++) { - const time = runTrack( - `C:\\Users\\test\\skills\\skill-${i}\\SKILL.md`, - '', - TEST_DIR - ); - times.push(time); - } - - const avgTime = times.reduce((a, b) => a + b, 0) / times.length; - const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length; - const stdDev = Math.sqrt(variance); - - console.log(` Consistency: avg=${avgTime.toFixed(2)}ms, stdDev=${stdDev.toFixed(2)}ms`); - - // Standard deviation should be reasonable (not highly variable) - // Allow higher std dev in test environment due to resource contention and Windows spawn variability - assert.ok(stdDev < 100, `Standard deviation ${stdDev.toFixed(2)}ms should be < 100ms (includes Windows spawn variability)`); - }); -}); - -describe('VAL-PERF-002: Memory Leaks', () => { - test('no memory accumulation over repeated executions', () => { - const iterations = 100; - const memoryBefore = process.memoryUsage().heapUsed; - - for (let i = 0; i < iterations; i++) { - runTrack( - `C:\\Users\\test\\skills\\skill-${i}\\SKILL.md`, - '', - TEST_DIR - ); - } - - const memoryAfter = process.memoryUsage().heapUsed; - const memoryDiffMB = (memoryAfter - memoryBefore) / 1024 / 1024; - - console.log(` Memory diff after ${iterations} runs: ${memoryDiffMB.toFixed(2)}MB`); - - // Memory shouldn't grow significantly (allow some variance) - // Each iteration spawns a new process, so memory should not accumulate - assert.ok(Math.abs(memoryDiffMB) < 10, `Memory diff ${memoryDiffMB.toFixed(2)}MB should be < 10MB`); - }); - - test('script does not hold references after exit', () => { - // Run multiple batches and check memory between each - const batches = 5; - const batchMemory = []; - - for (let b = 0; b < batches; b++) { - for (let i = 0; i < 20; i++) { - runTrack( - `C:\\Users\\test\\skills\\batch${b}-skill${i}\\SKILL.md`, - '', - TEST_DIR - ); - } - batchMemory.push(process.memoryUsage().heapUsed / 1024 / 1024); - } - - const maxMemory = Math.max(...batchMemory); - const minMemory = Math.min(...batchMemory); - const range = maxMemory - minMemory; - - console.log(` Memory across batches: min=${minMemory.toFixed(2)}MB, max=${maxMemory.toFixed(2)}MB, range=${range.toFixed(2)}MB`); - - // Memory should not grow consistently across batches - assert.ok(range < 15, `Memory range ${range.toFixed(2)}MB should be < 15MB`); - }); -}); - -describe('VAL-PERF-003: Large File Handling', () => { - test('append to 10k entry file completes quickly', () => { - // Create pulse.jsonl with 10,000 entries - const entryCount = 10000; - const entries = []; - - for (let i = 0; i < entryCount; i++) { - entries.push(JSON.stringify({ - skill: `skill-${i}`, - ts: Math.floor(Date.now() / 1000) - i, - trigger: i % 2 === 0 ? 'auto' : 'explicit' - })); - } - fs.writeFileSync(PULSE_FILE, entries.join('\n') + '\n', 'utf8'); - - const fileSizeMB = fs.statSync(PULSE_FILE).size / 1024 / 1024; - console.log(` Created pulse.jsonl: ${entryCount} entries, ${fileSizeMB.toFixed(2)}MB`); - - // Warm up (doesn't count toward test) - runTrack('C:\\Users\\test\\skills\\warmup\\SKILL.md', '', TEST_DIR); - - // Time the append operation - const times = []; - const appendIterations = 15; - - for (let i = 0; i < appendIterations; i++) { - const time = runTrack( - 'C:\\Users\\test\\skills\\new-skill\\SKILL.md', - '', - TEST_DIR - ); - times.push(time); - } - - // Sort and remove outliers - times.sort((a, b) => a - b); - const trimmedTimes = times.slice(2, -2); - - const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length; - const maxTime = Math.max(...trimmedTimes); - - console.log(` Append times to 10k file (trimmed): avg=${avgTime.toFixed(2)}ms, max=${maxTime.toFixed(2)}ms`); - - // Append should be quick even with large file - // Note: Windows process spawn overhead adds 50-100ms to each execution - // The actual file append is O(1), but we need to account for spawn overhead - assert.ok(avgTime < 150, `Average append time ${avgTime.toFixed(2)}ms should be < 150ms (includes Windows spawn overhead)`); - - // Verify entry was actually appended - // Note: 1 warmup + appendIterations = total new entries - const lineCount = fs.readFileSync(PULSE_FILE, 'utf8') - .split('\n') - .filter(line => line.trim().length > 0).length; - - // 1 warmup entry + appendIterations entries = total expected - assert.strictEqual(lineCount, entryCount + 1 + appendIterations, 'Entry count should match'); - }); - - test('append performance does not degrade with file size', () => { - // Test with progressively larger files - const sizes = [100, 1000, 5000, 10000]; - const avgTimes = []; - - // Warm up - runTrack('C:\\Users\\test\\skills\\warmup\\SKILL.md', '', TEST_DIR); - - for (const size of sizes) { - // Clean up and create new file - if (fs.existsSync(PULSE_FILE)) { - fs.unlinkSync(PULSE_FILE); - } - - const entries = []; - for (let i = 0; i < size; i++) { - entries.push(JSON.stringify({ - skill: `skill-${i}`, - ts: Math.floor(Date.now() / 1000) - i, - trigger: 'auto' - })); - } - fs.writeFileSync(PULSE_FILE, entries.join('\n') + '\n', 'utf8'); - - // Measure append time - const times = []; - for (let i = 0; i < 10; i++) { - times.push(runTrack( - `C:\\Users\\test\\skills\\test-skill\\SKILL.md`, - '', - TEST_DIR - )); - } - - // Remove outliers - times.sort((a, b) => a - b); - const trimmedTimes = times.slice(1, -1); - - const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length; - avgTimes.push(avgTime); - console.log(` File with ${size} entries: avg append time = ${avgTime.toFixed(2)}ms`); - } - - // Performance should not degrade significantly (within 2x) - const minTime = Math.min(...avgTimes); - const maxTime = Math.max(...avgTimes); - const ratio = maxTime / minTime; - - console.log(` Performance ratio (max/min): ${ratio.toFixed(2)}x`); - - // All times should be under 150ms regardless of file size (includes Windows spawn overhead) - assert.ok(maxTime < 150, `Max append time ${maxTime.toFixed(2)}ms should be < 150ms (includes Windows spawn overhead)`); - - // Since we use append-only, performance should be relatively constant - // Allow some variance but not 3x degradation - assert.ok(ratio < 3, `Performance ratio ${ratio.toFixed(2)}x should be < 3x`); - }); -}); diff --git a/scripts/track.sh b/scripts/track.sh deleted file mode 100644 index d0baafb..0000000 --- a/scripts/track.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# SkillPulse - Track skill usage when SKILL.md files are read -# This hook fires after every Read tool call - -# Debug: log env vars to verify they exist -# env | grep CLAUDE >> /tmp/skillpulse-debug.log - -# CLAUDE_TOOL_INPUT contains the Read tool's arguments as JSON -FILE=$(echo "$CLAUDE_TOOL_INPUT" | grep -o '"file_path":"[^"]*"' | cut -d'"' -f4) - -# Only track if reading a SKILL.md file -if [[ "$FILE" == *"/SKILL.md" ]]; then - SKILL_NAME=$(basename "$(dirname "$FILE")") - - # Determine trigger type - TRIGGER="auto" - # Check if invoked via slash command by reading user input - if [[ "$CLAUDE_HUMAN_TURN" == *"/$SKILL_NAME"* ]]; then - TRIGGER="explicit" - fi - - # Log to plugin data directory - echo "{\"skill\":\"$SKILL_NAME\",\"ts\":$(date +%s),\"trigger\":\"$TRIGGER\"}" \ - >> "${CLAUDE_PLUGIN_DATA}/pulse.jsonl" -fi diff --git a/scripts/track.test.js b/scripts/track.test.js deleted file mode 100644 index e13ae91..0000000 --- a/scripts/track.test.js +++ /dev/null @@ -1,1069 +0,0 @@ -/** - * Unit tests for track.js - SkillPulse tracking script - * - * Tests cover: - * - SKILL.md detection (forward slash, backslash, mixed) - * - Skill name extraction from various path depths - * - Trigger classification (explicit vs auto) - * - JSONL output format validation - * - Error handling (missing env vars, invalid input) - * - Cross-platform path handling - * - * Uses Node.js built-in test runner (node:test) and assertions (node:assert). - * Tests are isolated and deterministic - no shared state between tests. - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execSync, spawn } = require('child_process'); -const os = require('os'); - -// Path to the script under test -const TRACK_SCRIPT_PATH = path.join(__dirname, 'track.js'); - -/** - * Helper to create a unique temp directory for each test - * This ensures test isolation - */ -function createTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'track-test-')); -} - -/** - * Helper to run track.js with given environment variables - * Returns { stdout, stderr, exitCode } - */ -function runTrack(env) { - const result = { - stdout: '', - stderr: '', - exitCode: null - }; - - try { - result.stdout = execSync( - `node "${TRACK_SCRIPT_PATH}"`, - { - env: { ...process.env, ...env }, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 - } - ); - } catch (error) { - result.stdout = error.stdout || ''; - result.stderr = error.stderr || ''; - result.exitCode = error.status; - return result; - } - - result.exitCode = 0; - return result; -} - -/** - * Helper to read pulse.jsonl from a directory - * Returns array of parsed entries - */ -function readPulseFile(dir) { - const pulsePath = path.join(dir, 'pulse.jsonl'); - if (!fs.existsSync(pulsePath)) { - return null; - } - const content = fs.readFileSync(pulsePath, 'utf8'); - return content.trim().split('\n').map(line => JSON.parse(line)); -} - -// ============================================================================ -// SKILL.md DETECTION TESTS (VAL-TEST-002, VAL-PLAT-001, VAL-PLAT-002, VAL-PLAT-003) -// ============================================================================ - -describe('SKILL.md detection', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - // Cleanup temp directory - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('detects SKILL.md with forward slash path (Unix-style)', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/home/user/.claude/skills/careful/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - assert.ok(entries, 'pulse.jsonl should be created'); - assert.strictEqual(entries.length, 1, 'Should have one entry'); - assert.strictEqual(entries[0].skill, 'careful', 'Skill name should be "careful"'); - }); - - test('detects SKILL.md with backslash path (Windows-style)', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 'C:\\Users\\name\\.claude\\skills\\careful\\SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.ok(entries, 'pulse.jsonl should be created'); - assert.strictEqual(entries.length, 1, 'Should have one entry'); - assert.strictEqual(entries[0].skill, 'careful', 'Skill name should be extracted from backslash path'); - }); - - test('detects SKILL.md with mixed separators', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 'C:/Users\\name/.claude\\skills/careful/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.ok(entries, 'pulse.jsonl should be created'); - assert.strictEqual(entries.length, 1, 'Should have one entry'); - assert.strictEqual(entries[0].skill, 'careful', 'Skill name should be extracted from mixed path'); - }); - - test('is case-sensitive for SKILL.md (lowercase skill.md not detected)', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/careful/skill.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(entries, null, 'pulse.jsonl should NOT be created for lowercase skill.md'); - }); - - test('is case-sensitive for SKILL.md (SKILL.MD uppercase not detected)', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/careful/SKILL.MD' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(entries, null, 'pulse.jsonl should NOT be created for uppercase SKILL.MD'); - }); - - test('ignores non-SKILL.md files', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/home/user/README.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(entries, null, 'pulse.jsonl should NOT be created for README.md'); - }); - - test('ignores files that end with SKILL.md but are not exact match', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/home/user/NOTSKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(entries, null, 'pulse.jsonl should NOT be created'); - }); -}); - -// ============================================================================ -// SKILL NAME EXTRACTION TESTS (VAL-TRACK-002, VAL-TRACK-009, VAL-TRACK-010) -// ============================================================================ - -describe('Skill name extraction', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('extracts skill name from simple path', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/my-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].skill, 'my-skill'); - }); - - test('extracts skill name from deeply nested path', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/a/b/c/d/e/deep-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].skill, 'deep-skill', 'Should extract immediate parent directory'); - }); - - test('extracts skill name with hyphens', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/my-skill-123/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].skill, 'my-skill-123'); - }); - - test('extracts skill name with underscores', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/skill_name/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].skill, 'skill_name'); - }); - - test('extracts skill name with numbers', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/skill123/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].skill, 'skill123'); - }); - - test('extracts skill name with mixed special characters', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/my-skill_v2-final/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].skill, 'my-skill_v2-final'); - }); - - test('extracts skill name from Windows UNC path', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '\\\\server\\share\\skills\\network-skill\\SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].skill, 'network-skill'); - }); -}); - -// ============================================================================ -// TRIGGER CLASSIFICATION TESTS (VAL-TEST-003, VAL-TRACK-004, VAL-TRACK-005) -// ============================================================================ - -describe('Trigger classification', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('classifies explicit trigger when user message contains /skillname', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/careful/SKILL.md' }), - CLAUDE_HUMAN_TURN: 'Please run /careful on this code', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].trigger, 'explicit'); - }); - - test('classifies auto trigger when user message does not contain skill name', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/careful/SKILL.md' }), - CLAUDE_HUMAN_TURN: 'Please analyze this code', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].trigger, 'auto'); - }); - - test('classifies auto trigger when CLAUDE_HUMAN_TURN is empty', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/careful/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].trigger, 'auto'); - }); - - test('classifies auto trigger when CLAUDE_HUMAN_TURN is undefined', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/careful/SKILL.md' }), - // CLAUDE_HUMAN_TURN not set - CLAUDE_PLUGIN_DATA: tempDir - }; - delete env.CLAUDE_HUMAN_TURN; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].trigger, 'auto'); - }); - - test('classifies auto when user message contains different skill name', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/careful/SKILL.md' }), - CLAUDE_HUMAN_TURN: 'Please run /freeze on this code', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - // Reading 'careful' but user asked for 'freeze' - this is auto - assert.strictEqual(entries[0].trigger, 'auto'); - }); - - test('classifies explicit for skill name with hyphens', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/my-skill-123/SKILL.md' }), - CLAUDE_HUMAN_TURN: 'Run /my-skill-123 please', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].trigger, 'explicit'); - }); - - test('classifies explicit for skill name with underscores', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/skill_name/SKILL.md' }), - CLAUDE_HUMAN_TURN: 'Run /skill_name please', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(entries[0].trigger, 'explicit'); - }); -}); - -// ============================================================================ -// JSONL OUTPUT FORMAT TESTS (VAL-TEST-004, VAL-TRACK-006) -// ============================================================================ - -describe('JSONL output format', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('outputs valid JSON on single line', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - const content = fs.readFileSync(pulsePath, 'utf8'); - - // Should be valid JSON - const parsed = JSON.parse(content.trim()); - assert.ok(parsed, 'Output should be valid JSON'); - - // Should be single line (no newlines within the JSON) - assert.strictEqual(content.trim().split('\n').length, 1, 'Should be single line'); - }); - - test('contains all required fields', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.ok('skill' in entries[0], 'Should have skill field'); - assert.ok('ts' in entries[0], 'Should have ts field'); - assert.ok('trigger' in entries[0], 'Should have trigger field'); - assert.strictEqual(Object.keys(entries[0]).length, 3, 'Should have exactly 3 fields'); - }); - - test('skill field is string', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(typeof entries[0].skill, 'string'); - }); - - test('ts field is integer unix timestamp', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const beforeTs = Math.floor(Date.now() / 1000); - const result = runTrack(env); - const afterTs = Math.floor(Date.now() / 1000); - const entries = readPulseFile(tempDir); - - assert.strictEqual(typeof entries[0].ts, 'number'); - assert.ok(Number.isInteger(entries[0].ts), 'ts should be integer'); - assert.ok(entries[0].ts >= beforeTs, 'ts should be >= before timestamp'); - assert.ok(entries[0].ts <= afterTs, 'ts should be <= after timestamp'); - }); - - test('trigger field is "auto" or "explicit"', () => { - // Test auto - let env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - let result = runTrack(env); - let entries = readPulseFile(tempDir); - assert.ok(['auto', 'explicit'].includes(entries[0].trigger)); - - // Cleanup and test explicit - fs.rmSync(tempDir, { recursive: true, force: true }); - tempDir = createTempDir(); - - env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '/test-skill', - CLAUDE_PLUGIN_DATA: tempDir - }; - - result = runTrack(env); - entries = readPulseFile(tempDir); - assert.ok(['auto', 'explicit'].includes(entries[0].trigger)); - }); - - test('appends to existing pulse.jsonl', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/skill-one/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - // First write - runTrack(env); - - // Second write with different skill - env.CLAUDE_TOOL_INPUT = JSON.stringify({ file_path: '/skills/skill-two/SKILL.md' }); - runTrack(env); - - const entries = readPulseFile(tempDir); - assert.strictEqual(entries.length, 2, 'Should have 2 entries'); - assert.strictEqual(entries[0].skill, 'skill-one'); - assert.strictEqual(entries[1].skill, 'skill-two'); - }); - - test('creates pulse.jsonl if it does not exist', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/new-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - // Ensure pulse.jsonl does not exist - const pulsePath = path.join(tempDir, 'pulse.jsonl'); - assert.ok(!fs.existsSync(pulsePath), 'pulse.jsonl should not exist initially'); - - const result = runTrack(env); - assert.ok(fs.existsSync(pulsePath), 'pulse.jsonl should be created'); - }); -}); - -// ============================================================================ -// ERROR HANDLING TESTS (VAL-TEST-005, VAL-ERR-001 through VAL-ERR-010) -// ============================================================================ - -describe('Error handling', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('exits with code 0 on success (no output)', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles missing CLAUDE_PLUGIN_DATA gracefully', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '' - // CLAUDE_PLUGIN_DATA not set - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles empty CLAUDE_PLUGIN_DATA gracefully', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: '' - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles missing CLAUDE_TOOL_INPUT gracefully', () => { - const env = { - // CLAUDE_TOOL_INPUT not set - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles empty CLAUDE_TOOL_INPUT gracefully', () => { - const env = { - CLAUDE_TOOL_INPUT: '', - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles malformed JSON in CLAUDE_TOOL_INPUT gracefully', () => { - const env = { - CLAUDE_TOOL_INPUT: '{not valid json', - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles CLAUDE_TOOL_INPUT with non-object value', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify('not an object'), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles CLAUDE_TOOL_INPUT with null value', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify(null), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles CLAUDE_TOOL_INPUT with missing file_path field', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ other_field: 'value' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles CLAUDE_TOOL_INPUT with non-string file_path', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 123 }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles CLAUDE_TOOL_INPUT with empty file_path', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); - - test('handles write permission denied gracefully', () => { - // Create a read-only directory - const readOnlyDir = path.join(tempDir, 'readonly'); - fs.mkdirSync(readOnlyDir); - - // Make directory read-only (works on Unix-like systems) - if (process.platform !== 'win32') { - fs.chmodSync(readOnlyDir, 0o555); - - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: readOnlyDir - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - - // Restore permissions for cleanup - fs.chmodSync(readOnlyDir, 0o755); - } else { - // On Windows, skip this test as chmod behaves differently - assert.ok(true, 'Skipped on Windows'); - } - }); - - test('handles CLAUDE_HUMAN_TURN being null', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: null, - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(entries[0].trigger, 'auto', 'Should default to auto trigger'); - }); - - test('handles non-existent CLAUDE_PLUGIN_DATA directory', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/test-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: '/nonexistent/path/that/does/not/exist' - }; - - const result = runTrack(env); - - assert.strictEqual(result.exitCode, 0, 'Exit code should be 0'); - assert.strictEqual(result.stdout, '', 'Should produce no stdout'); - assert.strictEqual(result.stderr, '', 'Should produce no stderr'); - }); -}); - -// ============================================================================ -// CROSS-PLATFORM PATH HANDLING TESTS (VAL-TEST-006) -// ============================================================================ - -describe('Cross-platform path handling', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles Unix absolute path', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/home/user/.claude/skills/unix-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].skill, 'unix-skill'); - }); - - test('handles Windows drive letter path', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 'C:\\Users\\user\\.claude\\skills\\win-skill\\SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].skill, 'win-skill'); - }); - - test('handles relative path with forward slashes', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 'skills/relative-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].skill, 'relative-skill'); - }); - - test('handles relative path with backslashes', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 'skills\\relative-skill\\SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].skill, 'relative-skill'); - }); - - test('handles path with forward slashes after backslashes', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 'C:\\Users\\user/skills/mixed-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].skill, 'mixed-skill'); - }); - - test('handles very long file paths', () => { - // Create a very long path (> 260 characters, Windows limit) - const longPath = '/a/' + 'very_long_directory_name_'.repeat(20) + '/skill/SKILL.md'; - - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: longPath }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - // Should handle without crashing - assert.strictEqual(result.exitCode, 0); - - const entries = readPulseFile(tempDir); - assert.strictEqual(entries[0].skill, 'skill'); - }); - - test('handles path with spaces in skill name', () => { - // Note: This tests the path handling, though skill names with spaces are unusual - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/skill with spaces/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].skill, 'skill with spaces'); - }); -}); - -// ============================================================================ -// ISOLATION AND DETERMINISM TESTS (VAL-TEST-007) -// ============================================================================ - -describe('Test isolation and determinism', () => { - test('tests use unique temp directories', () => { - const tempDirs = []; - - // Create multiple temp dirs - for (let i = 0; i < 5; i++) { - const dir = createTempDir(); - tempDirs.push(dir); - } - - // All should be unique - const uniqueDirs = new Set(tempDirs); - assert.strictEqual(uniqueDirs.size, 5, 'Each temp dir should be unique'); - - // Cleanup - tempDirs.forEach(dir => fs.rmSync(dir, { recursive: true, force: true })); - }); - - test('same input produces same output (deterministic)', () => { - const tempDir1 = createTempDir(); - const tempDir2 = createTempDir(); - - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/deterministic-skill/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir1 - }; - - runTrack(env); - const entries1 = readPulseFile(tempDir1); - - env.CLAUDE_PLUGIN_DATA = tempDir2; - runTrack(env); - const entries2 = readPulseFile(tempDir2); - - // Same skill and trigger - assert.strictEqual(entries1[0].skill, entries2[0].skill); - assert.strictEqual(entries1[0].trigger, entries2[0].trigger); - - // Timestamps should be very close (within 1 second) - const tsDiff = Math.abs(entries1[0].ts - entries2[0].ts); - assert.ok(tsDiff <= 1, 'Timestamps should be within 1 second'); - - // Cleanup - fs.rmSync(tempDir1, { recursive: true, force: true }); - fs.rmSync(tempDir2, { recursive: true, force: true }); - }); - - test('tests do not affect each other (no shared state)', () => { - // Run two tests in sequence with different skills - let tempDir1 = createTempDir(); - - // First test - let env1 = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/isolated-a/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir1 - }; - runTrack(env1); - let entries1 = readPulseFile(tempDir1); - assert.strictEqual(entries1.length, 1); - assert.strictEqual(entries1[0].skill, 'isolated-a'); - - // Cleanup and run second test with completely separate directory - fs.rmSync(tempDir1, { recursive: true, force: true }); - - let tempDir2 = createTempDir(); - let env2 = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/isolated-b/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir2 - }; - runTrack(env2); - let entries2 = readPulseFile(tempDir2); - - // Should only have isolated-b, not isolated-a - assert.strictEqual(entries2.length, 1); - assert.strictEqual(entries2[0].skill, 'isolated-b'); - - // Cleanup - fs.rmSync(tempDir2, { recursive: true, force: true }); - }); -}); - -// ============================================================================ -// EDGE CASES -// ============================================================================ - -describe('Edge cases', () => { - let tempDir; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - test('handles SKILL.md at root level (no parent directory)', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: 'SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - // Should exit silently without creating log entry - assert.strictEqual(result.exitCode, 0); - const entries = readPulseFile(tempDir); - assert.strictEqual(entries, null, 'Should not create entry for root-level SKILL.md'); - }); - - test('handles path ending with just directory separator before SKILL.md', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills//SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - - // Should handle gracefully - assert.strictEqual(result.exitCode, 0); - }); - - test('handles unicode in file paths', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/技能测试/SKILL.md' }), - CLAUDE_HUMAN_TURN: '', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].skill, '技能测试'); - }); - - test('handles CLAUDE_HUMAN_TURN with unicode characters', () => { - const env = { - CLAUDE_TOOL_INPUT: JSON.stringify({ file_path: '/skills/技能/SKILL.md' }), - CLAUDE_HUMAN_TURN: '请运行 /技能', - CLAUDE_PLUGIN_DATA: tempDir - }; - - const result = runTrack(env); - const entries = readPulseFile(tempDir); - - assert.strictEqual(result.exitCode, 0); - assert.strictEqual(entries[0].trigger, 'explicit'); - }); -}); diff --git a/skills/export/SKILL.md b/skills/export/SKILL.md deleted file mode 100644 index dffb721..0000000 --- a/skills/export/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: export -description: Export pulse.jsonl analytics data in JSON or CSV format. Usage: /skillpulse:export [json|csv] -disable-model-invocation: true -allowed-tools: Read, Bash ---- - -# SkillPulse Export - -Export analytics data from `${CLAUDE_PLUGIN_DATA}/pulse.jsonl` in JSON or CSV format. - -## Usage - -``` -/skillpulse:export # Default: JSON format -/skillpulse:export json # JSON array of entries -/skillpulse:export csv # CSV with headers: skill,ts,trigger -``` - -## Implementation - -Run the export script: - -```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/export.js" [json|csv] -``` - -The script: -1. Reads all entries from `${CLAUDE_PLUGIN_DATA}/pulse.jsonl` -2. Parses each JSON line (skips corrupted entries) -3. Outputs in the requested format - -## Output Formats - -### JSON (default) -```json -[ - {"skill":"careful","ts":1711234567,"trigger":"auto"}, - {"skill":"freeze","ts":1711234568,"trigger":"explicit"} -] -``` - -### CSV -``` -skill,ts,trigger -careful,1711234567,auto -freeze,1711234568,explicit -``` - -## Edge Cases - -- **Empty data**: JSON returns `[]`, CSV returns headers only -- **Missing pulse.jsonl**: Same as empty data -- **Corrupted entries**: Skipped silently, valid entries preserved - -## Use Cases - -- Export to file: `/skillpulse:export json > analytics.json` -- CSV for spreadsheets: `/skillpulse:export csv > analytics.csv` -- Data backup before reset diff --git a/skills/pulse/README.md b/skills/pulse/README.md new file mode 100644 index 0000000..426606e --- /dev/null +++ b/skills/pulse/README.md @@ -0,0 +1,60 @@ +# Skill Stats + +> Analyze which Claude Code skills you actually use, which are effective, and which should be removed. + +## Features + +- **Usage tracking** — See which skills you use most (24h, 7d, 30d, or all-time) +- **Effectiveness analysis** — Success/failure rates per skill +- **Cleanup recommendations** — Identifies unused skills to remove +- **One-command analysis** — Just run `/skill-stats` + +## Usage + +```bash +# Default: 7 days +/skill-stats + +# Specific period +/skill-stats 24h # Today +/skill-stats 7d # Week (default) +/skill-stats 30d # Month +/skill-stats all # All time +``` + +## Output Example + +``` +## Skill Usage Analysis (7 days) + +📊 **39 skills installed** + +**11 used** | **28 unused** + +### 📈 Most Used + +| Skill | Calls | Status | +|-------|-------|--------| +| /careful | 98 | ✅ Active | +| /freeze | 18 | ✅ Active | +| /ship | 13 | ✅ Active | + +### 🗑️ Unused Skills (28) + +These haven't been used in the selected period: +- /baseline-ui, /benchmark, /browse, /codex... +``` + +## How It Works + +Reads analytics from `~/.gstack/analytics/skill-usage.jsonl` which gstack skills automatically track. + +## Installation + +1. Copy to `~/.claude/skills/skill-stats/` +2. Reload Claude Code +3. Run `/skill-stats` + +## License + +MIT diff --git a/skills/pulse/SKILL.md b/skills/pulse/SKILL.md index 3c61ea1..89f4564 100644 --- a/skills/pulse/SKILL.md +++ b/skills/pulse/SKILL.md @@ -1,53 +1,68 @@ --- name: pulse -description: Show which Claude Code skills you use most. Usage: /skillpulse:pulse [24h|7d|30d|all] -disable-model-invocation: true -allowed-tools: Read, Bash +version: 1.0.0 +description: | + See your Claude Code skills come alive. Track usage, discover patterns, + identify unused skills, and keep your setup lean. + + Use when: "skill usage", "which skills do i use", "skill stats", "pulse" +allowed-tools: + - Bash + - Read --- -# SkillPulse Analytics +## SkillPulse Analytics -Read the analytics log at `${CLAUDE_PLUGIN_DATA}/pulse.jsonl` +Track your skill usage with beautiful, actionable insights. -Parse each line as JSON with fields: skill, ts (unix timestamp), trigger +### Quick Commands -Filter by time period from the command argument (default: 7d): -- 24h = last 86400 seconds -- 7d = last 604800 seconds -- 30d = last 2592000 seconds -- all = no filter +```bash +# Default: 7 days pulse +~/.claude/plugins/skillpulse/skills/pulse/bin/pulse.sh -Then scan `${CLAUDE_PLUGINS_DIR}` and `~/.claude/skills/` for all installed skill folders (directories containing SKILL.md). This gives you total installed count. +# Time periods +~/.claude/plugins/skillpulse/skills/pulse/bin/pulse.sh 24h # Today +~/.claude/plugins/skillpulse/skills/pulse/bin/pulse.sh 7d # Week +~/.claude/plugins/skillpulse/skills/pulse/bin/pulse.sh 30d # Month +~/.claude/plugins/skillpulse/skills/pulse/bin/pulse.sh all # All time +``` -Output this exact format to terminal: +### Output Format ``` -╭──────────────────────────────────────────╮ -│ skillpulse • Last 7 days │ -├──────────────────────────────────────────┤ -│ {total} skills • {used} used • {unused} unused │ -│ │ -│ 🔥 Hot │ -│ /{skill} {n} calls {bar} │ -│ ... │ -│ │ -│ ❄️ Cold ({unused} unused) │ -│ /{skill}, /{skill}, ... │ -╰──────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────╮ +│ SkillPulse • Last 7 days │ +├─────────────────────────────────────────────────────┤ +│ 📊 39 skills • 11 used • 28 unused │ +│ │ +│ 🔥 Hot │ +│ /careful 98 calls ████████████████████ │ +│ /freeze 18 calls ███ │ +│ /ship 13 calls ██ │ +│ │ +│ ❄️ Cold (28 unused) │ +│ /baseline-ui, /benchmark, /browse... │ +╰─────────────────────────────────────────────────────╯ + +💡 Remove unused: rm -rf ~/.claude/skills/SKILL_NAME ``` -Bar = "█" characters, max 20, proportional to top skill count. -Cold = skills installed but zero invocations in the period. - -## Implementation Steps - -1. Read `${CLAUDE_PLUGIN_DATA}/pulse.jsonl` (create empty if doesn't exist) -2. Calculate cutoff timestamp based on period -3. Parse each JSON line, filter by timestamp -4. Count invocations per skill -5. Scan for installed skills: - - List directories in `${CLAUDE_PLUGINS_DIR}` containing `skills/` subdirs - - List directories in `~/.claude/skills/` - - Extract skill names from folder names containing SKILL.md -6. Render output with ASCII box formatting -7. Show cleanup hint: `💡 Remove unused: rm -rf ~/.claude/skills/SKILL_NAME` +### For Skill Authors + +Add pulse tracking to your skill — add this after your frontmatter: + +```markdown +## Pulse + +```bash +# Log skill invocation (silent) +echo "{\"skill\":\"YOUR_SKILL_NAME\",\"ts\":$(date +%s)}" >> ~/.claude/skills/pulse.jsonl 2>/dev/null || true +``` +``` + +### Analytics File + +Data stored at: `~/.claude/skills/pulse.jsonl` + +Format: `{"skill":"name","ts":1234567890}` diff --git a/skills/pulse/bin/pulse.sh b/skills/pulse/bin/pulse.sh new file mode 100644 index 0000000..1d00a84 --- /dev/null +++ b/skills/pulse/bin/pulse.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# SkillPulse — See your Claude Code skills come alive +# Analytics: ~/.claude/skills/pulse.jsonl + +set -euo pipefail + +ANALYTICS_FILE="$HOME/.claude/skills/pulse.jsonl" +SKILLS_DIR="$HOME/.claude/skills" +PERIOD="${1:-7d}" +NOW=$(date +%s) + +# Period variants - explicit functions for each period +today_cutoff() { echo $((NOW - 86400)); } +week_cutoff() { echo $((NOW - 604800)); } +month_cutoff() { echo $((NOW - 2592000)); } +all_cutoff() { echo 0; } + +# Period registry +resolve_cutoff() { + case "$1" in + 24h|today) today_cutoff; echo "today" ;; + 7d|week|"") week_cutoff; echo "7 days" ;; + 30d|month) month_cutoff; echo "30 days" ;; + all|ever) all_cutoff; echo "all time" ;; + *) week_cutoff; echo "7 days" ;; + esac +} + +# Resolve period variant +RESULT=$(resolve_cutoff "$PERIOD") +CUTOFF=$(echo "$RESULT" | head -1) +LABEL=$(echo "$RESULT" | tail -1) + +# Ensure analytics directory exists +mkdir -p "$(dirname "$ANALYTICS_FILE")" + +# Count total skills +TOTAL_SKILLS=$(ls -A "$SKILLS_DIR" 2>/dev/null | wc -l) + +# Get usage data within period (single-pass awk) +USAGE_DATA=$(awk -v cutoff="$CUTOFF" ' + /"ts":[0-9]+/ { + match($0, /"ts":([0-9]+)/, a); ts = a[1] + if (ts >= cutoff) { + match($0, /"skill":"([^"]+)"/, a) + if (a[1] != "") skills[a[1]]++ + } + } + END { for (s in skills) print skills[s], s } +' "$ANALYTICS_FILE" 2>/dev/null | sort -rn) + +# Count used skills +if [ -n "$USAGE_DATA" ]; then + USED_SKILLS=$(echo "$USAGE_DATA" | wc -l) + MAX_CALLS=$(echo "$USAGE_DATA" | head -1 | awk '{print $1}') +else + USED_SKILLS=0 + MAX_CALLS=1 +fi + +UNUSED_SKILLS=$((TOTAL_SKILLS - USED_SKILLS)) + +# Render output +render_header() { + local label="$1" + local total="$2" + local used="$3" + local unused="$4" + + echo "╭─────────────────────────────────────────────────────╮" + echo "│ SkillPulse • Last $label" | tr -d '\n' | head -c 54 && echo " │" + echo "├─────────────────────────────────────────────────────┤" + echo "│ 📊 $total skills • $used used • $unused unused" | tr -d '\n' | head -c 54 && echo " │" + echo "│ │" + echo "│ 🔥 Hot │" + echo "│ ──────────────────────────────────────────────────── │" +} + +render_skill_bar() { + local name="$1" + local count="$2" + local max="$3" + + local bar_length=$((count * 40 / max)) + [ "$bar_length" -gt 40 ] && bar_length=40 + local bar=$(printf '█%.0s' $(seq 1 $bar_length)) + + printf "│ /%-12s %3d calls %-30s │\n" "$name" "$count" "$bar" +} + +render_cold() { + local unused="$1" + + echo "│ │" + echo "│ ❄️ Cold ($unused unused) │" + echo "│ ──────────────────────────────────────────────────── │" +} + +render_footer() { + echo "╰─────────────────────────────────────────────────────╯" + echo "" + echo "💡 Remove unused: rm -rf ~/.claude/skills/SKILL_NAME" + echo "💡 Usage: /pulse [24h|7d|30d|all]" +} + +# Main render +render_header "$LABEL" "$TOTAL_SKILLS" "$USED_SKILLS" "$UNUSED_SKILLS" + +if [ -n "$USAGE_DATA" ]; then + echo "$USAGE_DATA" | head -5 | while read -r count skill; do + [ -z "$skill" ] && continue + [ "$skill" = '""' ] && continue + render_skill_bar "$skill" "$count" "$MAX_CALLS" + done +fi + +render_cold "$UNUSED_SKILLS" + +# Pre-compute used skill names for O(1) lookup +USED_SKILL_NAMES=$(echo "$USAGE_DATA" | awk '{print $2}' | sort -u) + +# Get first few unused skills +UNUSED_COUNT=0 +for skill_dir in "$SKILLS_DIR"/*/; do + [ -d "$skill_dir" ] || continue + skill_name=$(basename "$skill_dir") + + if echo "$USED_SKILL_NAMES" | grep -qx "$skill_name"; then + continue + fi + + [ "$skill_name" = "pulse" ] && continue + + if [ "$UNUSED_COUNT" -lt 3 ]; then + printf "│ /%-13s │\n" "$skill_name" + UNUSED_COUNT=$((UNUSED_COUNT + 1)) + fi +done + +if [ "$UNUSED_SKILLS" -gt 3 ]; then + echo "│ ... and $((UNUSED_SKILLS - 3)) more │" +fi + +render_footer diff --git a/skills/pulse/skill.json b/skills/pulse/skill.json new file mode 100644 index 0000000..20f547a --- /dev/null +++ b/skills/pulse/skill.json @@ -0,0 +1,12 @@ +{ + "name": "skill-stats", + "version": "1.0.0", + "description": "Analyze skill usage patterns to show which skills you actually use, which are effective, and which should be removed.", + "author": "Your Name", + "homepage": "https://github.com/doanbactam/skill-stats", + "license": "MIT", + "tags": ["analytics", "productivity", "skills", "cli"], + "categories": ["Developer Tools", "Productivity"], + "works_with": ["claude-code"], + "analytic": true +} diff --git a/src/handlers.js b/src/handlers.js new file mode 100644 index 0000000..d537716 --- /dev/null +++ b/src/handlers.js @@ -0,0 +1,102 @@ +/** + * MCP Tool Handlers + * Compound structure - each handler is self-contained with its own schema + */ + +import * as Storage from './storage.js'; +import { getPeriod } from './periods.js'; + +// Log pulse tool +export const LogPulse = { + name: 'log_pulse', + description: 'Log when a skill is used for SkillPulse analytics', + schema: { + type: 'object', + properties: { + skill: { type: 'string', description: 'Name of the skill being used' }, + outcome: { + type: 'string', + enum: ['success', 'error', 'abort'], + description: 'Outcome of the skill execution', + }, + }, + required: ['skill'], + }, + handle(args) { + const { skill, outcome = 'success' } = args; + const entry = { + skill, + outcome, + ts: Math.floor(Date.now() / 1000), + pid: process.pid, + }; + Storage.appendEntry(entry); + return { + content: [{ type: 'text', text: `Logged usage for skill: ${skill}` }], + }; + }, +}; + +// Get skill stats tool +export const GetSkillStats = { + name: 'get_skill_stats', + description: 'Get skill usage statistics', + schema: { + type: 'object', + properties: { + period: { + type: 'string', + enum: ['24h', '7d', '30d', 'all'], + description: 'Time period for stats', + }, + }, + }, + handle(args) { + const { period = '7d' } = args; + const variant = getPeriod(period); + const now = Math.floor(Date.now() / 1000); + const cutoff = variant.cutoff(now); + + const entries = [...Storage.readEntriesSince(cutoff)]; + const stats = Storage.aggregateStats(entries); + + const sorted = Object.entries(stats) + .map(([skill, data]) => ({ skill, ...data })) + .sort((a, b) => b.calls - a.calls); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ period, stats: sorted }, null, 2), + }], + }; + }, +}; + +// List skills tool +export const ListSkills = { + name: 'list_skills', + description: 'List all installed skills with descriptions', + schema: { + type: 'object', + properties: {}, + }, + handle() { + const skills = []; + for (const name of Storage.listInstalledSkills()) { + skills.push({ + name, + description: Storage.readSkillDescription(name), + }); + } + return { + content: [{ + type: 'text', + text: JSON.stringify(skills, null, 2), + }], + }; + }, +}; + +// Tool registry +export const Tools = [LogPulse, GetSkillStats, ListSkills]; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..1f1f36d --- /dev/null +++ b/src/index.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * SkillPulse MCP Server + * + * Track your Claude Code skills. See usage patterns, identify unused skills. + * Analytics stored at: ~/.claude/skills/pulse.jsonl + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Tools } from './handlers.js'; + +// Create server +const server = new Server( + { + name: 'skillpulse', + version: '1.0.0', + }, + { + capabilities: {}, + } +); + +// List tools - compound structure, self-documenting +server.setRequestHandler('tools/list', async () => ({ + tools: Tools.map(({ name, description, schema }) => ({ + name, + description, + inputSchema: schema, + })), +})); + +// Handle tool calls - delegate to respective handler +server.setRequestHandler('tools/call', async (request) => { + const { name, arguments: args } = request.params; + const tool = Tools.find((t) => t.name === name); + + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + return tool.handle(args); +}); + +// Start server +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Claude Skill Analytics MCP Server running'); +} + +main().catch(console.error); diff --git a/src/periods.js b/src/periods.js new file mode 100644 index 0000000..2652e81 --- /dev/null +++ b/src/periods.js @@ -0,0 +1,56 @@ +/** + * Period Variants + * Explicit period handlers instead of switch/case with magic strings + */ + +const SECONDS = { + DAY: 86400, + WEEK: 604800, + MONTH: 2592000, +}; + +// Period variant: Today (24 hours) +export const Today = { + name: '24h', + label: 'today', + cutoff: (now) => now - SECONDS.DAY, +}; + +// Period variant: This week (7 days) +export const Week = { + name: '7d', + label: '7 days', + cutoff: (now) => now - SECONDS.WEEK, +}; + +// Period variant: This month (30 days) +export const Month = { + name: '30d', + label: '30 days', + cutoff: (now) => now - SECONDS.MONTH, +}; + +// Period variant: All time +export const AllTime = { + name: 'all', + label: 'all time', + cutoff: () => 0, +}; + +// Period registry for lookup +export const Periods = { + '24h': Today, + '7d': Week, + '30d': Month, + 'all': AllTime, + // Aliases + today: Today, + week: Week, + month: Month, + ever: AllTime, +}; + +// Get period variant, defaulting to Week +export function getPeriod(key = '7d') { + return Periods[key] ?? Week; +} diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..adda4ef --- /dev/null +++ b/src/storage.js @@ -0,0 +1,79 @@ +/** + * Analytics Storage Layer + * Decoupled from presentation - can swap implementations without changing tools + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export const ANALYTICS_FILE = path.join(os.homedir(), '.claude', 'skills', 'pulse.jsonl'); +export const SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills'); + +// Ensure directory exists (idempotent) +export function ensureStorage() { + fs.mkdirSync(path.dirname(ANALYTICS_FILE), { recursive: true }); +} + +// Append a single entry +export function appendEntry(entry) { + ensureStorage(); + fs.appendFileSync(ANALYTICS_FILE, JSON.stringify(entry) + '\n'); +} + +// Read entries within time range +export function* readEntriesSince(cutoff) { + if (!fs.existsSync(ANALYTICS_FILE)) return; + + const content = fs.readFileSync(ANALYTICS_FILE, 'utf-8'); + for (const line of content.split('\n')) { + if (!line) continue; + try { + const entry = JSON.parse(line); + if (entry.ts >= cutoff) { + yield entry; + } + } catch { + // Skip malformed entries + } + } +} + +// Aggregate stats from entries +export function aggregateStats(entries) { + const stats = {}; + for (const entry of entries) { + if (!stats[entry.skill]) { + stats[entry.skill] = { calls: 0, success: 0, error: 0, abort: 0 }; + } + stats[entry.skill].calls++; + if (entry.outcome && stats[entry.skill][entry.outcome] !== undefined) { + stats[entry.skill][entry.outcome]++; + } + } + return stats; +} + +// List installed skills +export function* listInstalledSkills() { + const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true }); + for (const dir of dirs) { + if (!dir.isDirectory()) continue; + yield dir.name; + } +} + +// Read skill description +export function readSkillDescription(skillName) { + const skillFile = path.join(SKILLS_DIR, skillName, 'SKILL.md'); + try { + const content = fs.readFileSync(skillFile, 'utf-8'); + const match = content.match(/^description:\s*\n([\s\S]*?)(?=\n---|\nallowed-tools:|$)/m); + if (match) { + return match[1].trim().split('\n')[0].substring(0, 80); + } + } catch { + // File doesn't exist or can't be read + } + return 'No description'; +} diff --git a/tests/e2e/pulse-cli.test.sh b/tests/e2e/pulse-cli.test.sh new file mode 100644 index 0000000..8d4d505 --- /dev/null +++ b/tests/e2e/pulse-cli.test.sh @@ -0,0 +1,324 @@ +#!/bin/bash +# E2E tests for SkillPulse CLI +# Tests the pulse skill output with various scenarios + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Setup test environment +TEST_DIR="$(mktemp -d)" +TEST_HOME="$TEST_DIR/home" +ANALYTICS_FILE="$TEST_HOME/.claude/skills/pulse.jsonl" +SKILLS_DIR="$TEST_HOME/.claude/skills" +# Copy pulse script to test dir to ensure isolation +cp "$(dirname "$0")/../../skills/pulse/bin/pulse.sh" "$TEST_DIR/pulse.sh" +chmod +x "$TEST_DIR/pulse.sh" +PULSE_SCRIPT="$TEST_DIR/pulse.sh" + +cleanup() { + rm -rf "$TEST_DIR" +} + +trap cleanup EXIT + +# Helper functions +log_info() { + echo -e "${YELLOW}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[PASS]${NC} $1" +} + +log_error() { + echo -e "${RED}[FAIL]${NC} $1" +} + +run_test() { + local test_name="$1" + TESTS_RUN=$((TESTS_RUN + 1)) + log_info "Running: $test_name" +} + +assert_contains() { + local output="$1" + local expected="$2" + local test_name="$3" + + if echo "$output" | grep -q "$expected"; then + log_success "$test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + return 0 + else + log_error "$test_name - Expected to find: $expected" + TESTS_FAILED=$((TESTS_FAILED + 1)) + return 1 + fi +} + +assert_equals() { + local actual="$1" + local expected="$2" + local test_name="$3" + + if [ "$actual" = "$expected" ]; then + log_success "$test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + return 0 + else + log_error "$test_name - Expected: $expected, Got: $actual" + TESTS_FAILED=$((TESTS_FAILED + 1)) + return 1 + fi +} + +setup_test_env() { + # Create directory structure + mkdir -p "$SKILLS_DIR" + + # Create mock skill directories + mkdir -p "$SKILLS_DIR/skill1" + mkdir -p "$SKILLS_DIR/skill2" + mkdir -p "$SKILLS_DIR/skill3" + mkdir -p "$SKILLS_DIR/pulse" + + # Create skill.json files + for skill in skill1 skill2 skill3 pulse; do + echo '{"name":"'$skill'","version":"1.0.0"}' > "$SKILLS_DIR/$skill/skill.json" + done + + # Create analytics file (note: not in skills dir to avoid being counted) + mkdir -p "$(dirname "$ANALYTICS_FILE")" + touch "$ANALYTICS_FILE" +} + +# Test 1: Empty analytics file +test_empty_analytics() { + run_test "Empty analytics shows zero usage" + + setup_test_env + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + assert_contains "$output" "0 used" "Shows 0 used skills" + # Note: script counts pulse.jsonl file as a "skill" (known bug) + assert_contains "$output" "5 unused" "Shows 5 unused skills (includes pulse.jsonl file)" + assert_contains "$output" "SkillPulse" "Contains SkillPulse header" +} + +# Test 2: Single skill usage +test_single_skill_usage() { + run_test "Single skill usage displays correctly" + + setup_test_env + echo '{"skill":"skill1","ts":'$(($(date +%s) - 100))'}' > "$ANALYTICS_FILE" + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + assert_contains "$output" "1 used" "Shows 1 used skill" + assert_contains "$output" "4 unused" "Shows 4 unused skills (5 total - 1 used)" + assert_contains "$output" "/skill1" "Shows skill1 in output" +} + +# Test 3: Multiple skills with different call counts +test_multiple_skills_ranking() { + run_test "Multiple skills ranked by call count" + + setup_test_env + now=$(date +%s) + + # skill1: 10 calls + for i in $(seq 1 10); do + echo '{"skill":"skill1","ts":'$((now - i * 100))'}' >> "$ANALYTICS_FILE" + done + + # skill2: 5 calls + for i in $(seq 1 5); do + echo '{"skill":"skill2","ts":'$((now - i * 100))'}' >> "$ANALYTICS_FILE" + done + + # skill3: 2 calls + for i in $(seq 1 2); do + echo '{"skill":"skill3","ts":'$((now - i * 100))'}' >> "$ANALYTICS_FILE" + done + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + assert_contains "$output" "3 used" "Shows 3 used skills" + assert_contains "$output" "2 unused" "Shows 2 unused skills (5 total - 3 used)" + assert_contains "$output" "10 calls" "Shows 10 calls for top skill" + + # Check that skill1 appears before skill2 (more calls = higher rank) + skill1_pos=$(echo "$output" | grep -n "skill1" | head -1 | cut -d: -f1) + skill2_pos=$(echo "$output" | grep -n "skill2" | head -1 | cut -d: -f1) + + if [ "$skill1_pos" -lt "$skill2_pos" ]; then + log_success "Higher count skill appears first" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + log_error "Skills not properly ranked by count" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +# Test 4: Period filtering - 24h +test_period_filter_24h() { + run_test "Period filter 24h excludes old entries" + + setup_test_env + now=$(date +%s) + + # Recent entry (within 24h) + echo '{"skill":"skill1","ts":'$((now - 3600))'}' > "$ANALYTICS_FILE" + + # Old entry (outside 24h) + echo '{"skill":"skill2","ts":'$((now - 100000))'}' >> "$ANALYTICS_FILE" + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 24h 2>&1) + + assert_contains "$output" "1 used" "Shows only 1 used skill in 24h" + assert_contains "$output" "/skill1" "Shows recent skill1" +} + +# Test 5: Period filter - all +test_period_filter_all() { + run_test "Period filter 'all' includes all entries" + + setup_test_env + now=$(date +%s) + + # Very old entry + echo '{"skill":"skill1","ts":'$((now - 10000000))'}' > "$ANALYTICS_FILE" + + output=$(HOME="$TEST_HOME" bash "$PULSE_SCRIPT" all 2>&1) + + assert_contains "$output" "1 used" "Shows 1 used skill for all time" + assert_contains "$output" "all time" "Shows 'all time' in header" +} + +# Test 6: Cold skills display +test_cold_skills_display() { + run_test "Unused skills shown in Cold section" + + setup_test_env + echo '{"skill":"skill1","ts":'$(($(date +%s) - 100))'}' > "$ANALYTICS_FILE" + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + assert_contains "$output" "Cold" "Shows Cold section header" + assert_contains "$output" "/skill2" "Lists unused skill2" + assert_contains "$output" "/skill3" "Lists unused skill3" +} + +# Test 7: ASCII box rendering +test_ascii_box_rendering() { + run_test "ASCII box characters render correctly" + + setup_test_env + touch "$ANALYTICS_FILE" + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + assert_contains "$output" "╭" "Has top-left corner" + assert_contains "$output" "╮" "Has top-right corner" + assert_contains "$output" "╰" "Has bottom-left corner" + assert_contains "$output" "╯" "Has bottom-right corner" + assert_contains "$output" "│" "Has vertical borders" + assert_contains "$output" "─" "Has horizontal borders" +} + +# Test 8: Help text +test_help_text() { + run_test "Help tips displayed at bottom" + + setup_test_env + touch "$ANALYTICS_FILE" + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + assert_contains "$output" "Remove unused" "Shows remove tip" + assert_contains "$output" "Usage:" "Shows usage tip" +} + +# Test 9: Bar visualization +test_bar_visualization() { + run_test "Call count bar visualization" + + setup_test_env + now=$(date +%s) + + # skill1: 20 calls + for i in $(seq 1 20); do + echo '{"skill":"skill1","ts":'$((now - i * 100))'}' >> "$ANALYTICS_FILE" + done + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + # Check for bar characters (█) + assert_contains "$output" "█" "Contains bar visualization characters" +} + +# Test 10: Many unused skills truncation +test_many_unused_truncation() { + run_test "Many unused skills truncated with 'and X more'" + + setup_test_env + + # Create 10 skills + for i in $(seq 1 10); do + mkdir -p "$SKILLS_DIR/skill$i" + echo '{"name":"skill'$i'"}' > "$SKILLS_DIR/skill$i/skill.json" + done + + # Use only 1 skill + echo '{"skill":"skill1","ts":'$(($(date +%s) - 100))'}' > "$ANALYTICS_FILE" + + output=$(export HOME="$TEST_HOME" && bash "$PULSE_SCRIPT" 7d 2>&1) + + assert_contains "$output" "and .* more" "Shows truncation message for many unused" +} + +# Run all tests +echo "======================================" +echo "SkillPulse CLI E2E Tests" +echo "======================================" +echo "" + +test_empty_analytics +test_single_skill_usage +test_multiple_skills_ranking +test_period_filter_24h +test_period_filter_all +test_cold_skills_display +test_ascii_box_rendering +test_help_text +test_bar_visualization +test_many_unused_truncation + +# Summary +echo "" +echo "======================================" +echo "Test Summary" +echo "======================================" +echo "Total: $TESTS_RUN" +echo -e "${GREEN}Passed: $TESTS_PASSED${NC}" +echo -e "${RED}Failed: $TESTS_FAILED${NC}" +echo "" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi