diff --git a/skills/continuous-learning-v2/agents/start-observer.sh b/skills/continuous-learning-v2/agents/start-observer.sh index 096a5d7bb..e31209f9a 100755 --- a/skills/continuous-learning-v2/agents/start-observer.sh +++ b/skills/continuous-learning-v2/agents/start-observer.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Continuous Learning v2 - Observer Agent Launcher # # Starts the background observer agent that analyzes observations diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 45d962971..48172e981 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Continuous Learning v2 - Observation Hook # # Captures tool use events for pattern analysis. diff --git a/skills/continuous-learning-v2/scripts/detect-project.sh b/skills/continuous-learning-v2/scripts/detect-project.sh index dbe9c5edc..2d129fe8c 100755 --- a/skills/continuous-learning-v2/scripts/detect-project.sh +++ b/skills/continuous-learning-v2/scripts/detect-project.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Continuous Learning v2 - Project Detection Helper # # Shared logic for detecting current project context. diff --git a/tests/hooks/continuous-learning-shebang-consistency.test.js b/tests/hooks/continuous-learning-shebang-consistency.test.js new file mode 100644 index 000000000..630124205 --- /dev/null +++ b/tests/hooks/continuous-learning-shebang-consistency.test.js @@ -0,0 +1,101 @@ +/** + * Tests for shebang consistency across continuous-learning-v2 shell scripts + * + * Every `*.sh` script under skills/continuous-learning-v2/ must use the + * portable `#!/usr/bin/env bash` shebang rather than the hardcoded + * `#!/bin/bash`. The hardcoded interpreter path fails on systems where bash + * is not installed at /bin/bash (NixOS, some Homebrew layouts, FreeBSD), and + * observe.sh runs on every hook invocation. This guards against the + * inconsistency (#2303) reappearing. + * + * Run with: node tests/hooks/continuous-learning-shebang-consistency.test.js + */ + +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + process.stdout.write(` ✓ ${name}\n`); + passed++; + } catch (err) { + process.stderr.write(` ✗ ${name}\n`); + process.stderr.write(` ${err && err.stack ? err.stack : String(err)}\n`); + failed++; + } +} + +const repoRoot = path.resolve(__dirname, '..', '..'); +const skillDir = path.join(repoRoot, 'skills', 'continuous-learning-v2'); +const PORTABLE_SHEBANG = '#!/usr/bin/env bash'; +const HARDCODED_SHEBANG = '#!/bin/bash'; + +function collectShellScripts(dir, acc = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Skip hidden/runtime directories (e.g. the observer's `.observer-tmp`) + // so an untracked local artifact cannot trigger a false failure; only + // committed skill scripts are checked. + if (entry.name.startsWith('.')) { + continue; + } + collectShellScripts(fullPath, acc); + } else if (entry.isFile() && entry.name.endsWith('.sh')) { + acc.push(fullPath); + } + } + return acc; +} + +function firstLine(filePath) { + return fs.readFileSync(filePath, 'utf8').split(/\r?\n/, 1)[0]; +} + +console.log('\n=== continuous-learning-v2 shebang consistency ===\n'); + +const scripts = collectShellScripts(skillDir); + +test('skill directory contains shell scripts to check', () => { + assert.ok(scripts.length > 0, `expected at least one .sh under ${skillDir}`); +}); + +for (const scriptPath of scripts) { + const rel = path.relative(repoRoot, scriptPath).split(path.sep).join('/'); + test(`${rel} uses portable '#!/usr/bin/env bash'`, () => { + assert.strictEqual( + firstLine(scriptPath), + PORTABLE_SHEBANG, + `${rel} should start with '${PORTABLE_SHEBANG}'` + ); + }); +} + +test('no continuous-learning-v2 script uses hardcoded #!/bin/bash', () => { + const offenders = scripts + .filter(scriptPath => firstLine(scriptPath) === HARDCODED_SHEBANG) + .map(scriptPath => path.relative(repoRoot, scriptPath).split(path.sep).join('/')); + assert.strictEqual( + offenders.length, + 0, + `hardcoded #!/bin/bash found in: ${offenders.join(', ')}` + ); +}); + +// ────────────────────────────────────────────────────── +// Summary +// ────────────────────────────────────────────────────── + +console.log('\n=== Test Results ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}\n`); + +process.exit(failed > 0 ? 1 : 0);