From 7d8d3d4b06868f1f00834db9764560c7686db466 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 15:38:20 -0700 Subject: [PATCH 01/10] fix(codex): suppress SessionStart hook auto-discovery with empty hooks object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex auto-discovers a plugin's hooks/hooks.json whenever the Codex manifest has no `hooks` field: load_plugin_hooks falls back to a hardcoded DEFAULT_HOOKS_CONFIG_FILE = "hooks/hooks.json" and registers it. hooks/hooks.json is the Claude Code SessionStart hook, it is tracked in this repo, and the Codex marketplace installs the whole repo root (source url "./"), so the fallback re-registered the SessionStart hook and its install-time trust prompt on Codex. Removing the Codex hook file and the manifest `hooks` pointer (commit "Remove Codex hooks") did not disable the hook on Codex — it removed the explicit declaration that was overriding the fallback, so the fallback took over and found the Claude hooks/hooks.json. Declare an empty inline hooks object ({}) in .codex-plugin/plugin.json. It parses as an empty inline hook set and stops Codex reaching the auto-discovery fallback. An absent field, an empty array ([]), and an empty inline list all collapse back to the fallback, so the value must be exactly {}. Update the test to assert the manifest declares hooks: {} (and that hooks/hooks.json exists, which is what makes the declaration necessary), replacing the prior assertion that the field was absent — which passed while the hook was still being auto-discovered. Co-Authored-By: Claude Opus 4.8 (1M context) --- .codex-plugin/plugin.json | 1 + tests/codex/test-marketplace-manifest.sh | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a4917778..812e72c7 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -21,6 +21,7 @@ "workflow" ], "skills": "./skills/", + "hooks": {}, "interface": { "displayName": "Superpowers", "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents", diff --git a/tests/codex/test-marketplace-manifest.sh b/tests/codex/test-marketplace-manifest.sh index 3045cde6..4301a06e 100755 --- a/tests/codex/test-marketplace-manifest.sh +++ b/tests/codex/test-marketplace-manifest.sh @@ -51,10 +51,25 @@ if not plugin_manifest.exists(): manifest = json.loads(plugin_manifest.read_text(encoding="utf-8")) assert_equal(manifest.get("name"), plugin.get("name"), "plugin manifest name") + +# Codex auto-discovers a plugin's hooks/hooks.json whenever the Codex manifest +# has no `hooks` field: load_plugin_hooks falls back to a hardcoded +# DEFAULT_HOOKS_CONFIG_FILE = "hooks/hooks.json" and registers it. That file is +# the Claude Code SessionStart hook, it is tracked in this repo, and this +# marketplace installs the whole repo root (source url "./"), so on Codex the +# fallback re-registers the SessionStart hook and its install-time trust prompt. +# Declaring an empty inline hooks object ({}) parses as an empty inline hook set +# and suppresses the auto-discovery. An absent field, an empty array ([]), and +# an empty inline list all collapse back to the fallback, so the value must be +# exactly an empty object. +hooks_config = repo_root / "hooks" / "hooks.json" +if not hooks_config.exists(): + raise AssertionError("hooks/hooks.json must exist (Claude Code SessionStart hook)") + assert_equal( manifest.get("hooks"), - None, - "Codex manifest ships no hooks", + {}, + "Codex manifest must declare empty hooks {} to suppress hooks/hooks.json auto-discovery", ) print("Codex marketplace manifest looks good") From da4125d52b80a7049c2881e7263f8b71af2172cd Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 13:41:12 -0700 Subject: [PATCH 02/10] Add Codex portal package script --- scripts/package-codex-plugin.sh | 256 +++++++++++++++++++++++ tests/codex/test-package-codex-plugin.sh | 133 ++++++++++++ 2 files changed, 389 insertions(+) create mode 100755 scripts/package-codex-plugin.sh create mode 100755 tests/codex/test-package-codex-plugin.sh diff --git a/scripts/package-codex-plugin.sh b/scripts/package-codex-plugin.sh new file mode 100755 index 00000000..667b7d28 --- /dev/null +++ b/scripts/package-codex-plugin.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# +# Package the Superpowers Codex plugin as a rootless .tar.gz for portal upload. +# +# The Codex portal artifact differs from the old openai/plugins sync flow: +# it is a standalone archive, but it still needs the OpenAI-owned +# skills/*/agents/openai.yaml metadata that used to be preserved from the +# destination plugin repo. Seed that metadata from a prior official package. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +REF="HEAD" +OUTPUT="" +METADATA_SOURCE="" +ALLOW_DIRTY=0 +KEEP_STAGE=0 + +usage() { + cat <<'EOF' +Usage: + scripts/package-codex-plugin.sh [options] + +Options: + --output PATH Write archive to PATH. + Default: ../_tmp/sup-codex-packaging/superpowers-VERSION.tar.gz + --metadata-source PATH Prior official package directory or .tar.gz used to + seed skills/*/agents/openai.yaml. + Default: ../_tmp/sup-codex-packaging/superpowers, + falling back to ../_tmp/sup-codex-packaging/superpowers.tar.gz + --ref REF Git ref to package. Default: HEAD. + --allow-dirty Permit a dirty working tree. The archive still uses --ref. + --keep-stage Print and keep the temporary staging directory. + -h, --help Show this help. + +The archive is rootless: .codex-plugin/, assets/, skills/, README.md, LICENSE, +and CODE_OF_CONDUCT.md sit at the tar root. Source-only repo files, hooks, tests, +docs, and other harness manifests are intentionally not shipped. +EOF +} + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + [[ $# -ge 2 ]] || die "--output requires a path" + OUTPUT="$2" + shift 2 + ;; + --metadata-source) + [[ $# -ge 2 ]] || die "--metadata-source requires a path" + METADATA_SOURCE="$2" + shift 2 + ;; + --ref) + [[ $# -ge 2 ]] || die "--ref requires a value" + REF="$2" + shift 2 + ;; + --allow-dirty) + ALLOW_DIRTY=1 + shift + ;; + --keep-stage) + KEEP_STAGE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +command -v git >/dev/null || die "git not found in PATH" +command -v jq >/dev/null || die "jq not found in PATH" +command -v tar >/dev/null || die "tar not found in PATH" +command -v gzip >/dev/null || die "gzip not found in PATH" +command -v shasum >/dev/null || die "shasum not found in PATH" + +[[ -d "$REPO_ROOT/.git" ]] || die "repo root is not a git checkout: $REPO_ROOT" +git -C "$REPO_ROOT" rev-parse --verify "$REF^{commit}" >/dev/null || + die "git ref does not resolve to a commit: $REF" + +if [[ "$ALLOW_DIRTY" -ne 1 ]]; then + dirty_status="$(git -C "$REPO_ROOT" status --porcelain --untracked-files=all)" + if [[ -n "$dirty_status" ]]; then + echo "Working tree has uncommitted changes:" >&2 + printf '%s\n' "$dirty_status" | sed 's/^/ /' >&2 + die "commit or stash changes first, or pass --allow-dirty to package $REF anyway" + fi +fi + +if [[ -z "$METADATA_SOURCE" ]]; then + if [[ -d "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers" ]]; then + METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers" + elif [[ -f "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz" ]]; then + METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz" + else + die "no metadata source found; pass --metadata-source " + fi +fi + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/superpowers-codex-package.XXXXXX")" +STAGE="$WORK_DIR/payload" +METADATA_WORK="$WORK_DIR/metadata" +TAR_LIST="$WORK_DIR/tar-list" + +cleanup() { + if [[ "$KEEP_STAGE" -eq 1 ]]; then + echo "Keeping staging directory: $WORK_DIR" >&2 + else + rm -rf "$WORK_DIR" + fi +} +trap cleanup EXIT + +mkdir -p "$STAGE" "$METADATA_WORK" + +metadata_root_from_dir() { + local candidate="$1" + local nested + + if [[ -d "$candidate/skills" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + + nested="$(find "$candidate" -mindepth 2 -maxdepth 2 -type d -name skills -print | head -n 1)" + if [[ -n "$nested" ]]; then + dirname "$nested" + return 0 + fi + + return 1 +} + +prepare_metadata_root() { + local source="$1" + local root + + if [[ -d "$source" ]]; then + root="$(cd "$source" && pwd)" + elif [[ -f "$source" ]]; then + case "$source" in + *.tar.gz|*.tgz) + tar -xzf "$source" -C "$METADATA_WORK" + root="$METADATA_WORK" + ;; + *) + die "metadata source must be a directory or .tar.gz: $source" + ;; + esac + else + die "metadata source does not exist: $source" + fi + + metadata_root_from_dir "$root" || + die "metadata source does not contain a skills/ directory: $source" +} + +METADATA_ROOT="$(prepare_metadata_root "$METADATA_SOURCE")" + +git -C "$REPO_ROOT" archive --format=tar "$REF" -- \ + .codex-plugin \ + CODE_OF_CONDUCT.md \ + LICENSE \ + README.md \ + assets \ + skills \ + | tar -xf - -C "$STAGE" + +VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")" +[[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json" + +if jq -e 'has("hooks")' "$STAGE/.codex-plugin/plugin.json" >/dev/null; then + die "Codex manifest must not declare hooks for the portal package" +fi + +if [[ -z "$OUTPUT" ]]; then + OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.tar.gz" +fi +mkdir -p "$(dirname "$OUTPUT")" +OUTPUT="$(cd "$(dirname "$OUTPUT")" && pwd)/$(basename "$OUTPUT")" + +missing_metadata=0 +while IFS= read -r skill_dir; do + skill_name="${skill_dir##*/}" + metadata_file="$METADATA_ROOT/skills/$skill_name/agents/openai.yaml" + + if [[ ! -f "$metadata_file" ]]; then + echo "Missing OpenAI agent metadata for skill: $skill_name" >&2 + missing_metadata=1 + continue + fi + + mkdir -p "$skill_dir/agents" + cp "$metadata_file" "$skill_dir/agents/openai.yaml" +done < <(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d -print | sort) + +if [[ "$missing_metadata" -ne 0 ]]; then + die "metadata source is incomplete" +fi + +skill_count="$(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" +metadata_count="$(find "$STAGE/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')" +[[ "$skill_count" == "$metadata_count" ]] || + die "metadata count mismatch: $metadata_count metadata files for $skill_count skills" + +# Match the prior official archive's deterministic tar entry metadata. +TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} + + +( + cd "$STAGE" + { + find . -mindepth 1 -type d | sed 's#^\./##' | LC_ALL=C sort + find . -mindepth 1 -type f | sed 's#^\./##' | LC_ALL=C sort + } >"$TAR_LIST" + + rm -f "$OUTPUT" + COPYFILE_DISABLE=1 tar -cnf - --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$TAR_LIST" | + gzip -9n >"$OUTPUT" +) + +if command -v xattr >/dev/null 2>&1; then + xattr -c "$OUTPUT" 2>/dev/null || true +fi + +unexpected_paths="$( + tar -tzf "$OUTPUT" | + grep -E '(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' || true +)" +if [[ -n "$unexpected_paths" ]]; then + printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2 + die "archive contains source-only paths" +fi + +entry_count="$(tar -tzf "$OUTPUT" | wc -l | tr -d ' ')" +checksum="$(shasum -a 256 "$OUTPUT" | awk '{print $1}')" + +echo "Archive: $OUTPUT" +echo "Version: $VERSION" +echo "Entries: $entry_count" +echo "Skills: $skill_count" +echo "SHA-256: $checksum" diff --git a/tests/codex/test-package-codex-plugin.sh b/tests/codex/test-package-codex-plugin.sh new file mode 100755 index 00000000..804568d1 --- /dev/null +++ b/tests/codex/test-package-codex-plugin.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCRIPT_UNDER_TEST="$REPO_ROOT/scripts/package-codex-plugin.sh" + +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)) +} + +assert_equals() { + local actual="$1" + local expected="$2" + local description="$3" + + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description" + echo " expected: $expected" + echo " actual: $actual" + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local description="$3" + + if printf '%s' "$haystack" | grep -Fq -- "$needle"; then + pass "$description" + else + fail "$description" + echo " expected to find: $needle" + fi +} + +assert_not_matches() { + local haystack="$1" + local pattern="$2" + local description="$3" + + if printf '%s' "$haystack" | grep -Eq -- "$pattern"; then + fail "$description" + echo " did not expect to match: $pattern" + else + pass "$description" + fi +} + +write_metadata_fixture() { + local destination="$1" + local skill + + while IFS= read -r skill; do + mkdir -p "$destination/skills/$skill/agents" + cat >"$destination/skills/$skill/agents/openai.yaml" <&1)"; then + pass "package script exits successfully" +else + fail "package script exits successfully" + printf '%s\n' "$output" | sed 's/^/ /' +fi + +if [[ -f "$archive" ]]; then + pass "package script writes archive" +else + fail "package script writes archive" +fi + +assert_contains "$output" "Archive:" "reports archive path" +assert_contains "$output" "SHA-256:" "reports archive checksum" + +mkdir -p "$extracted" +tar -xzf "$archive" -C "$extracted" + +archive_paths="$(tar -tzf "$archive" | sort)" +unexpected_pattern='(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' +assert_not_matches "$archive_paths" "$unexpected_pattern" "archive excludes source-only paths" +assert_contains "$archive_paths" ".codex-plugin/plugin.json" "archive includes Codex manifest" +assert_contains "$archive_paths" "skills/brainstorming/SKILL.md" "archive includes skills" +assert_contains "$archive_paths" "skills/brainstorming/agents/openai.yaml" "archive includes OpenAI skill metadata" +assert_contains "$archive_paths" "assets/app-icon.png" "archive includes app icon" +assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon" + +manifest_summary="$(tar -xOf "$archive" .codex-plugin/plugin.json | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\t".join([data["name"], data["version"], data["skills"], str(data.get("hooks"))]))')" +expected_version="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json"))["version"])')" +assert_equals "$manifest_summary" "superpowers $expected_version ./skills/ None" "archive manifest is current and hook-free" + +skill_count="$(find "$extracted/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" +metadata_count="$(find "$extracted/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')" +assert_equals "$metadata_count" "$skill_count" "every packaged skill has OpenAI metadata" + +task_brief_mode="$(tar -tzvf "$archive" skills/subagent-driven-development/scripts/task-brief | awk '{print $1}')" +assert_equals "$task_brief_mode" "-rwxr-xr-x" "archive preserves executable script mode" + +metadata_times="$(tar -tzvf "$archive" | awk '{print $6, $7, $8}' | sort -u)" +assert_equals "$metadata_times" "Dec 31 1969" "archive normalizes entry timestamps" + +if [[ "$FAILURES" -eq 0 ]]; then + echo "All Codex package archive tests passed" +else + echo "$FAILURES Codex package archive test(s) failed" + exit 1 +fi From 1a1243b6dd09525518b26fa41d1194f52b8f7de8 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 13:45:54 -0700 Subject: [PATCH 03/10] Harden Codex package script checks --- scripts/package-codex-plugin.sh | 4 +- tests/codex/test-package-codex-plugin.sh | 55 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/scripts/package-codex-plugin.sh b/scripts/package-codex-plugin.sh index 667b7d28..60be05d5 100755 --- a/scripts/package-codex-plugin.sh +++ b/scripts/package-codex-plugin.sh @@ -137,7 +137,7 @@ metadata_root_from_dir() { return 0 fi - nested="$(find "$candidate" -mindepth 2 -maxdepth 2 -type d -name skills -print | head -n 1)" + nested="$(find "$candidate" -mindepth 2 -maxdepth 2 -type d -name skills -print -quit)" if [[ -n "$nested" ]]; then dirname "$nested" return 0 @@ -229,7 +229,7 @@ TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} + } >"$TAR_LIST" rm -f "$OUTPUT" - COPYFILE_DISABLE=1 tar -cnf - --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$TAR_LIST" | + COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$TAR_LIST" | gzip -9n >"$OUTPUT" ) diff --git a/tests/codex/test-package-codex-plugin.sh b/tests/codex/test-package-codex-plugin.sh index 804568d1..50be9ba7 100755 --- a/tests/codex/test-package-codex-plugin.sh +++ b/tests/codex/test-package-codex-plugin.sh @@ -125,6 +125,61 @@ assert_equals "$task_brief_mode" "-rwxr-xr-x" "archive preserves executable scri metadata_times="$(tar -tzvf "$archive" | awk '{print $6, $7, $8}' | sort -u)" assert_equals "$metadata_times" "Dec 31 1969" "archive normalizes entry timestamps" +metadata_archive="$TEST_ROOT/metadata-source.tar.gz" +archive_from_tar_source="$TEST_ROOT/superpowers-from-tar-source.tar.gz" +( + cd "$metadata_source" + tar -czf "$metadata_archive" . +) + +if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_archive" --output "$archive_from_tar_source" 2>&1)"; then + pass "package script accepts tarball metadata source" +else + fail "package script accepts tarball metadata source" + printf '%s\n' "$output" | sed 's/^/ /' +fi + +if cmp -s "$archive" "$archive_from_tar_source"; then + pass "tarball metadata source produces identical archive" +else + fail "tarball metadata source produces identical archive" +fi + +incomplete_metadata="$TEST_ROOT/incomplete-metadata" +mkdir -p "$incomplete_metadata/skills/brainstorming/agents" +cp "$metadata_source/skills/brainstorming/agents/openai.yaml" \ + "$incomplete_metadata/skills/brainstorming/agents/openai.yaml" + +set +e +missing_output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$incomplete_metadata" --output "$TEST_ROOT/missing.tar.gz" 2>&1)" +missing_status=$? +set -e +if [[ "$missing_status" -ne 0 ]]; then + pass "package script rejects incomplete metadata source" +else + fail "package script rejects incomplete metadata source" +fi +assert_contains "$missing_output" "ERROR: metadata source is incomplete" "incomplete metadata reports clear error" + +dirty_repo="$TEST_ROOT/dirty-repo" +git clone -q --no-local "$REPO_ROOT" "$dirty_repo" +printf '\n# dirty fixture\n' >>"$dirty_repo/README.md" +set +e +dirty_output="$( + cd "$dirty_repo" + scripts/package-codex-plugin.sh \ + --metadata-source "$metadata_source" \ + --output "$TEST_ROOT/dirty.tar.gz" 2>&1 +)" +dirty_status=$? +set -e +if [[ "$dirty_status" -ne 0 ]]; then + pass "package script rejects dirty worktree by default" +else + fail "package script rejects dirty worktree by default" +fi +assert_contains "$dirty_output" "Working tree has uncommitted changes:" "dirty worktree reports changed files" + if [[ "$FAILURES" -eq 0 ]]; then echo "All Codex package archive tests passed" else From cf12786d081a14a9dee89a6f909c3874c46eb32b Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 14:08:40 -0700 Subject: [PATCH 04/10] Default Codex portal package to zip --- scripts/package-codex-plugin.sh | 128 +++++++++++++++++++---- tests/codex/test-package-codex-plugin.sh | 123 ++++++++++++++++++++-- 2 files changed, 221 insertions(+), 30 deletions(-) diff --git a/scripts/package-codex-plugin.sh b/scripts/package-codex-plugin.sh index 60be05d5..008644ed 100755 --- a/scripts/package-codex-plugin.sh +++ b/scripts/package-codex-plugin.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Package the Superpowers Codex plugin as a rootless .tar.gz for portal upload. +# Package the Superpowers Codex plugin as a rootless archive for portal upload. # # The Codex portal artifact differs from the old openai/plugins sync flow: # it is a standalone archive, but it still needs the OpenAI-owned @@ -14,6 +14,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" REF="HEAD" OUTPUT="" +FORMAT="" METADATA_SOURCE="" ALLOW_DIRTY=0 KEEP_STAGE=0 @@ -25,18 +26,21 @@ Usage: Options: --output PATH Write archive to PATH. - Default: ../_tmp/sup-codex-packaging/superpowers-VERSION.tar.gz - --metadata-source PATH Prior official package directory or .tar.gz used to + Default: ../_tmp/sup-codex-packaging/superpowers-VERSION.zip + --format FORMAT Archive format: zip or tar.gz. Default: zip. + If --output ends in .zip, .tar.gz, or .tgz, that + extension is used when --format is omitted. + --metadata-source PATH Prior official package directory, .zip, or .tar.gz used to seed skills/*/agents/openai.yaml. Default: ../_tmp/sup-codex-packaging/superpowers, - falling back to ../_tmp/sup-codex-packaging/superpowers.tar.gz + falling back to superpowers.zip, then superpowers.tar.gz --ref REF Git ref to package. Default: HEAD. --allow-dirty Permit a dirty working tree. The archive still uses --ref. --keep-stage Print and keep the temporary staging directory. -h, --help Show this help. The archive is rootless: .codex-plugin/, assets/, skills/, README.md, LICENSE, -and CODE_OF_CONDUCT.md sit at the tar root. Source-only repo files, hooks, tests, +and CODE_OF_CONDUCT.md sit at the archive root. Source-only repo files, hooks, tests, docs, and other harness manifests are intentionally not shipped. EOF } @@ -53,6 +57,21 @@ while [[ $# -gt 0 ]]; do OUTPUT="$2" shift 2 ;; + --format) + [[ $# -ge 2 ]] || die "--format requires a value" + case "$2" in + zip) + FORMAT="zip" + ;; + tar.gz|tgz) + FORMAT="tar.gz" + ;; + *) + die "--format must be zip or tar.gz" + ;; + esac + shift 2 + ;; --metadata-source) [[ $# -ge 2 ]] || die "--metadata-source requires a path" METADATA_SOURCE="$2" @@ -83,11 +102,43 @@ while [[ $# -gt 0 ]]; do esac done +infer_format_from_output() { + local output_path="$1" + + case "$output_path" in + *.tar.gz|*.tgz) + printf '%s\n' "tar.gz" + ;; + *.zip) + printf '%s\n' "zip" + ;; + *) + return 1 + ;; + esac +} + +if [[ -z "$FORMAT" ]]; then + FORMAT="$(infer_format_from_output "$OUTPUT" || true)" + if [[ -z "$FORMAT" ]]; then + FORMAT="zip" + fi +else + output_format="$(infer_format_from_output "$OUTPUT" || true)" + if [[ -n "$output_format" && "$output_format" != "$FORMAT" ]]; then + die "--output extension does not match --format $FORMAT: $OUTPUT" + fi +fi + command -v git >/dev/null || die "git not found in PATH" command -v jq >/dev/null || die "jq not found in PATH" command -v tar >/dev/null || die "tar not found in PATH" command -v gzip >/dev/null || die "gzip not found in PATH" command -v shasum >/dev/null || die "shasum not found in PATH" +if [[ "$FORMAT" == "zip" ]]; then + command -v zip >/dev/null || die "zip not found in PATH" + command -v unzip >/dev/null || die "unzip not found in PATH" +fi [[ -d "$REPO_ROOT/.git" ]] || die "repo root is not a git checkout: $REPO_ROOT" git -C "$REPO_ROOT" rev-parse --verify "$REF^{commit}" >/dev/null || @@ -105,17 +156,19 @@ fi if [[ -z "$METADATA_SOURCE" ]]; then if [[ -d "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers" ]]; then METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers" + elif [[ -f "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.zip" ]]; then + METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.zip" elif [[ -f "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz" ]]; then METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz" else - die "no metadata source found; pass --metadata-source " + die "no metadata source found; pass --metadata-source " fi fi WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/superpowers-codex-package.XXXXXX")" STAGE="$WORK_DIR/payload" METADATA_WORK="$WORK_DIR/metadata" -TAR_LIST="$WORK_DIR/tar-list" +ARCHIVE_LIST="$WORK_DIR/archive-list" cleanup() { if [[ "$KEEP_STAGE" -eq 1 ]]; then @@ -158,8 +211,13 @@ prepare_metadata_root() { tar -xzf "$source" -C "$METADATA_WORK" root="$METADATA_WORK" ;; + *.zip) + command -v unzip >/dev/null || die "unzip not found in PATH" + unzip -q "$source" -d "$METADATA_WORK" + root="$METADATA_WORK" + ;; *) - die "metadata source must be a directory or .tar.gz: $source" + die "metadata source must be a directory, .zip, or .tar.gz: $source" ;; esac else @@ -189,7 +247,14 @@ if jq -e 'has("hooks")' "$STAGE/.codex-plugin/plugin.json" >/dev/null; then fi if [[ -z "$OUTPUT" ]]; then - OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.tar.gz" + case "$FORMAT" in + zip) + OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.zip" + ;; + tar.gz) + OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.tar.gz" + ;; + esac fi mkdir -p "$(dirname "$OUTPUT")" OUTPUT="$(cd "$(dirname "$OUTPUT")" && pwd)/$(basename "$OUTPUT")" @@ -218,27 +283,51 @@ metadata_count="$(find "$STAGE/skills" -path '*/agents/openai.yaml' -type f | wc [[ "$skill_count" == "$metadata_count" ]] || die "metadata count mismatch: $metadata_count metadata files for $skill_count skills" -# Match the prior official archive's deterministic tar entry metadata. -TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} + - ( cd "$STAGE" { find . -mindepth 1 -type d | sed 's#^\./##' | LC_ALL=C sort find . -mindepth 1 -type f | sed 's#^\./##' | LC_ALL=C sort - } >"$TAR_LIST" - - rm -f "$OUTPUT" - COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$TAR_LIST" | - gzip -9n >"$OUTPUT" + } >"$ARCHIVE_LIST" ) +case "$FORMAT" in + zip) + # ZIP cannot represent dates earlier than 1980. + TZ=UTC find "$STAGE" -exec touch -t 198001010000 {} + + ( + cd "$STAGE" + rm -f "$OUTPUT" + COPYFILE_DISABLE=1 zip -X -q - -@ <"$ARCHIVE_LIST" >"$OUTPUT" + ) + ;; + tar.gz) + # Match the prior official archive's deterministic tar entry metadata. + TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} + + ( + cd "$STAGE" + rm -f "$OUTPUT" + COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$ARCHIVE_LIST" | + gzip -9n >"$OUTPUT" + ) + ;; +esac + if command -v xattr >/dev/null 2>&1; then xattr -c "$OUTPUT" 2>/dev/null || true fi +case "$FORMAT" in + zip) + archive_paths="$(unzip -Z1 "$OUTPUT" | sed 's#/$##')" + ;; + tar.gz) + archive_paths="$(tar -tzf "$OUTPUT")" + ;; +esac + unexpected_paths="$( - tar -tzf "$OUTPUT" | + printf '%s\n' "$archive_paths" | grep -E '(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' || true )" if [[ -n "$unexpected_paths" ]]; then @@ -246,10 +335,11 @@ if [[ -n "$unexpected_paths" ]]; then die "archive contains source-only paths" fi -entry_count="$(tar -tzf "$OUTPUT" | wc -l | tr -d ' ')" +entry_count="$(printf '%s\n' "$archive_paths" | wc -l | tr -d ' ')" checksum="$(shasum -a 256 "$OUTPUT" | awk '{print $1}')" echo "Archive: $OUTPUT" +echo "Format: $FORMAT" echo "Version: $VERSION" echo "Entries: $entry_count" echo "Skills: $skill_count" diff --git a/tests/codex/test-package-codex-plugin.sh b/tests/codex/test-package-codex-plugin.sh index 50be9ba7..d608674c 100755 --- a/tests/codex/test-package-codex-plugin.sh +++ b/tests/codex/test-package-codex-plugin.sh @@ -62,6 +62,61 @@ assert_not_matches() { fi } +list_archive() { + local archive_path="$1" + + case "$archive_path" in + *.tar.gz|*.tgz) + tar -tzf "$archive_path" + ;; + *.zip) + unzip -Z1 "$archive_path" + ;; + *) + unzip -Z1 "$archive_path" + ;; + esac +} + +normalize_archive_paths() { + sed 's#/$##' | LC_ALL=C sort +} + +extract_archive() { + local archive_path="$1" + local destination="$2" + + mkdir -p "$destination" + case "$archive_path" in + *.tar.gz|*.tgz) + tar -xzf "$archive_path" -C "$destination" + ;; + *.zip) + unzip -q "$archive_path" -d "$destination" + ;; + *) + unzip -q "$archive_path" -d "$destination" + ;; + esac +} + +read_archive_file() { + local archive_path="$1" + local file_path="$2" + + case "$archive_path" in + *.tar.gz|*.tgz) + tar -xOf "$archive_path" "$file_path" + ;; + *.zip) + unzip -p "$archive_path" "$file_path" + ;; + *) + unzip -p "$archive_path" "$file_path" + ;; + esac +} + write_metadata_fixture() { local destination="$1" local skill @@ -79,8 +134,10 @@ EOF echo "Codex package archive tests" metadata_source="$TEST_ROOT/metadata-source" -archive="$TEST_ROOT/superpowers.tar.gz" +archive="$TEST_ROOT/superpowers" +tar_archive="$TEST_ROOT/superpowers.tar.gz" extracted="$TEST_ROOT/extracted" +tar_extracted="$TEST_ROOT/tar-extracted" write_metadata_fixture "$metadata_source" if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --output "$archive" 2>&1)"; then @@ -97,12 +154,12 @@ else fi assert_contains "$output" "Archive:" "reports archive path" +assert_contains "$output" "Format: zip" "reports default zip format" assert_contains "$output" "SHA-256:" "reports archive checksum" -mkdir -p "$extracted" -tar -xzf "$archive" -C "$extracted" +extract_archive "$archive" "$extracted" -archive_paths="$(tar -tzf "$archive" | sort)" +archive_paths="$(list_archive "$archive" | normalize_archive_paths)" unexpected_pattern='(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' assert_not_matches "$archive_paths" "$unexpected_pattern" "archive excludes source-only paths" assert_contains "$archive_paths" ".codex-plugin/plugin.json" "archive includes Codex manifest" @@ -111,7 +168,7 @@ assert_contains "$archive_paths" "skills/brainstorming/agents/openai.yaml" "arch assert_contains "$archive_paths" "assets/app-icon.png" "archive includes app icon" assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon" -manifest_summary="$(tar -xOf "$archive" .codex-plugin/plugin.json | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\t".join([data["name"], data["version"], data["skills"], str(data.get("hooks"))]))')" +manifest_summary="$(read_archive_file "$archive" .codex-plugin/plugin.json | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\t".join([data["name"], data["version"], data["skills"], str(data.get("hooks"))]))')" expected_version="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json"))["version"])')" assert_equals "$manifest_summary" "superpowers $expected_version ./skills/ None" "archive manifest is current and hook-free" @@ -119,17 +176,48 @@ skill_count="$(find "$extracted/skills" -mindepth 1 -maxdepth 1 -type d | wc -l metadata_count="$(find "$extracted/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')" assert_equals "$metadata_count" "$skill_count" "every packaged skill has OpenAI metadata" -task_brief_mode="$(tar -tzvf "$archive" skills/subagent-driven-development/scripts/task-brief | awk '{print $1}')" -assert_equals "$task_brief_mode" "-rwxr-xr-x" "archive preserves executable script mode" +if [[ -x "$extracted/skills/subagent-driven-development/scripts/task-brief" ]]; then + pass "archive preserves executable script mode" +else + fail "archive preserves executable script mode" +fi -metadata_times="$(tar -tzvf "$archive" | awk '{print $6, $7, $8}' | sort -u)" -assert_equals "$metadata_times" "Dec 31 1969" "archive normalizes entry timestamps" +zip_times="$(python3 - "$archive" <<'PY' +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as archive: + print("\n".join(sorted({str(info.date_time) for info in archive.infolist()}))) +PY +)" +assert_equals "$zip_times" "(1980, 1, 1, 0, 0, 0)" "zip archive normalizes entry timestamps" + +if tar_output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --format tar.gz --output "$tar_archive" 2>&1)"; then + pass "package script writes explicit tar.gz archive" +else + fail "package script writes explicit tar.gz archive" + printf '%s\n' "$tar_output" | sed 's/^/ /' +fi +assert_contains "$tar_output" "Format: tar.gz" "reports explicit tar.gz format" + +extract_archive "$tar_archive" "$tar_extracted" +tar_archive_paths="$(list_archive "$tar_archive" | normalize_archive_paths)" +assert_equals "$tar_archive_paths" "$archive_paths" "zip and tar.gz archives contain the same paths" + +tar_task_brief_mode="$(tar -tzvf "$tar_archive" skills/subagent-driven-development/scripts/task-brief | awk '{print $1}')" +assert_equals "$tar_task_brief_mode" "-rwxr-xr-x" "tar.gz archive preserves executable script mode" + +tar_metadata_times="$(tar -tzvf "$tar_archive" | awk '{print $6, $7, $8}' | sort -u)" +assert_equals "$tar_metadata_times" "Dec 31 1969" "tar.gz archive normalizes entry timestamps" metadata_archive="$TEST_ROOT/metadata-source.tar.gz" -archive_from_tar_source="$TEST_ROOT/superpowers-from-tar-source.tar.gz" +metadata_zip="$TEST_ROOT/metadata-source.zip" +archive_from_tar_source="$TEST_ROOT/superpowers-from-tar-source.zip" +archive_from_zip_source="$TEST_ROOT/superpowers-from-zip-source.zip" ( cd "$metadata_source" tar -czf "$metadata_archive" . + zip -X -q -r "$metadata_zip" . ) if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_archive" --output "$archive_from_tar_source" 2>&1)"; then @@ -145,6 +233,19 @@ else fail "tarball metadata source produces identical archive" fi +if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_zip" --output "$archive_from_zip_source" 2>&1)"; then + pass "package script accepts zip metadata source" +else + fail "package script accepts zip metadata source" + printf '%s\n' "$output" | sed 's/^/ /' +fi + +if cmp -s "$archive" "$archive_from_zip_source"; then + pass "zip metadata source produces identical archive" +else + fail "zip metadata source produces identical archive" +fi + incomplete_metadata="$TEST_ROOT/incomplete-metadata" mkdir -p "$incomplete_metadata/skills/brainstorming/agents" cp "$metadata_source/skills/brainstorming/agents/openai.yaml" \ @@ -169,7 +270,7 @@ dirty_output="$( cd "$dirty_repo" scripts/package-codex-plugin.sh \ --metadata-source "$metadata_source" \ - --output "$TEST_ROOT/dirty.tar.gz" 2>&1 + --output "$TEST_ROOT/dirty.zip" 2>&1 )" dirty_status=$? set -e From 4572974c46315b9070bd1c211a1d06a0a4f59842 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 14:16:14 -0700 Subject: [PATCH 05/10] Fix Codex plugin category --- .codex-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 812e72c7..6ecfff4f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -27,7 +27,7 @@ "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents", "longDescription": "Use Superpowers to guide agent work through brainstorming, implementation planning, test-driven development, systematic debugging, parallel execution, code review, and finish-the-branch workflows.", "developerName": "Jesse Vincent", - "category": "Coding", + "category": "Developer Tools", "capabilities": [ "Interactive", "Read", From 28b96af905dbb9d5f2b7a2df3030ba860858ef2d Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 15:57:52 -0700 Subject: [PATCH 06/10] chore(codex): remove orphaned session-start-codex hook + refresh hook docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/porting-to-a-new-harness.md | 47 ++++++++++++++++--------------- docs/windows/polyglot-hooks.md | 2 +- hooks/session-start-codex | 26 ----------------- tests/hooks/test-session-start.sh | 44 +++-------------------------- 4 files changed, 29 insertions(+), 90 deletions(-) delete mode 100755 hooks/session-start-codex diff --git a/docs/porting-to-a-new-harness.md b/docs/porting-to-a-new-harness.md index d74b1c64..986cf561 100644 --- a/docs/porting-to-a-new-harness.md +++ b/docs/porting-to-a-new-harness.md @@ -227,18 +227,20 @@ you may **not** do is bridge a gap by editing the user's global config. The harness has a hook system that runs a shell command at session start and reads JSON from its stdout. The configured command runs `run-hook.cmd`, a polyglot wrapper that just locates bash and dispatches the named script; the -script (`hooks/session-start`, or a harness-specific variant like -`hooks/session-start-codex`) is what reads `using-superpowers/SKILL.md` and -prints a JSON object whose **field name and nesting differ per harness**. +script (`hooks/session-start`, or a harness-specific variant) is what reads +`using-superpowers/SKILL.md` and prints a JSON object whose **field name and +nesting differ per harness**. -- Reference: `hooks/session-start` (and `hooks/session-start-codex`), - `hooks/run-hook.cmd`, and the per-harness hook config `hooks/hooks.json` - (Claude Code), `hooks/hooks-codex.json` (Codex), `hooks/hooks-cursor.json` +- Reference: `hooks/session-start`, `hooks/run-hook.cmd`, and the per-harness + hook config `hooks/hooks.json` (Claude Code) and `hooks/hooks-cursor.json` (Cursor). -- Manifests: `.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json` point the - harness at `./skills/` and the right `hooks-*.json`. (Claude Code's - `.claude-plugin/plugin.json` sets neither field — it auto-discovers `skills/` - and `hooks/hooks.json` by convention.) +- Manifests: `.cursor-plugin/plugin.json` points the harness at `./skills/` and + the right `hooks-*.json`. (Claude Code's `.claude-plugin/plugin.json` sets + neither field — it auto-discovers `skills/` and `hooks/hooks.json` by + convention. Codex's `.codex-plugin/plugin.json` ships skills but declares an + empty `hooks` object: Codex auto-discovers `hooks/hooks.json` when the field + is absent, so the empty object suppresses that — Codex surfaces skills + natively and runs no session-start hook.) > **A hook *system* is not a session-start *event*.** A harness can have a > `hooks.json` mechanism — and even contain the literal string `SessionStart` in @@ -287,7 +289,7 @@ part of the installed extension** — never substitute "edit the user's global | If the harness… | Use shape | Copy from | |---|---|---| -| runs a shell command at session start and reads its stdout | A (shell-hook) | Codex (`hooks/session-start-codex` + `hooks/hooks-codex.json` + `.codex-plugin/`) | +| runs a shell command at session start and reads its stdout | A (shell-hook) | Cursor (`hooks/session-start` + `hooks/hooks-cursor.json` + `.cursor-plugin/`) | | is a JS/TS plugin host with session/message lifecycle callbacks | B (in-process) | OpenCode (`.opencode/`) — or pi (`.pi/`) if it has no native skill tool | | ships an extension-declared context file it always loads | C (instructions-file) | Gemini (`gemini-extension.json` + `GEMINI.md` + `references/gemini-tools.md`) | | has a plugin install command and a manifest `contextFileName` (or equivalent) the installer keeps | C via the plugin installer | Antigravity (`.antigravity-plugin/` — `agy plugin install` ships a generated context file; verify the installer preserves it — Part 6) | @@ -375,25 +377,24 @@ both double-injects). Find the exact field, nesting, and event-matcher values your harness expects. Then decide: add a fourth branch to `hooks/session-start`, or — if the harness needs a different bootstrap message or env contract — add a dedicated -`hooks/session-start-` script, the way Codex did. If you add a branch +`hooks/session-start-` script. If you add a branch and your harness *also* sets an env var an earlier branch keys on (some harnesses set `CLAUDE_PLUGIN_ROOT` too), order your branch before the one that would otherwise shadow it. Match the harness's -own event-matcher strings (Claude Code uses `startup|clear|compact`, Codex -`startup|resume|clear`, Cursor `sessionStart`); wrong matchers mean the hook -silently never fires. +own event-matcher strings (Claude Code uses `startup|clear|compact`, Cursor +`sessionStart`); wrong matchers mean the hook silently never fires. The **hook-config schema itself varies per harness** — don't assume the -Claude/Codex shape is universal. Compare `hooks/hooks.json`, -`hooks/hooks-codex.json`, and `hooks/hooks-cursor.json`: Cursor's uses +Claude Code shape is universal. Compare `hooks/hooks.json` and +`hooks/hooks-cursor.json`: Cursor's uses `"version": 1`, a lowercase `sessionStart` key, a relative -`./hooks/run-hook.cmd` command, and omits the `matcher`/`type`/`async` fields the -others use. Match your `hooks-.json` to whichever existing file is +`./hooks/run-hook.cmd` command, and omits the `matcher`/`type`/`async` fields +Claude Code uses. Match your `hooks-.json` to whichever existing file is closest, not to a single canonical template. The hook **command string references a harness-provided plugin-root variable**, and its name differs per harness: `hooks.json` uses `${CLAUDE_PLUGIN_ROOT}`, -`hooks-codex.json` uses `${PLUGIN_ROOT}`, Cursor uses a relative path. Use +`hooks-cursor.json` uses a relative path. Use whatever your harness exports. (The `session-start` script re-derives the root itself via `dirname`, so the script body doesn't depend on this — but the command in the manifest does.) @@ -784,7 +785,7 @@ Use this as the live index; when in doubt, read the files, not this table. | Harness | Entry point | Bootstrap mechanism | Tool mapping | Tests | Distribution | |---|---|---|---|---|---| | Claude Code | `.claude-plugin/plugin.json` + `hooks/hooks.json` | shell hook → `hooks/session-start` (`hookSpecificOutput.additionalContext`) | native `Skill` tool; `references/claude-code-tools.md` | `tests/hooks/` | marketplace | -| Codex | `.codex-plugin/plugin.json` + `hooks/hooks-codex.json` | shell hook → `hooks/session-start-codex` | `references/codex-tools.md` | `tests/codex-plugin-sync/`, `tests/hooks/` | fork sync (`scripts/sync-to-codex-plugin.sh`) | +| Codex | `.codex-plugin/plugin.json` (declares empty `hooks`) | native skill discovery (no session-start hook) | `references/codex-tools.md` | `tests/codex/`, `tests/codex-plugin-sync/` | fork sync (`scripts/sync-to-codex-plugin.sh`) | | Cursor | `.cursor-plugin/plugin.json` + `hooks/hooks-cursor.json` | shell hook → `hooks/session-start` (`additional_context`) | `references/claude-code-tools.md` | `tests/hooks/` | hand-authored | | Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | `references/copilot-tools.md` | `tests/hooks/` | — | | Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` | @@ -799,10 +800,10 @@ Use this as the live index; when in doubt, read the files, not this table. - **Wrong JSON field → silent failure or double injection.** Shape A only. Confirm the exact field/nesting; Claude Code reads two fields without dedup. - **Hook-config schema varies per harness.** Shape A. Cursor's `hooks-cursor.json` - looks nothing like the Claude/Codex one (`version`, lowercase `sessionStart`, + looks nothing like the Claude Code one (`version`, lowercase `sessionStart`, relative command, no `matcher`/`type`/`async`). Match the closest existing file. - **Plugin-root env var differs per harness.** Shape A. The hook command uses - `${CLAUDE_PLUGIN_ROOT}` (Claude), `${PLUGIN_ROOT}` (Codex), or a relative path + `${CLAUDE_PLUGIN_ROOT}` (Claude) or a relative path (Cursor). Use what your harness exports; the script re-derives the root itself. - **System-message injection.** Shape B injects a *user* message on purpose (#750, #894). Don't "fix" it to a system message. diff --git a/docs/windows/polyglot-hooks.md b/docs/windows/polyglot-hooks.md index ca597c3a..8b84f271 100644 --- a/docs/windows/polyglot-hooks.md +++ b/docs/windows/polyglot-hooks.md @@ -140,7 +140,7 @@ Check that the script filename is **extensionless** in `hooks.json`. A command l ### Hook doesn't fire at all -Verify the `matcher` in `hooks.json` matches the event type your harness emits. Claude Code uses `startup|clear|compact`; Codex uses `startup|resume|clear`. Check `hooks-codex.json` for the Codex variant. +Verify the `matcher` in `hooks.json` matches the event type your harness emits. Claude Code uses `startup|clear|compact`; Cursor uses `sessionStart`. Check `hooks-cursor.json` for the Cursor variant. ## Related Issues diff --git a/hooks/session-start-codex b/hooks/session-start-codex deleted file mode 100755 index f25ea084..00000000 --- a/hooks/session-start-codex +++ /dev/null @@ -1,26 +0,0 @@ -#!/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" | cat - -exit 0 diff --git a/tests/hooks/test-session-start.sh b/tests/hooks/test-session-start.sh index 989d72c6..b027f3c6 100755 --- a/tests/hooks/test-session-start.sh +++ b/tests/hooks/test-session-start.sh @@ -4,7 +4,6 @@ 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 @@ -154,35 +153,15 @@ assert_command_output \ 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" +wrapper_home="$(make_home run-hook-wrapper)" assert_command_output \ - "Codex plugin hooks use dedicated script and emit nested SessionStart additionalContext" \ + "run-hook.cmd wrapper dispatches to the named session-start script" \ "nested" \ "" \ "" \ - "$codex_home" \ - PLUGIN_DATA="$codex_data" \ - CLAUDE_PLUGIN_DATA="$codex_data" \ - PLUGIN_ROOT="$REPO_ROOT" \ + "$wrapper_home" \ 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 + bash "$WRAPPER_UNDER_TEST" session-start cursor_home="$(make_home cursor)" assert_command_output \ @@ -217,21 +196,6 @@ assert_command_output \ 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 From ec46cf8277fe15540ec74804011054bdcc078df5 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 17:10:01 -0700 Subject: [PATCH 07/10] docs: re-anchor Shape A examples away from Codex --- docs/porting-to-a-new-harness.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/porting-to-a-new-harness.md b/docs/porting-to-a-new-harness.md index 986cf561..d288c6b0 100644 --- a/docs/porting-to-a-new-harness.md +++ b/docs/porting-to-a-new-harness.md @@ -90,7 +90,7 @@ every session, with no per-session opt-in by your human partner.** This is the one non-negotiable capability. It can take any form: - a **hook/event system** that runs a shell command at session start and reads - its stdout (Claude Code, Codex, Cursor, Copilot CLI), or + its stdout (Claude Code, Cursor, Copilot CLI), or - an **in-process plugin/extension** with a session-start or message lifecycle callback that can mutate the message array (OpenCode, pi), or - an **instructions-file** convention where the harness loads a context file that @@ -234,13 +234,13 @@ nesting differ per harness**. - Reference: `hooks/session-start`, `hooks/run-hook.cmd`, and the per-harness hook config `hooks/hooks.json` (Claude Code) and `hooks/hooks-cursor.json` (Cursor). -- Manifests: `.cursor-plugin/plugin.json` points the harness at `./skills/` and - the right `hooks-*.json`. (Claude Code's `.claude-plugin/plugin.json` sets - neither field — it auto-discovers `skills/` and `hooks/hooks.json` by - convention. Codex's `.codex-plugin/plugin.json` ships skills but declares an - empty `hooks` object: Codex auto-discovers `hooks/hooks.json` when the field - is absent, so the empty object suppresses that — Codex surfaces skills - natively and runs no session-start hook.) +- Manifests: `.cursor-plugin/plugin.json` is the Shape A manifest example that + points the harness at `./skills/` and the right `hooks-*.json`. Claude Code's + `.claude-plugin/plugin.json` sets neither field — it auto-discovers `skills/` + and `hooks/hooks.json` by convention. Do **not** copy Codex's + `.codex-plugin/plugin.json` for Shape A: it declares an empty `hooks` object + specifically to suppress Codex's `hooks/hooks.json` auto-discovery, because + Codex surfaces skills natively and runs no session-start hook. > **A hook *system* is not a session-start *event*.** A harness can have a > `hooks.json` mechanism — and even contain the literal string `SessionStart` in @@ -311,7 +311,7 @@ patterns below are summaries; the code is the spec. Create whatever the harness uses to recognize the plugin. Match the existing ones in spirit: -- **Shape A:** a `*-plugin/plugin.json` (see `.codex-plugin/plugin.json`) with +- **Shape A:** a `*-plugin/plugin.json` (see `.cursor-plugin/plugin.json`) with `name`, `version`, `description`, author/license/keywords, `"skills": "./skills/"`, and `"hooks": "./hooks/hooks-.json"`. Plus the `hooks-.json` itself, registering a session-start hook whose command From a50def9cefccb47d2e937fc237df980ee09e1fad Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 17:32:44 -0700 Subject: [PATCH 08/10] Strip hooks from Codex portal package --- scripts/package-codex-plugin.sh | 4 +++- tests/codex/test-package-codex-plugin.sh | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/package-codex-plugin.sh b/scripts/package-codex-plugin.sh index 008644ed..91458b51 100755 --- a/scripts/package-codex-plugin.sh +++ b/scripts/package-codex-plugin.sh @@ -243,7 +243,9 @@ VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")" [[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json" if jq -e 'has("hooks")' "$STAGE/.codex-plugin/plugin.json" >/dev/null; then - die "Codex manifest must not declare hooks for the portal package" + manifest_tmp="$WORK_DIR/plugin-manifest.json" + jq 'del(.hooks)' "$STAGE/.codex-plugin/plugin.json" >"$manifest_tmp" + mv "$manifest_tmp" "$STAGE/.codex-plugin/plugin.json" fi if [[ -z "$OUTPUT" ]]; then diff --git a/tests/codex/test-package-codex-plugin.sh b/tests/codex/test-package-codex-plugin.sh index d608674c..3a3d715d 100755 --- a/tests/codex/test-package-codex-plugin.sh +++ b/tests/codex/test-package-codex-plugin.sh @@ -140,6 +140,9 @@ extracted="$TEST_ROOT/extracted" tar_extracted="$TEST_ROOT/tar-extracted" write_metadata_fixture "$metadata_source" +source_hooks="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json")).get("hooks"))')" +assert_equals "$source_hooks" "{}" "source Codex manifest suppresses local hook auto-discovery" + if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --output "$archive" 2>&1)"; then pass "package script exits successfully" else From 1c330e1b874b93ce77aee57b88c8327cbb228e57 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 30 Jun 2026 17:45:41 -0700 Subject: [PATCH 09/10] Preserve hooks in Codex package manifest --- scripts/package-codex-plugin.sh | 6 ------ tests/codex/test-package-codex-plugin.sh | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/scripts/package-codex-plugin.sh b/scripts/package-codex-plugin.sh index 91458b51..00399f06 100755 --- a/scripts/package-codex-plugin.sh +++ b/scripts/package-codex-plugin.sh @@ -242,12 +242,6 @@ git -C "$REPO_ROOT" archive --format=tar "$REF" -- \ VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")" [[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json" -if jq -e 'has("hooks")' "$STAGE/.codex-plugin/plugin.json" >/dev/null; then - manifest_tmp="$WORK_DIR/plugin-manifest.json" - jq 'del(.hooks)' "$STAGE/.codex-plugin/plugin.json" >"$manifest_tmp" - mv "$manifest_tmp" "$STAGE/.codex-plugin/plugin.json" -fi - if [[ -z "$OUTPUT" ]]; then case "$FORMAT" in zip) diff --git a/tests/codex/test-package-codex-plugin.sh b/tests/codex/test-package-codex-plugin.sh index 3a3d715d..62c73f1c 100755 --- a/tests/codex/test-package-codex-plugin.sh +++ b/tests/codex/test-package-codex-plugin.sh @@ -173,7 +173,7 @@ assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive include manifest_summary="$(read_archive_file "$archive" .codex-plugin/plugin.json | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\t".join([data["name"], data["version"], data["skills"], str(data.get("hooks"))]))')" expected_version="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json"))["version"])')" -assert_equals "$manifest_summary" "superpowers $expected_version ./skills/ None" "archive manifest is current and hook-free" +assert_equals "$manifest_summary" "superpowers $expected_version ./skills/ $source_hooks" "archive manifest preserves source hooks" skill_count="$(find "$extracted/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" metadata_count="$(find "$extracted/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')" From d884ae04edebef577e82ff7c4e143debd0bbec99 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Thu, 2 Jul 2026 14:23:30 -0700 Subject: [PATCH 10/10] Release v6.1.1: fix Codex SessionStart hook re-registration, add Codex portal packaging --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- .kimi-plugin/plugin.json | 2 +- RELEASE-NOTES.md | 11 +++++++++++ gemini-extension.json | 2 +- package.json | 2 +- 8 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f8343e4b..acb6ae66 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "superpowers", "description": "Core skills library for Claude Code: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.1.0", + "version": "6.1.1", "source": "./", "author": { "name": "Jesse Vincent", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 83ebf071..2fe026fd 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "superpowers", "description": "Core skills library for Claude Code: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.1.0", + "version": "6.1.1", "author": { "name": "Jesse Vincent", "email": "jesse@fsck.com" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 6ecfff4f..a6b31431 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superpowers", - "version": "6.1.0", + "version": "6.1.1", "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.", "author": { "name": "Jesse Vincent", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index d94de6f0..18d788b2 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "superpowers", "displayName": "Superpowers", "description": "Core skills library: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.1.0", + "version": "6.1.1", "author": { "name": "Jesse Vincent", "email": "jesse@fsck.com" diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json index 32c3ea58..e5d90d0c 100644 --- a/.kimi-plugin/plugin.json +++ b/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superpowers", - "version": "6.1.0", + "version": "6.1.1", "description": "An agentic skills framework and software development methodology.", "author": { "name": "Jesse Vincent", diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 1d9c5053..823948e7 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,16 @@ # Superpowers Release Notes +## v6.1.1 (2026-07-02) + +### Codex + +- **Codex no longer re-registers the Claude SessionStart hook.** v6.1.0 removed the Codex hook config and its manifest `hooks` pointer, meaning to stop Codex from installing a SessionStart hook — but with no `hooks` field, Codex fell back to auto-discovering `hooks/hooks.json`, the Claude Code SessionStart hook that the marketplace ships from the repo root, and re-registered it along with its install-time trust prompt. The Codex manifest now declares an explicit empty hooks object (`hooks: {}`), which Codex reads as "no hooks" instead of reaching the auto-discovery fallback. An absent field, `[]`, and an empty inline list all collapse back to the fallback, so the value has to be exactly `{}`. +- **Removed orphaned Codex session-start dead code.** `hooks/session-start-codex` had no caller once the Codex hook config was deleted, so it and its redundant test cases are gone. The worked shell-hook example in `docs/porting-to-a-new-harness.md` moves from Codex — now native skill discovery with no session-start hook — to Cursor, a live shell-hook harness, and the stale `hooks-codex.json` pointer in `docs/windows/polyglot-hooks.md` is corrected. The Codex plugin category is also fixed to "Developer Tools". + +### Packaging + +- **New `package-codex-plugin.sh` for building the Codex portal package.** A maintainer script produces a deterministic Codex "portal" archive — `.zip` by default, `tar.gz` on request — that normalizes entry timestamps, preserves executable modes, verifies every packaged skill ships its OpenAI metadata, includes the app and composer icons, and refuses to run against a dirty worktree. The packaged manifest keeps the source `hooks: {}` object so a portal-installed plugin avoids the same SessionStart auto-discovery, and the script can rebuild a byte-identical archive from a saved metadata source. Covered by a new test suite. + ## v6.1.0 (2026-06-30) ### Lower Per-Session Token Cost diff --git a/gemini-extension.json b/gemini-extension.json index 0fac6898..dc5e1f64 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "superpowers", "description": "Core skills library: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.1.0", + "version": "6.1.1", "contextFileName": "GEMINI.md" } diff --git a/package.json b/package.json index 387b763c..ad25028d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superpowers", - "version": "6.1.0", + "version": "6.1.1", "description": "Superpowers skills and runtime bootstrap for coding agents", "type": "module", "main": ".opencode/plugins/superpowers.js",