From 440178d6970e1187e4882b427074ec5beaa5c567 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 9 Mar 2026 21:07:42 -0700 Subject: [PATCH] fix: harden hook portability and plugin docs --- commands/e2e.md | 6 +- commands/plan.md | 6 +- commands/tdd.md | 10 +- scripts/hooks/post-edit-format.js | 91 ++++++--- scripts/hooks/pre-bash-dev-server-block.js | 144 +++++++++++++- scripts/hooks/run-with-flags-shell.sh | 6 +- .../agents/start-observer.sh | 29 +-- .../continuous-learning-v2/hooks/observe.sh | 38 +++- .../scripts/detect-project.sh | 44 ++++- skills/iterative-retrieval/SKILL.md | 2 +- tests/hooks/hooks.test.js | 176 ++++++++++++++++++ 11 files changed, 490 insertions(+), 62 deletions(-) diff --git a/commands/e2e.md b/commands/e2e.md index f0f4a5b7..8caf086d 100644 --- a/commands/e2e.md +++ b/commands/e2e.md @@ -337,8 +337,10 @@ For PMX, prioritize these E2E tests: ## Related Agents -This command invokes the `e2e-runner` agent located at: -`~/.claude/agents/e2e-runner.md` +This command invokes the `e2e-runner` agent provided by ECC. + +For manual installs, the source file lives at: +`agents/e2e-runner.md` ## Quick Commands diff --git a/commands/plan.md b/commands/plan.md index b7e9905d..198ea5a4 100644 --- a/commands/plan.md +++ b/commands/plan.md @@ -109,5 +109,7 @@ After planning: ## Related Agents -This command invokes the `planner` agent located at: -`~/.claude/agents/planner.md` +This command invokes the `planner` agent provided by ECC. + +For manual installs, the source file lives at: +`agents/planner.md` diff --git a/commands/tdd.md b/commands/tdd.md index 3f7b02b5..f98cb58b 100644 --- a/commands/tdd.md +++ b/commands/tdd.md @@ -319,8 +319,10 @@ Never skip the RED phase. Never write code before tests. ## Related Agents -This command invokes the `tdd-guide` agent located at: -`~/.claude/agents/tdd-guide.md` +This command invokes the `tdd-guide` agent provided by ECC. -And can reference the `tdd-workflow` skill at: -`~/.claude/skills/tdd-workflow/` +The related `tdd-workflow` skill is also bundled with ECC. + +For manual installs, the source files live at: +- `agents/tdd-guide.md` +- `skills/tdd-workflow/SKILL.md` diff --git a/scripts/hooks/post-edit-format.js b/scripts/hooks/post-edit-format.js index 2f1d0334..d8a1754c 100644 --- a/scripts/hooks/post-edit-format.js +++ b/scripts/hooks/post-edit-format.js @@ -13,8 +13,31 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { getPackageManager } = require('../lib/package-manager'); const MAX_STDIN = 1024 * 1024; // 1MB limit +const BIOME_CONFIGS = ['biome.json', 'biome.jsonc']; +const PRETTIER_CONFIGS = [ + '.prettierrc', + '.prettierrc.json', + '.prettierrc.json5', + '.prettierrc.js', + '.prettierrc.cjs', + '.prettierrc.mjs', + '.prettierrc.ts', + '.prettierrc.cts', + '.prettierrc.mts', + '.prettierrc.yml', + '.prettierrc.yaml', + '.prettierrc.toml', + 'prettier.config.js', + 'prettier.config.cjs', + 'prettier.config.mjs', + 'prettier.config.ts', + 'prettier.config.cts', + 'prettier.config.mts', +]; +const PROJECT_ROOT_MARKERS = ['package.json', ...BIOME_CONFIGS, ...PRETTIER_CONFIGS]; let data = ''; process.stdin.setEncoding('utf8'); @@ -27,46 +50,66 @@ process.stdin.on('data', chunk => { function findProjectRoot(startDir) { let dir = startDir; - while (dir !== path.dirname(dir)) { - if (fs.existsSync(path.join(dir, 'package.json'))) return dir; - dir = path.dirname(dir); + + while (true) { + if (PROJECT_ROOT_MARKERS.some(marker => fs.existsSync(path.join(dir, marker)))) { + return dir; + } + + const parentDir = path.dirname(dir); + if (parentDir === dir) break; + dir = parentDir; } + return startDir; } function detectFormatter(projectRoot) { - const biomeConfigs = ['biome.json', 'biome.jsonc']; - for (const cfg of biomeConfigs) { + for (const cfg of BIOME_CONFIGS) { if (fs.existsSync(path.join(projectRoot, cfg))) return 'biome'; } - const prettierConfigs = [ - '.prettierrc', - '.prettierrc.json', - '.prettierrc.js', - '.prettierrc.cjs', - '.prettierrc.mjs', - '.prettierrc.yml', - '.prettierrc.yaml', - '.prettierrc.toml', - 'prettier.config.js', - 'prettier.config.cjs', - 'prettier.config.mjs', - ]; - for (const cfg of prettierConfigs) { + for (const cfg of PRETTIER_CONFIGS) { if (fs.existsSync(path.join(projectRoot, cfg))) return 'prettier'; } return null; } -function getFormatterCommand(formatter, filePath) { - const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; +function getRunnerBin(bin) { + if (process.platform !== 'win32') return bin; + if (bin === 'npx') return 'npx.cmd'; + if (bin === 'pnpm') return 'pnpm.cmd'; + if (bin === 'yarn') return 'yarn.cmd'; + if (bin === 'bunx') return 'bunx.cmd'; + return bin; +} + +function getFormatterRunner(projectRoot) { + const pm = getPackageManager({ projectDir: projectRoot }); + const execCmd = pm?.config?.execCmd || 'npx'; + const [bin = 'npx', ...prefix] = execCmd.split(/\s+/).filter(Boolean); + + return { + bin: getRunnerBin(bin), + prefix + }; +} + +function getFormatterCommand(formatter, filePath, projectRoot) { + const runner = getFormatterRunner(projectRoot); + if (formatter === 'biome') { - return { bin: npxBin, args: ['@biomejs/biome', 'format', '--write', filePath] }; + return { + bin: runner.bin, + args: [...runner.prefix, '@biomejs/biome', 'format', '--write', filePath] + }; } if (formatter === 'prettier') { - return { bin: npxBin, args: ['prettier', '--write', filePath] }; + return { + bin: runner.bin, + args: [...runner.prefix, 'prettier', '--write', filePath] + }; } return null; } @@ -80,7 +123,7 @@ process.stdin.on('end', () => { try { const projectRoot = findProjectRoot(path.dirname(path.resolve(filePath))); const formatter = detectFormatter(projectRoot); - const cmd = getFormatterCommand(formatter, filePath); + const cmd = getFormatterCommand(formatter, filePath, projectRoot); if (cmd) { execFileSync(cmd.bin, cmd.args, { diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 26b2a555..01c76c1f 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -4,6 +4,142 @@ const MAX_STDIN = 1024 * 1024; const { splitShellSegments } = require('../lib/shell-split'); +const DEV_COMMAND_WORDS = new Set([ + 'npm', + 'pnpm', + 'yarn', + 'bun', + 'npx', + 'bash', + 'sh', + 'zsh', + 'fish', + 'tmux' +]); +const SKIPPABLE_PREFIX_WORDS = new Set(['env', 'command', 'builtin', 'exec', 'noglob', 'sudo']); +const PREFIX_OPTION_VALUE_WORDS = { + env: new Set(['-u', '-C', '-S', '--unset', '--chdir', '--split-string']), + sudo: new Set([ + '-u', + '-g', + '-h', + '-p', + '-r', + '-t', + '-C', + '--user', + '--group', + '--host', + '--prompt', + '--role', + '--type', + '--close-from' + ]) +}; + +function readToken(input, startIndex) { + let index = startIndex; + while (index < input.length && /\s/.test(input[index])) index += 1; + if (index >= input.length) return null; + + let token = ''; + let quote = null; + + while (index < input.length) { + const ch = input[index]; + + if (quote) { + if (ch === quote) { + quote = null; + index += 1; + continue; + } + + if (ch === '\\' && quote === '"' && index + 1 < input.length) { + token += input[index + 1]; + index += 2; + continue; + } + + token += ch; + index += 1; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + index += 1; + continue; + } + + if (/\s/.test(ch)) break; + + if (ch === '\\' && index + 1 < input.length) { + token += input[index + 1]; + index += 2; + continue; + } + + token += ch; + index += 1; + } + + return { token, end: index }; +} + +function shouldSkipOptionValue(wrapper, optionToken) { + if (!wrapper || !optionToken || optionToken.includes('=')) return false; + const optionSet = PREFIX_OPTION_VALUE_WORDS[wrapper]; + return Boolean(optionSet && optionSet.has(optionToken)); +} + +function isOptionToken(token) { + return token.startsWith('-') && token.length > 1; +} + +function getLeadingCommandWord(segment) { + let index = 0; + let activeWrapper = null; + let skipNextValue = false; + + while (index < segment.length) { + const parsed = readToken(segment, index); + if (!parsed) return null; + index = parsed.end; + + const token = parsed.token; + if (!token) continue; + + if (skipNextValue) { + skipNextValue = false; + continue; + } + + if (token === '--') { + activeWrapper = null; + continue; + } + + if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) continue; + + if (SKIPPABLE_PREFIX_WORDS.has(token)) { + activeWrapper = token; + continue; + } + + if (activeWrapper && isOptionToken(token)) { + if (shouldSkipOptionValue(activeWrapper, token)) { + skipNextValue = true; + } + continue; + } + + return token; + } + + return null; +} + let raw = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { @@ -23,7 +159,13 @@ process.stdin.on('end', () => { const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/; - const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment)); + const hasBlockedDev = segments.some(segment => { + const commandWord = getLeadingCommandWord(segment); + if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) { + return false; + } + return devPattern.test(segment) && !tmuxLauncher.test(segment); + }); if (hasBlockedDev) { console.error('[Hook] BLOCKED: Dev server must run in tmux for log access'); diff --git a/scripts/hooks/run-with-flags-shell.sh b/scripts/hooks/run-with-flags-shell.sh index 21c65ebb..4b064c32 100755 --- a/scripts/hooks/run-with-flags-shell.sh +++ b/scripts/hooks/run-with-flags-shell.sh @@ -4,6 +4,8 @@ set -euo pipefail HOOK_ID="${1:-}" REL_SCRIPT_PATH="${2:-}" PROFILES_CSV="${3:-standard,strict}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" # Preserve stdin for passthrough or script execution INPUT="$(cat)" @@ -14,13 +16,13 @@ if [[ -z "$HOOK_ID" || -z "$REL_SCRIPT_PATH" ]]; then fi # Ask Node helper if this hook is enabled -ENABLED="$(node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK_ID" "$PROFILES_CSV" 2>/dev/null || echo yes)" +ENABLED="$(node "${PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK_ID" "$PROFILES_CSV" 2>/dev/null || echo yes)" if [[ "$ENABLED" != "yes" ]]; then printf '%s' "$INPUT" exit 0 fi -SCRIPT_PATH="${CLAUDE_PLUGIN_ROOT}/${REL_SCRIPT_PATH}" +SCRIPT_PATH="${PLUGIN_ROOT}/${REL_SCRIPT_PATH}" if [[ ! -f "$SCRIPT_PATH" ]]; then echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}" >&2 printf '%s' "$INPUT" diff --git a/skills/continuous-learning-v2/agents/start-observer.sh b/skills/continuous-learning-v2/agents/start-observer.sh index ba9994de..c6be18a8 100755 --- a/skills/continuous-learning-v2/agents/start-observer.sh +++ b/skills/continuous-learning-v2/agents/start-observer.sh @@ -28,6 +28,7 @@ OBSERVER_LOOP_SCRIPT="${SCRIPT_DIR}/observer-loop.sh" # Source shared project detection helper # This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR source "${SKILL_ROOT}/scripts/detect-project.sh" +PYTHON_CMD="${CLV2_PYTHON_CMD:-}" # ───────────────────────────────────────────── # Configuration @@ -46,7 +47,10 @@ OBSERVER_INTERVAL_MINUTES=5 MIN_OBSERVATIONS=20 OBSERVER_ENABLED=false if [ -f "$CONFIG_FILE" ]; then - _config=$(CLV2_CONFIG="$CONFIG_FILE" python3 -c " + if [ -z "$PYTHON_CMD" ]; then + echo "No python interpreter found; using built-in observer defaults." >&2 + else + _config=$(CLV2_CONFIG="$CONFIG_FILE" "$PYTHON_CMD" -c " import json, os with open(os.environ['CLV2_CONFIG']) as f: cfg = json.load(f) @@ -57,17 +61,18 @@ print(str(obs.get('enabled', False)).lower()) " 2>/dev/null || echo "5 20 false") - _interval=$(echo "$_config" | sed -n '1p') - _min_obs=$(echo "$_config" | sed -n '2p') - _enabled=$(echo "$_config" | sed -n '3p') - if [ "$_interval" -gt 0 ] 2>/dev/null; then - OBSERVER_INTERVAL_MINUTES="$_interval" - fi - if [ "$_min_obs" -gt 0 ] 2>/dev/null; then - MIN_OBSERVATIONS="$_min_obs" - fi - if [ "$_enabled" = "true" ]; then - OBSERVER_ENABLED=true + _interval=$(echo "$_config" | sed -n '1p') + _min_obs=$(echo "$_config" | sed -n '2p') + _enabled=$(echo "$_config" | sed -n '3p') + if [ "$_interval" -gt 0 ] 2>/dev/null; then + OBSERVER_INTERVAL_MINUTES="$_interval" + fi + if [ "$_min_obs" -gt 0 ] 2>/dev/null; then + MIN_OBSERVATIONS="$_min_obs" + fi + if [ "$_enabled" = "true" ]; then + OBSERVER_ENABLED=true + fi fi fi OBSERVER_INTERVAL_SECONDS=$((OBSERVER_INTERVAL_MINUTES * 60)) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 307cde67..ea44386b 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -27,13 +27,38 @@ if [ -z "$INPUT_JSON" ]; then exit 0 fi +resolve_python_cmd() { + if [ -n "${CLV2_PYTHON_CMD:-}" ] && command -v "$CLV2_PYTHON_CMD" >/dev/null 2>&1; then + printf '%s\n' "$CLV2_PYTHON_CMD" + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + printf '%s\n' python3 + return 0 + fi + + if command -v python >/dev/null 2>&1; then + printf '%s\n' python + return 0 + fi + + return 1 +} + +PYTHON_CMD="$(resolve_python_cmd 2>/dev/null || true)" +if [ -z "$PYTHON_CMD" ]; then + echo "[observe] No python interpreter found, skipping observation" >&2 + exit 0 +fi + # ───────────────────────────────────────────── # Extract cwd from stdin for project detection # ───────────────────────────────────────────── # Extract cwd from the hook JSON to use for project detection. # This avoids spawning a separate git subprocess when cwd is available. -STDIN_CWD=$(echo "$INPUT_JSON" | python3 -c ' +STDIN_CWD=$(echo "$INPUT_JSON" | "$PYTHON_CMD" -c ' import json, sys try: data = json.load(sys.stdin) @@ -58,6 +83,7 @@ SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Source shared project detection helper # This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR source "${SKILL_ROOT}/scripts/detect-project.sh" +PYTHON_CMD="${CLV2_PYTHON_CMD:-$PYTHON_CMD}" # ───────────────────────────────────────────── # Configuration @@ -79,9 +105,9 @@ if [ ! -f "$PURGE_MARKER" ] || [ "$(find "$PURGE_MARKER" -mtime +1 2>/dev/null)" touch "$PURGE_MARKER" 2>/dev/null || true fi -# Parse using python via stdin pipe (safe for all JSON payloads) +# Parse using Python via stdin pipe (safe for all JSON payloads) # Pass HOOK_PHASE via env var since Claude Code does not include hook type in stdin JSON -PARSED=$(echo "$INPUT_JSON" | HOOK_PHASE="$HOOK_PHASE" python3 -c ' +PARSED=$(echo "$INPUT_JSON" | HOOK_PHASE="$HOOK_PHASE" "$PYTHON_CMD" -c ' import json import sys import os @@ -129,13 +155,13 @@ except Exception as e: ') # Check if parsing succeeded -PARSED_OK=$(echo "$PARSED" | python3 -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))" 2>/dev/null || echo "False") +PARSED_OK=$(echo "$PARSED" | "$PYTHON_CMD" -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))" 2>/dev/null || echo "False") if [ "$PARSED_OK" != "True" ]; then # Fallback: log raw input for debugging (scrub secrets before persisting) timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") export TIMESTAMP="$timestamp" - echo "$INPUT_JSON" | python3 -c ' + echo "$INPUT_JSON" | "$PYTHON_CMD" -c ' import json, sys, os, re _SECRET_RE = re.compile( @@ -170,7 +196,7 @@ export PROJECT_ID_ENV="$PROJECT_ID" export PROJECT_NAME_ENV="$PROJECT_NAME" export TIMESTAMP="$timestamp" -echo "$PARSED" | python3 -c ' +echo "$PARSED" | "$PYTHON_CMD" -c ' import json, sys, os, re parsed = json.load(sys.stdin) diff --git a/skills/continuous-learning-v2/scripts/detect-project.sh b/skills/continuous-learning-v2/scripts/detect-project.sh index a4bbfb32..b2677538 100755 --- a/skills/continuous-learning-v2/scripts/detect-project.sh +++ b/skills/continuous-learning-v2/scripts/detect-project.sh @@ -23,6 +23,28 @@ _CLV2_HOMUNCULUS_DIR="${HOME}/.claude/homunculus" _CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects" _CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json" +_clv2_resolve_python_cmd() { + if [ -n "${CLV2_PYTHON_CMD:-}" ] && command -v "$CLV2_PYTHON_CMD" >/dev/null 2>&1; then + printf '%s\n' "$CLV2_PYTHON_CMD" + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + printf '%s\n' python3 + return 0 + fi + + if command -v python >/dev/null 2>&1; then + printf '%s\n' python + return 0 + fi + + return 1 +} + +_CLV2_PYTHON_CMD="$(_clv2_resolve_python_cmd 2>/dev/null || true)" +export CLV2_PYTHON_CMD + _clv2_detect_project() { local project_root="" local project_name="" @@ -73,10 +95,12 @@ _clv2_detect_project() { fi local hash_input="${remote_url:-$project_root}" - # Use SHA256 via python3 (portable across macOS/Linux, no shasum/sha256sum divergence) - project_id=$(printf '%s' "$hash_input" | python3 -c "import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])" 2>/dev/null) + # Prefer Python for consistent SHA256 behavior across shells/platforms. + if [ -n "$_CLV2_PYTHON_CMD" ]; then + project_id=$(printf '%s' "$hash_input" | "$_CLV2_PYTHON_CMD" -c "import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])" 2>/dev/null) + fi - # Fallback if python3 failed + # Fallback if Python is unavailable or hash generation failed. if [ -z "$project_id" ]; then project_id=$(printf '%s' "$hash_input" | shasum -a 256 2>/dev/null | cut -c1-12 || \ printf '%s' "$hash_input" | sha256sum 2>/dev/null | cut -c1-12 || \ @@ -85,9 +109,9 @@ _clv2_detect_project() { # Backward compatibility: if credentials were stripped and the hash changed, # check if a project dir exists under the legacy hash and reuse it - if [ "$legacy_hash_input" != "$hash_input" ]; then - local legacy_id - legacy_id=$(printf '%s' "$legacy_hash_input" | python3 -c "import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])" 2>/dev/null) + if [ "$legacy_hash_input" != "$hash_input" ] && [ -n "$_CLV2_PYTHON_CMD" ]; then + local legacy_id="" + legacy_id=$(printf '%s' "$legacy_hash_input" | "$_CLV2_PYTHON_CMD" -c "import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])" 2>/dev/null) if [ -n "$legacy_id" ] && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then # Migrate legacy directory to new hash mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null || project_id="$legacy_id" @@ -120,14 +144,18 @@ _clv2_update_project_registry() { mkdir -p "$(dirname "$_CLV2_REGISTRY_FILE")" + if [ -z "$_CLV2_PYTHON_CMD" ]; then + return 0 + fi + # Pass values via env vars to avoid shell→python injection. - # python3 reads them with os.environ, which is safe for any string content. + # Python reads them with os.environ, which is safe for any string content. _CLV2_REG_PID="$pid" \ _CLV2_REG_PNAME="$pname" \ _CLV2_REG_PROOT="$proot" \ _CLV2_REG_PREMOTE="$premote" \ _CLV2_REG_FILE="$_CLV2_REGISTRY_FILE" \ - python3 -c ' + "$_CLV2_PYTHON_CMD" -c ' import json, os from datetime import datetime, timezone diff --git a/skills/iterative-retrieval/SKILL.md b/skills/iterative-retrieval/SKILL.md index 27760f9a..0a24a6dd 100644 --- a/skills/iterative-retrieval/SKILL.md +++ b/skills/iterative-retrieval/SKILL.md @@ -208,4 +208,4 @@ When retrieving context for this task: - [The Longform Guide](https://x.com/affaanmustafa/status/2014040193557471352) - Subagent orchestration section - `continuous-learning` skill - For patterns that improve over time -- Agent definitions in `~/.claude/agents/` +- Agent definitions bundled with ECC (manual install path: `agents/`) diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 2c873204..6cc2f45b 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -75,6 +75,35 @@ function cleanupTestDir(testDir) { fs.rmSync(testDir, { recursive: true, force: true }); } +function createCommandShim(binDir, baseName, logFile) { + fs.mkdirSync(binDir, { recursive: true }); + + const shimJs = path.join(binDir, `${baseName}-shim.js`); + fs.writeFileSync(shimJs, [ + 'const fs = require(\'fs\');', + `fs.appendFileSync(${JSON.stringify(logFile)}, JSON.stringify({ bin: ${JSON.stringify(baseName)}, args: process.argv.slice(2), cwd: process.cwd() }) + '\\n');` + ].join('\n')); + + if (process.platform === 'win32') { + const shimCmd = path.join(binDir, `${baseName}.cmd`); + fs.writeFileSync(shimCmd, `@echo off\r\nnode "${shimJs}" %*\r\n`); + return shimCmd; + } + + const shimPath = path.join(binDir, baseName); + fs.writeFileSync(shimPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimJs)});\n`); + fs.chmodSync(shimPath, 0o755); + return shimPath; +} + +function readCommandLog(logFile) { + if (!fs.existsSync(logFile)) return []; + return fs.readFileSync(logFile, 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); +} + // Test suite async function runTests() { console.log('\n=== Testing Hook Scripts ===\n'); @@ -701,6 +730,131 @@ async function runTests() { assert.ok(result.stdout.includes('tool_input'), 'Should pass through original data'); })) passed++; else failed++; + if (await asyncTest('finds formatter config in parent dirs without package.json', async () => { + const testDir = createTestDir(); + const rootDir = path.join(testDir, 'config-only-repo'); + const nestedDir = path.join(rootDir, 'src', 'nested'); + const filePath = path.join(nestedDir, 'component.ts'); + const binDir = path.join(testDir, 'bin'); + const logFile = path.join(testDir, 'formatter.log'); + + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(path.join(rootDir, '.prettierrc'), '{}'); + fs.writeFileSync(filePath, 'export const value = 1;\n'); + createCommandShim(binDir, 'npx', logFile); + + const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, { + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + }); + + assert.strictEqual(result.code, 0, 'Should exit 0 for config-only repo'); + const logEntries = readCommandLog(logFile); + assert.strictEqual(logEntries.length, 1, 'Should invoke formatter once'); + assert.strictEqual( + fs.realpathSync(logEntries[0].cwd), + fs.realpathSync(rootDir), + 'Should run formatter from config root' + ); + assert.deepStrictEqual( + logEntries[0].args, + ['prettier', '--write', filePath], + 'Should use the formatter on the nested file' + ); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (await asyncTest('respects CLAUDE_PACKAGE_MANAGER for formatter fallback runner', async () => { + const testDir = createTestDir(); + const rootDir = path.join(testDir, 'pnpm-repo'); + const filePath = path.join(rootDir, 'index.ts'); + const binDir = path.join(testDir, 'bin'); + const logFile = path.join(testDir, 'pnpm.log'); + + fs.mkdirSync(rootDir, { recursive: true }); + fs.writeFileSync(path.join(rootDir, '.prettierrc'), '{}'); + fs.writeFileSync(filePath, 'export const value = 1;\n'); + createCommandShim(binDir, 'pnpm', logFile); + + const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, { + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + CLAUDE_PACKAGE_MANAGER: 'pnpm' + }); + + assert.strictEqual(result.code, 0, 'Should exit 0 when pnpm fallback is used'); + const logEntries = readCommandLog(logFile); + assert.strictEqual(logEntries.length, 1, 'Should invoke pnpm fallback runner once'); + assert.strictEqual(logEntries[0].bin, 'pnpm', 'Should use pnpm runner'); + assert.deepStrictEqual( + logEntries[0].args, + ['dlx', 'prettier', '--write', filePath], + 'Should use pnpm dlx for fallback formatter execution' + ); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (await asyncTest('respects project package-manager config for formatter fallback runner', async () => { + const testDir = createTestDir(); + const rootDir = path.join(testDir, 'bun-repo'); + const filePath = path.join(rootDir, 'index.ts'); + const binDir = path.join(testDir, 'bin'); + const logFile = path.join(testDir, 'bun.log'); + + fs.mkdirSync(path.join(rootDir, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(rootDir, '.claude', 'package-manager.json'), JSON.stringify({ packageManager: 'bun' })); + fs.writeFileSync(path.join(rootDir, '.prettierrc'), '{}'); + fs.writeFileSync(filePath, 'export const value = 1;\n'); + createCommandShim(binDir, 'bunx', logFile); + + const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, { + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + }); + + assert.strictEqual(result.code, 0, 'Should exit 0 when project config selects bun'); + const logEntries = readCommandLog(logFile); + assert.strictEqual(logEntries.length, 1, 'Should invoke bunx fallback runner once'); + assert.strictEqual(logEntries[0].bin, 'bunx', 'Should use bunx runner'); + assert.deepStrictEqual( + logEntries[0].args, + ['prettier', '--write', filePath], + 'Should use bunx for fallback formatter execution' + ); + cleanupTestDir(testDir); + })) passed++; else failed++; + + console.log('\npre-bash-dev-server-block.js:'); + + if (await asyncTest('allows non-dev commands whose heredoc text mentions npm run dev', async () => { + const command = [ + 'gh pr create --title "fix: docs" --body "$(cat <<\'EOF\'', + '## Test plan', + '- run npm run dev to verify the site starts', + 'EOF', + ')"' + ].join('\n'); + const stdinJson = JSON.stringify({ tool_input: { command } }); + const result = await runScript(path.join(scriptsDir, 'pre-bash-dev-server-block.js'), stdinJson); + + assert.strictEqual(result.code, 0, 'Non-dev commands should pass through'); + assert.strictEqual(result.stdout, stdinJson, 'Should preserve original input'); + assert.ok(!result.stderr.includes('BLOCKED'), 'Should not emit a block message'); + })) passed++; else failed++; + + if (await asyncTest('blocks bare npm run dev outside tmux on non-Windows platforms', async () => { + const stdinJson = JSON.stringify({ tool_input: { command: 'npm run dev' } }); + const result = await runScript(path.join(scriptsDir, 'pre-bash-dev-server-block.js'), stdinJson); + + if (process.platform === 'win32') { + assert.strictEqual(result.code, 0, 'Windows path should pass through'); + assert.strictEqual(result.stdout, stdinJson, 'Windows path should preserve original input'); + } else { + assert.strictEqual(result.code, 2, 'Unix path should block bare dev servers'); + assert.ok(result.stderr.includes('BLOCKED'), 'Should explain why the command was blocked'); + } + })) passed++; else failed++; + // post-edit-typecheck.js tests console.log('\npost-edit-typecheck.js:'); @@ -1516,6 +1670,28 @@ async function runTests() { assert.ok(typecheckSource.includes('npx.cmd'), 'Should use npx.cmd for Windows cross-platform safety'); })) passed++; else failed++; + console.log('\nShell wrapper portability:'); + + if (test('run-with-flags-shell resolves plugin root when CLAUDE_PLUGIN_ROOT is unset', () => { + const wrapperSource = fs.readFileSync(path.join(scriptsDir, 'run-with-flags-shell.sh'), 'utf8'); + assert.ok( + wrapperSource.includes('PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-'), + 'Shell wrapper should derive PLUGIN_ROOT from its own script path' + ); + })) passed++; else failed++; + + if (test('continuous-learning shell scripts use resolved Python command instead of hardcoded python3 invocations', () => { + const observeSource = fs.readFileSync(path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'), 'utf8'); + const startObserverSource = fs.readFileSync(path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'agents', 'start-observer.sh'), 'utf8'); + const detectProjectSource = fs.readFileSync(path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'scripts', 'detect-project.sh'), 'utf8'); + + assert.ok(!/python3\s+-c/.test(observeSource), 'observe.sh should not invoke python3 directly'); + assert.ok(!/python3\s+-c/.test(startObserverSource), 'start-observer.sh should not invoke python3 directly'); + assert.ok(observeSource.includes('PYTHON_CMD'), 'observe.sh should resolve Python dynamically'); + assert.ok(startObserverSource.includes('CLV2_PYTHON_CMD'), 'start-observer.sh should reuse detected Python command'); + assert.ok(detectProjectSource.includes('_clv2_resolve_python_cmd'), 'detect-project.sh should provide shared Python resolution'); + })) passed++; else failed++; + if (await asyncTest('matches .tsx extension for type checking', async () => { const testDir = createTestDir(); const testFile = path.join(testDir, 'component.tsx');