mirror of
https://github.com/obra/superpowers.git
synced 2026-07-01 23:19:04 +08:00
hooks/session-start-codex has had no caller since "Remove Codex hooks" (#1845) deleted hooks-codex.json and its manifest registration; the Codex manifest now declares an empty hooks object so Codex registers no session-start hook at all. The script is Codex-specific dead code — nothing executes it on Codex or any other harness. - Delete hooks/session-start-codex. - tests/hooks/test-session-start.sh: drop the two Codex cases that are redundant with the generic session-start tests (nested-format and the legacy-warning omission are already covered by the Claude Code cases). Re-point the "wrapper dispatches" case to the live `session-start` script so run-hook.cmd dispatch coverage — used by Claude Code and Cursor in production — is preserved rather than lost. - docs/porting-to-a-new-harness.md: Codex is no longer a Shape A (shell-hook) harness, so re-anchor that worked example to Cursor (a live shell-hook harness that demonstrates the same per-harness field, schema, and matcher variance) and mark Codex as native skill discovery with no session-start hook. Clears the references to the deleted hooks-codex.json. - docs/windows/polyglot-hooks.md: the "check hooks-codex.json" pointer referenced a file deleted in #1845; re-point to hooks-cursor.json. RELEASE-NOTES.md keeps its historical mention of hooks-codex.json (it accurately records what that release did). The tests/codex-plugin-sync fixtures build their own synthetic session-start-codex and test the sync mechanism generically, so they are intentionally left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
5.2 KiB
Bash
Executable File
205 lines
5.2 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"
|
|
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"
|
|
|
|
wrapper_home="$(make_home run-hook-wrapper)"
|
|
assert_command_output \
|
|
"run-hook.cmd wrapper dispatches to the named session-start script" \
|
|
"nested" \
|
|
"" \
|
|
"" \
|
|
"$wrapper_home" \
|
|
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"
|
|
|
|
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"
|
|
|
|
if [[ "$FAILURES" -gt 0 ]]; then
|
|
echo "STATUS: FAILED ($FAILURES failure(s))"
|
|
exit 1
|
|
fi
|
|
|
|
echo "STATUS: PASSED"
|