2026-05-12 17:34:16 -07:00
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR = " $( cd " $( dirname " $0 " ) " && pwd ) "
REPO_ROOT = " $( cd " $SCRIPT_DIR /../.. " && pwd ) "
HOOK_UNDER_TEST = " $REPO_ROOT /hooks/session-start "
WRAPPER_UNDER_TEST = " $REPO_ROOT /hooks/run-hook.cmd "
FAILURES = 0
TEST_ROOT = " $( mktemp -d) "
cleanup( ) {
rm -rf " $TEST_ROOT "
}
trap cleanup EXIT
pass( ) {
echo " [PASS] $1 "
}
fail( ) {
echo " [FAIL] $1 "
FAILURES = $(( FAILURES + 1 ))
}
make_home( ) {
local name = " $1 "
local home = " $TEST_ROOT / $name /home "
mkdir -p " $home "
printf '%s\n' " $home "
}
assert_command_output( ) {
local description = " $1 "
local shape = " $2 "
local contains = " $3 "
local not_contains = " $4 "
local home = " $5 "
shift 5
local output
if ! output = " $( env -i PATH = " ${ PATH :- } " HOME = " $home " " $@ " 2>& 1) " ; then
fail " $description "
echo " hook exited non-zero"
echo " $output " | sed 's/^/ /'
return
fi
if printf '%s' " $output " | \
EXPECT_SHAPE = " $shape " \
EXPECT_CONTAINS = " $contains " \
EXPECT_NOT_CONTAINS = " $not_contains " \
node -e '
const fs = require( "fs" ) ;
const input = fs.readFileSync( 0, "utf8" ) ;
let payload;
try {
payload = JSON.parse( input) ;
} catch ( error) {
console.error( ` invalid JSON: ${ error .message } ` ) ;
process.exit( 1) ;
}
function hasOwn( object, key) {
return Object.prototype.hasOwnProperty.call( object, key) ;
}
function fail( message) {
console.error( message) ;
process.exit( 1) ;
}
const shape = process.env.EXPECT_SHAPE;
let context;
if ( shape = = = "nested" ) {
if ( !hasOwn( payload, "hookSpecificOutput" ) ) {
fail( "missing hookSpecificOutput" ) ;
}
if ( hasOwn( payload, "additional_context" ) || hasOwn( payload, "additionalContext" ) ) {
fail( "nested output also included a top-level context field" ) ;
}
const hookOutput = payload.hookSpecificOutput;
if ( !hookOutput || typeof hookOutput != = "object" || Array.isArray( hookOutput) ) {
fail( "hookSpecificOutput is not an object" ) ;
}
if ( hookOutput.hookEventName != = "SessionStart" ) {
fail( ` unexpected hookEventName: ${ hookOutput .hookEventName } ` ) ;
}
context = hookOutput.additionalContext;
} else if ( shape = = = "cursor" ) {
if ( hasOwn( payload, "hookSpecificOutput" ) ) {
fail( "cursor output included hookSpecificOutput" ) ;
}
if ( !hasOwn( payload, "additional_context" ) ) {
fail( "cursor output missing additional_context" ) ;
}
if ( hasOwn( payload, "additionalContext" ) ) {
fail( "cursor output included additionalContext" ) ;
}
context = payload.additional_context;
} else if ( shape = = = "sdk" ) {
if ( hasOwn( payload, "hookSpecificOutput" ) ) {
fail( "sdk output included hookSpecificOutput" ) ;
}
if ( !hasOwn( payload, "additionalContext" ) ) {
fail( "sdk output missing additionalContext" ) ;
}
if ( hasOwn( payload, "additional_context" ) ) {
fail( "sdk output included additional_context" ) ;
}
context = payload.additionalContext;
} else {
fail( ` unknown expected shape: ${ shape } ` ) ;
}
if ( typeof context != = "string" || context.trim( ) = = = "" ) {
fail( "injected context was empty" ) ;
}
const expectedText = process.env.EXPECT_CONTAINS || "" ;
if ( expectedText && !context.includes( expectedText) ) {
fail( ` context did not contain expected text: ${ expectedText } ` ) ;
}
const forbiddenTexts = ( process.env.EXPECT_NOT_CONTAINS || "" )
.split( "\u001f" )
.filter( Boolean) ;
for ( const forbiddenText of forbiddenTexts) {
if ( context.includes( forbiddenText) ) {
fail( ` context contained forbidden text: ${ forbiddenText } ` ) ;
}
}
' ; then
pass " $description "
else
fail " $description "
echo " output:"
echo " $output " | sed 's/^/ /'
fi
}
echo "SessionStart hook output tests"
claude_home = " $( make_home claude-code) "
assert_command_output \
"Claude Code emits nested SessionStart additionalContext" \
"nested" \
"" \
"" \
" $claude_home " \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
bash " $HOOK_UNDER_TEST "
codex_home = " $( make_home codex-plugin-hooks) "
codex_data = " $TEST_ROOT /codex-plugin-hooks/data "
mkdir -p " $codex_data "
assert_command_output \
"Codex plugin hooks emit nested SessionStart additionalContext" \
"nested" \
"" \
"" \
" $codex_home " \
PLUGIN_DATA = " $codex_data " \
CLAUDE_PLUGIN_DATA = " $codex_data " \
PLUGIN_ROOT = " $REPO_ROOT " \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
bash " $HOOK_UNDER_TEST "
codex_wrapper_home = " $( make_home codex-wrapper) "
codex_wrapper_data = " $TEST_ROOT /codex-wrapper/data "
mkdir -p " $codex_wrapper_data "
assert_command_output \
"Codex wrapper path emits nested SessionStart additionalContext" \
"nested" \
"" \
"" \
" $codex_wrapper_home " \
PLUGIN_DATA = " $codex_wrapper_data " \
CLAUDE_PLUGIN_DATA = " $codex_wrapper_data " \
PLUGIN_ROOT = " $REPO_ROOT " \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
bash " $WRAPPER_UNDER_TEST " session-start
cursor_home = " $( make_home cursor) "
assert_command_output \
"Cursor emits top-level additional_context only" \
"cursor" \
"" \
"" \
" $cursor_home " \
CURSOR_PLUGIN_ROOT = " $REPO_ROOT " \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
bash " $HOOK_UNDER_TEST "
copilot_home = " $( make_home copilot-cli) "
assert_command_output \
"Copilot CLI emits top-level additionalContext only" \
"sdk" \
"" \
"" \
" $copilot_home " \
COPILOT_CLI = 1 \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
bash " $HOOK_UNDER_TEST "
claude_legacy_home = " $( make_home claude-legacy-warning) "
mkdir -p " $claude_legacy_home /.config/superpowers/skills "
assert_command_output \
"Claude legacy warning points custom skills to ~/.claude/skills" \
"nested" \
"Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead." \
"" \
" $claude_legacy_home " \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
2026-05-12 19:01:32 -07:00
bash " $HOOK_UNDER_TEST "
claude_data_home = " $( make_home claude-data-warning) "
claude_data = " $TEST_ROOT /claude-data-warning/data "
mkdir -p " $claude_data_home /.config/superpowers/skills " " $claude_data "
assert_command_output \
"Claude with CLAUDE_PLUGIN_DATA still uses Claude legacy warning" \
"nested" \
"Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead." \
"" \
" $claude_data_home " \
CLAUDE_PLUGIN_DATA = " $claude_data " \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
2026-05-12 17:34:16 -07:00
bash " $HOOK_UNDER_TEST "
codex_legacy_home = " $( make_home codex-legacy-warning) "
codex_legacy_data = " $TEST_ROOT /codex-legacy-warning/data "
mkdir -p " $codex_legacy_home /.config/superpowers/skills " " $codex_legacy_data "
assert_command_output \
"Codex legacy warning uses harness-neutral custom-skill wording" \
"nested" \
"WARNING: Superpowers now uses your coding agent's native skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to a skills location supported by your coding agent. To make this message go away, remove ~/.config/superpowers/skills" \
"~/.claude/skills" $'\037' "Claude Code's skills system" \
" $codex_legacy_home " \
PLUGIN_DATA = " $codex_legacy_data " \
CLAUDE_PLUGIN_DATA = " $codex_legacy_data " \
PLUGIN_ROOT = " $REPO_ROOT " \
CLAUDE_PLUGIN_ROOT = " $REPO_ROOT " \
bash " $HOOK_UNDER_TEST "
if [ [ " $FAILURES " -gt 0 ] ] ; then
echo " STATUS: FAILED ( $FAILURES failure(s)) "
exit 1
fi
echo "STATUS: PASSED"