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"