mirror of
https://github.com/obra/superpowers.git
synced 2026-05-15 21:49:05 +08:00
* docs: specify Codex native hooks parity * docs: refine Codex hooks spec after review * docs: record Codex hook contract spike * docs: plan Codex native hooks implementation * feat: support Codex native plugin hooks * test: add Codex native hook drill coverage * Simplify Codex hook entrypoint
241 lines
6.5 KiB
Bash
Executable File
241 lines
6.5 KiB
Bash
Executable File
#!/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"
|
|
CODEX_HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start-codex"
|
|
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 use dedicated script and 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 "$CODEX_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 dispatches to dedicated script" \
|
|
"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-codex
|
|
|
|
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"
|
|
|
|
legacy_home="$(make_home legacy-warning-removed)"
|
|
mkdir -p "$legacy_home/.config/superpowers/skills"
|
|
assert_command_output \
|
|
"SessionStart omits obsolete legacy custom-skill warning" \
|
|
"nested" \
|
|
"" \
|
|
"Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
|
|
"$legacy_home" \
|
|
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
|
bash "$HOOK_UNDER_TEST"
|
|
|
|
codex_legacy_home="$(make_home codex-legacy-warning-removed)"
|
|
codex_legacy_data="$TEST_ROOT/codex-legacy-warning-removed/data"
|
|
mkdir -p "$codex_legacy_home/.config/superpowers/skills" "$codex_legacy_data"
|
|
assert_command_output \
|
|
"Codex SessionStart omits obsolete legacy custom-skill warning" \
|
|
"nested" \
|
|
"" \
|
|
"Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
|
|
"$codex_legacy_home" \
|
|
PLUGIN_DATA="$codex_legacy_data" \
|
|
CLAUDE_PLUGIN_DATA="$codex_legacy_data" \
|
|
PLUGIN_ROOT="$REPO_ROOT" \
|
|
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
|
bash "$CODEX_HOOK_UNDER_TEST"
|
|
|
|
if [[ "$FAILURES" -gt 0 ]]; then
|
|
echo "STATUS: FAILED ($FAILURES failure(s))"
|
|
exit 1
|
|
fi
|
|
|
|
echo "STATUS: PASSED"
|