diff --git a/hooks/rtk-awareness.md b/hooks/rtk-awareness.md index 0eaf3d5..fddf9e8 100644 --- a/hooks/rtk-awareness.md +++ b/hooks/rtk-awareness.md @@ -26,4 +26,32 @@ which rtk # Verify correct binary All other commands are automatically rewritten by the Claude Code hook. Example: `git status` → `rtk git status` (transparent, 0 tokens overhead) +### Hook Installation + +**macOS/Linux (bash):** +```json +{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "~/.claude/hooks/rtk-rewrite.sh" }] + }] + } +} +``` + +**Windows (Node.js/Bun):** +```json +{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "bun C:/Users/YOU/.claude/hooks/rtk-rewrite.js" }] + }] + } +} +``` + +The JS version also handles chained commands: `cd "..." && git status` → `cd "..." && rtk git status` + Refer to CLAUDE.md for full command reference. diff --git a/hooks/rtk-rewrite.js b/hooks/rtk-rewrite.js new file mode 100644 index 0000000..c500ad7 --- /dev/null +++ b/hooks/rtk-rewrite.js @@ -0,0 +1,514 @@ +#!/usr/bin/env node +// RTK auto-rewrite hook for Claude Code PreToolUse:Bash +// Cross-platform version (Node.js/Bun) - Works on Windows, macOS, Linux +// Transparently rewrites raw commands to their rtk equivalents. +// Outputs JSON with updatedInput to modify the command before execution. + +const fs = require('fs'); + +// ============================================================================ +// LEXER - Quote-aware tokenizer for shell commands +// ============================================================================ + +const TokenKind = { + ARG: 'arg', + OPERATOR: 'operator', // &&, ||, ; + PIPE: 'pipe', // | + REDIRECT: 'redirect', // >, >>, <, 2>, etc. + SHELLISM: 'shellism', // *, ?, $(), ``, etc. +}; + +/** + * Tokenize a shell command string, respecting quotes. + * Returns array of { kind, value } tokens. + */ +function tokenize(input) { + const tokens = []; + let i = 0; + let current = ''; + let inSingleQuote = false; + let inDoubleQuote = false; + + const pushArg = () => { + if (current) { + // Check for shellisms in unquoted content + // Skip if the token is fully quoted (starts and ends with same quote) + const isFullyQuoted = (current.startsWith('"') && current.endsWith('"')) || + (current.startsWith("'") && current.endsWith("'")); + if (!isFullyQuoted && /[*?]|\$\(|`/.test(current)) { + tokens.push({ kind: TokenKind.SHELLISM, value: current }); + } else { + tokens.push({ kind: TokenKind.ARG, value: current }); + } + current = ''; + } + }; + + while (i < input.length) { + const ch = input[i]; + const next = input[i + 1]; + + // Handle escape sequences + if (ch === '\\' && !inSingleQuote) { + current += ch + (next || ''); + i += 2; + continue; + } + + // Handle quotes + if (ch === "'" && !inDoubleQuote) { + current += ch; + inSingleQuote = !inSingleQuote; + i++; + continue; + } + + if (ch === '"' && !inSingleQuote) { + current += ch; + inDoubleQuote = !inDoubleQuote; + i++; + continue; + } + + // Inside quotes, just accumulate + if (inSingleQuote || inDoubleQuote) { + current += ch; + i++; + continue; + } + + // Whitespace - delimiter + if (/\s/.test(ch)) { + pushArg(); + i++; + continue; + } + + // Operators: &&, ||, ; + if (ch === '&' && next === '&') { + pushArg(); + tokens.push({ kind: TokenKind.OPERATOR, value: '&&' }); + i += 2; + continue; + } + + if (ch === '|' && next === '|') { + pushArg(); + tokens.push({ kind: TokenKind.OPERATOR, value: '||' }); + i += 2; + continue; + } + + if (ch === ';') { + pushArg(); + tokens.push({ kind: TokenKind.OPERATOR, value: ';' }); + i++; + continue; + } + + // Pipe: | + if (ch === '|') { + pushArg(); + tokens.push({ kind: TokenKind.PIPE, value: '|' }); + i++; + continue; + } + + // Redirects: >, >>, <, 2>, 2>&1, etc. + if (ch === '>' || ch === '<') { + pushArg(); + let redirect = ch; + if (next === '>' || next === '&') { + redirect += next; + i++; + } + tokens.push({ kind: TokenKind.REDIRECT, value: redirect }); + i++; + continue; + } + + // Check for 2> redirect + if (ch === '2' && (next === '>' || next === '&')) { + pushArg(); + let redirect = '2'; + i++; + while (i < input.length && /[>&]/.test(input[i])) { + redirect += input[i]; + i++; + } + tokens.push({ kind: TokenKind.REDIRECT, value: redirect }); + continue; + } + + // Regular character + current += ch; + i++; + } + + pushArg(); + return tokens; +} + +/** + * Check if tokens contain shellisms that need real shell + */ +function needsShell(tokens) { + return tokens.some(t => + t.kind === TokenKind.SHELLISM || + t.kind === TokenKind.PIPE || + t.kind === TokenKind.REDIRECT + ); +} + +/** + * Parse tokens into command chain. + * Returns array of { args: string[], operator: string|null } + */ +function parseChain(tokens) { + const commands = []; + let currentArgs = []; + + for (const token of tokens) { + if (token.kind === TokenKind.OPERATOR) { + if (currentArgs.length > 0) { + commands.push({ args: currentArgs, operator: token.value }); + currentArgs = []; + } + } else if (token.kind === TokenKind.ARG) { + currentArgs.push(token.value); + } + // Skip PIPE, REDIRECT, SHELLISM - handled by needsShell + } + + // Last command (no trailing operator) + if (currentArgs.length > 0) { + commands.push({ args: currentArgs, operator: null }); + } + + return commands; +} + +/** + * Strip quotes from a string for pattern matching + */ +function stripQuotes(s) { + if ((s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; +} + +/** + * Reconstruct a command from args array + */ +function argsToString(args) { + return args.join(' '); +} + +// ============================================================================ +// REWRITE RULES +// ============================================================================ + +/** + * Check if a command should be rewritten and return the rewritten version. + * Returns null if no rewrite needed. + */ +function rewriteCommand(args) { + if (args.length === 0) return null; + + const binary = stripQuotes(args[0]); + const cmdStr = argsToString(args); + + // Skip if already using rtk + if (binary === 'rtk') return null; + + // Strip leading env var assignments for pattern matching + let matchArgs = args; + let envPrefix = ''; + while (matchArgs.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(matchArgs[0])) { + envPrefix += matchArgs[0] + ' '; + matchArgs = matchArgs.slice(1); + } + + if (matchArgs.length === 0) return null; + + const matchBinary = stripQuotes(matchArgs[0]); + const matchCmdStr = argsToString(matchArgs); + + // --- Git commands --- + if (matchBinary === 'git' && matchArgs.length > 1) { + // Find the subcommand (skip flags like -C, -c, --no-pager) + let subcmdIdx = 1; + while (subcmdIdx < matchArgs.length) { + const arg = matchArgs[subcmdIdx]; + if (arg === '-C' || arg === '-c') { + subcmdIdx += 2; // skip flag and its value + } else if (arg.startsWith('--')) { + subcmdIdx++; + } else { + break; + } + } + if (subcmdIdx < matchArgs.length) { + const subcmd = stripQuotes(matchArgs[subcmdIdx]); + if (/^(status|diff|log|add|commit|push|pull|branch|fetch|stash|show|worktree)$/.test(subcmd)) { + return `${envPrefix}rtk ${matchCmdStr}`; + } + } + } + + // --- GitHub CLI --- + if (matchBinary === 'gh' && matchArgs.length > 1) { + const subcmd = stripQuotes(matchArgs[1]); + if (/^(pr|issue|run|api|release)$/.test(subcmd)) { + return `${envPrefix}rtk ${matchCmdStr}`; + } + } + + // --- Cargo --- + if (matchBinary === 'cargo' && matchArgs.length > 1) { + let subcmdIdx = 1; + // Skip toolchain specifier like +nightly + if (matchArgs[1] && matchArgs[1].startsWith('+')) { + subcmdIdx = 2; + } + if (subcmdIdx < matchArgs.length) { + const subcmd = stripQuotes(matchArgs[subcmdIdx]); + if (/^(test|build|clippy|check|install|fmt)$/.test(subcmd)) { + return `${envPrefix}rtk ${matchCmdStr}`; + } + } + } + + // --- File operations --- + if (matchBinary === 'cat') { + return `${envPrefix}rtk read ${argsToString(matchArgs.slice(1))}`; + } + if (matchBinary === 'rg' || matchBinary === 'grep') { + return `${envPrefix}rtk grep ${argsToString(matchArgs.slice(1))}`; + } + if (matchBinary === 'ls') { + return `${envPrefix}rtk ls ${argsToString(matchArgs.slice(1))}`.trim(); + } + if (matchBinary === 'tree') { + return `${envPrefix}rtk tree ${argsToString(matchArgs.slice(1))}`.trim(); + } + if (matchBinary === 'find') { + return `${envPrefix}rtk find ${argsToString(matchArgs.slice(1))}`; + } + if (matchBinary === 'diff') { + return `${envPrefix}rtk diff ${argsToString(matchArgs.slice(1))}`; + } + + // --- JS/TS tooling --- + if (matchBinary === 'vitest' || + (matchBinary === 'npx' && matchArgs[1] === 'vitest') || + (matchBinary === 'pnpm' && matchArgs[1] === 'vitest')) { + const rest = matchBinary === 'vitest' ? matchArgs.slice(1) : matchArgs.slice(2); + const restStr = argsToString(rest).replace(/^run\s*/, ''); + return `${envPrefix}rtk vitest run ${restStr}`.trim(); + } + if (matchBinary === 'pnpm' && matchArgs[1] === 'test') { + return `${envPrefix}rtk vitest run ${argsToString(matchArgs.slice(2))}`.trim(); + } + if (matchBinary === 'npm' && matchArgs[1] === 'test') { + return `${envPrefix}rtk npm test ${argsToString(matchArgs.slice(2))}`.trim(); + } + if (matchBinary === 'npm' && matchArgs[1] === 'run') { + return `${envPrefix}rtk npm ${argsToString(matchArgs.slice(2))}`; + } + + // TypeScript + if (matchBinary === 'tsc' || matchBinary === 'vue-tsc' || + (matchBinary === 'npx' && (matchArgs[1] === 'tsc' || matchArgs[1] === 'vue-tsc')) || + (matchBinary === 'pnpm' && matchArgs[1] === 'tsc')) { + const rest = matchBinary === 'tsc' || matchBinary === 'vue-tsc' ? matchArgs.slice(1) : matchArgs.slice(2); + return `${envPrefix}rtk tsc ${argsToString(rest)}`.trim(); + } + + // Linting + if (matchBinary === 'eslint' || (matchBinary === 'npx' && matchArgs[1] === 'eslint')) { + const rest = matchBinary === 'eslint' ? matchArgs.slice(1) : matchArgs.slice(2); + return `${envPrefix}rtk lint ${argsToString(rest)}`.trim(); + } + if (matchBinary === 'pnpm' && matchArgs[1] === 'lint') { + return `${envPrefix}rtk lint ${argsToString(matchArgs.slice(2))}`.trim(); + } + + // Prettier + if (matchBinary === 'prettier' || (matchBinary === 'npx' && matchArgs[1] === 'prettier')) { + const rest = matchBinary === 'prettier' ? matchArgs.slice(1) : matchArgs.slice(2); + return `${envPrefix}rtk prettier ${argsToString(rest)}`.trim(); + } + + // Playwright + if (matchBinary === 'playwright' || + (matchBinary === 'npx' && matchArgs[1] === 'playwright') || + (matchBinary === 'pnpm' && matchArgs[1] === 'playwright')) { + const rest = matchBinary === 'playwright' ? matchArgs.slice(1) : matchArgs.slice(2); + return `${envPrefix}rtk playwright ${argsToString(rest)}`.trim(); + } + + // Prisma + if (matchBinary === 'prisma' || (matchBinary === 'npx' && matchArgs[1] === 'prisma')) { + const rest = matchBinary === 'prisma' ? matchArgs.slice(1) : matchArgs.slice(2); + return `${envPrefix}rtk prisma ${argsToString(rest)}`.trim(); + } + + // --- Containers --- + if (matchBinary === 'docker') { + if (matchArgs.length > 1) { + const subcmd = stripQuotes(matchArgs[1]); + if (subcmd === 'compose' || /^(ps|images|logs|run|build|exec)$/.test(subcmd)) { + return `${envPrefix}rtk docker ${argsToString(matchArgs.slice(1))}`; + } + } + } + if (matchBinary === 'kubectl' && matchArgs.length > 1) { + // Skip context/namespace flags to find subcommand + let subcmdIdx = 1; + while (subcmdIdx < matchArgs.length) { + const arg = matchArgs[subcmdIdx]; + if (arg === '--context' || arg === '--kubeconfig' || arg === '--namespace' || arg === '-n') { + subcmdIdx += 2; + } else if (arg.startsWith('--')) { + subcmdIdx++; + } else { + break; + } + } + if (subcmdIdx < matchArgs.length) { + const subcmd = stripQuotes(matchArgs[subcmdIdx]); + if (/^(get|logs|describe|apply)$/.test(subcmd)) { + return `${envPrefix}rtk kubectl ${argsToString(matchArgs.slice(1))}`; + } + } + } + + // --- Network --- + if (matchBinary === 'curl') { + return `${envPrefix}rtk curl ${argsToString(matchArgs.slice(1))}`; + } + if (matchBinary === 'wget') { + return `${envPrefix}rtk wget ${argsToString(matchArgs.slice(1))}`; + } + + // --- pnpm package management --- + if (matchBinary === 'pnpm' && matchArgs.length > 1) { + const subcmd = stripQuotes(matchArgs[1]); + if (/^(list|ls|outdated)$/.test(subcmd)) { + return `${envPrefix}rtk pnpm ${argsToString(matchArgs.slice(1))}`; + } + } + + // --- Python tooling --- + if (matchBinary === 'pytest') { + return `${envPrefix}rtk pytest ${argsToString(matchArgs.slice(1))}`.trim(); + } + if (matchBinary === 'python' && matchArgs[1] === '-m' && matchArgs[2] === 'pytest') { + return `${envPrefix}rtk pytest ${argsToString(matchArgs.slice(3))}`.trim(); + } + if (matchBinary === 'ruff' && matchArgs.length > 1) { + const subcmd = stripQuotes(matchArgs[1]); + if (/^(check|format)$/.test(subcmd)) { + return `${envPrefix}rtk ruff ${argsToString(matchArgs.slice(1))}`; + } + } + if (matchBinary === 'pip' && matchArgs.length > 1) { + const subcmd = stripQuotes(matchArgs[1]); + if (/^(list|outdated|install|show)$/.test(subcmd)) { + return `${envPrefix}rtk pip ${argsToString(matchArgs.slice(1))}`; + } + } + if (matchBinary === 'uv' && matchArgs[1] === 'pip' && matchArgs.length > 2) { + const subcmd = stripQuotes(matchArgs[2]); + if (/^(list|outdated|install|show)$/.test(subcmd)) { + return `${envPrefix}rtk pip ${argsToString(matchArgs.slice(2))}`; + } + } + + // --- Go tooling --- + if (matchBinary === 'go' && matchArgs.length > 1) { + const subcmd = stripQuotes(matchArgs[1]); + if (/^(test|build|vet)$/.test(subcmd)) { + return `${envPrefix}rtk go ${argsToString(matchArgs.slice(1))}`; + } + } + if (matchBinary === 'golangci-lint') { + return `${envPrefix}rtk golangci-lint ${argsToString(matchArgs.slice(1))}`.trim(); + } + + return null; +} + +// ============================================================================ +// MAIN +// ============================================================================ + +// Read stdin +let input = ''; +try { + input = fs.readFileSync(0, 'utf-8'); +} catch (e) { + process.exit(0); +} + +let data; +try { + data = JSON.parse(input); +} catch (e) { + process.exit(0); +} + +const cmd = data?.tool_input?.command; +if (!cmd) process.exit(0); + +// Skip heredocs entirely +if (cmd.includes('<<')) process.exit(0); + +// Tokenize the command +const tokens = tokenize(cmd); + +// If command has pipes/redirects/shellisms, pass through unchanged +if (needsShell(tokens)) process.exit(0); + +// Parse into command chain +const chain = parseChain(tokens); +if (chain.length === 0) process.exit(0); + +// Rewrite each command in the chain +let anyRewritten = false; +const rewrittenParts = []; + +for (const { args, operator } of chain) { + const rewritten = rewriteCommand(args); + if (rewritten) { + anyRewritten = true; + rewrittenParts.push(rewritten); + } else { + rewrittenParts.push(argsToString(args)); + } + if (operator) { + rewrittenParts.push(` ${operator} `); + } +} + +// If nothing was rewritten, exit silently +if (!anyRewritten) process.exit(0); + +// Reconstruct the full command +const rewrittenCmd = rewrittenParts.join('').trim(); + +// Build the updated tool_input with all original fields preserved +const updatedInput = { ...data.tool_input, command: rewrittenCmd }; + +// Output the rewrite instruction +console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: "RTK auto-rewrite", + updatedInput: updatedInput + } +})); diff --git a/hooks/test-rtk-rewrite.js b/hooks/test-rtk-rewrite.js new file mode 100644 index 0000000..e9845db --- /dev/null +++ b/hooks/test-rtk-rewrite.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// Tests for rtk-rewrite.js lexer and rewrite logic +// Note: Uses execSync with hardcoded test inputs (no user input) + +const fs = require('fs'); +const { execSync } = require('child_process'); +const path = require('path'); + +const hookPath = path.join(__dirname, 'rtk-rewrite.js'); + +function runHook(command) { + const input = JSON.stringify({ tool_input: { command } }); + try { + const result = execSync(`node "${hookPath}"`, { + input, + encoding: 'utf-8', + timeout: 5000 + }); + if (!result.trim()) return null; // No rewrite + return JSON.parse(result).hookSpecificOutput.updatedInput.command; + } catch (e) { + if (e.status === 0) return null; // Clean exit, no rewrite + throw e; + } +} + +let passed = 0; +let failed = 0; + +function test(name, input, expected) { + const result = runHook(input); + const ok = result === expected; + if (ok) { + passed++; + console.log(`✓ ${name}`); + } else { + failed++; + console.log(`✗ ${name}`); + console.log(` Input: ${input}`); + console.log(` Expected: ${expected}`); + console.log(` Got: ${result}`); + } +} + +console.log('\n=== LEXER TESTS: Quote Awareness ===\n'); + +// Critical test: quotes with && inside should NOT split +test( + 'git commit with && in message - should NOT split', + 'git commit -m "Fix && cleanup"', + 'rtk git commit -m "Fix && cleanup"' +); + +test( + 'git commit with || in message - should NOT split', + 'git commit -m "Option A || Option B"', + 'rtk git commit -m "Option A || Option B"' +); + +test( + 'single quotes with operators inside', + "git commit -m 'Fix; cleanup'", + "rtk git commit -m 'Fix; cleanup'" +); + +console.log('\n=== CHAIN PARSING TESTS ===\n'); + +test( + 'simple && chain: cd && git status', + 'cd /tmp && git status', + 'cd /tmp && rtk git status' +); + +test( + '|| chain: cmd1 || git status', + 'false || git status', + 'false || rtk git status' +); + +test( + '; chain: cmd1 ; git status', + 'echo hello ; git status', + 'echo hello ; rtk git status' +); + +test( + 'triple chain: cd && git add && git commit', + 'cd /tmp && git add . && git commit -m "test"', + 'cd /tmp && rtk git add . && rtk git commit -m "test"' +); + +test( + 'mixed operators: cmd && cmd || cmd', + 'cd /tmp && git status || git log', + 'cd /tmp && rtk git status || rtk git log' +); + +console.log('\n=== SHELLISM DETECTION (should pass through unchanged) ===\n'); + +test( + 'glob pattern - should NOT rewrite', + 'ls *.js', + null +); + +test( + 'pipe - should NOT rewrite', + 'git status | grep modified', + null +); + +test( + 'redirect - should NOT rewrite', + 'git log > output.txt', + null +); + +test( + 'subshell $() - should NOT rewrite', + 'echo $(git status)', + null +); + +test( + 'backticks - should NOT rewrite', + 'echo `git status`', + null +); + +console.log('\n=== BASIC COMMAND REWRITES ===\n'); + +test('git status', 'git status', 'rtk git status'); +test('git diff', 'git diff', 'rtk git diff'); +test('git log', 'git log --oneline', 'rtk git log --oneline'); +test('git add', 'git add .', 'rtk git add .'); +test('git commit', 'git commit -m "test"', 'rtk git commit -m "test"'); +test('git push', 'git push origin main', 'rtk git push origin main'); +test('git pull', 'git pull', 'rtk git pull'); +test('git branch', 'git branch -a', 'rtk git branch -a'); +test('git stash', 'git stash', 'rtk git stash'); +test('git show', 'git show HEAD', 'rtk git show HEAD'); + +test('gh pr view', 'gh pr view 123', 'rtk gh pr view 123'); +test('gh issue list', 'gh issue list', 'rtk gh issue list'); +test('gh run list', 'gh run list', 'rtk gh run list'); + +test('cargo test', 'cargo test', 'rtk cargo test'); +test('cargo build', 'cargo build --release', 'rtk cargo build --release'); +test('cargo clippy', 'cargo clippy', 'rtk cargo clippy'); + +test('cat file', 'cat README.md', 'rtk read README.md'); +test('grep pattern', 'grep -r "TODO" .', 'rtk grep -r "TODO" .'); +test('ls', 'ls -la', 'rtk ls -la'); +test('find', 'find . -name "*.js"', 'rtk find . -name "*.js"'); + +test('vitest', 'vitest run', 'rtk vitest run'); +test('npx vitest', 'npx vitest run', 'rtk vitest run'); +test('pnpm test', 'pnpm test', 'rtk vitest run'); + +test('tsc', 'tsc --noEmit', 'rtk tsc --noEmit'); +test('eslint', 'eslint src/', 'rtk lint src/'); +test('prettier', 'prettier --check .', 'rtk prettier --check .'); + +test('docker ps', 'docker ps', 'rtk docker ps'); +test('kubectl get', 'kubectl get pods', 'rtk kubectl get pods'); + +test('curl', 'curl https://api.example.com', 'rtk curl https://api.example.com'); + +test('pytest', 'pytest tests/', 'rtk pytest tests/'); +test('go test', 'go test ./...', 'rtk go test ./...'); + +console.log('\n=== ENV VAR PREFIX HANDLING ===\n'); + +test( + 'env var prefix: TEST_VAR=1 npx vitest', + 'TEST_VAR=1 npx vitest run', + 'TEST_VAR=1 rtk vitest run' +); + +test( + 'multiple env vars', + 'FOO=1 BAR=2 git status', + 'FOO=1 BAR=2 rtk git status' +); + +console.log('\n=== SKIP CASES ===\n'); + +test('already using rtk', 'rtk git status', null); +test('heredoc', 'cat < 0 ? 1 : 0);