diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index da22bc7e..55df548c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -21,6 +21,7 @@ "workflow" ], "skills": "./skills/", + "hooks": "./hooks/hooks-codex.json", "interface": { "displayName": "Superpowers", "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents", diff --git a/hooks/hooks-codex.json b/hooks/hooks-codex.json new file mode 100644 index 00000000..5c357fcc --- /dev/null +++ b/hooks/hooks-codex.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex", + "async": false + } + ] + } + ] + } +} diff --git a/hooks/session-start b/hooks/session-start index 24604295..0731962f 100755 --- a/hooks/session-start +++ b/hooks/session-start @@ -7,13 +7,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -# Check if legacy skills directory exists and build warning -warning_message="" -legacy_skills_dir="${HOME}/.config/superpowers/skills" -if [ -d "$legacy_skills_dir" ]; then - warning_message="\n\nIN YOUR FIRST REPLY AFTER SEEING THIS MESSAGE YOU MUST TELL THE USER:⚠️ **WARNING:** 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. To make this message go away, remove ~/.config/superpowers/skills" -fi - # Read using-superpowers content using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill") @@ -31,8 +24,7 @@ escape_for_json() { } using_superpowers_escaped=$(escape_for_json "$using_superpowers_content") -warning_escaped=$(escape_for_json "$warning_message") -session_context="\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_superpowers_escaped}\n\n${warning_escaped}\n" +session_context="\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_superpowers_escaped}\n" # Output context injection as JSON. # Cursor hooks expect additional_context (snake_case). diff --git a/hooks/session-start-codex b/hooks/session-start-codex new file mode 100755 index 00000000..a6cc3cf4 --- /dev/null +++ b/hooks/session-start-codex @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Codex SessionStart hook for superpowers plugin + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill") + +escape_for_json() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + printf '%s' "$s" +} + +using_superpowers_escaped=$(escape_for_json "$using_superpowers_content") +session_context="\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, follow the Codex skill-loading instructions in that skill:**\n\n${using_superpowers_escaped}\n" + +printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context" + +exit 0 diff --git a/scripts/sync-to-codex-plugin.sh b/scripts/sync-to-codex-plugin.sh index 4ced132c..8c91b4cd 100755 --- a/scripts/sync-to-codex-plugin.sh +++ b/scripts/sync-to-codex-plugin.sh @@ -70,7 +70,6 @@ EXCLUDES=( "/commands/" "/docs/" "/evals/" - "/hooks/" "/lib/" "/scripts/" "/tests/" @@ -420,7 +419,7 @@ if [[ $BOOTSTRAP -eq 1 ]]; then COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT" PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION). -Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\` and \`assets/\`. +Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\`, \`assets/\`, and \`hooks/\`. Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\` Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA @@ -430,7 +429,7 @@ else COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT" PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION). -Copies the tracked plugin files from upstream, including the committed Codex manifest and assets. +Copies the tracked plugin files from upstream, including the committed Codex manifest, assets, and hooks. Run via: \`scripts/sync-to-codex-plugin.sh\` Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA diff --git a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh index 353a8c0f..a94cdecf 100755 --- a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh +++ b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh @@ -178,6 +178,7 @@ write_upstream_fixture() { "$repo/.private-journal" \ "$repo/assets" \ "$repo/evals/drill" \ + "$repo/hooks" \ "$repo/scripts" \ "$repo/skills/example" @@ -218,6 +219,40 @@ EOF printf 'png fixture\n' > "$repo/assets/app-icon.png" printf 'eval harness fixture\n' > "$repo/evals/drill/README.md" + cat > "$repo/hooks/hooks-codex.json" <<'EOF' +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex", + "async": false + } + ] + } + ] + } +} +EOF + + cat > "$repo/hooks/session-start" <<'EOF' +#!/usr/bin/env sh +echo "session-start fixture" +EOF + cat > "$repo/hooks/session-start-codex" <<'EOF' +#!/usr/bin/env sh +echo "session-start-codex fixture" +EOF + + cat > "$repo/hooks/run-hook.cmd" <<'EOF' +@echo off +echo run-hook fixture +EOF + chmod +x "$repo/hooks/session-start" "$repo/hooks/session-start-codex" "$repo/hooks/run-hook.cmd" + cat > "$repo/skills/example/SKILL.md" <<'EOF' # Example Skill @@ -236,6 +271,10 @@ EOF assets/app-icon.png \ assets/superpowers-small.svg \ evals/drill/README.md \ + hooks/hooks-codex.json \ + hooks/run-hook.cmd \ + hooks/session-start \ + hooks/session-start-codex \ package.json \ scripts/sync-to-codex-plugin.sh \ skills/example/SKILL.md @@ -293,6 +332,7 @@ write_synced_destination_fixture() { "$repo/plugins/superpowers/.codex-plugin" \ "$repo/plugins/superpowers/.private-journal" \ "$repo/plugins/superpowers/assets" \ + "$repo/plugins/superpowers/hooks" \ "$repo/plugins/superpowers/skills/example/agents" \ "$repo/plugins/superpowers/skills/example" @@ -309,6 +349,40 @@ EOF printf 'png fixture\n' > "$repo/plugins/superpowers/assets/app-icon.png" + cat > "$repo/plugins/superpowers/hooks/hooks-codex.json" <<'EOF' +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex", + "async": false + } + ] + } + ] + } +} +EOF + + cat > "$repo/plugins/superpowers/hooks/session-start" <<'EOF' +#!/usr/bin/env sh +echo "session-start fixture" +EOF + cat > "$repo/plugins/superpowers/hooks/session-start-codex" <<'EOF' +#!/usr/bin/env sh +echo "session-start-codex fixture" +EOF + + cat > "$repo/plugins/superpowers/hooks/run-hook.cmd" <<'EOF' +@echo off +echo run-hook fixture +EOF + chmod +x "$repo/plugins/superpowers/hooks/session-start" "$repo/plugins/superpowers/hooks/session-start-codex" "$repo/plugins/superpowers/hooks/run-hook.cmd" + cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF' # Example Skill @@ -327,6 +401,10 @@ EOF plugins/superpowers/.codex-plugin/plugin.json \ plugins/superpowers/assets/app-icon.png \ plugins/superpowers/assets/superpowers-small.svg \ + plugins/superpowers/hooks/hooks-codex.json \ + plugins/superpowers/hooks/run-hook.cmd \ + plugins/superpowers/hooks/session-start \ + plugins/superpowers/hooks/session-start-codex \ plugins/superpowers/skills/example/agents/openai.yaml \ plugins/superpowers/skills/example/SKILL.md \ plugins/superpowers/.private-journal/keep.txt @@ -542,6 +620,10 @@ main() { assert_contains "$preview_section" ".codex-plugin/plugin.json" "Preview includes manifest path" assert_contains "$preview_section" "assets/superpowers-small.svg" "Preview includes SVG asset" assert_contains "$preview_section" "assets/app-icon.png" "Preview includes PNG asset" + assert_contains "$preview_section" "hooks/hooks-codex.json" "Preview includes Codex hook manifest" + assert_contains "$preview_section" "hooks/session-start" "Preview includes session-start hook" + assert_contains "$preview_section" "hooks/session-start-codex" "Preview includes Codex session-start hook" + assert_contains "$preview_section" "hooks/run-hook.cmd" "Preview includes hook command wrapper" assert_contains "$preview_section" ".private-journal/keep.txt" "Preview includes tracked ignored file" assert_not_contains "$preview_section" ".private-journal/leak.txt" "Preview excludes ignored untracked file" assert_not_contains "$preview_section" "ignored-cache/" "Preview excludes pure ignored directories" diff --git a/tests/hooks/test-session-start.sh b/tests/hooks/test-session-start.sh new file mode 100755 index 00000000..989d72c6 --- /dev/null +++ b/tests/hooks/test-session-start.sh @@ -0,0 +1,240 @@ +#!/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"