diff --git a/README.md b/README.md index d87e6ad..0065ec2 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,7 @@ cd claude-hooks && ./scripts/install.sh ## Requirements -- **Node.js** 16+ (for npm installation) -- **Python** 3.8+ (for hook scripts) +- **Node.js** 16+ (only runtime required) - **Claude Code** (hooks integrate with Claude's native system) ## License diff --git a/bin/claude-hooks.js b/bin/claude-hooks.js old mode 100644 new mode 100755 index 4575f0a..4f26421 --- a/bin/claude-hooks.js +++ b/bin/claude-hooks.js @@ -2,41 +2,107 @@ const { Command } = require('commander'); const chalk = require('chalk'); -const { execSync, spawn } = require('child_process'); const path = require('path'); -const fs = require('fs'); +const fs = require('fs-extra'); const os = require('os'); const program = new Command(); const packageRoot = path.dirname(__dirname); -// Helper to run Python scripts -function runPythonScript(scriptName, args = []) { - const scriptPath = path.join(packageRoot, 'scripts', scriptName); - return spawn('python3', [scriptPath, ...args], { - stdio: 'inherit', - cwd: packageRoot +// Helper to create hooks configuration +async function createHooksConfig() { + const claudeConfigDir = path.join(os.homedir(), '.config', 'claude'); + const hooksConfigFile = path.join(claudeConfigDir, 'hooks.json'); + + await fs.ensureDir(claudeConfigDir); + + const hooksConfig = { + hooks: { + UserPromptSubmit: `node ${path.join(packageRoot, 'hooks', 'context-monitor.js')}`, + PreToolUse: { + Bash: `node ${path.join(packageRoot, 'hooks', 'command-validator.js')}` + }, + PostToolUse: { + '*': `node ${path.join(packageRoot, 'hooks', 'session-logger.js')}` + }, + Stop: `node ${path.join(packageRoot, 'hooks', 'session-finalizer.js')}` + } + }; + + await fs.writeJson(hooksConfigFile, hooksConfig, { spaces: 2 }); + return hooksConfigFile; +} + +// Helper to test a hook script +async function testHookScript(scriptName, testInput) { + return new Promise((resolve) => { + const { spawn } = require('child_process'); + const scriptPath = path.join(packageRoot, 'hooks', scriptName); + + const child = spawn('node', [scriptPath], { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: packageRoot + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.stdin.write(JSON.stringify(testInput)); + child.stdin.end(); + + const timeout = setTimeout(() => { + child.kill(); + resolve({ success: false, error: 'Timeout' }); + }, 10000); + + child.on('close', (code) => { + clearTimeout(timeout); + resolve({ + success: code === 0, + stdout: stdout.trim(), + stderr: stderr.trim() + }); + }); + + child.on('error', (error) => { + clearTimeout(timeout); + resolve({ success: false, error: error.message }); + }); }); } // Helper to check installation status -function checkStatus() { +async function checkStatus() { const hooksConfig = path.join(os.homedir(), '.config', 'claude', 'hooks.json'); - const hasHooks = fs.existsSync(hooksConfig); + const hasHooks = await fs.pathExists(hooksConfig); console.log(chalk.blue('Claude Hooks Status:')); console.log(hasHooks ? chalk.green('✓ Hooks configuration installed') : chalk.red('✗ No hooks configuration found')); - - try { - execSync('python3 --version', { stdio: 'pipe' }); - console.log(chalk.green('✓ Python 3 available')); - } catch { - console.log(chalk.red('✗ Python 3 not found')); - } + console.log(chalk.green('✓ Node.js runtime available')); return hasHooks; } +// Helper to remove hooks configuration +async function removeHooksConfig() { + const hooksConfig = path.join(os.homedir(), '.config', 'claude', 'hooks.json'); + + if (await fs.pathExists(hooksConfig)) { + await fs.remove(hooksConfig); + return true; + } + + return false; +} + program .name('claude-hooks') .description('Intelligent hooks system for Claude Code') @@ -46,82 +112,173 @@ program .command('init') .description('Initialize Claude Hooks (run after npm install)') .option('--force', 'Force reinstallation even if already configured') - .action((options) => { - console.log(chalk.blue('Initializing Claude Hooks...')); - - if (!options.force && checkStatus()) { - console.log(chalk.yellow('⚠️ Claude Hooks already configured. Use --force to reinstall.')); - return; + .option('--auto-setup', 'Auto-setup during npm postinstall') + .option('--quiet', 'Minimal output') + .action(async (options) => { + if (!options.quiet) { + console.log(chalk.blue('Initializing Claude Hooks...')); } - const initScript = runPythonScript('install.py'); - initScript.on('close', (code) => { - if (code === 0) { - console.log(chalk.green('\n🎉 Claude Hooks initialized successfully!')); + try { + // Check for existing installation + if (!options.force && await checkStatus()) { + if (!options.quiet) { + console.log(chalk.yellow('⚠️ Claude Hooks already configured. Use --force to reinstall.')); + } + return; + } + + // Create runtime directories + const runtimeDir = path.join(packageRoot, '.claude_hooks'); + await fs.ensureDir(path.join(runtimeDir, 'backups')); + await fs.ensureDir(path.join(runtimeDir, 'logs')); + await fs.ensureDir(path.join(runtimeDir, 'patterns')); + + // Create hooks configuration + const configFile = await createHooksConfig(); + + if (!options.quiet) { + console.log(chalk.green('✅ Claude Hooks initialized successfully!')); + console.log(chalk.blue('Configuration file:'), configFile); console.log(chalk.blue('Next steps:')); console.log('1. Restart Claude Code to activate hooks'); console.log('2. Try: claude-hooks test'); - } else { - console.log(chalk.red('❌ Initialization failed')); - process.exit(1); } - }); + + } catch (error) { + console.error(chalk.red('❌ Initialization failed:'), error.message); + process.exit(1); + } }); program .command('status') .description('Show Claude Hooks installation status') - .action(() => { - checkStatus(); + .action(async () => { + await checkStatus(); }); program .command('test') .description('Run Claude Hooks tests') - .action(() => { + .action(async () => { console.log(chalk.blue('Running Claude Hooks tests...')); - const testScript = runPythonScript('test.py'); - testScript.on('close', (code) => { - if (code === 0) { - console.log(chalk.green('✅ All tests passed!')); - } else { - console.log(chalk.red('❌ Some tests failed')); - process.exit(1); + + let passed = 0; + let total = 0; + + // Test 1: Check configuration + total++; + console.log('Testing configuration...'); + if (await checkStatus()) { + console.log(chalk.green('✓ Configuration exists')); + passed++; + } else { + console.log(chalk.red('✗ Configuration missing')); + } + + // Test 2: Test hook scripts + const hookTests = [ + { + name: 'context-monitor.js', + input: { prompt: 'test prompt', context_size: 1000 } + }, + { + name: 'command-validator.js', + input: { tool: 'Bash', parameters: { command: 'pip install requests' } } + }, + { + name: 'session-logger.js', + input: { tool: 'Bash', parameters: { command: 'echo test' }, success: true } + }, + { + name: 'session-finalizer.js', + input: {} } - }); + ]; + + for (const test of hookTests) { + total++; + console.log(`Testing ${test.name}...`); + + const result = await testHookScript(test.name, test.input); + + if (result.success) { + console.log(chalk.green(`✓ ${test.name} working`)); + passed++; + } else { + console.log(chalk.red(`✗ ${test.name} failed: ${result.error || result.stderr}`)); + } + } + + // Summary + console.log(); + if (passed === total) { + console.log(chalk.green(`✅ All ${total} tests passed!`)); + console.log(chalk.green('Claude Hooks is ready to use!')); + } else { + console.log(chalk.yellow(`⚠️ ${passed}/${total} tests passed`)); + console.log(chalk.blue('Try: claude-hooks init --force')); + process.exit(1); + } }); program .command('uninstall') .description('Remove Claude Hooks configuration') - .action(() => { - console.log(chalk.blue('Uninstalling Claude Hooks...')); - const uninstallScript = runPythonScript('uninstall.py'); - uninstallScript.on('close', (code) => { - if (code === 0) { - console.log(chalk.green('✅ Claude Hooks uninstalled successfully')); - console.log(chalk.yellow('Note: To completely remove, run: npm uninstall -g claude-hooks')); + .option('--quiet', 'Minimal output') + .action(async (options) => { + if (!options.quiet) { + console.log(chalk.blue('Uninstalling Claude Hooks...')); + } + + try { + const removed = await removeHooksConfig(); + + if (removed) { + if (!options.quiet) { + console.log(chalk.green('✅ Claude Hooks configuration removed')); + console.log(chalk.yellow('Note: To completely remove the package: npm uninstall -g claude-hooks')); + } } else { - console.log(chalk.red('❌ Uninstall failed')); - process.exit(1); + if (!options.quiet) { + console.log(chalk.yellow('⚠️ No Claude Hooks configuration found')); + } } - }); + + } catch (error) { + console.error(chalk.red('❌ Uninstall failed:'), error.message); + process.exit(1); + } }); program .command('backup') .description('Manually trigger a backup') - .action(() => { + .action(async () => { console.log(chalk.blue('Creating manual backup...')); - const backupScript = runPythonScript('manual-backup.py'); - backupScript.on('close', (code) => { - if (code === 0) { - console.log(chalk.green('✅ Backup created successfully')); + + try { + // Import backup manager + const { BackupManager } = require(path.join(packageRoot, 'lib', 'backup-manager')); + const backupManager = new BackupManager(); + + const backupId = await backupManager.createBackup(process.cwd(), { + trigger: 'manual', + timestamp: new Date().toISOString() + }); + + if (backupId) { + console.log(chalk.green(`✅ Backup created: ${backupId}`)); } else { console.log(chalk.red('❌ Backup failed')); process.exit(1); } - }); + + } catch (error) { + console.error(chalk.red('❌ Backup failed:'), error.message); + process.exit(1); + } }); program.parse(); \ No newline at end of file diff --git a/config/hooks.json.template b/config/hooks.json.template index 8e01bcf..c105544 100644 --- a/config/hooks.json.template +++ b/config/hooks.json.template @@ -1,12 +1,12 @@ { "hooks": { - "UserPromptSubmit": "python3 {{INSTALL_PATH}}/hooks/context_monitor.py", + "UserPromptSubmit": "node {{INSTALL_PATH}}/hooks/context-monitor.js", "PreToolUse": { - "Bash": "python3 {{INSTALL_PATH}}/hooks/command_validator.py" + "Bash": "node {{INSTALL_PATH}}/hooks/command-validator.js" }, "PostToolUse": { - "*": "python3 {{INSTALL_PATH}}/hooks/session_logger.py" + "*": "node {{INSTALL_PATH}}/hooks/session-logger.js" }, - "Stop": "python3 {{INSTALL_PATH}}/hooks/session_finalizer.py" + "Stop": "node {{INSTALL_PATH}}/hooks/session-finalizer.js" } } \ No newline at end of file diff --git a/docs/BOOTSTRAP_PROMPT.md b/docs/BOOTSTRAP_PROMPT.md index 958b488..559fd92 100644 --- a/docs/BOOTSTRAP_PROMPT.md +++ b/docs/BOOTSTRAP_PROMPT.md @@ -23,17 +23,17 @@ If either check shows existing hooks, **STOP** and run the uninstall process fir - `claude-hooks uninstall` (if available) - Or manually remove: `rm -rf ~/.config/claude/hooks.json` -**Installation (choose one method):** +**Installation:** -**Method 1 - NPM (Recommended):** +**NPM (Recommended):** 1. `npm install -g claude-hooks` -2. `claude-hooks init` -3. `claude-hooks test` +2. Hooks auto-configure during install +3. `claude-hooks test` to verify -**Method 2 - Git (For developers):** +**Git (For developers):** 1. `git clone https://git.supported.systems/rsp2k/claude-hooks.git` -2. `cd claude-hooks && ./scripts/install.sh` -3. `./scripts/test.sh` +2. `cd claude-hooks && npm install && npm run postinstall` +3. `claude-hooks test` **After installation:** - Restart Claude Code to activate the hooks @@ -42,6 +42,6 @@ If either check shows existing hooks, **STOP** and run the uninstall process fir **To uninstall later:** `claude-hooks uninstall` or `npm uninstall -g claude-hooks` -Please guide me through this installation step-by-step, including the pre-installation checks. Use the NPM method unless I specifically request git installation. Handle any errors that come up and make sure everything works. After installation, help me test it by trying some commands that commonly fail on different systems (like `pip` vs `pip3`, `python` vs `python3`) so I can see the intelligence in action. +Please guide me through this installation step-by-step, including the pre-installation checks. Use the NPM method unless I specifically request git installation. Handle any errors that come up and make sure everything works. The installation now auto-configures during npm install - no manual steps needed! After installation, help me test it by trying some commands that commonly fail on different systems (like `pip` vs `pip3`, `python` vs `python3`) so I can see the intelligence in action. The goal is to have a working Claude Hooks system that starts learning from my environment immediately. \ No newline at end of file diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 3993edf..cd17355 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -150,27 +150,30 @@ This feedback loop is what transforms Claude from a stateless assistant into an The shadow learner implements a classic observer pattern, but with sophisticated intelligence: -```python -class ShadowLearner: - def observe(self, execution: ToolExecution): - # Extract patterns from execution - patterns = self.extract_patterns(execution) +```javascript +class ShadowLearner { + observe(execution) { + // Extract patterns from execution + const patterns = this.extractPatterns(execution); - # Update confidence scores - self.update_confidence(patterns) + // Update confidence scores + this.updateConfidence(patterns); - # Store new knowledge - self.knowledge_base.update(patterns) + // Store new knowledge + this.knowledgeBase.update(patterns); + } - def predict(self, proposed_action): - # Match against known patterns - similar_patterns = self.find_similar(proposed_action) + predict(proposedAction) { + // Match against known patterns + const similarPatterns = this.findSimilar(proposedAction); - # Calculate confidence - confidence = self.calculate_confidence(similar_patterns) + // Calculate confidence + const confidence = this.calculateConfidence(similarPatterns); - # Return prediction - return Prediction(confidence, similar_patterns) + // Return prediction + return new Prediction(confidence, similarPatterns); + } +} ``` The key insight is that the learner doesn't just record what happened - it actively builds predictive models that can guide future decisions. @@ -179,26 +182,29 @@ The key insight is that the learner doesn't just record what happened - it activ The context monitor implements a resource management pattern, treating Claude's context as a finite resource that must be carefully managed: -```python -class ContextMonitor: - def estimate_usage(self): - # Multiple estimation strategies - estimates = [ - self.token_based_estimate(), - self.activity_based_estimate(), - self.time_based_estimate() - ] +```javascript +class ContextMonitor { + estimateUsage() { + // Multiple estimation strategies + const estimates = [ + this.tokenBasedEstimate(), + this.activityBasedEstimate(), + this.timeBasedEstimate() + ]; - # Weighted combination - return self.combine_estimates(estimates) + // Weighted combination + return this.combineEstimates(estimates); + } - def should_backup(self): - usage = self.estimate_usage() + shouldBackup() { + const usage = this.estimateUsage(); - # Adaptive thresholds based on session complexity - threshold = self.calculate_threshold() + // Adaptive thresholds based on session complexity + const threshold = this.calculateThreshold(); - return usage > threshold + return usage > threshold; + } +} ``` This architectural approach means the system can make intelligent decisions about when to intervene, rather than using simple rule-based triggers. @@ -207,25 +213,31 @@ This architectural approach means the system can make intelligent decisions abou The backup manager implements a strategy pattern, using different backup approaches based on circumstances: -```python -class BackupManager: - def __init__(self): - self.strategies = [ - GitBackupStrategy(), - FilesystemBackupStrategy(), - EmergencyBackupStrategy() - ] +```javascript +class BackupManager { + constructor() { + this.strategies = [ + new GitBackupStrategy(), + new FilesystemBackupStrategy(), + new EmergencyBackupStrategy() + ]; + } - def execute_backup(self, context): - for strategy in self.strategies: - try: - result = strategy.backup(context) - if result.success: - return result - except Exception: - continue # Try next strategy + async executeBackup(context) { + for (const strategy of this.strategies) { + try { + const result = await strategy.backup(context); + if (result.success) { + return result; + } + } catch (error) { + continue; // Try next strategy + } + } - return self.emergency_backup(context) + return this.emergencyBackup(context); + } +} ``` This ensures that backups almost always succeed, gracefully degrading to simpler approaches when sophisticated methods fail. diff --git a/docs/explanation/shadow-learner.md b/docs/explanation/shadow-learner.md index 2baf450..0a7bf52 100644 --- a/docs/explanation/shadow-learner.md +++ b/docs/explanation/shadow-learner.md @@ -55,14 +55,14 @@ Traditional ML models are trained once and deployed. Shadow learners improve inc The shadow learner identifies several types of patterns: **Command Patterns**: Which commands tend to succeed or fail in your environment -- `pip install` fails 90% of the time → suggest `pip3 install` -- `python script.py` fails on your system → suggest `python3 script.py` -- `npm install` without `--save` in certain projects → warn about dependency tracking +- `npm install` fails 90% of the time → suggest `npm ci` or check package-lock.json +- `node script.js` fails on your system → suggest `node --version` check or use `npx` +- `npm install` without `--save-dev` for dev dependencies → warn about production vs development packages **Sequence Patterns**: Common workflows and command chains - `git add . && git commit` often follows file edits - `npm install` typically precedes `npm test` -- Reading config files often precedes configuration changes +- Reading package.json often precedes dependency updates **Context Patterns**: Environmental factors that affect command success - Commands fail differently in Docker containers vs. native environments @@ -70,9 +70,9 @@ The shadow learner identifies several types of patterns: - Time-of-day patterns (builds failing during peak hours due to resource contention) **Error Patterns**: Common failure modes and their solutions -- "Permission denied" errors often require sudo or chmod -- "Command not found" errors have specific alternative commands -- Network timeouts suggest retry strategies +- "Permission denied" errors often require sudo or npm config set prefix +- "Command not found" errors suggest missing global packages or PATH issues +- Network timeouts suggest retry strategies or alternative registries ### Confidence Building @@ -100,8 +100,8 @@ The shadow learner doesn't just record patterns - it builds confidence scores ba The shadow learner develops deep knowledge about your specific development environment: -- Which Python version is actually available -- How package managers are configured +- Which Node.js version is actually available +- How npm/yarn/pnpm package managers are configured - What development tools are installed and working - How permissions are set up - What network restrictions exist diff --git a/docs/explanation/why-hooks.md b/docs/explanation/why-hooks.md index 8cbe978..93d802c 100644 --- a/docs/explanation/why-hooks.md +++ b/docs/explanation/why-hooks.md @@ -14,7 +14,7 @@ This creates a jarring experience: you're deep in a debugging session, making pr ### The Repetitive Failure Problem -Human developers naturally learn from mistakes. Try a command that fails, remember not to do it again, adapt. But each new Claude session starts with no memory of previous failures. You find yourself watching Claude repeat the same mistakes - `pip` instead of `pip3`, `python` instead of `python3`, dangerous operations that you know will fail. +Human developers naturally learn from mistakes. Try a command that fails, remember not to do it again, adapt. But each new Claude session starts with no memory of previous failures. You find yourself watching Claude repeat the same mistakes - `npm install` instead of `npm ci`, `node` without proper version checks, dangerous operations that you know will fail. This isn't Claude's fault - it's a fundamental limitation of the stateless conversation model. But it creates frustration and inefficiency. @@ -62,7 +62,7 @@ You might wonder: why not just train Claude to be better at avoiding these probl The answer lies in the fundamental difference between general intelligence and environmental adaptation: -**General intelligence** (what Claude provides) is knowledge that applies across all contexts - how to write Python, how to use git, how to debug problems. +**General intelligence** (what Claude provides) is knowledge that applies across all contexts - how to write JavaScript, how to use git, how to debug problems. **Environmental adaptation** (what shadow learning provides) is knowledge specific to your setup - which commands work on your system, what your typical workflows are, what mistakes you commonly make. diff --git a/docs/how-to/customize-patterns.md b/docs/how-to/customize-patterns.md index 2dd4600..5fb16df 100644 --- a/docs/how-to/customize-patterns.md +++ b/docs/how-to/customize-patterns.md @@ -8,31 +8,31 @@ If you have commands that should never be run in your environment: 1. **Edit the command validator**: ```bash - nano hooks/command_validator.py + nano hooks/command-validator.js ``` -2. **Find the dangerous_patterns list** (around line 23): - ```python - self.dangerous_patterns = [ - r'rm\s+-rf\s+/', # Delete root - r'mkfs\.', # Format filesystem - # Add your pattern here - ] +2. **Find the dangerousPatterns array** (around line 23): + ```javascript + this.dangerousPatterns = [ + /rm\s+-rf\s+\//, // Delete root + /mkfs\./, // Format filesystem + // Add your pattern here + ]; ``` 3. **Add your pattern**: - ```python - self.dangerous_patterns = [ - r'rm\s+-rf\s+/', # Delete root - r'mkfs\.', # Format filesystem - r'docker\s+system\s+prune\s+--all', # Delete all Docker data - r'kubectl\s+delete\s+namespace\s+production', # Delete prod namespace - ] + ```javascript + this.dangerousPatterns = [ + /rm\s+-rf\s+\//, // Delete root + /mkfs\./, // Format filesystem + /docker\s+system\s+prune\s+--all/, // Delete all Docker data + /kubectl\s+delete\s+namespace\s+production/, // Delete prod namespace + ]; ``` 4. **Test your pattern**: ```bash - echo '{"tool": "Bash", "parameters": {"command": "docker system prune --all"}}' | python3 hooks/command_validator.py + echo '{"tool": "Bash", "parameters": {"command": "docker system prune --all"}}' | node hooks/command-validator.js ``` Should return: `{"allow": false, "message": "⛔ Command blocked: Dangerous command pattern detected"}` @@ -41,23 +41,23 @@ If you have commands that should never be run in your environment: For commands that are risky but sometimes legitimate: -1. **Find the suspicious_patterns list**: - ```python - self.suspicious_patterns = [ - r'sudo\s+rm', # Sudo with rm - r'chmod\s+777', # Overly permissive - # Add your pattern here - ] +1. **Find the suspiciousPatterns array**: + ```javascript + this.suspiciousPatterns = [ + /sudo\s+rm/, // Sudo with rm + /chmod\s+777/, // Overly permissive + // Add your pattern here + ]; ``` 2. **Add patterns that should warn but not block**: - ```python - self.suspicious_patterns = [ - r'sudo\s+rm', # Sudo with rm - r'chmod\s+777', # Overly permissive - r'npm\s+install\s+.*--global', # Global npm installs - r'pip\s+install.*--user', # User pip installs - ] + ```javascript + this.suspiciousPatterns = [ + /sudo\s+rm/, // Sudo with rm + /chmod\s+777/, // Overly permissive + /npm\s+install\s+.*--global/, // Global npm installs + /pip\s+install.*--user/, // User pip installs + ]; ``` ## Customize for Your Tech Stack @@ -65,66 +65,66 @@ For commands that are risky but sometimes legitimate: ### For Docker Environments Add Docker-specific protections: -```python -# In dangerous_patterns: -r'docker\s+rm\s+.*-f.*', # Force remove containers -r'docker\s+rmi\s+.*-f.*', # Force remove images +```javascript +// In dangerousPatterns: +/docker\s+rm\s+.*-f.*/, // Force remove containers +/docker\s+rmi\s+.*-f.*/, // Force remove images -# In suspicious_patterns: -r'docker\s+run.*--privileged', # Privileged containers -r'docker.*-v\s+/:/.*', # Mount root filesystem +// In suspiciousPatterns: +/docker\s+run.*--privileged/, // Privileged containers +/docker.*-v\s+\/:\/.*/, // Mount root filesystem ``` ### For Kubernetes Protect production namespaces: -```python -# In dangerous_patterns: -r'kubectl\s+delete\s+.*production.*', -r'kubectl\s+delete\s+.*prod.*', -r'helm\s+delete\s+.*production.*', +```javascript +// In dangerousPatterns: +/kubectl\s+delete\s+.*production.*/, +/kubectl\s+delete\s+.*prod.*/, +/helm\s+delete\s+.*production.*/, -# In suspicious_patterns: -r'kubectl\s+apply.*production.*', -r'kubectl.*--all-namespaces.*delete', +// In suspiciousPatterns: +/kubectl\s+apply.*production.*/, +/kubectl.*--all-namespaces.*delete/, ``` ### For Database Operations Prevent destructive database commands: -```python -# In dangerous_patterns: -r'DROP\s+DATABASE.*', -r'TRUNCATE\s+TABLE.*', -r'DELETE\s+FROM.*WHERE\s+1=1', +```javascript +// In dangerousPatterns: +/DROP\s+DATABASE.*/i, +/TRUNCATE\s+TABLE.*/i, +/DELETE\s+FROM.*WHERE\s+1=1/i, -# In suspicious_patterns: -r'UPDATE.*SET.*WHERE\s+1=1', -r'ALTER\s+TABLE.*DROP.*', +// In suspiciousPatterns: +/UPDATE.*SET.*WHERE\s+1=1/i, +/ALTER\s+TABLE.*DROP.*/i, ``` ## Environment-Specific Patterns ### For Production Servers -```python -# In dangerous_patterns: -r'systemctl\s+stop\s+(nginx|apache|mysql)', -r'service\s+(nginx|apache|mysql)\s+stop', -r'killall\s+-9.*', +```javascript +// In dangerousPatterns: +/systemctl\s+stop\s+(nginx|apache|mysql)/, +/service\s+(nginx|apache|mysql)\s+stop/, +/killall\s+-9.*/, -# In suspicious_patterns: -r'sudo\s+systemctl\s+restart.*', -r'sudo\s+service.*restart.*', +// In suspiciousPatterns: +/sudo\s+systemctl\s+restart.*/, +/sudo\s+service.*restart.*/, ``` ### For Development Machines -```python -# In suspicious_patterns: -r'rm\s+-rf\s+node_modules', # Can break local dev -r'git\s+reset\s+--hard\s+HEAD~[0-9]+', # Lose multiple commits -r'git\s+push\s+.*--force.*', # Force push +```javascript +// In suspiciousPatterns: +/rm\s+-rf\s+node_modules/, // Can break local dev +/git\s+reset\s+--hard\s+HEAD~[0-9]+/, // Lose multiple commits +/git\s+push\s+.*--force.*/, // Force push ``` ## Test Your Custom Patterns @@ -137,15 +137,15 @@ cat > test_patterns.sh << 'EOF' # Test dangerous pattern (should block) echo "Testing dangerous pattern..." -echo '{"tool": "Bash", "parameters": {"command": "docker system prune --all"}}' | python3 hooks/command_validator.py +echo '{"tool": "Bash", "parameters": {"command": "docker system prune --all"}}' | node hooks/command-validator.js # Test suspicious pattern (should warn) echo "Testing suspicious pattern..." -echo '{"tool": "Bash", "parameters": {"command": "npm install -g dangerous-package"}}' | python3 hooks/command_validator.py +echo '{"tool": "Bash", "parameters": {"command": "npm install -g dangerous-package"}}' | node hooks/command-validator.js # Test normal command (should pass) echo "Testing normal command..." -echo '{"tool": "Bash", "parameters": {"command": "ls -la"}}' | python3 hooks/command_validator.py +echo '{"tool": "Bash", "parameters": {"command": "ls -la"}}' | node hooks/command-validator.js EOF chmod +x test_patterns.sh @@ -157,29 +157,34 @@ chmod +x test_patterns.sh For patterns that depend on file context: 1. **Edit the validation function** to check current directory or files: - ```python - def validate_command_safety(self, command: str) -> ValidationResult: - # Your existing patterns... + ```javascript + validateCommandSafety(command) { + // Your existing patterns... - # Context-aware validation - if "git push" in command.lower(): - # Check if we're in a production branch - try: - current_branch = subprocess.check_output(['git', 'branch', '--show-current'], - text=True).strip() - if current_branch in ['main', 'master', 'production']: - return ValidationResult( - allowed=True, - reason="⚠️ Pushing to protected branch", - severity="warning" - ) - except: - pass + // Context-aware validation + if (command.toLowerCase().includes("git push")) { + // Check if we're in a production branch + try { + const { execSync } = require('child_process'); + const currentBranch = execSync('git branch --show-current', + { encoding: 'utf8' }).trim(); + if (['main', 'master', 'production'].includes(currentBranch)) { + return { + allowed: true, + reason: "⚠️ Pushing to protected branch", + severity: "warning" + }; + } + } catch { + // Ignore errors + } + } + } ``` ## Pattern Syntax Reference -Use Python regex patterns: +Use JavaScript regex patterns: - `\s+` - One or more whitespace characters - `.*` - Any characters (greedy) @@ -188,11 +193,12 @@ Use Python regex patterns: - `(option1|option2)` - Either option1 or option2 - `^` - Start of string - `$` - End of string +- `i` flag - Case insensitive matching **Examples**: -- `r'rm\s+-rf\s+/'` - Matches "rm -rf /" -- `r'git\s+push.*--force'` - Matches "git push" followed by "--force" anywhere -- `r'^sudo\s+'` - Matches commands starting with "sudo" +- `/rm\s+-rf\s+\//` - Matches "rm -rf /" +- `/git\s+push.*--force/` - Matches "git push" followed by "--force" anywhere +- `/^sudo\s+/` - Matches commands starting with "sudo" ## Reload Changes diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 3a49a25..e3a14ec 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -103,10 +103,11 @@ Export all hook data to a directory. ## Hook Scripts -### context_monitor.py +### context-monitor.js **Type**: UserPromptSubmit hook -**Purpose**: Monitor context usage and trigger backups +**Purpose**: Monitor context usage and trigger backups +**Runtime**: Node.js **Input format**: ```json @@ -128,10 +129,11 @@ Export all hook data to a directory. --- -### command_validator.py +### command-validator.js **Type**: PreToolUse[Bash] hook -**Purpose**: Validate bash commands for safety and success probability +**Purpose**: Validate bash commands for safety and success probability +**Runtime**: Node.js **Input format**: ```json @@ -163,10 +165,11 @@ Export all hook data to a directory. --- -### session_logger.py +### session-logger.js **Type**: PostToolUse[*] hook -**Purpose**: Log tool executions and update learning data +**Purpose**: Log tool executions and update learning data +**Runtime**: Node.js **Input format**: ```json @@ -192,10 +195,11 @@ Export all hook data to a directory. --- -### session_finalizer.py +### session-finalizer.js **Type**: Stop hook -**Purpose**: Create session documentation and save state +**Purpose**: Create session documentation and save state +**Runtime**: Node.js **Input format**: ```json diff --git a/docs/reference/hook-api.md b/docs/reference/hook-api.md index 2f334ad..7d0efed 100644 --- a/docs/reference/hook-api.md +++ b/docs/reference/hook-api.md @@ -263,52 +263,62 @@ Hooks are designed to fail safely: Always validate hook input: -```python -def validate_input(input_data): - if not isinstance(input_data, dict): - raise ValueError("Input must be JSON object") +```javascript +function validateInput(inputData) { + if (typeof inputData !== 'object' || inputData === null || Array.isArray(inputData)) { + throw new Error('Input must be JSON object'); + } - tool = input_data.get("tool", "") - if not isinstance(tool, str): - raise ValueError("Tool must be string") + const tool = inputData.tool || ''; + if (typeof tool !== 'string') { + throw new Error('Tool must be string'); + } - # Validate other fields... + // Validate other fields... +} ``` ### Output Sanitization Ensure hook output is safe: -```python -def safe_message(text): - # Remove potential injection characters - return text.replace('\x00', '').replace('\r', '').replace('\n', '\\n') - -response = { - "allow": True, - "message": safe_message(user_input) +```javascript +function safeMessage(text) { + // Remove potential injection characters + return text.replace(/\x00/g, '').replace(/\r/g, '').replace(/\n/g, '\\n'); } + +const response = { + allow: true, + message: safeMessage(userInput) +}; ``` ### File Path Validation For hooks that access files: -```python -def validate_file_path(path): - # Convert to absolute path - abs_path = os.path.abspath(path) +```javascript +const path = require('path'); + +function validateFilePath(filePath) { + // Convert to absolute path + const absPath = path.resolve(filePath); - # Check if within project boundaries - project_root = os.path.abspath(".") - if not abs_path.startswith(project_root): - raise ValueError("Path outside project directory") + // Check if within project boundaries + const projectRoot = path.resolve('.'); + if (!absPath.startsWith(projectRoot)) { + throw new Error('Path outside project directory'); + } - # Check for system files - system_paths = ['/etc', '/usr', '/var', '/sys', '/proc'] - for sys_path in system_paths: - if abs_path.startswith(sys_path): - raise ValueError("System file access denied") + // Check for system files + const systemPaths = ['/etc', '/usr', '/var', '/sys', '/proc']; + for (const sysPath of systemPaths) { + if (absPath.startsWith(sysPath)) { + throw new Error('System file access denied'); + } + } +} ``` --- @@ -319,27 +329,32 @@ def validate_file_path(path): Test hooks with sample inputs: -```python -def test_command_validator(): - import subprocess - import json +```javascript +const { spawn } = require('child_process'); + +function testCommandValidator() { + // Test dangerous command + const inputData = { + tool: 'Bash', + parameters: { command: 'rm -rf /' } + }; - # Test dangerous command - input_data = { - "tool": "Bash", - "parameters": {"command": "rm -rf /"} - } + const process = spawn('node', ['hooks/command-validator.js'], { + stdio: 'pipe' + }); - process = subprocess.run( - ["python3", "hooks/command_validator.py"], - input=json.dumps(input_data), - capture_output=True, - text=True - ) + process.stdin.write(JSON.stringify(inputData)); + process.stdin.end(); - assert process.returncode == 1 # Should block - response = json.loads(process.stdout) - assert response["allow"] == False + process.on('exit', (code) => { + console.assert(code === 1, 'Should block'); // Should block + }); + + process.stdout.on('data', (data) => { + const response = JSON.parse(data.toString()); + console.assert(response.allow === false); + }); +} ``` ### Integration Testing @@ -348,7 +363,7 @@ Test with Claude Code directly: ```bash # Test in development environment -echo '{"tool": "Bash", "parameters": {"command": "ls"}}' | python3 hooks/command_validator.py +echo '{"tool": "Bash", "parameters": {"command": "ls"}}' | node hooks/command-validator.js # Test hook registration claude-hooks status @@ -358,25 +373,33 @@ claude-hooks status Measure hook execution time: -```python -import time -import subprocess -import json +```javascript +const { spawn } = require('child_process'); -def benchmark_hook(hook_script, input_data, iterations=100): - times = [] +function benchmarkHook(hookScript, inputData, iterations = 100) { + const times = []; + let completed = 0; - for _ in range(iterations): - start = time.time() - subprocess.run( - ["python3", hook_script], - input=json.dumps(input_data), - capture_output=True - ) - times.append(time.time() - start) - - avg_time = sum(times) / len(times) - max_time = max(times) - - print(f"Average: {avg_time*1000:.1f}ms, Max: {max_time*1000:.1f}ms") + for (let i = 0; i < iterations; i++) { + const start = Date.now(); + const process = spawn('node', [hookScript], { + stdio: 'pipe' + }); + + process.stdin.write(JSON.stringify(inputData)); + process.stdin.end(); + + process.on('exit', () => { + times.push(Date.now() - start); + completed++; + + if (completed === iterations) { + const avgTime = times.reduce((a, b) => a + b, 0) / times.length; + const maxTime = Math.max(...times); + + console.log(`Average: ${avgTime.toFixed(1)}ms, Max: ${maxTime.toFixed(1)}ms`); + } + }); + } +} ``` \ No newline at end of file diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 11f70cb..a3c1c38 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -30,12 +30,12 @@ You should see output like this: ``` Claude Code Hooks Installation ================================== -Checking Python version... Python 3.11 found -✓ Python version is compatible -Installing Python dependencies... SUCCESS +Checking Node.js version... Node.js v18.17.0 found +✓ Node.js version is compatible +Installing dependencies... SUCCESS ``` -**Notice** that the installer found your Python version and installed dependencies automatically. +**Notice** that the installer found your Node.js version and installed dependencies automatically. The installer will ask if you want to automatically configure Claude Code. Say **yes** - we want to see this working right away: @@ -59,26 +59,24 @@ Let's deliberately try a command that often fails to see the validation in actio Start a new Claude conversation and try this: +> "Run `npm install express` to add the express library" + +Watch what happens. You should see the command execute normally. Now try a command that commonly fails: + > "Run `pip install requests` to add the requests library" -Watch what happens. You should see something like: +You should see something like: ``` ⚠️ Warning: pip commands often fail (confidence: 88%) -💡 Suggestion: Use "pip3 install requests" +💡 Suggestion: Use "pip3 install requests" or "npm install requests" ``` -**Notice** that Claude Hooks warned you about the command before it ran. The system doesn't have any learned patterns yet (it's brand new), but it has built-in knowledge about common failures. - -Now try the suggested command: - -> "Run `pip3 install requests`" - -This time it should work without warnings. The system is learning that `pip3` succeeds where `pip` fails on your system. +**Notice** that Claude Hooks warned you about the command before it ran. The system has built-in knowledge about common command failures and suggests working alternatives. ## Step 4: Experience the Shadow Learner -Let's make another common mistake and watch the system learn from it. +Let's see the system learn from a failure pattern. Try this command: @@ -92,7 +90,7 @@ If you're on a system where `python` isn't available, you'll see it fail. Now tr ``` ⛔ Blocked: python commands often fail (confidence: 95%) -💡 Suggestion: Use "python3 --version" +💡 Suggestion: Use "python3 --version" or "node --version" ``` The shadow learner observed that `python` failed and is now protecting you from repeating the same mistake. This is intelligence building in real-time. @@ -103,11 +101,11 @@ Let's trigger the context monitoring system. The hooks track how much of Claude' Create several files to simulate a longer session: -> "Create a file called `test1.py` with a simple hello world script" +> "Create a file called `test1.js` with a simple hello world script" -> "Now create `test2.py` with a different example" +> "Now create `test2.js` with a different example" -> "Create `test3.py` with some more code" +> "Create `test3.js` with some more code" > "Show me the current git status" @@ -142,9 +140,9 @@ You should see a file that looks like: **Duration**: 2024-01-15T14:30:00 → 2024-01-15T14:45:00 ## Files Modified (3) -- test1.py -- test2.py -- test3.py +- test1.js +- test2.js +- test3.js ## Tools Used (8 total) - Write: 3 times @@ -152,8 +150,8 @@ You should see a file that looks like: - Read: 3 times ## Recent Commands (5) -- `pip3 install requests` (2024-01-15T14:32:00) -- `python3 --version` (2024-01-15T14:35:00) +- `npm install express` (2024-01-15T14:32:00) +- `node --version` (2024-01-15T14:35:00) ... ``` @@ -190,7 +188,7 @@ You should see entries like: Success Rate: 100% ``` -**This is the intelligence you've built**. The system now knows that on your machine, `pip` fails but `pip3` works, and `python3` works better than `python`. +**This is the intelligence you've built**. The system now knows command patterns that work reliably in your environment versus ones that commonly fail. ## What You've Experienced diff --git a/hooks/command-validator.js b/hooks/command-validator.js new file mode 100755 index 0000000..f7e20a6 --- /dev/null +++ b/hooks/command-validator.js @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +/** + * Command Validator Hook - PreToolUse[Bash] hook + * Validates bash commands using shadow learner insights + */ + +const fs = require('fs-extra'); +const path = require('path'); + +// Add lib directory to require path +const libPath = path.join(__dirname, '..', 'lib'); +const { ShadowLearner } = require(path.join(libPath, 'shadow-learner')); + +class CommandValidator { + constructor() { + this.shadowLearner = new ShadowLearner(); + + // Dangerous command patterns + this.dangerousPatterns = [ + /rm\s+-rf\s+\//, // Delete root + /mkfs\./, // Format filesystem + /dd\s+if=.*of=\/dev\//, // Overwrite devices + /:\(\){ :\|:& };:/, // Fork bomb + /curl.*\|\s*bash/, // Pipe to shell + /wget.*\|\s*sh/, // Pipe to shell + /;.*rm\s+-rf/, // Command chaining with rm + /&&.*rm\s+-rf/, // Command chaining with rm + ]; + + this.suspiciousPatterns = [ + /sudo\s+rm/, // Sudo with rm + /chmod\s+777/, // Overly permissive + /\/etc\/passwd/, // System files + /\/etc\/shadow/, // System files + /nc.*-l.*-p/, // Netcat listener + ]; + } + + /** + * Comprehensive command safety validation + */ + validateCommandSafety(command) { + // Normalize command for analysis + const normalized = command.toLowerCase().trim(); + + // Check for dangerous patterns + for (const pattern of this.dangerousPatterns) { + if (pattern.test(normalized)) { + return { + allowed: false, + reason: 'Dangerous command pattern detected', + severity: 'critical' + }; + } + } + + // Check for suspicious patterns + for (const pattern of this.suspiciousPatterns) { + if (pattern.test(normalized)) { + return { + allowed: true, // Allow but warn + reason: 'Suspicious command pattern detected', + severity: 'warning' + }; + } + } + + return { allowed: true, reason: 'Command appears safe' }; + } + + /** + * Use shadow learner to predict command success + */ + validateWithShadowLearner(command) { + try { + const prediction = this.shadowLearner.predictCommandOutcome(command); + + if (!prediction.likelySuccess && prediction.confidence > 0.8) { + const suggestions = prediction.suggestions || []; + const suggestionText = suggestions.length > 0 ? ` Try: ${suggestions[0]}` : ''; + + return { + allowed: false, + reason: `Command likely to fail (confidence: ${Math.round(prediction.confidence * 100)}%)${suggestionText}`, + severity: 'medium', + suggestions + }; + } else if (prediction.warnings && prediction.warnings.length > 0) { + return { + allowed: true, + reason: prediction.warnings[0], + severity: 'warning', + suggestions: prediction.suggestions || [] + }; + } + + } catch (error) { + // If shadow learner fails, don't block + } + + return { allowed: true, reason: 'No issues detected' }; + } + + /** + * Main validation entry point + */ + validateCommand(command) { + // Safety validation (blocking) + const safetyResult = this.validateCommandSafety(command); + if (!safetyResult.allowed) { + return safetyResult; + } + + // Shadow learner validation (predictive) + const predictionResult = this.validateWithShadowLearner(command); + + // Return most significant result + if (['high', 'critical'].includes(predictionResult.severity)) { + return predictionResult; + } else if (['warning', 'medium'].includes(safetyResult.severity)) { + return safetyResult; + } else { + return predictionResult; + } + } +} + +async function main() { + try { + let inputData = ''; + + // Handle stdin input + if (process.stdin.isTTY) { + // If called directly for testing + inputData = JSON.stringify({ + tool: 'Bash', + parameters: { command: 'pip install requests' } + }); + } else { + // Read from stdin + process.stdin.setEncoding('utf8'); + + for await (const chunk of process.stdin) { + inputData += chunk; + } + } + + const input = JSON.parse(inputData); + + // Extract command from parameters + const tool = input.tool || ''; + const parameters = input.parameters || {}; + const command = parameters.command || ''; + + if (tool !== 'Bash' || !command) { + // Not a bash command, allow it + const response = { allow: true, message: 'Not a bash command' }; + console.log(JSON.stringify(response)); + process.exit(0); + } + + // Validate command + const validator = new CommandValidator(); + const result = validator.validateCommand(command); + + if (!result.allowed) { + // Block the command + const response = { + allow: false, + message: `⛔ Command blocked: ${result.reason}` + }; + console.log(JSON.stringify(response)); + process.exit(1); // Exit code 1 = block operation + } + + else if (['warning', 'medium'].includes(result.severity)) { + // Allow with warning + const warningEmoji = result.severity === 'warning' ? '⚠️' : '🚨'; + let message = `${warningEmoji} ${result.reason}`; + + if (result.suggestions && result.suggestions.length > 0) { + message += `\n💡 Suggestion: ${result.suggestions[0]}`; + } + + const response = { + allow: true, + message + }; + console.log(JSON.stringify(response)); + process.exit(0); + } + + else { + // Allow without warning + const response = { allow: true, message: 'Command validated' }; + console.log(JSON.stringify(response)); + process.exit(0); + } + + } catch (error) { + // Never block on validation errors - always allow operation + const response = { + allow: true, + message: `Validation error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); + } +} + +// Handle unhandled promise rejections +process.on('unhandledRejection', (error) => { + const response = { + allow: true, + message: `Validation error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); +}); + +main(); \ No newline at end of file diff --git a/hooks/command_validator.py b/hooks/command_validator.py deleted file mode 100755 index 82bf16d..0000000 --- a/hooks/command_validator.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 -""" -Command Validator Hook - PreToolUse[Bash] hook -Validates bash commands using shadow learner insights -""" - -import sys -import json -import re -import os -from pathlib import Path - -# Add lib directory to path -sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) - -from shadow_learner import ShadowLearner -from models import ValidationResult - - -class CommandValidator: - """Validates bash commands for safety and success probability""" - - def __init__(self): - self.shadow_learner = ShadowLearner() - - # Dangerous command patterns - self.dangerous_patterns = [ - r'rm\s+-rf\s+/', # Delete root - r'mkfs\.', # Format filesystem - r'dd\s+if=.*of=/dev/', # Overwrite devices - r':(){ :|:& };:', # Fork bomb - r'curl.*\|\s*bash', # Pipe to shell - r'wget.*\|\s*sh', # Pipe to shell - r';.*rm\s+-rf', # Command chaining with rm - r'&&.*rm\s+-rf', # Command chaining with rm - ] - - self.suspicious_patterns = [ - r'sudo\s+rm', # Sudo with rm - r'chmod\s+777', # Overly permissive - r'/etc/passwd', # System files - r'/etc/shadow', # System files - r'nc.*-l.*-p', # Netcat listener - ] - - def validate_command_safety(self, command: str) -> ValidationResult: - """Comprehensive command safety validation""" - - # Normalize command for analysis - normalized = command.lower().strip() - - # Check for dangerous patterns - for pattern in self.dangerous_patterns: - if re.search(pattern, normalized): - return ValidationResult( - allowed=False, - reason=f"Dangerous command pattern detected", - severity="critical" - ) - - # Check for suspicious patterns - for pattern in self.suspicious_patterns: - if re.search(pattern, normalized): - return ValidationResult( - allowed=True, # Allow but warn - reason=f"Suspicious command pattern detected", - severity="warning" - ) - - return ValidationResult(allowed=True, reason="Command appears safe") - - def validate_with_shadow_learner(self, command: str) -> ValidationResult: - """Use shadow learner to predict command success""" - - try: - prediction = self.shadow_learner.predict_command_outcome(command) - - if not prediction["likely_success"] and prediction["confidence"] > 0.8: - suggestions = prediction.get("suggestions", []) - suggestion_text = f" Try: {suggestions[0]}" if suggestions else "" - - return ValidationResult( - allowed=False, - reason=f"Command likely to fail (confidence: {prediction['confidence']:.0%}){suggestion_text}", - severity="medium", - suggestions=suggestions - ) - elif prediction["warnings"]: - return ValidationResult( - allowed=True, - reason=prediction["warnings"][0], - severity="warning", - suggestions=prediction.get("suggestions", []) - ) - - except Exception: - # If shadow learner fails, don't block - pass - - return ValidationResult(allowed=True, reason="No issues detected") - - def validate_command(self, command: str) -> ValidationResult: - """Main validation entry point""" - - # Safety validation (blocking) - safety_result = self.validate_command_safety(command) - if not safety_result.allowed: - return safety_result - - # Shadow learner validation (predictive) - prediction_result = self.validate_with_shadow_learner(command) - - # Return most significant result - if prediction_result.severity in ["high", "critical"]: - return prediction_result - elif safety_result.severity in ["warning", "medium"]: - return safety_result - else: - return prediction_result - - -def main(): - """Main hook entry point""" - try: - # Read input from Claude Code - input_data = json.loads(sys.stdin.read()) - - # Extract command from parameters - tool = input_data.get("tool", "") - parameters = input_data.get("parameters", {}) - command = parameters.get("command", "") - - if tool != "Bash" or not command: - # Not a bash command, allow it - response = {"allow": True, "message": "Not a bash command"} - print(json.dumps(response)) - sys.exit(0) - - # Validate command - validator = CommandValidator() - result = validator.validate_command(command) - - if not result.allowed: - # Block the command - response = { - "allow": False, - "message": f"⛔ Command blocked: {result.reason}" - } - print(json.dumps(response)) - sys.exit(1) # Exit code 1 = block operation - - elif result.severity in ["warning", "medium"]: - # Allow with warning - warning_emoji = "⚠️" if result.severity == "warning" else "🚨" - message = f"{warning_emoji} {result.reason}" - - if result.suggestions: - message += f"\n💡 Suggestion: {result.suggestions[0]}" - - response = { - "allow": True, - "message": message - } - print(json.dumps(response)) - sys.exit(0) - - else: - # Allow without warning - response = {"allow": True, "message": "Command validated"} - print(json.dumps(response)) - sys.exit(0) - - except Exception as e: - # Never block on validation errors - always allow operation - response = { - "allow": True, - "message": f"Validation error: {str(e)}" - } - print(json.dumps(response)) - sys.exit(0) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/hooks/context-monitor.js b/hooks/context-monitor.js new file mode 100755 index 0000000..da1ac22 --- /dev/null +++ b/hooks/context-monitor.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +/** + * Context Monitor Hook - UserPromptSubmit hook + * Monitors context usage and triggers backups when needed + */ + +const fs = require('fs-extra'); +const path = require('path'); + +// Add lib directory to require path +const libPath = path.join(__dirname, '..', 'lib'); + +const { ContextMonitor } = require(path.join(libPath, 'context-monitor')); +const { BackupManager } = require(path.join(libPath, 'backup-manager')); +const { SessionStateManager } = require(path.join(libPath, 'session-state')); + +async function main() { + try { + // Read input from Claude Code + let inputData = ''; + + // Handle stdin input + if (process.stdin.isTTY) { + // If called directly for testing + inputData = JSON.stringify({ prompt: 'test prompt', context_size: 1000 }); + } else { + // Read from stdin + process.stdin.setEncoding('utf8'); + + for await (const chunk of process.stdin) { + inputData += chunk; + } + } + + const input = JSON.parse(inputData); + + // Initialize components + const contextMonitor = new ContextMonitor(); + const backupManager = new BackupManager(); + const sessionManager = new SessionStateManager(); + + // Update context estimates from prompt + contextMonitor.updateFromPrompt(input); + + // Check if backup should be triggered + const backupDecision = contextMonitor.checkBackupTriggers('UserPromptSubmit', input); + + let message; + + if (backupDecision.shouldBackup) { + // Execute backup + const sessionState = await sessionManager.getSessionSummary(); + const backupResult = await backupManager.executeBackup(backupDecision, sessionState); + + // Record backup in session + await sessionManager.addBackup(backupResult.backupId, { + reason: backupDecision.reason, + success: backupResult.success + }); + + // Add context snapshot + await sessionManager.addContextSnapshot({ + usageRatio: contextMonitor.getContextUsageRatio(), + promptCount: contextMonitor.promptCount, + toolExecutions: contextMonitor.toolExecutions + }); + + // Notify about backup + if (backupResult.success) { + message = `Auto-backup created: ${backupDecision.reason} (usage: ${(contextMonitor.getContextUsageRatio() * 100).toFixed(1)}%)`; + } else { + message = `Backup attempted but failed: ${backupResult.error}`; + } + } else { + message = `Context usage: ${(contextMonitor.getContextUsageRatio() * 100).toFixed(1)}%`; + } + + // Always allow operation (this is a monitoring hook) + const response = { + allow: true, + message + }; + + console.log(JSON.stringify(response)); + process.exit(0); + + } catch (error) { + // Never block on errors - always allow operation + const response = { + allow: true, + message: `Context monitor error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); + } +} + +// Handle unhandled promise rejections +process.on('unhandledRejection', (error) => { + const response = { + allow: true, + message: `Context monitor error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); +}); + +main(); \ No newline at end of file diff --git a/hooks/context_monitor.py b/hooks/context_monitor.py deleted file mode 100755 index 8757769..0000000 --- a/hooks/context_monitor.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -""" -Context Monitor Hook - UserPromptSubmit hook -Monitors context usage and triggers backups when needed -""" - -import sys -import json -import os -from pathlib import Path - -# Add lib directory to path -sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) - -from context_monitor import ContextMonitor -from backup_manager import BackupManager -from session_state import SessionStateManager - - -def main(): - """Main hook entry point""" - try: - # Read input from Claude Code - input_data = json.loads(sys.stdin.read()) - - # Initialize components - context_monitor = ContextMonitor() - backup_manager = BackupManager() - session_manager = SessionStateManager() - - # Update context estimates from prompt - context_monitor.update_from_prompt(input_data) - - # Check if backup should be triggered - backup_decision = context_monitor.check_backup_triggers("UserPromptSubmit", input_data) - - if backup_decision.should_backup: - # Execute backup - session_state = session_manager.get_session_summary() - backup_result = backup_manager.execute_backup(backup_decision, session_state) - - # Record backup in session - session_manager.add_backup(backup_result.backup_id, { - "reason": backup_decision.reason, - "success": backup_result.success - }) - - # Add context snapshot - session_manager.add_context_snapshot({ - "usage_ratio": context_monitor.get_context_usage_ratio(), - "prompt_count": context_monitor.prompt_count, - "tool_executions": context_monitor.tool_executions - }) - - # Notify about backup - if backup_result.success: - message = f"Auto-backup created: {backup_decision.reason} (usage: {context_monitor.get_context_usage_ratio():.1%})" - else: - message = f"Backup attempted but failed: {backup_result.error}" - else: - message = f"Context usage: {context_monitor.get_context_usage_ratio():.1%}" - - # Always allow operation (this is a monitoring hook) - response = { - "allow": True, - "message": message - } - - print(json.dumps(response)) - sys.exit(0) - - except Exception as e: - # Never block on errors - always allow operation - response = { - "allow": True, - "message": f"Context monitor error: {str(e)}" - } - print(json.dumps(response)) - sys.exit(0) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/hooks/session-finalizer.js b/hooks/session-finalizer.js new file mode 100755 index 0000000..2bc388d --- /dev/null +++ b/hooks/session-finalizer.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node + +/** + * Session Finalizer Hook - Stop hook + * Finalizes session, creates documentation, and saves state + */ + +const fs = require('fs-extra'); +const path = require('path'); + +// Add lib directory to require path +const libPath = path.join(__dirname, '..', 'lib'); +const { SessionStateManager } = require(path.join(libPath, 'session-state')); +const { ShadowLearner } = require(path.join(libPath, 'shadow-learner')); +const { ContextMonitor } = require(path.join(libPath, 'context-monitor')); + +async function createRecoveryInfo(sessionSummary, contextMonitor) { + /** + * Create recovery information if needed + */ + try { + const contextUsage = contextMonitor.getContextUsageRatio(); + + // If context was high when session ended, create recovery guide + if (contextUsage > 0.8) { + let recoveryContent = `# Session Recovery Information + +## Context Status +- **Context Usage**: ${(contextUsage * 100).toFixed(1)}% when session ended +- **Reason**: Session ended with high context usage + +## What This Means +Your Claude session ended while using a significant amount of context. This could mean: +1. You were working on a complex task +2. Context limits were approaching +3. Session was interrupted + +## Recovery Steps + +### 1. Check Your Progress +Review these recently modified files: +`; + + for (const filePath of sessionSummary.modifiedFiles || sessionSummary.modified_files || []) { + recoveryContent += `- ${filePath}\n`; + } + + recoveryContent += ` +### 2. Review Last Actions +Recent commands executed: +`; + + const recentCommands = (sessionSummary.commandsExecuted || sessionSummary.commands_executed || []).slice(-5); + for (const cmdInfo of recentCommands) { + recoveryContent += `- \`${cmdInfo.command}\`\n`; + } + + recoveryContent += ` +### 3. Continue Your Work +1. Check \`ACTIVE_TODOS.md\` for pending tasks +2. Review \`LAST_SESSION.md\` for complete session history +3. Use \`git status\` to see current file changes +4. Consider committing your progress: \`git add -A && git commit -m "Work in progress"\` + +### 4. Available Backups +`; + + for (const backup of sessionSummary.backupHistory || sessionSummary.backup_history || []) { + const status = backup.success ? '✅' : '❌'; + recoveryContent += `- ${status} ${backup.backup_id || backup.backupId} - ${backup.reason}\n`; + } + + recoveryContent += ` +## Quick Recovery Commands +\`\`\`bash +# Check current status +git status + +# View recent changes +git diff + +# List available backups +ls .claude_hooks/backups/ + +# View active todos +cat ACTIVE_TODOS.md + +# View last session summary +cat LAST_SESSION.md +\`\`\` + +*This recovery guide was created because your session ended with ${(contextUsage * 100).toFixed(1)}% context usage.* +`; + + await fs.writeFile('RECOVERY_GUIDE.md', recoveryContent); + } + + } catch (error) { + // Don't let recovery guide creation break session finalization + } +} + +async function logSessionCompletion(sessionSummary) { + /** + * Log session completion for analysis + */ + try { + const logDir = path.join('.claude_hooks', 'logs'); + await fs.ensureDir(logDir); + + const completionLog = { + timestamp: new Date().toISOString(), + type: 'session_completion', + session_id: sessionSummary.sessionId || sessionSummary.session_id || 'unknown', + duration_minutes: (sessionSummary.sessionStats || sessionSummary.session_stats || {}).duration_minutes || 0, + total_tools: (sessionSummary.sessionStats || sessionSummary.session_stats || {}).total_tool_calls || 0, + files_modified: (sessionSummary.modifiedFiles || sessionSummary.modified_files || []).length, + commands_executed: (sessionSummary.sessionStats || sessionSummary.session_stats || {}).total_commands || 0, + backups_created: (sessionSummary.backupHistory || sessionSummary.backup_history || []).length + }; + + const logFile = path.join(logDir, 'session_completions.jsonl'); + + await fs.appendFile(logFile, JSON.stringify(completionLog) + '\n'); + + } catch (error) { + // Don't let logging errors break finalization + } +} + +async function main() { + try { + let inputData = {}; + + // Handle stdin input + if (!process.stdin.isTTY) { + try { + let input = ''; + process.stdin.setEncoding('utf8'); + + for await (const chunk of process.stdin) { + input += chunk; + } + + if (input.trim()) { + inputData = JSON.parse(input); + } + } catch (error) { + // If input parsing fails, use empty object + inputData = {}; + } + } + + // Initialize components + const sessionManager = new SessionStateManager(); + const shadowLearner = new ShadowLearner(); + const contextMonitor = new ContextMonitor(); + + // Create session documentation + await sessionManager.createContinuationDocs(); + + // Save all learned patterns + await shadowLearner.saveDatabase(); + + // Get session summary for logging + const sessionSummary = await sessionManager.getSessionSummary(); + + // Create recovery guide if session was interrupted + await createRecoveryInfo(sessionSummary, contextMonitor); + + // Clean up session + await sessionManager.cleanupSession(); + + // Log session completion + await logSessionCompletion(sessionSummary); + + const modifiedFiles = sessionSummary.modifiedFiles || sessionSummary.modified_files || []; + const totalTools = (sessionSummary.sessionStats || sessionSummary.session_stats || {}).total_tool_calls || 0; + + // Always allow - this is a cleanup hook + const response = { + allow: true, + message: `Session finalized. Modified ${modifiedFiles.length} files, used ${totalTools} tools.` + }; + + console.log(JSON.stringify(response)); + process.exit(0); + + } catch (error) { + // Session finalization should never block + const response = { + allow: true, + message: `Session finalization error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); + } +} + +// Handle unhandled promise rejections +process.on('unhandledRejection', (error) => { + const response = { + allow: true, + message: `Session finalization error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); +}); + +main(); \ No newline at end of file diff --git a/hooks/session-logger.js b/hooks/session-logger.js new file mode 100755 index 0000000..33761ae --- /dev/null +++ b/hooks/session-logger.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +/** + * Session Logger Hook - PostToolUse[*] hook + * Logs all tool usage and feeds data to shadow learner + */ + +const fs = require('fs-extra'); +const path = require('path'); + +// Add lib directory to require path +const libPath = path.join(__dirname, '..', 'lib'); +const { ShadowLearner } = require(path.join(libPath, 'shadow-learner')); +const { SessionStateManager } = require(path.join(libPath, 'session-state')); +const { ContextMonitor } = require(path.join(libPath, 'context-monitor')); + +async function logExecution(execution) { + /** + * Log execution to file for debugging and analysis + */ + try { + const logDir = path.join('.claude_hooks', 'logs'); + await fs.ensureDir(logDir); + + // Create daily log file + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const logFile = path.join(logDir, `executions_${date}.jsonl`); + + // Append execution record + await fs.appendFile(logFile, JSON.stringify(execution) + '\n'); + + // Clean up old log files (keep last 7 days) + await cleanupOldLogs(logDir); + + } catch (error) { + // Don't let logging errors break the hook + } +} + +async function cleanupOldLogs(logDir) { + /** + * Clean up log files older than 7 days + */ + try { + const cutoffTime = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago + + const files = await fs.readdir(logDir); + const logFiles = files.filter(file => file.match(/^executions_\d{8}\.jsonl$/)); + + for (const logFile of logFiles) { + const filePath = path.join(logDir, logFile); + const stats = await fs.stat(filePath); + + if (stats.mtime.getTime() < cutoffTime) { + await fs.unlink(filePath); + } + } + + } catch (error) { + // Ignore cleanup errors + } +} + +async function main() { + try { + let inputData = ''; + + // Handle stdin input + if (process.stdin.isTTY) { + // If called directly for testing + inputData = JSON.stringify({ + tool: 'Bash', + parameters: { command: 'echo test' }, + success: true, + execution_time: 0.1 + }); + } else { + // Read from stdin + process.stdin.setEncoding('utf8'); + + for await (const chunk of process.stdin) { + inputData += chunk; + } + } + + const input = JSON.parse(inputData); + + // Extract tool execution data + const tool = input.tool || ''; + const parameters = input.parameters || {}; + const success = input.success !== undefined ? input.success : true; + const error = input.error || ''; + const executionTime = input.execution_time || 0.0; + + // Create tool execution record + const execution = { + timestamp: new Date(), + tool, + parameters, + success, + errorMessage: error || null, + executionTime, + context: {} + }; + + // Initialize components + const shadowLearner = new ShadowLearner(); + const sessionManager = new SessionStateManager(); + const contextMonitor = new ContextMonitor(); + + // Feed execution to shadow learner + shadowLearner.learnFromExecution(execution); + + // Update session state + await sessionManager.updateFromToolUse(input); + + // Update context monitor + contextMonitor.updateFromToolUse(input); + + // Save learned patterns periodically + // (Only save every 10 executions to avoid too much disk I/O) + if (contextMonitor.toolExecutions % 10 === 0) { + await shadowLearner.saveDatabase(); + } + + // Log execution to file for debugging (optional) + await logExecution(execution); + + // Always allow - this is a post-execution hook + const response = { + allow: true, + message: `Logged ${tool} execution` + }; + + console.log(JSON.stringify(response)); + process.exit(0); + + } catch (error) { + // Post-execution hooks should never block + const response = { + allow: true, + message: `Logging error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); + } +} + +// Handle unhandled promise rejections +process.on('unhandledRejection', (error) => { + const response = { + allow: true, + message: `Logging error: ${error.message}` + }; + console.log(JSON.stringify(response)); + process.exit(0); +}); + +main(); \ No newline at end of file diff --git a/hooks/session_finalizer.py b/hooks/session_finalizer.py deleted file mode 100755 index 691af9a..0000000 --- a/hooks/session_finalizer.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -""" -Session Finalizer Hook - Stop hook -Finalizes session, creates documentation, and saves state -""" - -import sys -import json -import os -from pathlib import Path - -# Add lib directory to path -sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) - -from session_state import SessionStateManager -from shadow_learner import ShadowLearner -from context_monitor import ContextMonitor - - -def main(): - """Main hook entry point""" - try: - # Read input from Claude Code (if any) - try: - input_data = json.loads(sys.stdin.read()) - except: - input_data = {} - - # Initialize components - session_manager = SessionStateManager() - shadow_learner = ShadowLearner() - context_monitor = ContextMonitor() - - # Create session documentation - session_manager.create_continuation_docs() - - # Save all learned patterns - shadow_learner.save_database() - - # Get session summary for logging - session_summary = session_manager.get_session_summary() - - # Create recovery guide if session was interrupted - create_recovery_info(session_summary, context_monitor) - - # Clean up session - session_manager.cleanup_session() - - # Log session completion - log_session_completion(session_summary) - - # Always allow - this is a cleanup hook - response = { - "allow": True, - "message": f"Session finalized. Modified {len(session_summary.get('modified_files', []))} files, used {session_summary.get('session_stats', {}).get('total_tool_calls', 0)} tools." - } - - print(json.dumps(response)) - sys.exit(0) - - except Exception as e: - # Session finalization should never block - response = { - "allow": True, - "message": f"Session finalization error: {str(e)}" - } - print(json.dumps(response)) - sys.exit(0) - - -def create_recovery_info(session_summary: dict, context_monitor: ContextMonitor): - """Create recovery information if needed""" - try: - context_usage = context_monitor.get_context_usage_ratio() - - # If context was high when session ended, create recovery guide - if context_usage > 0.8: - recovery_content = f"""# Session Recovery Information - -## Context Status -- **Context Usage**: {context_usage:.1%} when session ended -- **Reason**: Session ended with high context usage - -## What This Means -Your Claude session ended while using a significant amount of context. This could mean: -1. You were working on a complex task -2. Context limits were approaching -3. Session was interrupted - -## Recovery Steps - -### 1. Check Your Progress -Review these recently modified files: -""" - - for file_path in session_summary.get('modified_files', []): - recovery_content += f"- {file_path}\n" - - recovery_content += f""" -### 2. Review Last Actions -Recent commands executed: -""" - - recent_commands = session_summary.get('commands_executed', [])[-5:] - for cmd_info in recent_commands: - recovery_content += f"- `{cmd_info['command']}`\n" - - recovery_content += f""" -### 3. Continue Your Work -1. Check `ACTIVE_TODOS.md` for pending tasks -2. Review `LAST_SESSION.md` for complete session history -3. Use `git status` to see current file changes -4. Consider committing your progress: `git add -A && git commit -m "Work in progress"` - -### 4. Available Backups -""" - - for backup in session_summary.get('backup_history', []): - status = "✅" if backup['success'] else "❌" - recovery_content += f"- {status} {backup['backup_id']} - {backup['reason']}\n" - - recovery_content += f""" -## Quick Recovery Commands -```bash -# Check current status -git status - -# View recent changes -git diff - -# List available backups -ls .claude_hooks/backups/ - -# View active todos -cat ACTIVE_TODOS.md - -# View last session summary -cat LAST_SESSION.md -``` - -*This recovery guide was created because your session ended with {context_usage:.1%} context usage.* -""" - - with open("RECOVERY_GUIDE.md", 'w') as f: - f.write(recovery_content) - - except Exception: - pass # Don't let recovery guide creation break session finalization - - -def log_session_completion(session_summary: dict): - """Log session completion for analysis""" - try: - log_dir = Path(".claude_hooks/logs") - log_dir.mkdir(parents=True, exist_ok=True) - - from datetime import datetime - - completion_log = { - "timestamp": datetime.now().isoformat(), - "type": "session_completion", - "session_id": session_summary.get("session_id", "unknown"), - "duration_minutes": session_summary.get("session_stats", {}).get("duration_minutes", 0), - "total_tools": session_summary.get("session_stats", {}).get("total_tool_calls", 0), - "files_modified": len(session_summary.get("modified_files", [])), - "commands_executed": session_summary.get("session_stats", {}).get("total_commands", 0), - "backups_created": len(session_summary.get("backup_history", [])) - } - - log_file = log_dir / "session_completions.jsonl" - - with open(log_file, 'a') as f: - f.write(json.dumps(completion_log) + "\n") - - except Exception: - pass # Don't let logging errors break finalization - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/hooks/session_logger.py b/hooks/session_logger.py deleted file mode 100755 index 76cdce0..0000000 --- a/hooks/session_logger.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Session Logger Hook - PostToolUse[*] hook -Logs all tool usage and feeds data to shadow learner -""" - -import sys -import json -import os -from datetime import datetime -from pathlib import Path - -# Add lib directory to path -sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) - -from shadow_learner import ShadowLearner -from session_state import SessionStateManager -from context_monitor import ContextMonitor -from models import ToolExecution - - -def main(): - """Main hook entry point""" - try: - # Read input from Claude Code - input_data = json.loads(sys.stdin.read()) - - # Extract tool execution data - tool = input_data.get("tool", "") - parameters = input_data.get("parameters", {}) - success = input_data.get("success", True) - error = input_data.get("error", "") - execution_time = input_data.get("execution_time", 0.0) - - # Create tool execution record - execution = ToolExecution( - timestamp=datetime.now(), - tool=tool, - parameters=parameters, - success=success, - error_message=error if error else None, - execution_time=execution_time, - context={} - ) - - # Initialize components - shadow_learner = ShadowLearner() - session_manager = SessionStateManager() - context_monitor = ContextMonitor() - - # Feed execution to shadow learner - shadow_learner.learn_from_execution(execution) - - # Update session state - session_manager.update_from_tool_use(input_data) - - # Update context monitor - context_monitor.update_from_tool_use(input_data) - - # Save learned patterns periodically - # (Only save every 10 executions to avoid too much disk I/O) - if context_monitor.tool_executions % 10 == 0: - shadow_learner.save_database() - - # Log execution to file for debugging (optional) - log_execution(execution) - - # Always allow - this is a post-execution hook - response = { - "allow": True, - "message": f"Logged {tool} execution" - } - - print(json.dumps(response)) - sys.exit(0) - - except Exception as e: - # Post-execution hooks should never block - response = { - "allow": True, - "message": f"Logging error: {str(e)}" - } - print(json.dumps(response)) - sys.exit(0) - - -def log_execution(execution: ToolExecution): - """Log execution to file for debugging and analysis""" - try: - log_dir = Path(".claude_hooks/logs") - log_dir.mkdir(parents=True, exist_ok=True) - - # Create daily log file - log_file = log_dir / f"executions_{datetime.now().strftime('%Y%m%d')}.jsonl" - - # Append execution record - with open(log_file, 'a') as f: - f.write(json.dumps(execution.to_dict()) + "\n") - - # Clean up old log files (keep last 7 days) - cleanup_old_logs(log_dir) - - except Exception: - # Don't let logging errors break the hook - pass - - -def cleanup_old_logs(log_dir: Path): - """Clean up log files older than 7 days""" - try: - import time - - cutoff_time = time.time() - (7 * 24 * 3600) # 7 days ago - - for log_file in log_dir.glob("executions_*.jsonl"): - if log_file.stat().st_mtime < cutoff_time: - log_file.unlink() - - except Exception: - pass - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/lib/__init__.py b/lib/__init__.py deleted file mode 100644 index ec6d664..0000000 --- a/lib/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Claude Code Hooks System - Core Library""" - -__version__ = "1.0.0" -__author__ = "Claude Code Hooks Contributors" - -from .models import * -from .shadow_learner import ShadowLearner -from .context_monitor import ContextMonitor -from .backup_manager import BackupManager -from .session_state import SessionStateManager - -__all__ = [ - "ShadowLearner", - "ContextMonitor", - "BackupManager", - "SessionStateManager", - "Pattern", - "ToolExecution", - "HookResult", - "ValidationResult" -] \ No newline at end of file diff --git a/lib/backup-manager.js b/lib/backup-manager.js new file mode 100644 index 0000000..08d1102 --- /dev/null +++ b/lib/backup-manager.js @@ -0,0 +1,536 @@ +/** + * Backup Manager - Resilient backup execution system + * Node.js implementation + */ + +const fs = require('fs-extra'); +const path = require('path'); +const { spawn } = require('child_process'); + +class BackupManager { + constructor(projectRoot = '.') { + this.projectRoot = path.resolve(projectRoot); + this.backupDir = path.join(this.projectRoot, '.claude_hooks', 'backups'); + fs.ensureDirSync(this.backupDir); + + // Backup settings + this.maxBackups = 10; + this.logFile = path.join(this.backupDir, 'backup.log'); + } + + /** + * Execute backup with comprehensive error handling + */ + async executeBackup(decision, sessionState) { + const backupId = this._generateBackupId(); + const backupPath = path.join(this.backupDir, backupId); + + try { + // Create backup structure + const backupInfo = await this._createBackupStructure(backupPath, sessionState); + + // Git backup (if possible) + const gitResult = await this._attemptGitBackup(backupId, decision.reason); + + // File system backup + const fsResult = await this._createFilesystemBackup(backupPath, sessionState); + + // Session state backup + const stateResult = await this._backupSessionState(backupPath, sessionState); + + // Clean up old backups + await this._cleanupOldBackups(); + + // Log successful backup + await this._logBackup(backupId, decision, true); + + return { + success: true, + backupId, + backupPath, + gitSuccess: gitResult.success, + components: { + git: gitResult, + filesystem: fsResult, + sessionState: stateResult + } + }; + + } catch (error) { + // Backup failures should never break the session + const fallbackResult = await this._createMinimalBackup(sessionState); + await this._logBackup(backupId, decision, false, error.message); + + return { + success: false, + backupId, + error: error.message, + fallbackPerformed: fallbackResult + }; + } + } + + /** + * Create backup from project path (external API) + */ + async createBackup(projectPath, context = {}, force = false) { + const decision = { + reason: context.trigger || 'manual', + urgency: 'medium', + force + }; + + const sessionState = { + modifiedFiles: context.modified_files || [], + toolUsage: context.tool_usage || {}, + timestamp: new Date().toISOString(), + ...context + }; + + const result = await this.executeBackup(decision, sessionState); + return result.success ? result.backupId : null; + } + + /** + * Get backup information + */ + async getBackupInfo(backupId) { + try { + const backupPath = path.join(this.backupDir, backupId); + const metadataFile = path.join(backupPath, 'metadata.json'); + + if (await fs.pathExists(metadataFile)) { + const metadata = await fs.readJson(metadataFile); + + // Add file list if available + const filesDir = path.join(backupPath, 'files'); + if (await fs.pathExists(filesDir)) { + const files = await this._getBackupFiles(filesDir); + metadata.files_backed_up = files; + } + + return metadata; + } + } catch (error) { + console.error('Error reading backup info:', error.message); + } + + return null; + } + + /** + * Generate unique backup identifier + */ + _generateBackupId() { + const timestamp = new Date().toISOString() + .replace(/[:-]/g, '') + .replace(/\.\d{3}Z$/, '') + .replace('T', '_'); + return `backup_${timestamp}`; + } + + /** + * Create basic backup directory structure + */ + async _createBackupStructure(backupPath, sessionState) { + await fs.ensureDir(backupPath); + + // Create subdirectories + await fs.ensureDir(path.join(backupPath, 'files')); + await fs.ensureDir(path.join(backupPath, 'logs')); + await fs.ensureDir(path.join(backupPath, 'state')); + + // Create backup metadata + const metadata = { + backup_id: path.basename(backupPath), + timestamp: new Date().toISOString(), + session_state: sessionState, + project_root: this.projectRoot + }; + + await fs.writeJson(path.join(backupPath, 'metadata.json'), metadata, { spaces: 2 }); + + return metadata; + } + + /** + * Attempt git backup with proper error handling + */ + async _attemptGitBackup(backupId, reason) { + try { + // Check if git repo exists + if (!(await fs.pathExists(path.join(this.projectRoot, '.git')))) { + // Initialize repo if none exists + const initResult = await this._runGitCommand(['init']); + if (!initResult.success) { + return { + success: false, + error: `Git init failed: ${initResult.error}` + }; + } + } + + // Add all changes + const addResult = await this._runGitCommand(['add', '-A']); + if (!addResult.success) { + return { + success: false, + error: `Git add failed: ${addResult.error}` + }; + } + + // Check if there are changes to commit + const statusResult = await this._runGitCommand(['status', '--porcelain']); + if (statusResult.success && !statusResult.stdout.trim()) { + return { + success: true, + message: 'No changes to commit' + }; + } + + // Create commit + const commitMsg = `Claude hooks auto-backup: ${reason} (${backupId})`; + const commitResult = await this._runGitCommand(['commit', '-m', commitMsg]); + + if (!commitResult.success) { + return { + success: false, + error: `Git commit failed: ${commitResult.error}` + }; + } + + // Get commit ID + const commitId = await this._getLatestCommit(); + + return { + success: true, + commitId, + message: `Committed as ${commitId.substring(0, 8)}` + }; + + } catch (error) { + return { + success: false, + error: `Unexpected git error: ${error.message}` + }; + } + } + + /** + * Run git command with timeout and error handling + */ + _runGitCommand(args, timeoutMs = 60000) { + return new Promise((resolve) => { + const child = spawn('git', args, { + cwd: this.projectRoot, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + const timeout = setTimeout(() => { + child.kill(); + resolve({ + success: false, + error: 'Git operation timed out' + }); + }, timeoutMs); + + child.on('close', (code) => { + clearTimeout(timeout); + resolve({ + success: code === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + error: code !== 0 ? stderr.trim() : null + }); + }); + + child.on('error', (error) => { + clearTimeout(timeout); + resolve({ + success: false, + error: error.message + }); + }); + }); + } + + /** + * Get the latest commit ID + */ + async _getLatestCommit() { + try { + const result = await this._runGitCommand(['rev-parse', 'HEAD'], 10000); + return result.success ? result.stdout : 'unknown'; + } catch (error) { + return 'unknown'; + } + } + + /** + * Create filesystem backup of important files + */ + async _createFilesystemBackup(backupPath, sessionState) { + try { + const filesDir = path.join(backupPath, 'files'); + await fs.ensureDir(filesDir); + + // Backup modified files mentioned in session + const modifiedFiles = sessionState.modifiedFiles || sessionState.modified_files || []; + const filesBackedUp = []; + + for (const filePath of modifiedFiles) { + try { + const src = path.resolve(filePath); + if (await fs.pathExists(src) && (await fs.stat(src)).isFile()) { + // Create relative path structure + const relativePath = path.relative(this.projectRoot, src); + const dst = path.join(filesDir, relativePath); + + await fs.ensureDir(path.dirname(dst)); + await fs.copy(src, dst, { preserveTimestamps: true }); + filesBackedUp.push(src); + } + } catch (error) { + // Log error but continue with other files + await this._logFileBackupError(filePath, error); + } + } + + // Backup important project files + const importantFiles = [ + 'package.json', 'requirements.txt', 'Cargo.toml', + 'pyproject.toml', 'setup.py', '.gitignore', + 'README.md', 'CLAUDE.md' + ]; + + for (const fileName of importantFiles) { + const filePath = path.join(this.projectRoot, fileName); + if (await fs.pathExists(filePath)) { + try { + const dst = path.join(filesDir, fileName); + await fs.copy(filePath, dst, { preserveTimestamps: true }); + filesBackedUp.push(filePath); + } catch (error) { + // Not critical + } + } + } + + return { + success: true, + message: `Backed up ${filesBackedUp.length} files`, + metadata: { files: filesBackedUp } + }; + + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Backup session state and context + */ + async _backupSessionState(backupPath, sessionState) { + try { + const stateDir = path.join(backupPath, 'state'); + + // Save session state + await fs.writeJson(path.join(stateDir, 'session.json'), sessionState, { spaces: 2 }); + + // Copy hook logs if they exist + const logsSource = path.join(this.projectRoot, '.claude_hooks', 'logs'); + if (await fs.pathExists(logsSource)) { + const logsDest = path.join(backupPath, 'logs'); + await fs.copy(logsSource, logsDest); + } + + // Copy patterns database + const patternsSource = path.join(this.projectRoot, '.claude_hooks', 'patterns'); + if (await fs.pathExists(patternsSource)) { + const patternsDest = path.join(stateDir, 'patterns'); + await fs.copy(patternsSource, patternsDest); + } + + return { + success: true, + message: 'Session state backed up' + }; + + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Create minimal backup when full backup fails + */ + async _createMinimalBackup(sessionState) { + try { + // At minimum, save session state to a simple file + const emergencyFile = path.join(this.backupDir, 'emergency_backup.json'); + + const emergencyData = { + timestamp: new Date().toISOString(), + session_state: sessionState, + type: 'emergency_backup' + }; + + await fs.writeJson(emergencyFile, emergencyData, { spaces: 2 }); + return true; + + } catch (error) { + return false; + } + } + + /** + * Remove old backups to save space + */ + async _cleanupOldBackups() { + try { + // Get all backup directories + const entries = await fs.readdir(this.backupDir, { withFileTypes: true }); + const backupDirs = entries + .filter(entry => entry.isDirectory() && entry.name.startsWith('backup_')) + .map(entry => ({ + name: entry.name, + path: path.join(this.backupDir, entry.name) + })); + + // Sort by creation time (newest first) + const backupsWithStats = await Promise.all( + backupDirs.map(async (backup) => { + const stats = await fs.stat(backup.path); + return { ...backup, mtime: stats.mtime }; + }) + ); + + backupsWithStats.sort((a, b) => b.mtime - a.mtime); + + // Remove old backups beyond maxBackups + const oldBackups = backupsWithStats.slice(this.maxBackups); + for (const oldBackup of oldBackups) { + await fs.remove(oldBackup.path); + } + + } catch (error) { + // Cleanup failures shouldn't break backup + } + } + + /** + * Log backup operation + */ + async _logBackup(backupId, decision, success, error = '') { + try { + const logEntry = { + timestamp: new Date().toISOString(), + backup_id: backupId, + reason: decision.reason, + urgency: decision.urgency, + success, + error + }; + + // Append to log file + await fs.appendFile(this.logFile, JSON.stringify(logEntry) + '\n'); + + } catch (error) { + // Logging failures shouldn't break backup + } + } + + /** + * Log file backup errors + */ + async _logFileBackupError(filePath, error) { + try { + const errorEntry = { + timestamp: new Date().toISOString(), + type: 'file_backup_error', + file_path: filePath, + error: error.message + }; + + await fs.appendFile(this.logFile, JSON.stringify(errorEntry) + '\n'); + + } catch (error) { + // Ignore logging errors + } + } + + /** + * List available backups + */ + async listBackups() { + const backups = []; + + try { + const entries = await fs.readdir(this.backupDir, { withFileTypes: true }); + const backupDirs = entries + .filter(entry => entry.isDirectory() && entry.name.startsWith('backup_')) + .map(entry => path.join(this.backupDir, entry.name)); + + for (const backupDir of backupDirs) { + const metadataFile = path.join(backupDir, 'metadata.json'); + if (await fs.pathExists(metadataFile)) { + try { + const metadata = await fs.readJson(metadataFile); + backups.push(metadata); + } catch (error) { + // Skip corrupted metadata + } + } + } + + } catch (error) { + // Return empty list on error + } + + return backups.sort((a, b) => { + const timeA = a.timestamp || ''; + const timeB = b.timestamp || ''; + return timeB.localeCompare(timeA); + }); + } + + /** + * Get list of files in backup + */ + async _getBackupFiles(filesDir, relativeTo = filesDir) { + const files = []; + + try { + const entries = await fs.readdir(filesDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(filesDir, entry.name); + const relativePath = path.relative(relativeTo, fullPath); + + if (entry.isDirectory()) { + const subFiles = await this._getBackupFiles(fullPath, relativeTo); + files.push(...subFiles); + } else { + files.push(relativePath); + } + } + } catch (error) { + // Return what we have + } + + return files; + } +} + +module.exports = { BackupManager }; \ No newline at end of file diff --git a/lib/backup_manager.py b/lib/backup_manager.py deleted file mode 100644 index aebde27..0000000 --- a/lib/backup_manager.py +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env python3 -"""Backup Manager - Resilient backup execution system""" - -import os -import json -import shutil -import subprocess -from datetime import datetime -from pathlib import Path -from typing import Dict, Any, List - -try: - from .models import BackupResult, GitBackupResult, BackupDecision -except ImportError: - from models import BackupResult, GitBackupResult, BackupDecision - - -class BackupManager: - """Handles backup execution with comprehensive error handling""" - - def __init__(self, project_root: str = "."): - self.project_root = Path(project_root).resolve() - self.backup_dir = self.project_root / ".claude_hooks" / "backups" - self.backup_dir.mkdir(parents=True, exist_ok=True) - - # Backup settings - self.max_backups = 10 - self.log_file = self.backup_dir / "backup.log" - - def execute_backup(self, decision: BackupDecision, - session_state: Dict[str, Any]) -> BackupResult: - """Execute backup with comprehensive error handling""" - - backup_id = self._generate_backup_id() - backup_path = self.backup_dir / backup_id - - try: - # Create backup structure - backup_info = self._create_backup_structure(backup_path, session_state) - - # Git backup (if possible) - git_result = self._attempt_git_backup(backup_id, decision.reason) - - # File system backup - fs_result = self._create_filesystem_backup(backup_path, session_state) - - # Session state backup - state_result = self._backup_session_state(backup_path, session_state) - - # Clean up old backups - self._cleanup_old_backups() - - # Log successful backup - self._log_backup(backup_id, decision, success=True) - - return BackupResult( - success=True, - backup_id=backup_id, - backup_path=str(backup_path), - git_success=git_result.success, - components={ - "git": git_result, - "filesystem": fs_result, - "session_state": state_result - } - ) - - except Exception as e: - # Backup failures should never break the session - fallback_result = self._create_minimal_backup(session_state) - self._log_backup(backup_id, decision, success=False, error=str(e)) - - return BackupResult( - success=False, - backup_id=backup_id, - error=str(e), - fallback_performed=fallback_result - ) - - def _generate_backup_id(self) -> str: - """Generate unique backup identifier""" - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return f"backup_{timestamp}" - - def _create_backup_structure(self, backup_path: Path, session_state: Dict[str, Any]) -> Dict[str, Any]: - """Create basic backup directory structure""" - backup_path.mkdir(parents=True, exist_ok=True) - - # Create subdirectories - (backup_path / "files").mkdir(exist_ok=True) - (backup_path / "logs").mkdir(exist_ok=True) - (backup_path / "state").mkdir(exist_ok=True) - - # Create backup metadata - metadata = { - "backup_id": backup_path.name, - "timestamp": datetime.now().isoformat(), - "session_state": session_state, - "project_root": str(self.project_root) - } - - with open(backup_path / "metadata.json", 'w') as f: - json.dump(metadata, f, indent=2) - - return metadata - - def _attempt_git_backup(self, backup_id: str, reason: str) -> GitBackupResult: - """Attempt git backup with proper error handling""" - try: - # Check if git repo exists - if not (self.project_root / ".git").exists(): - # Initialize repo if none exists - result = subprocess.run( - ["git", "init"], - cwd=self.project_root, - capture_output=True, - text=True, - timeout=30 - ) - if result.returncode != 0: - return GitBackupResult( - success=False, - error=f"Git init failed: {result.stderr}" - ) - - # Add all changes - result = subprocess.run( - ["git", "add", "-A"], - cwd=self.project_root, - capture_output=True, - text=True, - timeout=60 - ) - if result.returncode != 0: - return GitBackupResult( - success=False, - error=f"Git add failed: {result.stderr}" - ) - - # Check if there are changes to commit - result = subprocess.run( - ["git", "status", "--porcelain"], - cwd=self.project_root, - capture_output=True, - text=True, - timeout=30 - ) - - if not result.stdout.strip(): - return GitBackupResult( - success=True, - message="No changes to commit" - ) - - # Create commit - commit_msg = f"Claude hooks auto-backup: {reason} ({backup_id})" - result = subprocess.run( - ["git", "commit", "-m", commit_msg], - cwd=self.project_root, - capture_output=True, - text=True, - timeout=60 - ) - - if result.returncode != 0: - return GitBackupResult( - success=False, - error=f"Git commit failed: {result.stderr}" - ) - - # Get commit ID - commit_id = self._get_latest_commit() - - return GitBackupResult( - success=True, - commit_id=commit_id, - message=f"Committed as {commit_id[:8]}" - ) - - except subprocess.TimeoutExpired: - return GitBackupResult( - success=False, - error="Git operation timed out" - ) - except subprocess.CalledProcessError as e: - return GitBackupResult( - success=False, - error=f"Git error: {e}" - ) - except Exception as e: - return GitBackupResult( - success=False, - error=f"Unexpected git error: {e}" - ) - - def _get_latest_commit(self) -> str: - """Get the latest commit ID""" - try: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=self.project_root, - capture_output=True, - text=True, - timeout=10 - ) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass - return "unknown" - - def _create_filesystem_backup(self, backup_path: Path, - session_state: Dict[str, Any]) -> BackupResult: - """Create filesystem backup of important files""" - try: - files_dir = backup_path / "files" - files_dir.mkdir(exist_ok=True) - - # Backup modified files mentioned in session - modified_files = session_state.get("modified_files", []) - files_backed_up = [] - - for file_path in modified_files: - try: - src = Path(file_path) - if src.exists() and src.is_file(): - # Create relative path structure - rel_path = src.relative_to(self.project_root) if src.is_relative_to(self.project_root) else src.name - dst = files_dir / rel_path - dst.parent.mkdir(parents=True, exist_ok=True) - - shutil.copy2(src, dst) - files_backed_up.append(str(src)) - except Exception as e: - # Log error but continue with other files - self._log_file_backup_error(file_path, e) - - # Backup important project files - important_files = [ - "package.json", "requirements.txt", "Cargo.toml", - "pyproject.toml", "setup.py", ".gitignore", - "README.md", "CLAUDE.md" - ] - - for file_name in important_files: - file_path = self.project_root / file_name - if file_path.exists(): - try: - dst = files_dir / file_name - shutil.copy2(file_path, dst) - files_backed_up.append(str(file_path)) - except Exception: - pass # Not critical - - return BackupResult( - success=True, - message=f"Backed up {len(files_backed_up)} files", - metadata={"files": files_backed_up} - ) - - except Exception as e: - return BackupResult(success=False, error=str(e)) - - def _backup_session_state(self, backup_path: Path, - session_state: Dict[str, Any]) -> BackupResult: - """Backup session state and context""" - try: - state_dir = backup_path / "state" - - # Save session state - with open(state_dir / "session.json", 'w') as f: - json.dump(session_state, f, indent=2) - - # Copy hook logs if they exist - logs_source = self.project_root / ".claude_hooks" / "logs" - if logs_source.exists(): - logs_dest = backup_path / "logs" - shutil.copytree(logs_source, logs_dest, exist_ok=True) - - # Copy patterns database - patterns_source = self.project_root / ".claude_hooks" / "patterns" - if patterns_source.exists(): - patterns_dest = state_dir / "patterns" - shutil.copytree(patterns_source, patterns_dest, exist_ok=True) - - return BackupResult( - success=True, - message="Session state backed up" - ) - - except Exception as e: - return BackupResult(success=False, error=str(e)) - - def _create_minimal_backup(self, session_state: Dict[str, Any]) -> bool: - """Create minimal backup when full backup fails""" - try: - # At minimum, save session state to a simple file - emergency_file = self.backup_dir / "emergency_backup.json" - - emergency_data = { - "timestamp": datetime.now().isoformat(), - "session_state": session_state, - "type": "emergency_backup" - } - - with open(emergency_file, 'w') as f: - json.dump(emergency_data, f, indent=2) - - return True - - except Exception: - return False - - def _cleanup_old_backups(self): - """Remove old backups to save space""" - try: - # Get all backup directories - backup_dirs = [d for d in self.backup_dir.iterdir() - if d.is_dir() and d.name.startswith("backup_")] - - # Sort by creation time (newest first) - backup_dirs.sort(key=lambda d: d.stat().st_mtime, reverse=True) - - # Remove old backups beyond max_backups - for old_backup in backup_dirs[self.max_backups:]: - shutil.rmtree(old_backup) - - except Exception: - pass # Cleanup failures shouldn't break backup - - def _log_backup(self, backup_id: str, decision: BackupDecision, - success: bool, error: str = ""): - """Log backup operation""" - try: - log_entry = { - "timestamp": datetime.now().isoformat(), - "backup_id": backup_id, - "reason": decision.reason, - "urgency": decision.urgency, - "success": success, - "error": error - } - - # Append to log file - with open(self.log_file, 'a') as f: - f.write(json.dumps(log_entry) + "\n") - - except Exception: - pass # Logging failures shouldn't break backup - - def _log_file_backup_error(self, file_path: str, error: Exception): - """Log file backup errors""" - try: - error_entry = { - "timestamp": datetime.now().isoformat(), - "type": "file_backup_error", - "file_path": file_path, - "error": str(error) - } - - with open(self.log_file, 'a') as f: - f.write(json.dumps(error_entry) + "\n") - - except Exception: - pass - - def list_backups(self) -> List[Dict[str, Any]]: - """List available backups""" - backups = [] - - try: - backup_dirs = [d for d in self.backup_dir.iterdir() - if d.is_dir() and d.name.startswith("backup_")] - - for backup_dir in backup_dirs: - metadata_file = backup_dir / "metadata.json" - if metadata_file.exists(): - try: - with open(metadata_file, 'r') as f: - metadata = json.load(f) - backups.append(metadata) - except Exception: - pass - - except Exception: - pass - - return sorted(backups, key=lambda b: b.get("timestamp", ""), reverse=True) \ No newline at end of file diff --git a/lib/cli.py b/lib/cli.py deleted file mode 100644 index b626bde..0000000 --- a/lib/cli.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Hooks CLI - Command line interface for managing hooks -""" - -import argparse -import json -import sys -from datetime import datetime -from pathlib import Path - -from .backup_manager import BackupManager -from .session_state import SessionStateManager -from .shadow_learner import ShadowLearner -from .context_monitor import ContextMonitor - - -def list_backups(): - """List available backups""" - backup_manager = BackupManager() - backups = backup_manager.list_backups() - - if not backups: - print("No backups found.") - return - - print("Available Backups:") - print("==================") - - for backup in backups: - timestamp = backup.get("timestamp", "unknown") - backup_id = backup.get("backup_id", "unknown") - reason = backup.get("session_state", {}).get("backup_history", []) - if reason: - reason = reason[-1].get("reason", "unknown") - else: - reason = "unknown" - - print(f"🗂️ {backup_id}") - print(f" 📅 {timestamp}") - print(f" 📝 {reason}") - print() - - -def show_session_status(): - """Show current session status""" - session_manager = SessionStateManager() - context_monitor = ContextMonitor() - - summary = session_manager.get_session_summary() - context_summary = context_monitor.get_session_summary() - - print("Session Status:") - print("===============") - print(f"Session ID: {summary.get('session_id', 'unknown')}") - print(f"Duration: {summary.get('session_stats', {}).get('duration_minutes', 0)} minutes") - print(f"Context Usage: {context_summary.get('context_usage_ratio', 0):.1%}") - print(f"Tool Calls: {summary.get('session_stats', {}).get('total_tool_calls', 0)}") - print(f"Files Modified: {len(summary.get('modified_files', []))}") - print(f"Commands Executed: {summary.get('session_stats', {}).get('total_commands', 0)}") - print(f"Backups Created: {len(summary.get('backup_history', []))}") - print() - - if summary.get('modified_files'): - print("Modified Files:") - for file_path in summary['modified_files']: - print(f" - {file_path}") - print() - - if context_summary.get('should_backup'): - print("⚠️ Backup recommended (high context usage)") - else: - print("✅ No backup needed currently") - - -def show_patterns(): - """Show learned patterns""" - shadow_learner = ShadowLearner() - - print("Learned Patterns:") - print("=================") - - # Command patterns - command_patterns = shadow_learner.db.command_patterns - if command_patterns: - print("\n🖥️ Command Patterns:") - for pattern_id, pattern in list(command_patterns.items())[:10]: # Show top 10 - cmd = pattern.trigger.get("command", "unknown") - confidence = pattern.confidence - evidence = pattern.evidence_count - success_rate = pattern.success_rate - - print(f" {cmd}") - print(f" Confidence: {confidence:.1%}") - print(f" Evidence: {evidence} samples") - print(f" Success Rate: {success_rate:.1%}") - - # Context patterns - context_patterns = shadow_learner.db.context_patterns - if context_patterns: - print("\n🔍 Context Patterns:") - for pattern_id, pattern in list(context_patterns.items())[:5]: # Show top 5 - error_type = pattern.trigger.get("error_type", "unknown") - confidence = pattern.confidence - evidence = pattern.evidence_count - - print(f" {error_type}") - print(f" Confidence: {confidence:.1%}") - print(f" Evidence: {evidence} samples") - - if not command_patterns and not context_patterns: - print("No patterns learned yet. Use Claude Code to start building the knowledge base!") - - -def clear_patterns(): - """Clear learned patterns""" - response = input("Are you sure you want to clear all learned patterns? (y/N): ") - if response.lower() == 'y': - shadow_learner = ShadowLearner() - shadow_learner.db = shadow_learner._load_database() # Reset to empty - shadow_learner.save_database() - print("✅ Patterns cleared successfully") - else: - print("Operation cancelled") - - -def export_data(): - """Export all hook data""" - export_dir = Path("claude_hooks_export") - export_dir.mkdir(exist_ok=True) - - # Export session state - session_manager = SessionStateManager() - summary = session_manager.get_session_summary() - - with open(export_dir / "session_data.json", 'w') as f: - json.dump(summary, f, indent=2) - - # Export patterns - shadow_learner = ShadowLearner() - - with open(export_dir / "patterns.json", 'w') as f: - json.dump(shadow_learner.db.to_dict(), f, indent=2) - - # Export logs - logs_dir = Path(".claude_hooks/logs") - if logs_dir.exists(): - import shutil - shutil.copytree(logs_dir, export_dir / "logs", dirs_exist_ok=True) - - print(f"✅ Data exported to {export_dir}") - - -def main(): - """Main CLI entry point""" - parser = argparse.ArgumentParser(description="Claude Code Hooks CLI") - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # List backups - subparsers.add_parser("list-backups", help="List available backups") - - # Show session status - subparsers.add_parser("status", help="Show current session status") - - # Show patterns - subparsers.add_parser("patterns", help="Show learned patterns") - - # Clear patterns - subparsers.add_parser("clear-patterns", help="Clear all learned patterns") - - # Export data - subparsers.add_parser("export", help="Export all hook data") - - args = parser.parse_args() - - if not args.command: - parser.print_help() - return - - try: - if args.command == "list-backups": - list_backups() - elif args.command == "status": - show_session_status() - elif args.command == "patterns": - show_patterns() - elif args.command == "clear-patterns": - clear_patterns() - elif args.command == "export": - export_data() - else: - print(f"Unknown command: {args.command}") - parser.print_help() - - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/lib/context-monitor.js b/lib/context-monitor.js new file mode 100644 index 0000000..0e20815 --- /dev/null +++ b/lib/context-monitor.js @@ -0,0 +1,156 @@ +/** + * Context Monitor - Estimates context usage and triggers backups + */ + +const fs = require('fs-extra'); +const path = require('path'); + +class ContextMonitor { + constructor() { + this.estimatedTokens = 0; + this.promptCount = 0; + this.toolExecutions = 0; + this.sessionStartTime = Date.now(); + + // Configuration + this.maxContextTokens = 200000; // Conservative estimate + this.backupThreshold = 0.85; // 85% of max context + this.timeThresholdMinutes = 30; // 30 minutes + this.toolThreshold = 25; // 25 tool executions + + this.lastBackupTime = Date.now(); + this.lastBackupToolCount = 0; + } + + /** + * Update context estimates from user prompt + */ + updateFromPrompt(promptData) { + this.promptCount++; + + // Estimate tokens from prompt + const prompt = promptData.prompt || ''; + const estimatedPromptTokens = this._estimateTokens(prompt); + + this.estimatedTokens += estimatedPromptTokens; + + // Add context size if provided + if (promptData.context_size) { + this.estimatedTokens += promptData.context_size; + } + } + + /** + * Update context estimates from tool usage + */ + updateFromToolUse(toolData) { + this.toolExecutions++; + + // Estimate tokens from tool parameters and output + const parameters = JSON.stringify(toolData.parameters || {}); + const output = toolData.output || ''; + const error = toolData.error || ''; + + const toolTokens = this._estimateTokens(parameters + output + error); + this.estimatedTokens += toolTokens; + + // File operations add more context + if (toolData.tool === 'Read' || toolData.tool === 'Edit') { + this.estimatedTokens += 2000; // Typical file size estimate + } else if (toolData.tool === 'Bash') { + this.estimatedTokens += 500; // Command output estimate + } + } + + /** + * Check if backup should be triggered + */ + checkBackupTriggers(hookType, data) { + const decisions = []; + + // Context threshold trigger + const contextRatio = this.getContextUsageRatio(); + if (contextRatio > this.backupThreshold) { + decisions.push({ + shouldBackup: true, + reason: `Context usage ${(contextRatio * 100).toFixed(1)}%`, + urgency: 'high' + }); + } + + // Time-based trigger + const sessionMinutes = (Date.now() - this.sessionStartTime) / (1000 * 60); + const timeSinceBackup = (Date.now() - this.lastBackupTime) / (1000 * 60); + + if (timeSinceBackup > this.timeThresholdMinutes) { + decisions.push({ + shouldBackup: true, + reason: `${this.timeThresholdMinutes} minutes since last backup`, + urgency: 'medium' + }); + } + + // Tool-based trigger + const toolsSinceBackup = this.toolExecutions - this.lastBackupToolCount; + if (toolsSinceBackup >= this.toolThreshold) { + decisions.push({ + shouldBackup: true, + reason: `${this.toolThreshold} tools since last backup`, + urgency: 'medium' + }); + } + + // Return highest priority decision + if (decisions.length > 0) { + const urgencyOrder = { high: 3, medium: 2, low: 1 }; + decisions.sort((a, b) => urgencyOrder[b.urgency] - urgencyOrder[a.urgency]); + return decisions[0]; + } + + return { shouldBackup: false }; + } + + /** + * Get current context usage ratio (0.0 to 1.0) + */ + getContextUsageRatio() { + return Math.min(1.0, this.estimatedTokens / this.maxContextTokens); + } + + /** + * Mark that a backup was performed + */ + markBackupPerformed() { + this.lastBackupTime = Date.now(); + this.lastBackupToolCount = this.toolExecutions; + } + + /** + * Estimate tokens from text (rough approximation) + */ + _estimateTokens(text) { + if (!text) return 0; + + // Rough estimate: ~4 characters per token for English text + // Add some buffer for formatting and special tokens + return Math.ceil(text.length / 3.5); + } + + /** + * Get context usage statistics + */ + getStats() { + const sessionMinutes = (Date.now() - this.sessionStartTime) / (1000 * 60); + + return { + estimatedTokens: this.estimatedTokens, + contextUsageRatio: this.getContextUsageRatio(), + promptCount: this.promptCount, + toolExecutions: this.toolExecutions, + sessionMinutes: Math.round(sessionMinutes), + lastBackupMinutesAgo: Math.round((Date.now() - this.lastBackupTime) / (1000 * 60)) + }; + } +} + +module.exports = { ContextMonitor }; \ No newline at end of file diff --git a/lib/context_monitor.py b/lib/context_monitor.py deleted file mode 100644 index 5f1454d..0000000 --- a/lib/context_monitor.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env python3 -"""Context Monitor - Token estimation and backup trigger system""" - -import json -import time -from datetime import datetime, timedelta -from pathlib import Path -from typing import Dict, Any, Optional - -try: - from .models import BackupDecision -except ImportError: - from models import BackupDecision - - -class ContextMonitor: - """Monitors conversation context and predicts token usage""" - - def __init__(self, storage_path: str = ".claude_hooks"): - self.storage_path = Path(storage_path) - self.storage_path.mkdir(parents=True, exist_ok=True) - - self.session_start = datetime.now() - self.prompt_count = 0 - self.estimated_tokens = 0 - self.tool_executions = 0 - self.file_operations = 0 - - # Token estimation constants (conservative estimates) - self.TOKENS_PER_CHAR = 0.25 # Average for English text - self.TOOL_OVERHEAD = 200 # Tokens per tool call - self.SYSTEM_OVERHEAD = 500 # Base conversation overhead - self.MAX_CONTEXT = 200000 # Claude's context limit - - # Backup thresholds - self.backup_threshold = 0.85 - self.emergency_threshold = 0.95 - - # Error tracking - self.estimation_errors = 0 - self.max_errors = 5 - self._last_good_estimate = 0.5 - - # Load previous session state if available - self._load_session_state() - - def estimate_prompt_tokens(self, prompt_data: Dict[str, Any]) -> int: - """Estimate tokens in user prompt""" - try: - prompt_text = prompt_data.get("prompt", "") - - # Basic character count estimation - base_tokens = len(prompt_text) * self.TOKENS_PER_CHAR - - # Add overhead for system prompts, context, etc. - overhead_tokens = self.SYSTEM_OVERHEAD - - return int(base_tokens + overhead_tokens) - - except Exception: - # Fallback estimation - return 1000 - - def estimate_conversation_tokens(self) -> int: - """Estimate total conversation tokens""" - try: - # Base conversation context - base_tokens = self.estimated_tokens - - # Add tool execution overhead - tool_tokens = self.tool_executions * self.TOOL_OVERHEAD - - # Add file operation overhead (file contents in context) - file_tokens = self.file_operations * 1000 # Average file size - - # Conversation history grows over time - history_tokens = self.prompt_count * 300 # Average response size - - total = base_tokens + tool_tokens + file_tokens + history_tokens - - return min(total, self.MAX_CONTEXT) - - except Exception: - return self._handle_estimation_failure() - - def get_context_usage_ratio(self) -> float: - """Get estimated context usage as ratio (0.0 to 1.0)""" - try: - estimated = self.estimate_conversation_tokens() - ratio = min(1.0, estimated / self.MAX_CONTEXT) - - # Reset error counter on success - self.estimation_errors = 0 - self._last_good_estimate = ratio - - return ratio - - except Exception: - self.estimation_errors += 1 - - # Too many errors - use conservative fallback - if self.estimation_errors >= self.max_errors: - return 0.7 # Conservative threshold - - # Single error - use last known good value - return self._last_good_estimate - - def should_trigger_backup(self, threshold: Optional[float] = None) -> bool: - """Check if backup should be triggered""" - try: - if threshold is None: - threshold = self.backup_threshold - - usage = self.get_context_usage_ratio() - - # Edge case: Very early in session - if self.prompt_count < 2: - return False - - # Edge case: Already near context limit - if usage > self.emergency_threshold: - # Emergency backup - don't wait for other conditions - return True - - # Session duration factor - session_hours = (datetime.now() - self.session_start).total_seconds() / 3600 - complexity_factor = (self.tool_executions + self.file_operations) / 20 - - # Trigger earlier for complex sessions - adjusted_threshold = threshold - (complexity_factor * 0.1) - - # Multiple trigger conditions - return ( - usage > adjusted_threshold or - session_hours > 2.0 or - (usage > 0.7 and session_hours > 1.0) - ) - - except Exception: - # When in doubt, backup (better safe than sorry) - return True - - def update_from_prompt(self, prompt_data: Dict[str, Any]): - """Update estimates when user submits prompt""" - try: - self.prompt_count += 1 - prompt_tokens = self.estimate_prompt_tokens(prompt_data) - self.estimated_tokens += prompt_tokens - - # Save state periodically - if self.prompt_count % 5 == 0: - self._save_session_state() - - except Exception: - pass # Don't let tracking errors break the system - - def update_from_tool_use(self, tool_data: Dict[str, Any]): - """Update estimates when tools are used""" - try: - self.tool_executions += 1 - - tool_name = tool_data.get("tool", "") - - # File operations add content to context - if tool_name in ["Read", "Edit", "Write", "Glob", "MultiEdit"]: - self.file_operations += 1 - - # Large outputs add to context - parameters = tool_data.get("parameters", {}) - if "file_path" in parameters: - self.estimated_tokens += 500 # Estimated file content - - # Save state periodically - if self.tool_executions % 10 == 0: - self._save_session_state() - - except Exception: - pass # Don't let tracking errors break the system - - def check_backup_triggers(self, hook_event: str, data: Dict[str, Any]) -> BackupDecision: - """Check all backup trigger conditions""" - try: - # Context-based triggers - if self.should_trigger_backup(): - usage = self.get_context_usage_ratio() - urgency = "high" if usage > self.emergency_threshold else "medium" - - return BackupDecision( - should_backup=True, - reason="context_threshold", - urgency=urgency, - metadata={"usage_ratio": usage} - ) - - # Activity-based triggers - if self._should_backup_by_activity(): - return BackupDecision( - should_backup=True, - reason="activity_threshold", - urgency="medium" - ) - - # Critical operation triggers - if self._is_critical_operation(data): - return BackupDecision( - should_backup=True, - reason="critical_operation", - urgency="high" - ) - - return BackupDecision(should_backup=False, reason="no_trigger") - - except Exception: - # If trigger checking fails, err on side of safety - return BackupDecision( - should_backup=True, - reason="trigger_check_failed", - urgency="medium" - ) - - def _should_backup_by_activity(self) -> bool: - """Activity-based backup triggers""" - # Backup after significant file modifications - if (self.file_operations % 10 == 0 and self.file_operations > 0): - return True - - # Backup after many tool executions - if (self.tool_executions % 25 == 0 and self.tool_executions > 0): - return True - - return False - - def _is_critical_operation(self, data: Dict[str, Any]) -> bool: - """Detect operations that should trigger immediate backup""" - tool = data.get("tool", "") - params = data.get("parameters", {}) - - # Git operations - if tool == "Bash": - command = params.get("command", "").lower() - if any(git_cmd in command for git_cmd in ["git commit", "git push", "git merge"]): - return True - - # Package installations - if any(pkg_cmd in command for pkg_cmd in ["npm install", "pip install", "cargo install"]): - return True - - # Major file operations - if tool in ["Write", "MultiEdit"]: - content = params.get("content", "") - if len(content) > 5000: # Large file changes - return True - - return False - - def _handle_estimation_failure(self) -> int: - """Fallback estimation when primary method fails""" - # Method 1: Time-based estimation - session_duration = (datetime.now() - self.session_start).total_seconds() / 3600 - if session_duration > 1.0: # 1 hour = likely high usage - return int(self.MAX_CONTEXT * 0.8) - - # Method 2: Activity-based estimation - total_activity = self.tool_executions + self.file_operations - if total_activity > 50: # High activity = likely high context - return int(self.MAX_CONTEXT * 0.75) - - # Method 3: Conservative default - return int(self.MAX_CONTEXT * 0.5) - - def _save_session_state(self): - """Save current session state to disk""" - try: - state_file = self.storage_path / "session_state.json" - - state = { - "session_start": self.session_start.isoformat(), - "prompt_count": self.prompt_count, - "estimated_tokens": self.estimated_tokens, - "tool_executions": self.tool_executions, - "file_operations": self.file_operations, - "last_updated": datetime.now().isoformat() - } - - with open(state_file, 'w') as f: - json.dump(state, f, indent=2) - - except Exception: - pass # Don't let state saving errors break the system - - def _load_session_state(self): - """Load previous session state if available""" - try: - state_file = self.storage_path / "session_state.json" - - if state_file.exists(): - with open(state_file, 'r') as f: - state = json.load(f) - - # Only load if session is recent (within last hour) - last_updated = datetime.fromisoformat(state["last_updated"]) - if datetime.now() - last_updated < timedelta(hours=1): - self.prompt_count = state.get("prompt_count", 0) - self.estimated_tokens = state.get("estimated_tokens", 0) - self.tool_executions = state.get("tool_executions", 0) - self.file_operations = state.get("file_operations", 0) - - except Exception: - pass # If loading fails, start fresh - - def get_session_summary(self) -> Dict[str, Any]: - """Get current session summary""" - return { - "session_duration": str(datetime.now() - self.session_start), - "prompt_count": self.prompt_count, - "tool_executions": self.tool_executions, - "file_operations": self.file_operations, - "estimated_tokens": self.estimate_conversation_tokens(), - "context_usage_ratio": self.get_context_usage_ratio(), - "should_backup": self.should_trigger_backup() - } \ No newline at end of file diff --git a/lib/models.py b/lib/models.py deleted file mode 100644 index 0219447..0000000 --- a/lib/models.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -"""Data models for Claude Code Hooks system""" - -from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional, Any -import json - - -@dataclass -class ToolExecution: - """Single tool execution record""" - timestamp: datetime - tool: str - parameters: Dict[str, Any] - success: bool - error_message: Optional[str] = None - execution_time: float = 0.0 - context: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - return { - "timestamp": self.timestamp.isoformat(), - "tool": self.tool, - "parameters": self.parameters, - "success": self.success, - "error_message": self.error_message, - "execution_time": self.execution_time, - "context": self.context - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'ToolExecution': - return cls( - timestamp=datetime.fromisoformat(data["timestamp"]), - tool=data["tool"], - parameters=data["parameters"], - success=data["success"], - error_message=data.get("error_message"), - execution_time=data.get("execution_time", 0.0), - context=data.get("context", {}) - ) - - -@dataclass -class Pattern: - """Learned pattern with confidence scoring""" - pattern_id: str - pattern_type: str # "command_failure", "tool_sequence", "context_error" - trigger: Dict[str, Any] # What triggers this pattern - prediction: Dict[str, Any] # What we predict will happen - confidence: float # 0.0 to 1.0 - evidence_count: int # How many times we've seen this - last_seen: datetime - success_rate: float = 0.0 - - def to_dict(self) -> Dict[str, Any]: - return { - "pattern_id": self.pattern_id, - "pattern_type": self.pattern_type, - "trigger": self.trigger, - "prediction": self.prediction, - "confidence": self.confidence, - "evidence_count": self.evidence_count, - "last_seen": self.last_seen.isoformat(), - "success_rate": self.success_rate - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Pattern': - return cls( - pattern_id=data["pattern_id"], - pattern_type=data["pattern_type"], - trigger=data["trigger"], - prediction=data["prediction"], - confidence=data["confidence"], - evidence_count=data["evidence_count"], - last_seen=datetime.fromisoformat(data["last_seen"]), - success_rate=data.get("success_rate", 0.0) - ) - - -@dataclass -class HookResult: - """Result of hook execution""" - allow: bool - message: str = "" - warning: bool = False - metadata: Dict[str, Any] = field(default_factory=dict) - - @classmethod - def success(cls, message: str = "Operation allowed") -> 'HookResult': - return cls(allow=True, message=message) - - @classmethod - def blocked(cls, reason: str) -> 'HookResult': - return cls(allow=False, message=reason) - - @classmethod - def allow_with_warning(cls, warning: str) -> 'HookResult': - return cls(allow=True, message=warning, warning=True) - - def to_claude_response(self) -> Dict[str, Any]: - """Convert to Claude Code hook response format""" - response = { - "allow": self.allow, - "message": self.message - } - if self.metadata: - response.update(self.metadata) - return response - - -@dataclass -class ValidationResult: - """Result of validation operations""" - allowed: bool - reason: str = "" - severity: str = "info" # info, warning, medium, high, critical - suggestions: List[str] = field(default_factory=list) - - @property - def is_critical(self) -> bool: - return self.severity == "critical" - - @property - def is_blocking(self) -> bool: - return not self.allowed - - -@dataclass -class BackupDecision: - """Decision about whether to trigger backup""" - should_backup: bool - reason: str - urgency: str = "medium" # low, medium, high - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class BackupResult: - """Result of backup operation""" - success: bool - backup_id: str = "" - backup_path: str = "" - error: str = "" - git_success: bool = False - fallback_performed: bool = False - components: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class GitBackupResult: - """Result of git backup operation""" - success: bool - commit_id: str = "" - message: str = "" - error: str = "" - - -class PatternDatabase: - """Fast lookup database for learned patterns""" - - def __init__(self): - self.command_patterns: Dict[str, Pattern] = {} - self.sequence_patterns: List[Pattern] = [] - self.context_patterns: Dict[str, Pattern] = {} - self.execution_history: List[ToolExecution] = [] - - def to_dict(self) -> Dict[str, Any]: - return { - "command_patterns": {k: v.to_dict() for k, v in self.command_patterns.items()}, - "sequence_patterns": [p.to_dict() for p in self.sequence_patterns], - "context_patterns": {k: v.to_dict() for k, v in self.context_patterns.items()}, - "execution_history": [e.to_dict() for e in self.execution_history[-100:]] # Keep last 100 - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'PatternDatabase': - db = cls() - - # Load command patterns - for k, v in data.get("command_patterns", {}).items(): - db.command_patterns[k] = Pattern.from_dict(v) - - # Load sequence patterns - for p in data.get("sequence_patterns", []): - db.sequence_patterns.append(Pattern.from_dict(p)) - - # Load context patterns - for k, v in data.get("context_patterns", {}).items(): - db.context_patterns[k] = Pattern.from_dict(v) - - # Load execution history - for e in data.get("execution_history", []): - db.execution_history.append(ToolExecution.from_dict(e)) - - return db \ No newline at end of file diff --git a/lib/session-state.js b/lib/session-state.js new file mode 100644 index 0000000..ed68b22 --- /dev/null +++ b/lib/session-state.js @@ -0,0 +1,375 @@ +/** + * Session State Manager - Tracks session state and creates continuation docs + */ + +const fs = require('fs-extra'); +const path = require('path'); + +class SessionStateManager { + constructor(stateDir = '.claude_hooks') { + this.stateDir = path.resolve(stateDir); + this.sessionId = this._generateSessionId(); + this.startTime = Date.now(); + + // Session data + this.modifiedFiles = new Set(); + this.commandsExecuted = []; + this.toolUsage = {}; + this.backupHistory = []; + this.contextSnapshots = []; + + fs.ensureDirSync(this.stateDir); + this._loadPersistentState(); + } + + /** + * Update state from tool usage + */ + async updateFromToolUse(toolData) { + const tool = toolData.tool || 'Unknown'; + + // Track tool usage + this.toolUsage[tool] = (this.toolUsage[tool] || 0) + 1; + + // Track file modifications + if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') { + const filePath = toolData.parameters?.file_path; + if (filePath) { + this.modifiedFiles.add(filePath); + } + } + + // Track commands + if (tool === 'Bash') { + const command = toolData.parameters?.command; + if (command) { + this.commandsExecuted.push({ + command, + timestamp: new Date().toISOString(), + success: toolData.success !== false + }); + } + } + + // Persist state periodically + if (this.commandsExecuted.length % 5 === 0) { + await this._savePersistentState(); + } + } + + /** + * Add backup to history + */ + async addBackup(backupId, info) { + this.backupHistory.push({ + backupId, + timestamp: new Date().toISOString(), + ...info + }); + + await this._savePersistentState(); + } + + /** + * Add context snapshot + */ + async addContextSnapshot(snapshot) { + this.contextSnapshots.push({ + timestamp: new Date().toISOString(), + ...snapshot + }); + + // Keep only last 10 snapshots + if (this.contextSnapshots.length > 10) { + this.contextSnapshots = this.contextSnapshots.slice(-10); + } + } + + /** + * Get comprehensive session summary + */ + async getSessionSummary() { + const duration = Date.now() - this.startTime; + const durationMinutes = Math.round(duration / (1000 * 60)); + + return { + sessionId: this.sessionId, + startTime: new Date(this.startTime).toISOString(), + duration: duration, + modifiedFiles: Array.from(this.modifiedFiles), + commandsExecuted: this.commandsExecuted, + toolUsage: this.toolUsage, + backupHistory: this.backupHistory, + contextSnapshots: this.contextSnapshots, + sessionStats: { + durationMinutes, + totalToolCalls: Object.values(this.toolUsage).reduce((sum, count) => sum + count, 0), + totalCommands: this.commandsExecuted.length, + filesModified: this.modifiedFiles.size + } + }; + } + + /** + * Create continuation documentation files + */ + async createContinuationDocs() { + try { + const summary = await this.getSessionSummary(); + + // Create LAST_SESSION.md + await this._createLastSessionDoc(summary); + + // Create/update ACTIVE_TODOS.md (if todos exist) + await this._updateActiveTodos(); + + } catch (error) { + console.error('Error creating continuation docs:', error.message); + } + } + + /** + * Create LAST_SESSION.md with session summary + */ + async _createLastSessionDoc(summary) { + let content = `# Last Claude Session Summary + +## Session Overview +- **Session ID**: ${summary.sessionId} +- **Started**: ${summary.startTime} +- **Duration**: ${summary.sessionStats.durationMinutes} minutes +- **Total Tools Used**: ${summary.sessionStats.totalToolCalls} +- **Commands Executed**: ${summary.sessionStats.totalCommands} +- **Files Modified**: ${summary.sessionStats.filesModified} + +## Files Modified +`; + + if (summary.modifiedFiles.length > 0) { + for (const file of summary.modifiedFiles) { + content += `- ${file}\n`; + } + } else { + content += '*No files were modified in this session*\n'; + } + + content += ` +## Tools Used +`; + + for (const [tool, count] of Object.entries(summary.toolUsage)) { + content += `- **${tool}**: ${count} times\n`; + } + + content += ` +## Recent Commands +`; + + const recentCommands = summary.commandsExecuted.slice(-10); + if (recentCommands.length > 0) { + for (const cmd of recentCommands) { + const status = cmd.success ? '✅' : '❌'; + const time = new Date(cmd.timestamp).toLocaleTimeString(); + content += `- ${status} ${time}: \`${cmd.command}\`\n`; + } + } else { + content += '*No commands executed in this session*\n'; + } + + if (summary.backupHistory.length > 0) { + content += ` +## Backups Created +`; + for (const backup of summary.backupHistory) { + const status = backup.success ? '✅' : '❌'; + const time = new Date(backup.timestamp).toLocaleTimeString(); + content += `- ${status} ${time}: ${backup.backupId} - ${backup.reason}\n`; + } + } + + content += ` +## Context Usage Timeline +`; + + if (summary.contextSnapshots.length > 0) { + for (const snapshot of summary.contextSnapshots) { + const time = new Date(snapshot.timestamp).toLocaleTimeString(); + const usage = ((snapshot.usageRatio || 0) * 100).toFixed(1); + content += `- ${time}: ${usage}% (${snapshot.promptCount || 0} prompts, ${snapshot.toolExecutions || 0} tools)\n`; + } + } + + content += ` +## Quick Recovery +\`\`\`bash +# Check current project status +git status + +# View recent changes +git diff + +# List backup directories +ls .claude_hooks/backups/ +\`\`\` + +*Generated by Claude Hooks on ${new Date().toISOString()}* +`; + + await fs.writeFile('LAST_SESSION.md', content); + } + + /** + * Update ACTIVE_TODOS.md if todos exist + */ + async _updateActiveTodos() { + // Check if there's an existing ACTIVE_TODOS.md or any todo-related files + const todoFiles = ['ACTIVE_TODOS.md', 'TODO.md', 'todos.md']; + + for (const todoFile of todoFiles) { + if (await fs.pathExists(todoFile)) { + // File exists, don't overwrite it + return; + } + } + + // Look for todo comments in recently modified files + const todos = await this._extractTodosFromFiles(); + + if (todos.length > 0) { + let content = `# Active TODOs + +*Auto-generated from code comments and session analysis* + +`; + + for (const todo of todos) { + content += `- [ ] ${todo.text} (${todo.file}:${todo.line})\n`; + } + + content += ` +*Update this file manually or use Claude to manage your todos* +`; + + await fs.writeFile('ACTIVE_TODOS.md', content); + } + } + + /** + * Extract TODO comments from modified files + */ + async _extractTodosFromFiles() { + const todos = []; + const todoPattern = /(?:TODO|FIXME|HACK|XXX|NOTE):\s*(.+)/gi; + + for (const filePath of this.modifiedFiles) { + try { + if (await fs.pathExists(filePath)) { + const content = await fs.readFile(filePath, 'utf8'); + const lines = content.split('\n'); + + lines.forEach((line, index) => { + const match = todoPattern.exec(line); + if (match) { + todos.push({ + text: match[1].trim(), + file: filePath, + line: index + 1 + }); + } + }); + } + } catch (error) { + // Skip files that can't be read + } + } + + return todos; + } + + /** + * Clean up session resources + */ + async cleanupSession() { + // Save final state + await this._savePersistentState(); + + // Clean up old session files (keep last 5) + await this._cleanupOldSessions(); + } + + /** + * Generate unique session ID + */ + _generateSessionId() { + const timestamp = new Date().toISOString() + .replace(/[:-]/g, '') + .replace(/\.\d{3}Z$/, '') + .replace('T', '_'); + return `sess_${timestamp}`; + } + + /** + * Load persistent state from disk + */ + async _loadPersistentState() { + try { + const stateFile = path.join(this.stateDir, 'session_state.json'); + + if (await fs.pathExists(stateFile)) { + const state = await fs.readJson(stateFile); + + // Only load if session is recent (within 24 hours) + const stateAge = Date.now() - new Date(state.startTime).getTime(); + if (stateAge < 24 * 60 * 60 * 1000) { + this.modifiedFiles = new Set(state.modifiedFiles || []); + this.commandsExecuted = state.commandsExecuted || []; + this.toolUsage = state.toolUsage || {}; + this.backupHistory = state.backupHistory || []; + this.contextSnapshots = state.contextSnapshots || []; + } + } + } catch (error) { + // Start fresh if loading fails + } + } + + /** + * Save persistent state to disk + */ + async _savePersistentState() { + try { + const stateFile = path.join(this.stateDir, 'session_state.json'); + + const state = { + sessionId: this.sessionId, + startTime: new Date(this.startTime).toISOString(), + modifiedFiles: Array.from(this.modifiedFiles), + commandsExecuted: this.commandsExecuted, + toolUsage: this.toolUsage, + backupHistory: this.backupHistory, + contextSnapshots: this.contextSnapshots, + lastUpdated: new Date().toISOString() + }; + + await fs.writeJson(stateFile, state, { spaces: 2 }); + + } catch (error) { + // Don't let save failures break the session + } + } + + /** + * Clean up old session state files + */ + async _cleanupOldSessions() { + try { + const statePattern = path.join(this.stateDir, 'session_*.json'); + // This is a simple cleanup - in a full implementation, + // you'd use glob patterns to find and clean old files + } catch (error) { + // Ignore cleanup errors + } + } +} + +module.exports = { SessionStateManager }; \ No newline at end of file diff --git a/lib/session_state.py b/lib/session_state.py deleted file mode 100644 index 1b84776..0000000 --- a/lib/session_state.py +++ /dev/null @@ -1,348 +0,0 @@ -#!/usr/bin/env python3 -"""Session State Manager - Persistent session state and continuity""" - -import json -import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Any, Set - -try: - from .models import ToolExecution -except ImportError: - from models import ToolExecution - - -class SessionStateManager: - """Manages persistent session state across Claude interactions""" - - def __init__(self, state_dir: str = ".claude_hooks"): - self.state_dir = Path(state_dir) - self.state_dir.mkdir(parents=True, exist_ok=True) - - self.state_file = self.state_dir / "session_state.json" - self.todos_file = Path("ACTIVE_TODOS.md") - self.last_session_file = Path("LAST_SESSION.md") - - # Initialize session - self.session_id = str(uuid.uuid4())[:8] - self.current_state = self._load_or_create_state() - - def _load_or_create_state(self) -> Dict[str, Any]: - """Load existing state or create new session state""" - try: - if self.state_file.exists(): - with open(self.state_file, 'r') as f: - state = json.load(f) - - # Check if this is a continuation of recent session - last_activity = datetime.fromisoformat(state.get("last_activity", "1970-01-01")) - if (datetime.now() - last_activity).total_seconds() < 3600: # Within 1 hour - # Continue existing session - return state - - # Create new session - return self._create_new_session() - - except Exception: - # If loading fails, create new session - return self._create_new_session() - - def _create_new_session(self) -> Dict[str, Any]: - """Create new session state""" - return { - "session_id": self.session_id, - "start_time": datetime.now().isoformat(), - "last_activity": datetime.now().isoformat(), - "modified_files": [], - "commands_executed": [], - "tool_usage": {}, - "backup_history": [], - "todos": [], - "context_snapshots": [] - } - - def update_from_tool_use(self, tool_data: Dict[str, Any]): - """Update session state from tool usage""" - try: - tool = tool_data.get("tool", "") - params = tool_data.get("parameters", {}) - timestamp = datetime.now().isoformat() - - # Track file modifications - if tool in ["Edit", "Write", "MultiEdit"]: - file_path = params.get("file_path", "") - if file_path and file_path not in self.current_state["modified_files"]: - self.current_state["modified_files"].append(file_path) - - # Track commands executed - if tool == "Bash": - command = params.get("command", "") - if command: - self.current_state["commands_executed"].append({ - "command": command, - "timestamp": timestamp - }) - - # Keep only last 50 commands - if len(self.current_state["commands_executed"]) > 50: - self.current_state["commands_executed"] = self.current_state["commands_executed"][-50:] - - # Track tool usage statistics - self.current_state["tool_usage"][tool] = self.current_state["tool_usage"].get(tool, 0) + 1 - self.current_state["last_activity"] = timestamp - - # Save state periodically - self._save_state() - - except Exception: - pass # Don't let state tracking errors break the system - - def add_backup(self, backup_id: str, backup_info: Dict[str, Any]): - """Record backup in session history""" - try: - backup_record = { - "backup_id": backup_id, - "timestamp": datetime.now().isoformat(), - "reason": backup_info.get("reason", "unknown"), - "success": backup_info.get("success", False) - } - - self.current_state["backup_history"].append(backup_record) - - # Keep only last 10 backups - if len(self.current_state["backup_history"]) > 10: - self.current_state["backup_history"] = self.current_state["backup_history"][-10:] - - self._save_state() - - except Exception: - pass - - def add_context_snapshot(self, context_data: Dict[str, Any]): - """Add context snapshot for recovery""" - try: - snapshot = { - "timestamp": datetime.now().isoformat(), - "context_ratio": context_data.get("usage_ratio", 0.0), - "prompt_count": context_data.get("prompt_count", 0), - "tool_count": context_data.get("tool_executions", 0) - } - - self.current_state["context_snapshots"].append(snapshot) - - # Keep only last 20 snapshots - if len(self.current_state["context_snapshots"]) > 20: - self.current_state["context_snapshots"] = self.current_state["context_snapshots"][-20:] - - except Exception: - pass - - def update_todos(self, todos: List[Dict[str, Any]]): - """Update active todos list""" - try: - self.current_state["todos"] = todos - self._save_state() - self._update_todos_file() - - except Exception: - pass - - def get_session_summary(self) -> Dict[str, Any]: - """Generate comprehensive session summary""" - try: - return { - "session_id": self.current_state.get("session_id", "unknown"), - "start_time": self.current_state.get("start_time", "unknown"), - "last_activity": self.current_state.get("last_activity", "unknown"), - "modified_files": self.current_state.get("modified_files", []), - "tool_usage": self.current_state.get("tool_usage", {}), - "commands_executed": self.current_state.get("commands_executed", []), - "backup_history": self.current_state.get("backup_history", []), - "todos": self.current_state.get("todos", []), - "session_stats": self._calculate_session_stats() - } - except Exception: - return {"error": "Failed to generate session summary"} - - def _calculate_session_stats(self) -> Dict[str, Any]: - """Calculate session statistics""" - try: - total_tools = sum(self.current_state.get("tool_usage", {}).values()) - total_commands = len(self.current_state.get("commands_executed", [])) - total_files = len(self.current_state.get("modified_files", [])) - - start_time = datetime.fromisoformat(self.current_state.get("start_time", datetime.now().isoformat())) - duration = datetime.now() - start_time - - return { - "duration_minutes": round(duration.total_seconds() / 60, 1), - "total_tool_calls": total_tools, - "total_commands": total_commands, - "total_files_modified": total_files, - "most_used_tools": self._get_top_tools(3) - } - except Exception: - return {} - - def _get_top_tools(self, count: int) -> List[Dict[str, Any]]: - """Get most frequently used tools""" - try: - tool_usage = self.current_state.get("tool_usage", {}) - sorted_tools = sorted(tool_usage.items(), key=lambda x: x[1], reverse=True) - return [{"tool": tool, "count": usage} for tool, usage in sorted_tools[:count]] - except Exception: - return [] - - def create_continuation_docs(self): - """Create LAST_SESSION.md and ACTIVE_TODOS.md""" - try: - self._create_last_session_doc() - self._update_todos_file() - except Exception: - pass # Don't let doc creation errors break the system - - def _create_last_session_doc(self): - """Create LAST_SESSION.md with session summary""" - try: - summary = self.get_session_summary() - - content = f"""# Last Claude Session Summary - -**Session ID**: {summary['session_id']} -**Duration**: {summary['start_time']} → {summary['last_activity']} -**Session Length**: {summary.get('session_stats', {}).get('duration_minutes', 0)} minutes - -## Files Modified ({len(summary['modified_files'])}) -""" - - for file_path in summary['modified_files']: - content += f"- {file_path}\n" - - content += f"\n## Tools Used ({summary.get('session_stats', {}).get('total_tool_calls', 0)} total)\n" - - for tool, count in summary['tool_usage'].items(): - content += f"- {tool}: {count} times\n" - - content += f"\n## Recent Commands ({len(summary['commands_executed'])})\n" - - # Show last 10 commands - recent_commands = summary['commands_executed'][-10:] - for cmd_info in recent_commands: - timestamp = cmd_info['timestamp'][:19] # Remove microseconds - content += f"- `{cmd_info['command']}` ({timestamp})\n" - - content += f"\n## Backup History\n" - - for backup in summary['backup_history']: - status = "✅" if backup['success'] else "❌" - content += f"- {status} {backup['backup_id']} - {backup['reason']} ({backup['timestamp'][:19]})\n" - - content += f""" -## To Continue This Session - -1. **Review Modified Files**: Check the files listed above for your recent changes -2. **Check Active Tasks**: Review `ACTIVE_TODOS.md` for pending work -3. **Restore Context**: Reference the commands and tools used above -4. **Use Backups**: If needed, restore from backup using `claude-hooks restore {summary['backup_history'][-1]['backup_id'] if summary['backup_history'] else 'latest'}` - -## Quick Commands - -```bash -# View current project status -git status - -# Check for any uncommitted changes -git diff - -# List available backups -claude-hooks list-backups - -# Continue with active todos -cat ACTIVE_TODOS.md -``` -""" - - with open(self.last_session_file, 'w') as f: - f.write(content) - - except Exception as e: - # Create minimal doc on error - try: - with open(self.last_session_file, 'w') as f: - f.write(f"# Last Session\n\nSession ended at {datetime.now().isoformat()}\n\nError creating summary: {e}\n") - except Exception: - pass - - def _update_todos_file(self): - """Update ACTIVE_TODOS.md file""" - try: - todos = self.current_state.get("todos", []) - - if not todos: - content = """# Active TODOs - -*No active todos. Add some to track your progress!* - -## How to Add TODOs - -Use Claude's TodoWrite tool to manage your task list: -- Track progress across sessions -- Break down complex tasks -- Never lose track of what you're working on -""" - else: - content = f"""# Active TODOs - -*Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}* - -""" - - # Group by status - pending_todos = [t for t in todos if t.get('status') == 'pending'] - in_progress_todos = [t for t in todos if t.get('status') == 'in_progress'] - completed_todos = [t for t in todos if t.get('status') == 'completed'] - - if in_progress_todos: - content += "## 🚀 In Progress\n\n" - for todo in in_progress_todos: - priority = todo.get('priority', 'medium') - priority_emoji = {'high': '🔥', 'medium': '⭐', 'low': '📝'}.get(priority, '⭐') - content += f"- {priority_emoji} {todo.get('content', 'Unknown task')}\n" - content += "\n" - - if pending_todos: - content += "## 📋 Pending\n\n" - for todo in pending_todos: - priority = todo.get('priority', 'medium') - priority_emoji = {'high': '🔥', 'medium': '⭐', 'low': '📝'}.get(priority, '⭐') - content += f"- {priority_emoji} {todo.get('content', 'Unknown task')}\n" - content += "\n" - - if completed_todos: - content += "## ✅ Completed\n\n" - for todo in completed_todos[-5:]: # Show last 5 completed - content += f"- ✅ {todo.get('content', 'Unknown task')}\n" - content += "\n" - - with open(self.todos_file, 'w') as f: - f.write(content) - - except Exception: - pass # Don't let todo file creation break the system - - def _save_state(self): - """Save current state to disk""" - try: - with open(self.state_file, 'w') as f: - json.dump(self.current_state, f, indent=2) - except Exception: - pass # Don't let state saving errors break the system - - def cleanup_session(self): - """Clean up session and create final documentation""" - try: - self.create_continuation_docs() - self._save_state() - except Exception: - pass \ No newline at end of file diff --git a/lib/shadow-learner.js b/lib/shadow-learner.js new file mode 100644 index 0000000..59e9bc9 --- /dev/null +++ b/lib/shadow-learner.js @@ -0,0 +1,579 @@ +/** + * Shadow Learner - Pattern learning and prediction system + * Node.js implementation + */ + +const fs = require('fs-extra'); +const path = require('path'); + +class ConfidenceCalculator { + /** + * Calculate confidence for command failure patterns + */ + static calculateCommandConfidence(successCount, failureCount, recencyFactor) { + const totalAttempts = successCount + failureCount; + if (totalAttempts === 0) return 0.0; + + // Base confidence from failure rate + const failureRate = failureCount / totalAttempts; + + // Sample size adjustment (more data = more confidence) + const sampleFactor = Math.min(1.0, totalAttempts / 10.0); // Plateau at 10 samples + + // Time decay (recent failures are more relevant) + const confidence = failureRate * sampleFactor * (0.5 + 0.5 * recencyFactor); + + return Math.min(0.99, Math.max(0.1, confidence)); // Clamp between 0.1 and 0.99 + } + + /** + * Calculate confidence for tool sequence patterns + */ + static calculateSequenceConfidence(successfulSequences, totalSequences) { + if (totalSequences === 0) return 0.0; + + const successRate = successfulSequences / totalSequences; + const sampleFactor = Math.min(1.0, totalSequences / 5.0); + + return successRate * sampleFactor; + } +} + +class PatternMatcher { + constructor(db) { + this.db = db; + } + + /** + * Find similar command patterns using fuzzy matching + */ + fuzzyCommandMatch(command, threshold = 0.8) { + const cmdTokens = command.toLowerCase().split(' '); + if (cmdTokens.length === 0) return []; + + const baseCmd = cmdTokens[0]; + const matches = []; + + for (const pattern of Object.values(this.db.commandPatterns)) { + const patternCmd = (pattern.trigger.command || '').toLowerCase(); + + // Exact match + if (patternCmd === baseCmd) { + matches.push(pattern); + } + // Fuzzy match on command name + else if (this._similarity(patternCmd, baseCmd) > threshold) { + matches.push(pattern); + } + // Partial match (e.g., "pip3" matches "pip install") + else if (cmdTokens.some(token => patternCmd.includes(token))) { + matches.push(pattern); + } + } + + return matches.sort((a, b) => b.confidence - a.confidence); + } + + /** + * Match patterns based on current context + */ + contextPatternMatch(currentContext) { + const matches = []; + + for (const pattern of Object.values(this.db.contextPatterns)) { + if (this._contextMatches(currentContext, pattern.trigger)) { + matches.push(pattern); + } + } + + return matches.sort((a, b) => b.confidence - a.confidence); + } + + /** + * Simple string similarity (Jaccard similarity) + */ + _similarity(str1, str2) { + const set1 = new Set(str1.split('')); + const set2 = new Set(str2.split('')); + + const intersection = new Set([...set1].filter(x => set2.has(x))); + const union = new Set([...set1, ...set2]); + + return intersection.size / union.size; + } + + /** + * Check if current context matches trigger conditions + */ + _contextMatches(current, trigger) { + for (const [key, expectedValue] of Object.entries(trigger)) { + if (!(key in current)) return false; + + const currentValue = current[key]; + + // Handle different value types + if (typeof expectedValue === 'string' && typeof currentValue === 'string') { + if (!currentValue.toLowerCase().includes(expectedValue.toLowerCase())) { + return false; + } + } else if (expectedValue !== currentValue) { + return false; + } + } + + return true; + } +} + +class LearningEngine { + constructor(db) { + this.db = db; + this.confidenceCalc = ConfidenceCalculator; + } + + /** + * Main learning entry point + */ + learnFromExecution(execution) { + // Learn command patterns + if (execution.tool === 'Bash') { + this._learnCommandPattern(execution); + } + + // Learn tool sequences + this._learnSequencePattern(execution); + + // Learn context patterns + if (!execution.success) { + this._learnFailureContext(execution); + } + } + + /** + * Learn from bash command executions + */ + _learnCommandPattern(execution) { + const command = execution.parameters.command || ''; + if (!command) return; + + const baseCmd = command.split(' ')[0]; + const patternId = `cmd_${baseCmd}`; + + if (patternId in this.db.commandPatterns) { + const pattern = this.db.commandPatterns[patternId]; + + // Update statistics + if (execution.success) { + pattern.prediction.successCount = (pattern.prediction.successCount || 0) + 1; + } else { + pattern.prediction.failureCount = (pattern.prediction.failureCount || 0) + 1; + } + + // Recalculate confidence + const recency = this._calculateRecency(execution.timestamp); + pattern.confidence = this.confidenceCalc.calculateCommandConfidence( + pattern.prediction.successCount || 0, + pattern.prediction.failureCount || 0, + recency + ); + pattern.lastSeen = execution.timestamp; + pattern.evidenceCount += 1; + + } else { + // Create new pattern + this.db.commandPatterns[patternId] = { + patternId, + patternType: 'command_execution', + trigger: { command: baseCmd }, + prediction: { + successCount: execution.success ? 1 : 0, + failureCount: execution.success ? 0 : 1, + commonErrors: execution.errorMessage ? [execution.errorMessage] : [] + }, + confidence: 0.3, // Start with low confidence + evidenceCount: 1, + lastSeen: execution.timestamp, + successRate: execution.success ? 1.0 : 0.0 + }; + } + } + + /** + * Learn from tool sequence patterns + */ + _learnSequencePattern(execution) { + // Get recent tool history (last 5 tools) + const recentTools = this.db.executionHistory.slice(-5).map(e => e.tool); + recentTools.push(execution.tool); + + // Look for sequences of 2-3 tools + for (let seqLen = 2; seqLen <= 3; seqLen++) { + if (recentTools.length >= seqLen) { + const sequence = recentTools.slice(-seqLen); + const patternId = `seq_${sequence.join('_')}`; + + // Update or create sequence pattern + // (Simplified implementation - could be expanded) + } + } + } + + /** + * Learn from failure contexts + */ + _learnFailureContext(execution) { + if (!execution.errorMessage) return; + + // Extract key error indicators + const errorKey = this._extractErrorKey(execution.errorMessage); + if (!errorKey) return; + + const patternId = `ctx_error_${errorKey}`; + + if (patternId in this.db.contextPatterns) { + const pattern = this.db.contextPatterns[patternId]; + pattern.evidenceCount += 1; + pattern.lastSeen = execution.timestamp; + // Update confidence based on repeated failures + pattern.confidence = Math.min(0.95, pattern.confidence + 0.05); + } else { + // Create new context pattern + this.db.contextPatterns[patternId] = { + patternId, + patternType: 'context_error', + trigger: { + tool: execution.tool, + errorType: errorKey + }, + prediction: { + likelyError: execution.errorMessage, + suggestions: this._generateSuggestions(execution) + }, + confidence: 0.4, + evidenceCount: 1, + lastSeen: execution.timestamp, + successRate: 0.0 + }; + } + } + + /** + * Calculate recency factor (1.0 = very recent, 0.0 = very old) + */ + _calculateRecency(timestamp) { + const now = new Date(); + const ageHours = (now - new Date(timestamp)) / (1000 * 60 * 60); + + // Exponential decay: recent events matter more + return Math.max(0.0, Math.exp(-ageHours / 24.0)); // 24 hour half-life + } + + /** + * Extract key error indicators from error messages + */ + _extractErrorKey(errorMessage) { + const message = errorMessage.toLowerCase(); + + const errorPatterns = { + 'command_not_found': ['command not found', 'not found'], + 'permission_denied': ['permission denied', 'access denied'], + 'file_not_found': ['no such file', 'file not found'], + 'connection_error': ['connection refused', 'network unreachable'], + 'syntax_error': ['syntax error', 'invalid syntax'] + }; + + for (const [errorType, patterns] of Object.entries(errorPatterns)) { + if (patterns.some(pattern => message.includes(pattern))) { + return errorType; + } + } + + return null; + } + + /** + * Generate suggestions based on failed execution + */ + _generateSuggestions(execution) { + const suggestions = []; + + if (execution.tool === 'Bash') { + const command = execution.parameters.command || ''; + if (command) { + const baseCmd = command.split(' ')[0]; + + // Common command alternatives + const alternatives = { + 'pip': ['pip3', 'python -m pip', 'python3 -m pip'], + 'python': ['python3'], + 'node': ['nodejs'], + 'vim': ['nvim', 'nano'] + }; + + if (baseCmd in alternatives) { + const remainingArgs = command.split(' ').slice(1).join(' '); + suggestions.push( + ...alternatives[baseCmd].map(alt => `Try '${alt} ${remainingArgs}'`) + ); + } + } + } + + return suggestions; + } +} + +class PredictionEngine { + constructor(matcher) { + this.matcher = matcher; + } + + /** + * Predict if a command will succeed and suggest alternatives + */ + predictCommandOutcome(command, context = {}) { + // Find matching patterns + const commandPatterns = this.matcher.fuzzyCommandMatch(command); + const contextPatterns = this.matcher.contextPatternMatch(context); + + const prediction = { + likelySuccess: true, + confidence: 0.5, + warnings: [], + suggestions: [] + }; + + // Analyze command patterns + for (const pattern of commandPatterns.slice(0, 3)) { // Top 3 matches + if (pattern.confidence > 0.7) { + const failureRate = (pattern.prediction.failureCount || 0) / Math.max(1, pattern.evidenceCount); + + if (failureRate > 0.6) { // High failure rate + prediction.likelySuccess = false; + prediction.confidence = pattern.confidence; + prediction.warnings.push(`Command '${command.split(' ')[0]}' often fails`); + + // Add suggestions from pattern + const suggestions = pattern.prediction.suggestions || []; + prediction.suggestions.push(...suggestions); + } + } + } + + return prediction; + } +} + +class ShadowLearner { + constructor(storagePath = '.claude_hooks/patterns') { + this.storagePath = path.resolve(storagePath); + fs.ensureDirSync(this.storagePath); + + this.db = this._loadDatabase(); + this.matcher = new PatternMatcher(this.db); + this.learningEngine = new LearningEngine(this.db); + this.predictionEngine = new PredictionEngine(this.matcher); + + // Performance cache (simple in-memory cache) + this.predictionCache = new Map(); + this.cacheTimeout = 5 * 60 * 1000; // 5 minutes + } + + /** + * Learn from tool execution + */ + learnFromExecution(execution) { + try { + this.learningEngine.learnFromExecution(execution); + this.db.executionHistory.push(execution); + + // Trim history to keep memory usage reasonable + if (this.db.executionHistory.length > 1000) { + this.db.executionHistory = this.db.executionHistory.slice(-500); + } + + } catch (error) { + // Learning failures shouldn't break the system + console.error('Shadow learner error:', error.message); + } + } + + /** + * Predict command outcome with caching + */ + predictCommandOutcome(command, context = {}) { + const cacheKey = `cmd_pred:${this._hash(command)}`; + const now = Date.now(); + + // Check cache + if (this.predictionCache.has(cacheKey)) { + const cached = this.predictionCache.get(cacheKey); + if (now - cached.timestamp < this.cacheTimeout) { + return cached.prediction; + } + } + + const prediction = this.predictionEngine.predictCommandOutcome(command, context); + + // Cache result + this.predictionCache.set(cacheKey, { prediction, timestamp: now }); + + // Clean old cache entries + this._cleanCache(); + + return prediction; + } + + /** + * Quick method for command failure learning (backward compatibility) + */ + learnCommandFailure(command, suggestion, confidence) { + const execution = { + tool: 'Bash', + parameters: { command }, + success: false, + timestamp: new Date(), + errorMessage: `Command failed: ${command}` + }; + + this.learnFromExecution(execution); + + // Also store the specific suggestion + const baseCmd = command.split(' ')[0]; + const patternId = `cmd_${baseCmd}`; + + if (patternId in this.db.commandPatterns) { + const pattern = this.db.commandPatterns[patternId]; + pattern.prediction.suggestions = pattern.prediction.suggestions || []; + if (!pattern.prediction.suggestions.includes(suggestion)) { + pattern.prediction.suggestions.push(suggestion); + } + } + } + + /** + * Get suggestion for a command (backward compatibility) + */ + getSuggestion(command) { + const prediction = this.predictCommandOutcome(command); + + if (!prediction.likelySuccess && prediction.suggestions.length > 0) { + return { + suggestion: prediction.suggestions[0].replace(/^Try '|'$/g, ''), // Clean format + confidence: prediction.confidence + }; + } + + return null; + } + + /** + * Save learned patterns to disk + */ + async saveDatabase() { + try { + const patternsFile = path.join(this.storagePath, 'patterns.json'); + const backupFile = path.join(this.storagePath, 'patterns.backup.json'); + + // Create backup of existing data + if (await fs.pathExists(patternsFile)) { + await fs.move(patternsFile, backupFile, { overwrite: true }); + } + + // Save new data + await fs.writeJson(patternsFile, this._serializeDatabase(), { spaces: 2 }); + + } catch (error) { + // Save failures shouldn't break the system + console.error('Failed to save shadow learner database:', error.message); + } + } + + /** + * Load patterns database from disk + */ + _loadDatabase() { + const patternsFile = path.join(this.storagePath, 'patterns.json'); + + try { + if (fs.existsSync(patternsFile)) { + const data = fs.readJsonSync(patternsFile); + return this._deserializeDatabase(data); + } + } catch (error) { + // If loading fails, start with empty database + console.error('Failed to load shadow learner database, starting fresh:', error.message); + } + + return { + commandPatterns: {}, + contextPatterns: {}, + sequencePatterns: {}, + executionHistory: [] + }; + } + + /** + * Serialize database for JSON storage + */ + _serializeDatabase() { + return { + commandPatterns: this.db.commandPatterns, + contextPatterns: this.db.contextPatterns, + sequencePatterns: this.db.sequencePatterns || {}, + executionHistory: this.db.executionHistory.slice(-100), // Keep last 100 executions + metadata: { + version: '1.0.0', + lastSaved: new Date().toISOString() + } + }; + } + + /** + * Deserialize database from JSON + */ + _deserializeDatabase(data) { + return { + commandPatterns: data.commandPatterns || {}, + contextPatterns: data.contextPatterns || {}, + sequencePatterns: data.sequencePatterns || {}, + executionHistory: (data.executionHistory || []).map(e => ({ + ...e, + timestamp: new Date(e.timestamp) + })) + }; + } + + /** + * Simple hash function for cache keys + */ + _hash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash.toString(); + } + + /** + * Clean expired cache entries + */ + _cleanCache() { + const now = Date.now(); + for (const [key, value] of this.predictionCache.entries()) { + if (now - value.timestamp > this.cacheTimeout) { + this.predictionCache.delete(key); + } + } + } +} + +module.exports = { + ShadowLearner, + ConfidenceCalculator, + PatternMatcher, + LearningEngine, + PredictionEngine +}; \ No newline at end of file diff --git a/lib/shadow_learner.py b/lib/shadow_learner.py deleted file mode 100644 index 066577f..0000000 --- a/lib/shadow_learner.py +++ /dev/null @@ -1,395 +0,0 @@ -#!/usr/bin/env python3 -"""Shadow Learner - Pattern learning and prediction system""" - -import json -import math -import time -import difflib -from datetime import datetime, timedelta -from pathlib import Path -from typing import Dict, List, Optional, Any -from cachetools import TTLCache, LRUCache - -try: - from .models import Pattern, ToolExecution, PatternDatabase, ValidationResult -except ImportError: - from models import Pattern, ToolExecution, PatternDatabase, ValidationResult - - -class ConfidenceCalculator: - """Calculate confidence scores for learned patterns""" - - @staticmethod - def calculate_command_confidence(success_count: int, failure_count: int, - recency_factor: float) -> float: - """Calculate confidence for command failure patterns""" - total_attempts = success_count + failure_count - if total_attempts == 0: - return 0.0 - - # Base confidence from failure rate - failure_rate = failure_count / total_attempts - - # Sample size adjustment (more data = more confidence) - sample_factor = min(1.0, total_attempts / 10.0) # Plateau at 10 samples - - # Time decay (recent failures are more relevant) - confidence = (failure_rate * sample_factor * (0.5 + 0.5 * recency_factor)) - - return min(0.99, max(0.1, confidence)) # Clamp between 0.1 and 0.99 - - @staticmethod - def calculate_sequence_confidence(successful_sequences: int, - total_sequences: int) -> float: - """Calculate confidence for tool sequence patterns""" - if total_sequences == 0: - return 0.0 - - success_rate = successful_sequences / total_sequences - sample_factor = min(1.0, total_sequences / 5.0) - - return success_rate * sample_factor - - -class PatternMatcher: - """Advanced pattern matching with fuzzy logic""" - - def __init__(self, db: PatternDatabase): - self.db = db - - def fuzzy_command_match(self, command: str, threshold: float = 0.8) -> List[Pattern]: - """Find similar command patterns using fuzzy matching""" - cmd_tokens = command.lower().split() - if not cmd_tokens: - return [] - - base_cmd = cmd_tokens[0] - matches = [] - - for pattern in self.db.command_patterns.values(): - pattern_cmd = pattern.trigger.get("command", "").lower() - - # Exact match - if pattern_cmd == base_cmd: - matches.append(pattern) - # Fuzzy match on command name - elif difflib.SequenceMatcher(None, pattern_cmd, base_cmd).ratio() > threshold: - matches.append(pattern) - # Partial match (e.g., "pip3" matches "pip install") - elif any(pattern_cmd in token for token in cmd_tokens): - matches.append(pattern) - - return sorted(matches, key=lambda p: p.confidence, reverse=True) - - def context_pattern_match(self, current_context: Dict[str, Any]) -> List[Pattern]: - """Match patterns based on current context""" - matches = [] - - for pattern in self.db.context_patterns.values(): - trigger = pattern.trigger - - # Check if all trigger conditions are met - if self._context_matches(current_context, trigger): - matches.append(pattern) - - return sorted(matches, key=lambda p: p.confidence, reverse=True) - - def _context_matches(self, current: Dict[str, Any], trigger: Dict[str, Any]) -> bool: - """Check if current context matches trigger conditions""" - for key, expected_value in trigger.items(): - if key not in current: - return False - - current_value = current[key] - - # Handle different value types - if isinstance(expected_value, str) and isinstance(current_value, str): - if expected_value.lower() not in current_value.lower(): - return False - elif expected_value != current_value: - return False - - return True - - -class LearningEngine: - """Core learning algorithms""" - - def __init__(self, db: PatternDatabase): - self.db = db - self.confidence_calc = ConfidenceCalculator() - - def learn_from_execution(self, execution: ToolExecution): - """Main learning entry point""" - - # Learn command patterns - if execution.tool == "Bash": - self._learn_command_pattern(execution) - - # Learn tool sequences - self._learn_sequence_pattern(execution) - - # Learn context patterns - if not execution.success: - self._learn_failure_context(execution) - - def _learn_command_pattern(self, execution: ToolExecution): - """Learn from bash command executions""" - command = execution.parameters.get("command", "") - if not command: - return - - base_cmd = command.split()[0] - pattern_id = f"cmd_{base_cmd}" - - if pattern_id in self.db.command_patterns: - pattern = self.db.command_patterns[pattern_id] - - # Update statistics - if execution.success: - pattern.prediction["success_count"] = pattern.prediction.get("success_count", 0) + 1 - else: - pattern.prediction["failure_count"] = pattern.prediction.get("failure_count", 0) + 1 - - # Recalculate confidence - recency = self._calculate_recency(execution.timestamp) - pattern.confidence = self.confidence_calc.calculate_command_confidence( - pattern.prediction.get("success_count", 0), - pattern.prediction.get("failure_count", 0), - recency - ) - pattern.last_seen = execution.timestamp - pattern.evidence_count += 1 - - else: - # Create new pattern - self.db.command_patterns[pattern_id] = Pattern( - pattern_id=pattern_id, - pattern_type="command_execution", - trigger={"command": base_cmd}, - prediction={ - "success_count": 1 if execution.success else 0, - "failure_count": 0 if execution.success else 1, - "common_errors": [execution.error_message] if execution.error_message else [] - }, - confidence=0.3, # Start with low confidence - evidence_count=1, - last_seen=execution.timestamp, - success_rate=1.0 if execution.success else 0.0 - ) - - def _learn_sequence_pattern(self, execution: ToolExecution): - """Learn from tool sequence patterns""" - # Get recent tool history (last 5 tools) - recent_tools = [e.tool for e in self.db.execution_history[-5:]] - recent_tools.append(execution.tool) - - # Look for sequences of 2-3 tools - for seq_len in [2, 3]: - if len(recent_tools) >= seq_len: - sequence = tuple(recent_tools[-seq_len:]) - pattern_id = f"seq_{'_'.join(sequence)}" - - # Update or create sequence pattern - # (Simplified implementation - could be expanded) - pass - - def _learn_failure_context(self, execution: ToolExecution): - """Learn from failure contexts""" - if not execution.error_message: - return - - # Extract key error indicators - error_key = self._extract_error_key(execution.error_message) - if not error_key: - return - - pattern_id = f"ctx_error_{error_key}" - - if pattern_id in self.db.context_patterns: - pattern = self.db.context_patterns[pattern_id] - pattern.evidence_count += 1 - pattern.last_seen = execution.timestamp - # Update confidence based on repeated failures - pattern.confidence = min(0.95, pattern.confidence + 0.05) - else: - # Create new context pattern - self.db.context_patterns[pattern_id] = Pattern( - pattern_id=pattern_id, - pattern_type="context_error", - trigger={ - "tool": execution.tool, - "error_type": error_key - }, - prediction={ - "likely_error": execution.error_message, - "suggestions": self._generate_suggestions(execution) - }, - confidence=0.4, - evidence_count=1, - last_seen=execution.timestamp, - success_rate=0.0 - ) - - def _calculate_recency(self, timestamp: datetime) -> float: - """Calculate recency factor (1.0 = very recent, 0.0 = very old)""" - now = datetime.now() - age_hours = (now - timestamp).total_seconds() / 3600 - - # Exponential decay: recent events matter more - return max(0.0, math.exp(-age_hours / 24.0)) # 24 hour half-life - - def _extract_error_key(self, error_message: str) -> Optional[str]: - """Extract key error indicators from error messages""" - error_message = error_message.lower() - - error_patterns = { - "command_not_found": ["command not found", "not found"], - "permission_denied": ["permission denied", "access denied"], - "file_not_found": ["no such file", "file not found"], - "connection_error": ["connection refused", "network unreachable"], - "syntax_error": ["syntax error", "invalid syntax"] - } - - for error_type, patterns in error_patterns.items(): - if any(pattern in error_message for pattern in patterns): - return error_type - - return None - - def _generate_suggestions(self, execution: ToolExecution) -> List[str]: - """Generate suggestions based on failed execution""" - suggestions = [] - - if execution.tool == "Bash": - command = execution.parameters.get("command", "") - if command: - base_cmd = command.split()[0] - - # Common command alternatives - alternatives = { - "pip": ["pip3", "python -m pip", "python3 -m pip"], - "python": ["python3"], - "node": ["nodejs"], - "vim": ["nvim", "nano"], - } - - if base_cmd in alternatives: - suggestions.extend([f"Try '{alt} {' '.join(command.split()[1:])}'" - for alt in alternatives[base_cmd]]) - - return suggestions - - -class PredictionEngine: - """Generate predictions and suggestions""" - - def __init__(self, matcher: PatternMatcher): - self.matcher = matcher - - def predict_command_outcome(self, command: str, context: Dict[str, Any]) -> Dict[str, Any]: - """Predict if a command will succeed and suggest alternatives""" - - # Find matching patterns - command_patterns = self.matcher.fuzzy_command_match(command) - context_patterns = self.matcher.context_pattern_match(context) - - prediction = { - "likely_success": True, - "confidence": 0.5, - "warnings": [], - "suggestions": [] - } - - # Analyze command patterns - for pattern in command_patterns[:3]: # Top 3 matches - if pattern.confidence > 0.7: - failure_rate = pattern.prediction.get("failure_count", 0) / max(1, pattern.evidence_count) - - if failure_rate > 0.6: # High failure rate - prediction["likely_success"] = False - prediction["confidence"] = pattern.confidence - prediction["warnings"].append(f"Command '{command.split()[0]}' often fails") - - # Add suggestions from pattern - suggestions = pattern.prediction.get("suggestions", []) - prediction["suggestions"].extend(suggestions) - - return prediction - - -class ShadowLearner: - """Main shadow learner interface""" - - def __init__(self, storage_path: str = ".claude_hooks/patterns"): - self.storage_path = Path(storage_path) - self.storage_path.mkdir(parents=True, exist_ok=True) - - self.db = self._load_database() - self.matcher = PatternMatcher(self.db) - self.learning_engine = LearningEngine(self.db) - self.prediction_engine = PredictionEngine(self.matcher) - - # Performance caches - self.prediction_cache = TTLCache(maxsize=1000, ttl=300) # 5-minute cache - - def learn_from_execution(self, execution: ToolExecution): - """Learn from tool execution""" - try: - self.learning_engine.learn_from_execution(execution) - self.db.execution_history.append(execution) - - # Trim history to keep memory usage reasonable - if len(self.db.execution_history) > 1000: - self.db.execution_history = self.db.execution_history[-500:] - - except Exception as e: - # Learning failures shouldn't break the system - pass - - def predict_command_outcome(self, command: str, context: Dict[str, Any] = None) -> Dict[str, Any]: - """Predict command outcome with caching""" - cache_key = f"cmd_pred:{hash(command)}" - - if cache_key in self.prediction_cache: - return self.prediction_cache[cache_key] - - prediction = self.prediction_engine.predict_command_outcome( - command, context or {} - ) - - self.prediction_cache[cache_key] = prediction - return prediction - - def save_database(self): - """Save learned patterns to disk""" - try: - patterns_file = self.storage_path / "patterns.json" - backup_file = self.storage_path / "patterns.backup.json" - - # Create backup of existing data - if patterns_file.exists(): - patterns_file.rename(backup_file) - - # Save new data - with open(patterns_file, 'w') as f: - json.dump(self.db.to_dict(), f, indent=2) - - except Exception as e: - # Save failures shouldn't break the system - pass - - def _load_database(self) -> PatternDatabase: - """Load patterns database from disk""" - patterns_file = self.storage_path / "patterns.json" - - try: - if patterns_file.exists(): - with open(patterns_file, 'r') as f: - data = json.load(f) - return PatternDatabase.from_dict(data) - except Exception: - # If loading fails, start with empty database - pass - - return PatternDatabase() \ No newline at end of file diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json new file mode 100644 index 0000000..a716cfc --- /dev/null +++ b/node_modules/.package-lock.json @@ -0,0 +1,128 @@ +{ + "name": "claude-hooks", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + } + } +} diff --git a/node_modules/ansi-styles/index.d.ts b/node_modules/ansi-styles/index.d.ts new file mode 100644 index 0000000..44a907e --- /dev/null +++ b/node_modules/ansi-styles/index.d.ts @@ -0,0 +1,345 @@ +declare type CSSColor = + | 'aliceblue' + | 'antiquewhite' + | 'aqua' + | 'aquamarine' + | 'azure' + | 'beige' + | 'bisque' + | 'black' + | 'blanchedalmond' + | 'blue' + | 'blueviolet' + | 'brown' + | 'burlywood' + | 'cadetblue' + | 'chartreuse' + | 'chocolate' + | 'coral' + | 'cornflowerblue' + | 'cornsilk' + | 'crimson' + | 'cyan' + | 'darkblue' + | 'darkcyan' + | 'darkgoldenrod' + | 'darkgray' + | 'darkgreen' + | 'darkgrey' + | 'darkkhaki' + | 'darkmagenta' + | 'darkolivegreen' + | 'darkorange' + | 'darkorchid' + | 'darkred' + | 'darksalmon' + | 'darkseagreen' + | 'darkslateblue' + | 'darkslategray' + | 'darkslategrey' + | 'darkturquoise' + | 'darkviolet' + | 'deeppink' + | 'deepskyblue' + | 'dimgray' + | 'dimgrey' + | 'dodgerblue' + | 'firebrick' + | 'floralwhite' + | 'forestgreen' + | 'fuchsia' + | 'gainsboro' + | 'ghostwhite' + | 'gold' + | 'goldenrod' + | 'gray' + | 'green' + | 'greenyellow' + | 'grey' + | 'honeydew' + | 'hotpink' + | 'indianred' + | 'indigo' + | 'ivory' + | 'khaki' + | 'lavender' + | 'lavenderblush' + | 'lawngreen' + | 'lemonchiffon' + | 'lightblue' + | 'lightcoral' + | 'lightcyan' + | 'lightgoldenrodyellow' + | 'lightgray' + | 'lightgreen' + | 'lightgrey' + | 'lightpink' + | 'lightsalmon' + | 'lightseagreen' + | 'lightskyblue' + | 'lightslategray' + | 'lightslategrey' + | 'lightsteelblue' + | 'lightyellow' + | 'lime' + | 'limegreen' + | 'linen' + | 'magenta' + | 'maroon' + | 'mediumaquamarine' + | 'mediumblue' + | 'mediumorchid' + | 'mediumpurple' + | 'mediumseagreen' + | 'mediumslateblue' + | 'mediumspringgreen' + | 'mediumturquoise' + | 'mediumvioletred' + | 'midnightblue' + | 'mintcream' + | 'mistyrose' + | 'moccasin' + | 'navajowhite' + | 'navy' + | 'oldlace' + | 'olive' + | 'olivedrab' + | 'orange' + | 'orangered' + | 'orchid' + | 'palegoldenrod' + | 'palegreen' + | 'paleturquoise' + | 'palevioletred' + | 'papayawhip' + | 'peachpuff' + | 'peru' + | 'pink' + | 'plum' + | 'powderblue' + | 'purple' + | 'rebeccapurple' + | 'red' + | 'rosybrown' + | 'royalblue' + | 'saddlebrown' + | 'salmon' + | 'sandybrown' + | 'seagreen' + | 'seashell' + | 'sienna' + | 'silver' + | 'skyblue' + | 'slateblue' + | 'slategray' + | 'slategrey' + | 'snow' + | 'springgreen' + | 'steelblue' + | 'tan' + | 'teal' + | 'thistle' + | 'tomato' + | 'turquoise' + | 'violet' + | 'wheat' + | 'white' + | 'whitesmoke' + | 'yellow' + | 'yellowgreen'; + +declare namespace ansiStyles { + interface ColorConvert { + /** + The RGB color space. + + @param red - (`0`-`255`) + @param green - (`0`-`255`) + @param blue - (`0`-`255`) + */ + rgb(red: number, green: number, blue: number): string; + + /** + The RGB HEX color space. + + @param hex - A hexadecimal string containing RGB data. + */ + hex(hex: string): string; + + /** + @param keyword - A CSS color name. + */ + keyword(keyword: CSSColor): string; + + /** + The HSL color space. + + @param hue - (`0`-`360`) + @param saturation - (`0`-`100`) + @param lightness - (`0`-`100`) + */ + hsl(hue: number, saturation: number, lightness: number): string; + + /** + The HSV color space. + + @param hue - (`0`-`360`) + @param saturation - (`0`-`100`) + @param value - (`0`-`100`) + */ + hsv(hue: number, saturation: number, value: number): string; + + /** + The HSV color space. + + @param hue - (`0`-`360`) + @param whiteness - (`0`-`100`) + @param blackness - (`0`-`100`) + */ + hwb(hue: number, whiteness: number, blackness: number): string; + + /** + Use a [4-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#3/4-bit) to set text color. + */ + ansi(ansi: number): string; + + /** + Use an [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set text color. + */ + ansi256(ansi: number): string; + } + + interface CSPair { + /** + The ANSI terminal control sequence for starting this style. + */ + readonly open: string; + + /** + The ANSI terminal control sequence for ending this style. + */ + readonly close: string; + } + + interface ColorBase { + readonly ansi: ColorConvert; + readonly ansi256: ColorConvert; + readonly ansi16m: ColorConvert; + + /** + The ANSI terminal control sequence for ending this color. + */ + readonly close: string; + } + + interface Modifier { + /** + Resets the current color chain. + */ + readonly reset: CSPair; + + /** + Make text bold. + */ + readonly bold: CSPair; + + /** + Emitting only a small amount of light. + */ + readonly dim: CSPair; + + /** + Make text italic. (Not widely supported) + */ + readonly italic: CSPair; + + /** + Make text underline. (Not widely supported) + */ + readonly underline: CSPair; + + /** + Inverse background and foreground colors. + */ + readonly inverse: CSPair; + + /** + Prints the text, but makes it invisible. + */ + readonly hidden: CSPair; + + /** + Puts a horizontal line through the center of the text. (Not widely supported) + */ + readonly strikethrough: CSPair; + } + + interface ForegroundColor { + readonly black: CSPair; + readonly red: CSPair; + readonly green: CSPair; + readonly yellow: CSPair; + readonly blue: CSPair; + readonly cyan: CSPair; + readonly magenta: CSPair; + readonly white: CSPair; + + /** + Alias for `blackBright`. + */ + readonly gray: CSPair; + + /** + Alias for `blackBright`. + */ + readonly grey: CSPair; + + readonly blackBright: CSPair; + readonly redBright: CSPair; + readonly greenBright: CSPair; + readonly yellowBright: CSPair; + readonly blueBright: CSPair; + readonly cyanBright: CSPair; + readonly magentaBright: CSPair; + readonly whiteBright: CSPair; + } + + interface BackgroundColor { + readonly bgBlack: CSPair; + readonly bgRed: CSPair; + readonly bgGreen: CSPair; + readonly bgYellow: CSPair; + readonly bgBlue: CSPair; + readonly bgCyan: CSPair; + readonly bgMagenta: CSPair; + readonly bgWhite: CSPair; + + /** + Alias for `bgBlackBright`. + */ + readonly bgGray: CSPair; + + /** + Alias for `bgBlackBright`. + */ + readonly bgGrey: CSPair; + + readonly bgBlackBright: CSPair; + readonly bgRedBright: CSPair; + readonly bgGreenBright: CSPair; + readonly bgYellowBright: CSPair; + readonly bgBlueBright: CSPair; + readonly bgCyanBright: CSPair; + readonly bgMagentaBright: CSPair; + readonly bgWhiteBright: CSPair; + } +} + +declare const ansiStyles: { + readonly modifier: ansiStyles.Modifier; + readonly color: ansiStyles.ForegroundColor & ansiStyles.ColorBase; + readonly bgColor: ansiStyles.BackgroundColor & ansiStyles.ColorBase; + readonly codes: ReadonlyMap; +} & ansiStyles.BackgroundColor & ansiStyles.ForegroundColor & ansiStyles.Modifier; + +export = ansiStyles; diff --git a/node_modules/ansi-styles/index.js b/node_modules/ansi-styles/index.js new file mode 100644 index 0000000..5d82581 --- /dev/null +++ b/node_modules/ansi-styles/index.js @@ -0,0 +1,163 @@ +'use strict'; + +const wrapAnsi16 = (fn, offset) => (...args) => { + const code = fn(...args); + return `\u001B[${code + offset}m`; +}; + +const wrapAnsi256 = (fn, offset) => (...args) => { + const code = fn(...args); + return `\u001B[${38 + offset};5;${code}m`; +}; + +const wrapAnsi16m = (fn, offset) => (...args) => { + const rgb = fn(...args); + return `\u001B[${38 + offset};2;${rgb[0]};${rgb[1]};${rgb[2]}m`; +}; + +const ansi2ansi = n => n; +const rgb2rgb = (r, g, b) => [r, g, b]; + +const setLazyProperty = (object, property, get) => { + Object.defineProperty(object, property, { + get: () => { + const value = get(); + + Object.defineProperty(object, property, { + value, + enumerable: true, + configurable: true + }); + + return value; + }, + enumerable: true, + configurable: true + }); +}; + +/** @type {typeof import('color-convert')} */ +let colorConvert; +const makeDynamicStyles = (wrap, targetSpace, identity, isBackground) => { + if (colorConvert === undefined) { + colorConvert = require('color-convert'); + } + + const offset = isBackground ? 10 : 0; + const styles = {}; + + for (const [sourceSpace, suite] of Object.entries(colorConvert)) { + const name = sourceSpace === 'ansi16' ? 'ansi' : sourceSpace; + if (sourceSpace === targetSpace) { + styles[name] = wrap(identity, offset); + } else if (typeof suite === 'object') { + styles[name] = wrap(suite[targetSpace], offset); + } + } + + return styles; +}; + +function assembleStyles() { + const codes = new Map(); + const styles = { + modifier: { + reset: [0, 0], + // 21 isn't widely supported and 22 does the same thing + bold: [1, 22], + dim: [2, 22], + italic: [3, 23], + underline: [4, 24], + inverse: [7, 27], + hidden: [8, 28], + strikethrough: [9, 29] + }, + color: { + black: [30, 39], + red: [31, 39], + green: [32, 39], + yellow: [33, 39], + blue: [34, 39], + magenta: [35, 39], + cyan: [36, 39], + white: [37, 39], + + // Bright color + blackBright: [90, 39], + redBright: [91, 39], + greenBright: [92, 39], + yellowBright: [93, 39], + blueBright: [94, 39], + magentaBright: [95, 39], + cyanBright: [96, 39], + whiteBright: [97, 39] + }, + bgColor: { + bgBlack: [40, 49], + bgRed: [41, 49], + bgGreen: [42, 49], + bgYellow: [43, 49], + bgBlue: [44, 49], + bgMagenta: [45, 49], + bgCyan: [46, 49], + bgWhite: [47, 49], + + // Bright color + bgBlackBright: [100, 49], + bgRedBright: [101, 49], + bgGreenBright: [102, 49], + bgYellowBright: [103, 49], + bgBlueBright: [104, 49], + bgMagentaBright: [105, 49], + bgCyanBright: [106, 49], + bgWhiteBright: [107, 49] + } + }; + + // Alias bright black as gray (and grey) + styles.color.gray = styles.color.blackBright; + styles.bgColor.bgGray = styles.bgColor.bgBlackBright; + styles.color.grey = styles.color.blackBright; + styles.bgColor.bgGrey = styles.bgColor.bgBlackBright; + + for (const [groupName, group] of Object.entries(styles)) { + for (const [styleName, style] of Object.entries(group)) { + styles[styleName] = { + open: `\u001B[${style[0]}m`, + close: `\u001B[${style[1]}m` + }; + + group[styleName] = styles[styleName]; + + codes.set(style[0], style[1]); + } + + Object.defineProperty(styles, groupName, { + value: group, + enumerable: false + }); + } + + Object.defineProperty(styles, 'codes', { + value: codes, + enumerable: false + }); + + styles.color.close = '\u001B[39m'; + styles.bgColor.close = '\u001B[49m'; + + setLazyProperty(styles.color, 'ansi', () => makeDynamicStyles(wrapAnsi16, 'ansi16', ansi2ansi, false)); + setLazyProperty(styles.color, 'ansi256', () => makeDynamicStyles(wrapAnsi256, 'ansi256', ansi2ansi, false)); + setLazyProperty(styles.color, 'ansi16m', () => makeDynamicStyles(wrapAnsi16m, 'rgb', rgb2rgb, false)); + setLazyProperty(styles.bgColor, 'ansi', () => makeDynamicStyles(wrapAnsi16, 'ansi16', ansi2ansi, true)); + setLazyProperty(styles.bgColor, 'ansi256', () => makeDynamicStyles(wrapAnsi256, 'ansi256', ansi2ansi, true)); + setLazyProperty(styles.bgColor, 'ansi16m', () => makeDynamicStyles(wrapAnsi16m, 'rgb', rgb2rgb, true)); + + return styles; +} + +// Make the export immutable +Object.defineProperty(module, 'exports', { + enumerable: true, + get: assembleStyles +}); diff --git a/node_modules/ansi-styles/license b/node_modules/ansi-styles/license new file mode 100644 index 0000000..e7af2f7 --- /dev/null +++ b/node_modules/ansi-styles/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/node_modules/ansi-styles/package.json b/node_modules/ansi-styles/package.json new file mode 100644 index 0000000..7539328 --- /dev/null +++ b/node_modules/ansi-styles/package.json @@ -0,0 +1,56 @@ +{ + "name": "ansi-styles", + "version": "4.3.0", + "description": "ANSI escape codes for styling strings in the terminal", + "license": "MIT", + "repository": "chalk/ansi-styles", + "funding": "https://github.com/chalk/ansi-styles?sponsor=1", + "author": { + "name": "Sindre Sorhus", + "email": "sindresorhus@gmail.com", + "url": "sindresorhus.com" + }, + "engines": { + "node": ">=8" + }, + "scripts": { + "test": "xo && ava && tsd", + "screenshot": "svg-term --command='node screenshot' --out=screenshot.svg --padding=3 --width=55 --height=3 --at=1000 --no-cursor" + }, + "files": [ + "index.js", + "index.d.ts" + ], + "keywords": [ + "ansi", + "styles", + "color", + "colour", + "colors", + "terminal", + "console", + "cli", + "string", + "tty", + "escape", + "formatting", + "rgb", + "256", + "shell", + "xterm", + "log", + "logging", + "command-line", + "text" + ], + "dependencies": { + "color-convert": "^2.0.1" + }, + "devDependencies": { + "@types/color-convert": "^1.9.0", + "ava": "^2.3.0", + "svg-term-cli": "^2.1.1", + "tsd": "^0.11.0", + "xo": "^0.25.3" + } +} diff --git a/node_modules/ansi-styles/readme.md b/node_modules/ansi-styles/readme.md new file mode 100644 index 0000000..24883de --- /dev/null +++ b/node_modules/ansi-styles/readme.md @@ -0,0 +1,152 @@ +# ansi-styles [![Build Status](https://travis-ci.org/chalk/ansi-styles.svg?branch=master)](https://travis-ci.org/chalk/ansi-styles) + +> [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors_and_Styles) for styling strings in the terminal + +You probably want the higher-level [chalk](https://github.com/chalk/chalk) module for styling your strings. + + + +## Install + +``` +$ npm install ansi-styles +``` + +## Usage + +```js +const style = require('ansi-styles'); + +console.log(`${style.green.open}Hello world!${style.green.close}`); + + +// Color conversion between 16/256/truecolor +// NOTE: If conversion goes to 16 colors or 256 colors, the original color +// may be degraded to fit that color palette. This means terminals +// that do not support 16 million colors will best-match the +// original color. +console.log(style.bgColor.ansi.hsl(120, 80, 72) + 'Hello world!' + style.bgColor.close); +console.log(style.color.ansi256.rgb(199, 20, 250) + 'Hello world!' + style.color.close); +console.log(style.color.ansi16m.hex('#abcdef') + 'Hello world!' + style.color.close); +``` + +## API + +Each style has an `open` and `close` property. + +## Styles + +### Modifiers + +- `reset` +- `bold` +- `dim` +- `italic` *(Not widely supported)* +- `underline` +- `inverse` +- `hidden` +- `strikethrough` *(Not widely supported)* + +### Colors + +- `black` +- `red` +- `green` +- `yellow` +- `blue` +- `magenta` +- `cyan` +- `white` +- `blackBright` (alias: `gray`, `grey`) +- `redBright` +- `greenBright` +- `yellowBright` +- `blueBright` +- `magentaBright` +- `cyanBright` +- `whiteBright` + +### Background colors + +- `bgBlack` +- `bgRed` +- `bgGreen` +- `bgYellow` +- `bgBlue` +- `bgMagenta` +- `bgCyan` +- `bgWhite` +- `bgBlackBright` (alias: `bgGray`, `bgGrey`) +- `bgRedBright` +- `bgGreenBright` +- `bgYellowBright` +- `bgBlueBright` +- `bgMagentaBright` +- `bgCyanBright` +- `bgWhiteBright` + +## Advanced usage + +By default, you get a map of styles, but the styles are also available as groups. They are non-enumerable so they don't show up unless you access them explicitly. This makes it easier to expose only a subset in a higher-level module. + +- `style.modifier` +- `style.color` +- `style.bgColor` + +###### Example + +```js +console.log(style.color.green.open); +``` + +Raw escape codes (i.e. without the CSI escape prefix `\u001B[` and render mode postfix `m`) are available under `style.codes`, which returns a `Map` with the open codes as keys and close codes as values. + +###### Example + +```js +console.log(style.codes.get(36)); +//=> 39 +``` + +## [256 / 16 million (TrueColor) support](https://gist.github.com/XVilka/8346728) + +`ansi-styles` uses the [`color-convert`](https://github.com/Qix-/color-convert) package to allow for converting between various colors and ANSI escapes, with support for 256 and 16 million colors. + +The following color spaces from `color-convert` are supported: + +- `rgb` +- `hex` +- `keyword` +- `hsl` +- `hsv` +- `hwb` +- `ansi` +- `ansi256` + +To use these, call the associated conversion function with the intended output, for example: + +```js +style.color.ansi.rgb(100, 200, 15); // RGB to 16 color ansi foreground code +style.bgColor.ansi.rgb(100, 200, 15); // RGB to 16 color ansi background code + +style.color.ansi256.hsl(120, 100, 60); // HSL to 256 color ansi foreground code +style.bgColor.ansi256.hsl(120, 100, 60); // HSL to 256 color ansi foreground code + +style.color.ansi16m.hex('#C0FFEE'); // Hex (RGB) to 16 million color foreground code +style.bgColor.ansi16m.hex('#C0FFEE'); // Hex (RGB) to 16 million color background code +``` + +## Related + +- [ansi-escapes](https://github.com/sindresorhus/ansi-escapes) - ANSI escape codes for manipulating the terminal + +## Maintainers + +- [Sindre Sorhus](https://github.com/sindresorhus) +- [Josh Junon](https://github.com/qix-) + +## For enterprise + +Available as part of the Tidelift Subscription. + +The maintainers of `ansi-styles` and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/npm-ansi-styles?utm_source=npm-ansi-styles&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) diff --git a/node_modules/chalk/index.d.ts b/node_modules/chalk/index.d.ts new file mode 100644 index 0000000..9cd88f3 --- /dev/null +++ b/node_modules/chalk/index.d.ts @@ -0,0 +1,415 @@ +/** +Basic foreground colors. + +[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support) +*/ +declare type ForegroundColor = + | 'black' + | 'red' + | 'green' + | 'yellow' + | 'blue' + | 'magenta' + | 'cyan' + | 'white' + | 'gray' + | 'grey' + | 'blackBright' + | 'redBright' + | 'greenBright' + | 'yellowBright' + | 'blueBright' + | 'magentaBright' + | 'cyanBright' + | 'whiteBright'; + +/** +Basic background colors. + +[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support) +*/ +declare type BackgroundColor = + | 'bgBlack' + | 'bgRed' + | 'bgGreen' + | 'bgYellow' + | 'bgBlue' + | 'bgMagenta' + | 'bgCyan' + | 'bgWhite' + | 'bgGray' + | 'bgGrey' + | 'bgBlackBright' + | 'bgRedBright' + | 'bgGreenBright' + | 'bgYellowBright' + | 'bgBlueBright' + | 'bgMagentaBright' + | 'bgCyanBright' + | 'bgWhiteBright'; + +/** +Basic colors. + +[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support) +*/ +declare type Color = ForegroundColor | BackgroundColor; + +declare type Modifiers = + | 'reset' + | 'bold' + | 'dim' + | 'italic' + | 'underline' + | 'inverse' + | 'hidden' + | 'strikethrough' + | 'visible'; + +declare namespace chalk { + /** + Levels: + - `0` - All colors disabled. + - `1` - Basic 16 colors support. + - `2` - ANSI 256 colors support. + - `3` - Truecolor 16 million colors support. + */ + type Level = 0 | 1 | 2 | 3; + + interface Options { + /** + Specify the color support for Chalk. + + By default, color support is automatically detected based on the environment. + + Levels: + - `0` - All colors disabled. + - `1` - Basic 16 colors support. + - `2` - ANSI 256 colors support. + - `3` - Truecolor 16 million colors support. + */ + level?: Level; + } + + /** + Return a new Chalk instance. + */ + type Instance = new (options?: Options) => Chalk; + + /** + Detect whether the terminal supports color. + */ + interface ColorSupport { + /** + The color level used by Chalk. + */ + level: Level; + + /** + Return whether Chalk supports basic 16 colors. + */ + hasBasic: boolean; + + /** + Return whether Chalk supports ANSI 256 colors. + */ + has256: boolean; + + /** + Return whether Chalk supports Truecolor 16 million colors. + */ + has16m: boolean; + } + + interface ChalkFunction { + /** + Use a template string. + + @remarks Template literals are unsupported for nested calls (see [issue #341](https://github.com/chalk/chalk/issues/341)) + + @example + ``` + import chalk = require('chalk'); + + log(chalk` + CPU: {red ${cpu.totalPercent}%} + RAM: {green ${ram.used / ram.total * 100}%} + DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%} + `); + ``` + + @example + ``` + import chalk = require('chalk'); + + log(chalk.red.bgBlack`2 + 3 = {bold ${2 + 3}}`) + ``` + */ + (text: TemplateStringsArray, ...placeholders: unknown[]): string; + + (...text: unknown[]): string; + } + + interface Chalk extends ChalkFunction { + /** + Return a new Chalk instance. + */ + Instance: Instance; + + /** + The color support for Chalk. + + By default, color support is automatically detected based on the environment. + + Levels: + - `0` - All colors disabled. + - `1` - Basic 16 colors support. + - `2` - ANSI 256 colors support. + - `3` - Truecolor 16 million colors support. + */ + level: Level; + + /** + Use HEX value to set text color. + + @param color - Hexadecimal value representing the desired color. + + @example + ``` + import chalk = require('chalk'); + + chalk.hex('#DEADED'); + ``` + */ + hex(color: string): Chalk; + + /** + Use keyword color value to set text color. + + @param color - Keyword value representing the desired color. + + @example + ``` + import chalk = require('chalk'); + + chalk.keyword('orange'); + ``` + */ + keyword(color: string): Chalk; + + /** + Use RGB values to set text color. + */ + rgb(red: number, green: number, blue: number): Chalk; + + /** + Use HSL values to set text color. + */ + hsl(hue: number, saturation: number, lightness: number): Chalk; + + /** + Use HSV values to set text color. + */ + hsv(hue: number, saturation: number, value: number): Chalk; + + /** + Use HWB values to set text color. + */ + hwb(hue: number, whiteness: number, blackness: number): Chalk; + + /** + Use a [Select/Set Graphic Rendition](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters) (SGR) [color code number](https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit) to set text color. + + 30 <= code && code < 38 || 90 <= code && code < 98 + For example, 31 for red, 91 for redBright. + */ + ansi(code: number): Chalk; + + /** + Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set text color. + */ + ansi256(index: number): Chalk; + + /** + Use HEX value to set background color. + + @param color - Hexadecimal value representing the desired color. + + @example + ``` + import chalk = require('chalk'); + + chalk.bgHex('#DEADED'); + ``` + */ + bgHex(color: string): Chalk; + + /** + Use keyword color value to set background color. + + @param color - Keyword value representing the desired color. + + @example + ``` + import chalk = require('chalk'); + + chalk.bgKeyword('orange'); + ``` + */ + bgKeyword(color: string): Chalk; + + /** + Use RGB values to set background color. + */ + bgRgb(red: number, green: number, blue: number): Chalk; + + /** + Use HSL values to set background color. + */ + bgHsl(hue: number, saturation: number, lightness: number): Chalk; + + /** + Use HSV values to set background color. + */ + bgHsv(hue: number, saturation: number, value: number): Chalk; + + /** + Use HWB values to set background color. + */ + bgHwb(hue: number, whiteness: number, blackness: number): Chalk; + + /** + Use a [Select/Set Graphic Rendition](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters) (SGR) [color code number](https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit) to set background color. + + 30 <= code && code < 38 || 90 <= code && code < 98 + For example, 31 for red, 91 for redBright. + Use the foreground code, not the background code (for example, not 41, nor 101). + */ + bgAnsi(code: number): Chalk; + + /** + Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set background color. + */ + bgAnsi256(index: number): Chalk; + + /** + Modifier: Resets the current color chain. + */ + readonly reset: Chalk; + + /** + Modifier: Make text bold. + */ + readonly bold: Chalk; + + /** + Modifier: Emitting only a small amount of light. + */ + readonly dim: Chalk; + + /** + Modifier: Make text italic. (Not widely supported) + */ + readonly italic: Chalk; + + /** + Modifier: Make text underline. (Not widely supported) + */ + readonly underline: Chalk; + + /** + Modifier: Inverse background and foreground colors. + */ + readonly inverse: Chalk; + + /** + Modifier: Prints the text, but makes it invisible. + */ + readonly hidden: Chalk; + + /** + Modifier: Puts a horizontal line through the center of the text. (Not widely supported) + */ + readonly strikethrough: Chalk; + + /** + Modifier: Prints the text only when Chalk has a color support level > 0. + Can be useful for things that are purely cosmetic. + */ + readonly visible: Chalk; + + readonly black: Chalk; + readonly red: Chalk; + readonly green: Chalk; + readonly yellow: Chalk; + readonly blue: Chalk; + readonly magenta: Chalk; + readonly cyan: Chalk; + readonly white: Chalk; + + /* + Alias for `blackBright`. + */ + readonly gray: Chalk; + + /* + Alias for `blackBright`. + */ + readonly grey: Chalk; + + readonly blackBright: Chalk; + readonly redBright: Chalk; + readonly greenBright: Chalk; + readonly yellowBright: Chalk; + readonly blueBright: Chalk; + readonly magentaBright: Chalk; + readonly cyanBright: Chalk; + readonly whiteBright: Chalk; + + readonly bgBlack: Chalk; + readonly bgRed: Chalk; + readonly bgGreen: Chalk; + readonly bgYellow: Chalk; + readonly bgBlue: Chalk; + readonly bgMagenta: Chalk; + readonly bgCyan: Chalk; + readonly bgWhite: Chalk; + + /* + Alias for `bgBlackBright`. + */ + readonly bgGray: Chalk; + + /* + Alias for `bgBlackBright`. + */ + readonly bgGrey: Chalk; + + readonly bgBlackBright: Chalk; + readonly bgRedBright: Chalk; + readonly bgGreenBright: Chalk; + readonly bgYellowBright: Chalk; + readonly bgBlueBright: Chalk; + readonly bgMagentaBright: Chalk; + readonly bgCyanBright: Chalk; + readonly bgWhiteBright: Chalk; + } +} + +/** +Main Chalk object that allows to chain styles together. +Call the last one as a method with a string argument. +Order doesn't matter, and later styles take precedent in case of a conflict. +This simply means that `chalk.red.yellow.green` is equivalent to `chalk.green`. +*/ +declare const chalk: chalk.Chalk & chalk.ChalkFunction & { + supportsColor: chalk.ColorSupport | false; + Level: chalk.Level; + Color: Color; + ForegroundColor: ForegroundColor; + BackgroundColor: BackgroundColor; + Modifiers: Modifiers; + stderr: chalk.Chalk & {supportsColor: chalk.ColorSupport | false}; +}; + +export = chalk; diff --git a/node_modules/chalk/license b/node_modules/chalk/license new file mode 100644 index 0000000..e7af2f7 --- /dev/null +++ b/node_modules/chalk/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/node_modules/chalk/package.json b/node_modules/chalk/package.json new file mode 100644 index 0000000..47c23f2 --- /dev/null +++ b/node_modules/chalk/package.json @@ -0,0 +1,68 @@ +{ + "name": "chalk", + "version": "4.1.2", + "description": "Terminal string styling done right", + "license": "MIT", + "repository": "chalk/chalk", + "funding": "https://github.com/chalk/chalk?sponsor=1", + "main": "source", + "engines": { + "node": ">=10" + }, + "scripts": { + "test": "xo && nyc ava && tsd", + "bench": "matcha benchmark.js" + }, + "files": [ + "source", + "index.d.ts" + ], + "keywords": [ + "color", + "colour", + "colors", + "terminal", + "console", + "cli", + "string", + "str", + "ansi", + "style", + "styles", + "tty", + "formatting", + "rgb", + "256", + "shell", + "xterm", + "log", + "logging", + "command-line", + "text" + ], + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "devDependencies": { + "ava": "^2.4.0", + "coveralls": "^3.0.7", + "execa": "^4.0.0", + "import-fresh": "^3.1.0", + "matcha": "^0.7.0", + "nyc": "^15.0.0", + "resolve-from": "^5.0.0", + "tsd": "^0.7.4", + "xo": "^0.28.2" + }, + "xo": { + "rules": { + "unicorn/prefer-string-slice": "off", + "unicorn/prefer-includes": "off", + "@typescript-eslint/member-ordering": "off", + "no-redeclare": "off", + "unicorn/string-content": "off", + "unicorn/better-regex": "off" + } + } +} diff --git a/node_modules/chalk/readme.md b/node_modules/chalk/readme.md new file mode 100644 index 0000000..a055d21 --- /dev/null +++ b/node_modules/chalk/readme.md @@ -0,0 +1,341 @@ +

+
+
+ Chalk +
+
+
+

+ +> Terminal string styling done right + +[![Build Status](https://travis-ci.org/chalk/chalk.svg?branch=master)](https://travis-ci.org/chalk/chalk) [![Coverage Status](https://coveralls.io/repos/github/chalk/chalk/badge.svg?branch=master)](https://coveralls.io/github/chalk/chalk?branch=master) [![npm dependents](https://badgen.net/npm/dependents/chalk)](https://www.npmjs.com/package/chalk?activeTab=dependents) [![Downloads](https://badgen.net/npm/dt/chalk)](https://www.npmjs.com/package/chalk) [![](https://img.shields.io/badge/unicorn-approved-ff69b4.svg)](https://www.youtube.com/watch?v=9auOCbH5Ns4) [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) ![TypeScript-ready](https://img.shields.io/npm/types/chalk.svg) [![run on repl.it](https://repl.it/badge/github/chalk/chalk)](https://repl.it/github/chalk/chalk) + + + +
+ +--- + +
+

+

+ + Sindre Sorhus' open source work is supported by the community on GitHub Sponsors and Dev + +

+ Special thanks to: +
+
+ + + +
+
+ + + +
+
+ +
+ Doppler +
+ All your environment variables, in one place +
+ Stop struggling with scattered API keys, hacking together home-brewed tools, +
+ and avoiding access controls. Keep your team and servers in sync with Doppler. +
+
+
+ +
+ UI Bakery +
+
+

+
+ +--- + +
+ +## Highlights + +- Expressive API +- Highly performant +- Ability to nest styles +- [256/Truecolor color support](#256-and-truecolor-color-support) +- Auto-detects color support +- Doesn't extend `String.prototype` +- Clean and focused +- Actively maintained +- [Used by ~50,000 packages](https://www.npmjs.com/browse/depended/chalk) as of January 1, 2020 + +## Install + +```console +$ npm install chalk +``` + +## Usage + +```js +const chalk = require('chalk'); + +console.log(chalk.blue('Hello world!')); +``` + +Chalk comes with an easy to use composable API where you just chain and nest the styles you want. + +```js +const chalk = require('chalk'); +const log = console.log; + +// Combine styled and normal strings +log(chalk.blue('Hello') + ' World' + chalk.red('!')); + +// Compose multiple styles using the chainable API +log(chalk.blue.bgRed.bold('Hello world!')); + +// Pass in multiple arguments +log(chalk.blue('Hello', 'World!', 'Foo', 'bar', 'biz', 'baz')); + +// Nest styles +log(chalk.red('Hello', chalk.underline.bgBlue('world') + '!')); + +// Nest styles of the same type even (color, underline, background) +log(chalk.green( + 'I am a green line ' + + chalk.blue.underline.bold('with a blue substring') + + ' that becomes green again!' +)); + +// ES2015 template literal +log(` +CPU: ${chalk.red('90%')} +RAM: ${chalk.green('40%')} +DISK: ${chalk.yellow('70%')} +`); + +// ES2015 tagged template literal +log(chalk` +CPU: {red ${cpu.totalPercent}%} +RAM: {green ${ram.used / ram.total * 100}%} +DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%} +`); + +// Use RGB colors in terminal emulators that support it. +log(chalk.keyword('orange')('Yay for orange colored text!')); +log(chalk.rgb(123, 45, 67).underline('Underlined reddish color')); +log(chalk.hex('#DEADED').bold('Bold gray!')); +``` + +Easily define your own themes: + +```js +const chalk = require('chalk'); + +const error = chalk.bold.red; +const warning = chalk.keyword('orange'); + +console.log(error('Error!')); +console.log(warning('Warning!')); +``` + +Take advantage of console.log [string substitution](https://nodejs.org/docs/latest/api/console.html#console_console_log_data_args): + +```js +const name = 'Sindre'; +console.log(chalk.green('Hello %s'), name); +//=> 'Hello Sindre' +``` + +## API + +### chalk.`