diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f8343e4bf3..acb6ae6641 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 83ebf0713c..2fe026fdc5 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 a491777839..a6b31431fb 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", @@ -21,12 +21,13 @@ "workflow" ], "skills": "./skills/", + "hooks": {}, "interface": { "displayName": "Superpowers", "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", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index d94de6f015..18d788b2c0 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 32c3ea580f..e5d90d0c90 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 1d9c505353..823948e744 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/docs/porting-to-a-new-harness.md b/docs/porting-to-a-new-harness.md index d74b1c64ff..d288c6b078 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 @@ -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 +- 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.) + 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 @@ -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) | @@ -309,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 @@ -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 ca597c3abc..8b84f27176 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/gemini-extension.json b/gemini-extension.json index 0fac6898a5..dc5e1f6455 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/hooks/session-start-codex b/hooks/session-start-codex deleted file mode 100755 index f25ea0846e..0000000000 --- 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/package.json b/package.json index 387b763c3d..ad25028dab 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", diff --git a/scripts/package-codex-plugin.sh b/scripts/package-codex-plugin.sh new file mode 100755 index 0000000000..00399f061b --- /dev/null +++ b/scripts/package-codex-plugin.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +# +# 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 +# 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="" +FORMAT="" +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.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 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 archive 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 + ;; + --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" + 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 + +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 || + 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.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 " + fi +fi + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/superpowers-codex-package.XXXXXX")" +STAGE="$WORK_DIR/payload" +METADATA_WORK="$WORK_DIR/metadata" +ARCHIVE_LIST="$WORK_DIR/archive-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 -quit)" + 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" + ;; + *.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, .zip, 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 [[ -z "$OUTPUT" ]]; then + 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")" + +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" + +( + cd "$STAGE" + { + find . -mindepth 1 -type d | sed 's#^\./##' | LC_ALL=C sort + find . -mindepth 1 -type f | sed 's#^\./##' | LC_ALL=C sort + } >"$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="$( + 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 + printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2 + die "archive contains source-only paths" +fi + +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" +echo "SHA-256: $checksum" diff --git a/skills/subagent-driven-development/SKILL.md b/skills/subagent-driven-development/SKILL.md index d8ca081570..1edd4a5b82 100644 --- a/skills/subagent-driven-development/SKILL.md +++ b/skills/subagent-driven-development/SKILL.md @@ -251,7 +251,7 @@ sequences — the single most expensive failure observed. Track progress in a ledger file, not only in todos. - At skill start, check for a ledger: - `cat "$(git rev-parse --show-toplevel)/.superpowers/sdd/progress.md"`. Tasks listed there + `cat "$(scripts/sdd-workspace)/progress.md"`. Tasks listed there as complete are DONE — do not re-dispatch them; resume at the first task not marked complete. - When a task's review comes back clean, append one line to the ledger in diff --git a/skills/subagent-driven-development/scripts/sdd-workspace b/skills/subagent-driven-development/scripts/sdd-workspace index ea9bb08f89..fe0658b4ee 100755 --- a/skills/subagent-driven-development/scripts/sdd-workspace +++ b/skills/subagent-driven-development/scripts/sdd-workspace @@ -12,11 +12,24 @@ # Single source of truth for the workspace location, so task-brief and # review-package cannot drift to different directories. # +# When a harness provides a stable session id, scope the workspace under a +# sanitized single path segment so concurrent sessions on the same checkout do +# not overwrite each other's briefs, reports, diffs, or progress ledger. +# # Usage: sdd-workspace set -euo pipefail root=$(git rev-parse --show-toplevel) -dir="$root/.superpowers/sdd" +session="${SUPERPOWERS_SDD_SESSION:-${CLAUDE_CODE_SESSION_ID:-}}" +if [ -n "$session" ]; then + session=$(printf '%s' "$session" | LC_CTYPE=C tr -c 'A-Za-z0-9._-' '_') + case "$session" in + .|..) session="_$session" ;; + esac + dir="$root/.superpowers/sdd/$session" +else + dir="$root/.superpowers/sdd" +fi mkdir -p "$dir" printf '*\n' > "$dir/.gitignore" cd "$dir" && pwd diff --git a/skills/subagent-driven-development/scripts/task-brief b/skills/subagent-driven-development/scripts/task-brief index 247a767017..8a8c598672 100755 --- a/skills/subagent-driven-development/scripts/task-brief +++ b/skills/subagent-driven-development/scripts/task-brief @@ -4,8 +4,9 @@ # through the controller's context. # # Usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE] -# Default OUTFILE: /.superpowers/sdd/task--brief.md -# (per worktree; concurrent runs in the same working tree share it). +# Default OUTFILE: /task--brief.md +# where is per worktree and, when the harness provides a stable +# session id, per session. set -euo pipefail if [ $# -lt 2 ] || [ $# -gt 3 ]; then diff --git a/tests/claude-code/test-sdd-workspace.sh b/tests/claude-code/test-sdd-workspace.sh index 397e1ebc58..b82c8dbdee 100755 --- a/tests/claude-code/test-sdd-workspace.sh +++ b/tests/claude-code/test-sdd-workspace.sh @@ -22,6 +22,12 @@ cleanup() { fi } +without_sdd_session() { + unset CLAUDE_CODE_SESSION_ID + unset SUPERPOWERS_SDD_SESSION + "$@" +} + main() { echo "=== Test: sdd-workspace ===" @@ -36,7 +42,7 @@ main() { repo="$(cd "$TEST_ROOT/repo" && git rev-parse --show-toplevel)" local dir - dir="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace")" + dir="$(cd "$repo" && without_sdd_session "$SDD_SCRIPTS/sdd-workspace")" if [[ "$dir" == "$repo/.superpowers/sdd" ]]; then pass "prints /.superpowers/sdd" @@ -51,6 +57,15 @@ main() { fail "self-ignoring .gitignore created with '*'" fi + local empty_dir + empty_dir="$(cd "$repo" && CLAUDE_CODE_SESSION_ID= SUPERPOWERS_SDD_SESSION= "$SDD_SCRIPTS/sdd-workspace")" + if [[ "$empty_dir" == "$repo/.superpowers/sdd" ]]; then + pass "empty session vars fall back to the unscoped workspace" + else + fail "empty session vars fall back to the unscoped workspace" + echo " got: $empty_dir" + fi + printf 'x\n' > "$repo/.superpowers/sdd/artifact.md" local status status="$(cd "$repo" && git status --porcelain)" @@ -80,7 +95,7 @@ Do the first thing. PLAN local brief_out brief_path - brief_out="$(cd "$repo" && "$SDD_SCRIPTS/task-brief" plan.md 1)" + brief_out="$(cd "$repo" && without_sdd_session "$SDD_SCRIPTS/task-brief" plan.md 1)" brief_path="$(printf '%s\n' "$brief_out" | sed -n 's/^wrote \(.*\): [0-9][0-9]* lines$/\1/p')" case "$brief_path" in "$repo/.superpowers/sdd/"*) pass "task-brief writes its brief under the workspace" ;; @@ -97,7 +112,7 @@ PLAN && printf 'y\n' > f && git add f \ && git "${git_id[@]}" commit -qm c2 ) local rp_out rp_path - rp_out="$(cd "$repo" && "$SDD_SCRIPTS/review-package" HEAD~1 HEAD)" + rp_out="$(cd "$repo" && without_sdd_session "$SDD_SCRIPTS/review-package" HEAD~1 HEAD)" rp_path="$(printf '%s\n' "$rp_out" | sed -n 's/^wrote \(.*\): [0-9].*$/\1/p')" case "$rp_path" in "$repo/.superpowers/sdd/"*) pass "review-package writes its diff under the workspace" ;; @@ -107,12 +122,73 @@ PLAN ;; esac + local alpha_dir beta_dir + alpha_dir="$(cd "$repo" && CLAUDE_CODE_SESSION_ID=alpha "$SDD_SCRIPTS/sdd-workspace")" + beta_dir="$(cd "$repo" && CLAUDE_CODE_SESSION_ID=beta "$SDD_SCRIPTS/sdd-workspace")" + if [[ "$alpha_dir" == "$repo/.superpowers/sdd/alpha" && "$beta_dir" == "$repo/.superpowers/sdd/beta" && "$alpha_dir" != "$beta_dir" ]]; then + pass "CLAUDE_CODE_SESSION_ID scopes workspaces by session" + else + fail "CLAUDE_CODE_SESSION_ID scopes workspaces by session" + echo " alpha: $alpha_dir" + echo " beta: $beta_dir" + fi + + cat > "$repo/plan.md" <<'PLAN' +# Plan + +## Task 1: Alpha + +Alpha session content. +PLAN + local alpha_brief_out alpha_brief_path beta_brief_out beta_brief_path + alpha_brief_out="$(cd "$repo" && CLAUDE_CODE_SESSION_ID=alpha "$SDD_SCRIPTS/task-brief" plan.md 1)" + alpha_brief_path="$(printf '%s\n' "$alpha_brief_out" | sed -n 's/^wrote \(.*\): [0-9][0-9]* lines$/\1/p')" + + cat > "$repo/plan.md" <<'PLAN' +# Plan + +## Task 1: Beta + +Beta session content. +PLAN + beta_brief_out="$(cd "$repo" && CLAUDE_CODE_SESSION_ID=beta "$SDD_SCRIPTS/task-brief" plan.md 1)" + beta_brief_path="$(printf '%s\n' "$beta_brief_out" | sed -n 's/^wrote \(.*\): [0-9][0-9]* lines$/\1/p')" + + if [[ "$alpha_brief_path" == "$repo/.superpowers/sdd/alpha/task-1-brief.md" \ + && "$beta_brief_path" == "$repo/.superpowers/sdd/beta/task-1-brief.md" \ + && "$alpha_brief_path" != "$beta_brief_path" \ + && "$(cat "$alpha_brief_path")" == *"Alpha session content."* \ + && "$(cat "$beta_brief_path")" == *"Beta session content."* ]]; then + pass "task-brief writes same task number to session-isolated files" + else + fail "task-brief writes same task number to session-isolated files" + echo " alpha: $alpha_brief_path" + echo " beta: $beta_brief_path" + fi + + local override_dir unsafe_dir + override_dir="$(cd "$repo" && CLAUDE_CODE_SESSION_ID=alpha SUPERPOWERS_SDD_SESSION=override "$SDD_SCRIPTS/sdd-workspace")" + if [[ "$override_dir" == "$repo/.superpowers/sdd/override" ]]; then + pass "SUPERPOWERS_SDD_SESSION overrides CLAUDE_CODE_SESSION_ID" + else + fail "SUPERPOWERS_SDD_SESSION overrides CLAUDE_CODE_SESSION_ID" + echo " got: $override_dir" + fi + + unsafe_dir="$(cd "$repo" && CLAUDE_CODE_SESSION_ID=alpha SUPERPOWERS_SDD_SESSION="a/b .." "$SDD_SCRIPTS/sdd-workspace")" + if [[ "$unsafe_dir" == "$repo/.superpowers/sdd/a_b_.." && ! -d "$repo/.superpowers/sdd/a" ]]; then + pass "session ids are sanitized to one safe path segment" + else + fail "session ids are sanitized to one safe path segment" + echo " got: $unsafe_dir" + fi + # --- Worktree isolation: a linked worktree resolves its own workspace --- local wt="$TEST_ROOT/wt" ( cd "$repo" && git worktree add -q "$wt" -b wt-feature ) local wt_root wt_dir wt_root="$(cd "$wt" && git rev-parse --show-toplevel)" - wt_dir="$(cd "$wt" && "$SDD_SCRIPTS/sdd-workspace")" + wt_dir="$(cd "$wt" && without_sdd_session "$SDD_SCRIPTS/sdd-workspace")" if [[ "$wt_dir" == "$wt_root/.superpowers/sdd" && "$wt_dir" != "$dir" ]]; then pass "linked worktree resolves its own distinct workspace" else @@ -121,6 +197,16 @@ PLAN echo " wt: $wt_dir" fi + local wt_session_dir + wt_session_dir="$(cd "$wt" && CLAUDE_CODE_SESSION_ID=alpha "$SDD_SCRIPTS/sdd-workspace")" + if [[ "$wt_session_dir" == "$wt_root/.superpowers/sdd/alpha" && "$wt_session_dir" != "$alpha_dir" ]]; then + pass "linked worktree session workspace remains distinct" + else + fail "linked worktree session workspace remains distinct" + echo " main: $alpha_dir" + echo " wt: $wt_session_dir" + fi + printf 'y\n' > "$wt/.superpowers/sdd/artifact.md" local wt_status wt_status="$(cd "$wt" && git status --porcelain)" diff --git a/tests/codex/test-marketplace-manifest.sh b/tests/codex/test-marketplace-manifest.sh index 3045cde67b..4301a06eec 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") diff --git a/tests/codex/test-package-codex-plugin.sh b/tests/codex/test-package-codex-plugin.sh new file mode 100755 index 0000000000..62c73f1cc1 --- /dev/null +++ b/tests/codex/test-package-codex-plugin.sh @@ -0,0 +1,292 @@ +#!/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 +} + +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 + + 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" "Format: zip" "reports default zip format" +assert_contains "$output" "SHA-256:" "reports archive checksum" + +extract_archive "$archive" "$extracted" + +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" +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="$(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/ $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 ' ')" +assert_equals "$metadata_count" "$skill_count" "every packaged skill has OpenAI metadata" + +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 + +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" +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 + 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 + +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" \ + "$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.zip" 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 + echo "$FAILURES Codex package archive test(s) failed" + exit 1 +fi diff --git a/tests/hooks/test-session-start.sh b/tests/hooks/test-session-start.sh index 989d72c659..b027f3c650 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