Просмотр исходного кода

[codex] support native Codex plugin hooks (#1540)

* 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
Drew Ritter 3 месяцев назад
Родитель
Сommit
1e7cd987d3

+ 1 - 0
.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",

+ 16 - 0
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
+          }
+        ]
+      }
+    ]
+  }
+}

+ 1 - 9
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\n<important-reminder>IN 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</important-reminder>"
-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="<EXTREMELY_IMPORTANT>\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</EXTREMELY_IMPORTANT>"
+session_context="<EXTREMELY_IMPORTANT>\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</EXTREMELY_IMPORTANT>"
 
 # Output context injection as JSON.
 # Cursor hooks expect additional_context (snake_case).

+ 26 - 0
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="<EXTREMELY_IMPORTANT>\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</EXTREMELY_IMPORTANT>"
+
+printf '{\n  "hookSpecificOutput": {\n    "hookEventName": "SessionStart",\n    "additionalContext": "%s"\n  }\n}\n' "$session_context"
+
+exit 0

+ 2 - 3
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

+ 82 - 0
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"

+ 240 - 0
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"