diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
new file mode 100644
index 00000000..d4f3f7a3
--- /dev/null
+++ b/.codex-plugin/plugin.json
@@ -0,0 +1,44 @@
+{
+ "name": "superpowers",
+ "version": "5.0.7",
+ "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.",
+ "author": {
+ "name": "Jesse Vincent",
+ "email": "jesse@fsck.com",
+ "url": "https://github.com/obra"
+ },
+ "homepage": "https://github.com/obra/superpowers",
+ "repository": "https://github.com/obra/superpowers",
+ "license": "MIT",
+ "keywords": [
+ "brainstorming",
+ "subagent-driven-development",
+ "skills",
+ "planning",
+ "tdd",
+ "debugging",
+ "code-review",
+ "workflow"
+ ],
+ "skills": "./skills/",
+ "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",
+ "capabilities": [
+ "Interactive",
+ "Read",
+ "Write"
+ ],
+ "defaultPrompt": [
+ "I've got an idea for something I'd like to build.",
+ "Let's add a feature to this project."
+ ],
+ "brandColor": "#F59E0B",
+ "composerIcon": "./assets/superpowers-small.svg",
+ "logo": "./assets/app-icon.png",
+ "screenshots": []
+ }
+}
diff --git a/.version-bump.json b/.version-bump.json
index f5dbe315..323acb3f 100644
--- a/.version-bump.json
+++ b/.version-bump.json
@@ -3,6 +3,7 @@
{ "path": "package.json", "field": "version" },
{ "path": ".claude-plugin/plugin.json", "field": "version" },
{ "path": ".cursor-plugin/plugin.json", "field": "version" },
+ { "path": ".codex-plugin/plugin.json", "field": "version" },
{ "path": ".claude-plugin/marketplace.json", "field": "plugins.0.version" },
{ "path": "gemini-extension.json", "field": "version" }
],
diff --git a/assets/app-icon.png b/assets/app-icon.png
new file mode 100644
index 00000000..25518da4
Binary files /dev/null and b/assets/app-icon.png differ
diff --git a/assets/superpowers-small.svg b/assets/superpowers-small.svg
new file mode 100644
index 00000000..c514fe33
--- /dev/null
+++ b/assets/superpowers-small.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/sync-to-codex-plugin.sh b/scripts/sync-to-codex-plugin.sh
index 0566170a..16fd89ae 100755
--- a/scripts/sync-to-codex-plugin.sh
+++ b/scripts/sync-to-codex-plugin.sh
@@ -3,9 +3,9 @@
# sync-to-codex-plugin.sh
#
# Sync this superpowers checkout → prime-radiant-inc/openai-codex-plugins.
-# Clones the fork fresh into a temp dir, rsyncs upstream content, regenerates
-# the Codex overlay file (.codex-plugin/plugin.json) inline, commits, pushes a
-# sync branch, and opens a PR.
+# Clones the fork fresh into a temp dir, rsyncs tracked upstream plugin content
+# (including committed Codex files under .codex-plugin/ and assets/), commits,
+# pushes a sync branch, and opens a PR.
# Path/user agnostic — auto-detects upstream from script location.
#
# Deterministic: running twice against the same upstream SHA produces PRs with
@@ -17,13 +17,11 @@
# ./scripts/sync-to-codex-plugin.sh -y # skip confirm
# ./scripts/sync-to-codex-plugin.sh --local PATH # existing checkout
# ./scripts/sync-to-codex-plugin.sh --base BRANCH # default: main
-# ./scripts/sync-to-codex-plugin.sh --bootstrap --assets-src DIR # create initial plugin
+# ./scripts/sync-to-codex-plugin.sh --bootstrap # create plugin dir if missing
#
-# Bootstrap mode: skips the "plugin must exist on base" check and seeds
-# plugins/superpowers/assets/ from --assets-src
which must contain
-# PrimeRadiant_Favicon.svg and PrimeRadiant_Favicon.png. Run once by one
-# team member to create the initial PR; every subsequent run is a normal
-# (non-bootstrap) sync.
+# Bootstrap mode: skips the "plugin must exist on base" requirement and creates
+# plugins/superpowers/ when absent, then copies the tracked plugin files from
+# upstream just like a normal sync.
#
# Requires: bash, rsync, git, gh (authenticated), python3.
@@ -38,9 +36,6 @@ DEFAULT_BASE="main"
DEST_REL="plugins/superpowers"
# Paths in upstream that should NOT land in the embedded plugin.
-# The Codex-only paths are here too — they're managed by generate/bootstrap
-# steps, not by rsync.
-#
# All patterns use a leading "/" to anchor them to the source root.
# Unanchored patterns like "scripts/" would match any directory named
# "scripts" at any depth — including legitimate nested dirs like
@@ -78,68 +73,57 @@ EXCLUDES=(
"/scripts/"
"/tests/"
"/tmp/"
-
- # Codex-only paths — managed outside rsync
- "/.codex-plugin/"
- "/assets/"
)
# =============================================================================
-# Generated overlay file
+# Ignored-path helpers
# =============================================================================
-# Writes the Codex plugin manifest to "$1" with the given upstream version.
-# Args: dest_path, version
-generate_plugin_json() {
- local dest="$1"
- local version="$2"
- mkdir -p "$(dirname "$dest")"
- cat > "$dest" <&2; usage 2 ;;
esac
@@ -187,19 +169,11 @@ command -v python3 >/dev/null || die "python3 not found in PATH"
gh auth status >/dev/null 2>&1 || die "gh not authenticated — run 'gh auth login'"
[[ -d "$UPSTREAM/.git" ]] || die "upstream '$UPSTREAM' is not a git checkout"
-[[ -f "$UPSTREAM/package.json" ]] || die "upstream has no package.json — cannot read version"
+[[ -f "$UPSTREAM/.codex-plugin/plugin.json" ]] || die "committed Codex manifest missing at $UPSTREAM/.codex-plugin/plugin.json"
-# Bootstrap-mode validation
-if [[ $BOOTSTRAP -eq 1 ]]; then
- [[ -n "$ASSETS_SRC" ]] || die "--bootstrap requires --assets-src "
- ASSETS_SRC="$(cd "$ASSETS_SRC" 2>/dev/null && pwd)" || die "assets source '$ASSETS_SRC' is not a directory"
- [[ -f "$ASSETS_SRC/PrimeRadiant_Favicon.svg" ]] || die "assets source missing PrimeRadiant_Favicon.svg"
- [[ -f "$ASSETS_SRC/PrimeRadiant_Favicon.png" ]] || die "assets source missing PrimeRadiant_Favicon.png"
-fi
-
-# Read the upstream version from package.json
-UPSTREAM_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$UPSTREAM/package.json")"
-[[ -n "$UPSTREAM_VERSION" ]] || die "could not read 'version' from upstream package.json"
+# Read the upstream version from the committed Codex manifest.
+UPSTREAM_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$UPSTREAM/.codex-plugin/plugin.json")"
+[[ -n "$UPSTREAM_VERSION" ]] || die "could not read 'version' from committed Codex manifest"
UPSTREAM_BRANCH="$(cd "$UPSTREAM" && git branch --show-current)"
UPSTREAM_SHA="$(cd "$UPSTREAM" && git rev-parse HEAD)"
@@ -230,7 +204,9 @@ fi
CLEANUP_DIR=""
cleanup() {
- [[ -n "$CLEANUP_DIR" ]] && rm -rf "$CLEANUP_DIR"
+ if [[ -n "$CLEANUP_DIR" ]]; then
+ rm -rf "$CLEANUP_DIR"
+ fi
}
trap cleanup EXIT
@@ -245,22 +221,84 @@ else
fi
DEST="$DEST_REPO/$DEST_REL"
+PREVIEW_REPO="$DEST_REPO"
+PREVIEW_DEST="$DEST"
-# Checkout base branch
-cd "$DEST_REPO"
-git checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
+overlay_destination_paths() {
+ local repo="$1"
+ local path
+ local source_path
+ local preview_path
-# Plugin-existence check depends on mode
-if [[ $BOOTSTRAP -eq 1 ]]; then
- [[ ! -d "$DEST" ]] || die "--bootstrap but base branch '$BASE' already has '$DEST_REL/' — use normal sync instead"
- mkdir -p "$DEST"
-else
- [[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap + --assets-src, or pass --base "
-fi
+ while IFS= read -r -d '' path; do
+ source_path="$repo/$path"
+ preview_path="$PREVIEW_REPO/$path"
-# =============================================================================
-# Create sync branch
-# =============================================================================
+ if [[ -e "$source_path" ]]; then
+ mkdir -p "$(dirname "$preview_path")"
+ cp -R "$source_path" "$preview_path"
+ else
+ rm -rf "$preview_path"
+ fi
+ done
+}
+
+copy_local_destination_overlay() {
+ overlay_destination_paths "$DEST_REPO" < <(
+ git -C "$DEST_REPO" diff --name-only -z -- "$DEST_REL"
+ )
+ overlay_destination_paths "$DEST_REPO" < <(
+ git -C "$DEST_REPO" diff --cached --name-only -z -- "$DEST_REL"
+ )
+ overlay_destination_paths "$DEST_REPO" < <(
+ git -C "$DEST_REPO" ls-files --others --exclude-standard -z -- "$DEST_REL"
+ )
+ overlay_destination_paths "$DEST_REPO" < <(
+ git -C "$DEST_REPO" ls-files --others --ignored --exclude-standard -z -- "$DEST_REL"
+ )
+}
+
+local_checkout_has_uncommitted_destination_changes() {
+ [[ -n "$(git -C "$DEST_REPO" status --porcelain=1 --untracked-files=all --ignored=matching -- "$DEST_REL")" ]]
+}
+
+prepare_preview_checkout() {
+ if [[ -n "$LOCAL_CHECKOUT" ]]; then
+ [[ -n "$CLEANUP_DIR" ]] || CLEANUP_DIR="$(mktemp -d)"
+ PREVIEW_REPO="$CLEANUP_DIR/preview"
+ git clone -q --no-local "$DEST_REPO" "$PREVIEW_REPO"
+ PREVIEW_DEST="$PREVIEW_REPO/$DEST_REL"
+ fi
+
+ git -C "$PREVIEW_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
+ if [[ -n "$LOCAL_CHECKOUT" ]]; then
+ copy_local_destination_overlay
+ fi
+ if [[ $BOOTSTRAP -ne 1 ]]; then
+ [[ -d "$PREVIEW_DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base "
+ fi
+}
+
+prepare_apply_checkout() {
+ git -C "$DEST_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
+ if [[ $BOOTSTRAP -ne 1 ]]; then
+ [[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base "
+ fi
+}
+
+apply_to_preview_checkout() {
+ if [[ $BOOTSTRAP -eq 1 ]]; then
+ mkdir -p "$PREVIEW_DEST"
+ fi
+
+ rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$PREVIEW_DEST/"
+}
+
+preview_checkout_has_changes() {
+ [[ -n "$(git -C "$PREVIEW_REPO" status --porcelain "$DEST_REL")" ]]
+}
+
+prepare_preview_checkout
TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
if [[ $BOOTSTRAP -eq 1 ]]; then
@@ -268,14 +306,15 @@ if [[ $BOOTSTRAP -eq 1 ]]; then
else
SYNC_BRANCH="sync/superpowers-${UPSTREAM_SHORT}-${TIMESTAMP}"
fi
-git checkout -q -b "$SYNC_BRANCH"
# =============================================================================
# Build rsync args
# =============================================================================
-RSYNC_ARGS=(-av --delete)
+RSYNC_ARGS=(-av --delete --delete-excluded)
for pat in "${EXCLUDES[@]}"; do RSYNC_ARGS+=(--exclude="$pat"); done
+append_git_ignored_directory_excludes
+append_git_ignored_file_excludes
# =============================================================================
# Dry run preview (always shown)
@@ -288,20 +327,13 @@ echo "Fork: $FORK"
echo "Base: $BASE"
echo "Branch: $SYNC_BRANCH"
if [[ $BOOTSTRAP -eq 1 ]]; then
- echo "Mode: BOOTSTRAP (creating initial plugin from scratch)"
- echo "Assets: $ASSETS_SRC"
+ echo "Mode: BOOTSTRAP (creating plugins/superpowers/ when absent)"
fi
echo ""
echo "=== Preview (rsync --dry-run) ==="
-rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$DEST/"
+rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$PREVIEW_DEST/"
echo "=== End preview ==="
echo ""
-echo "Overlay file (.codex-plugin/plugin.json) will be regenerated with"
-echo "version $UPSTREAM_VERSION regardless of rsync output."
-if [[ $BOOTSTRAP -eq 1 ]]; then
- echo "Assets (superpowers-small.svg, app-icon.png) will be seeded from:"
- echo " $ASSETS_SRC"
-fi
if [[ $DRY_RUN -eq 1 ]]; then
echo ""
@@ -317,18 +349,26 @@ echo ""
confirm "Apply changes, push branch, and open PR?" || { echo "Aborted."; exit 1; }
echo ""
-echo "Syncing upstream content..."
-rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/"
+if [[ -n "$LOCAL_CHECKOUT" ]]; then
+ if local_checkout_has_uncommitted_destination_changes; then
+ die "local checkout has uncommitted changes under '$DEST_REL' — commit, stash, or discard them before syncing"
+ fi
-if [[ $BOOTSTRAP -eq 1 ]]; then
- echo "Seeding brand assets..."
- mkdir -p "$DEST/assets"
- cp "$ASSETS_SRC/PrimeRadiant_Favicon.svg" "$DEST/assets/superpowers-small.svg"
- cp "$ASSETS_SRC/PrimeRadiant_Favicon.png" "$DEST/assets/app-icon.png"
+ apply_to_preview_checkout
+ if ! preview_checkout_has_changes; then
+ echo "No changes — embedded plugin was already in sync with upstream $UPSTREAM_SHORT (v$UPSTREAM_VERSION)."
+ exit 0
+ fi
fi
-echo "Regenerating overlay file..."
-generate_plugin_json "$DEST/.codex-plugin/plugin.json" "$UPSTREAM_VERSION"
+prepare_apply_checkout
+cd "$DEST_REPO"
+git checkout -q -b "$SYNC_BRANCH"
+echo "Syncing upstream content..."
+if [[ $BOOTSTRAP -eq 1 ]]; then
+ mkdir -p "$DEST"
+fi
+rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/"
# Bail early if nothing actually changed
cd "$DEST_REPO"
@@ -347,16 +387,18 @@ if [[ $BOOTSTRAP -eq 1 ]]; then
COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
-Creates \`plugins/superpowers/\` from scratch: upstream content via rsync, \`.codex-plugin/plugin.json\` regenerated inline, brand assets seeded from a local Brand Assets directory.
+Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\` and \`assets/\`.
-Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap --assets-src \`
+Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
-This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs and will not touch the \`assets/\` directory."
+This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files."
else
COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
+Copies the tracked plugin files from upstream, including the committed Codex manifest and assets.
+
Run via: \`scripts/sync-to-codex-plugin.sh\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
diff --git a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh
new file mode 100755
index 00000000..a8fc245b
--- /dev/null
+++ b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh
@@ -0,0 +1,571 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+SYNC_SCRIPT_SOURCE="$REPO_ROOT/scripts/sync-to-codex-plugin.sh"
+BASH_UNDER_TEST="/bin/bash"
+PACKAGE_VERSION="1.2.3"
+MANIFEST_VERSION="9.8.7"
+
+FAILURES=0
+TEST_ROOT=""
+
+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_contains() {
+ local haystack="$1"
+ local needle="$2"
+ local description="$3"
+
+ if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
+ fail "$description"
+ echo " did not expect to find: $needle"
+ else
+ pass "$description"
+ fi
+}
+
+assert_matches() {
+ local haystack="$1"
+ local pattern="$2"
+ local description="$3"
+
+ if printf '%s' "$haystack" | grep -Eq -- "$pattern"; then
+ pass "$description"
+ else
+ fail "$description"
+ echo " expected to match: $pattern"
+ fi
+}
+
+assert_path_absent() {
+ local path="$1"
+ local description="$2"
+
+ if [[ ! -e "$path" ]]; then
+ pass "$description"
+ else
+ fail "$description"
+ echo " did not expect path to exist: $path"
+ fi
+}
+
+assert_branch_absent() {
+ local repo="$1"
+ local pattern="$2"
+ local description="$3"
+ local branches
+
+ branches="$(git -C "$repo" branch --list "$pattern")"
+
+ if [[ -z "$branches" ]]; then
+ pass "$description"
+ else
+ fail "$description"
+ echo " did not expect matching branches:"
+ echo "$branches" | sed 's/^/ /'
+ fi
+}
+
+assert_current_branch() {
+ local repo="$1"
+ local expected="$2"
+ local description="$3"
+ local actual
+
+ actual="$(git -C "$repo" branch --show-current)"
+ assert_equals "$actual" "$expected" "$description"
+}
+
+assert_file_equals() {
+ local path="$1"
+ local expected="$2"
+ local description="$3"
+ local actual
+
+ actual="$(cat "$path")"
+ assert_equals "$actual" "$expected" "$description"
+}
+
+cleanup() {
+ if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" ]]; then
+ rm -rf "$TEST_ROOT"
+ fi
+}
+
+configure_git_identity() {
+ local repo="$1"
+
+ git -C "$repo" config user.name "Test Bot"
+ git -C "$repo" config user.email "test@example.com"
+}
+
+init_repo() {
+ local repo="$1"
+
+ git init -q -b main "$repo"
+ configure_git_identity "$repo"
+}
+
+commit_fixture() {
+ local repo="$1"
+ local message="$2"
+
+ git -C "$repo" commit -q -m "$message"
+}
+
+checkout_fixture_branch() {
+ local repo="$1"
+ local branch="$2"
+
+ git -C "$repo" checkout -q -b "$branch"
+}
+
+write_upstream_fixture() {
+ local repo="$1"
+ local with_pure_ignored="${2:-1}"
+
+ mkdir -p \
+ "$repo/.codex-plugin" \
+ "$repo/.private-journal" \
+ "$repo/assets" \
+ "$repo/scripts" \
+ "$repo/skills/example"
+
+ if [[ "$with_pure_ignored" == "1" ]]; then
+ mkdir -p "$repo/ignored-cache/tmp"
+ fi
+
+ cp "$SYNC_SCRIPT_SOURCE" "$repo/scripts/sync-to-codex-plugin.sh"
+
+ cat > "$repo/package.json" < "$repo/.gitignore" <<'EOF'
+.private-journal/
+EOF
+
+ if [[ "$with_pure_ignored" == "1" ]]; then
+ cat >> "$repo/.gitignore" <<'EOF'
+ignored-cache/
+EOF
+ fi
+
+ cat > "$repo/.codex-plugin/plugin.json" < "$repo/assets/superpowers-small.svg" <<'EOF'
+
+EOF
+
+ printf 'png fixture\n' > "$repo/assets/app-icon.png"
+
+ cat > "$repo/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Fixture content.
+EOF
+
+ printf 'tracked keep\n' > "$repo/.private-journal/keep.txt"
+ printf 'ignored leak\n' > "$repo/.private-journal/leak.txt"
+ if [[ "$with_pure_ignored" == "1" ]]; then
+ printf 'ignored cache state\n' > "$repo/ignored-cache/tmp/state.json"
+ fi
+
+ git -C "$repo" add \
+ .codex-plugin/plugin.json \
+ .gitignore \
+ assets/app-icon.png \
+ assets/superpowers-small.svg \
+ package.json \
+ scripts/sync-to-codex-plugin.sh \
+ skills/example/SKILL.md
+ git -C "$repo" add -f .private-journal/keep.txt
+
+ commit_fixture "$repo" "Initial upstream fixture"
+}
+
+write_destination_fixture() {
+ local repo="$1"
+
+ mkdir -p "$repo/plugins/superpowers/skills/example"
+ printf 'fixture keep\n' > "$repo/plugins/superpowers/.fixture-keep"
+ cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Fixture content.
+EOF
+ git -C "$repo" add plugins/superpowers/.fixture-keep
+ git -C "$repo" add plugins/superpowers/skills/example/SKILL.md
+
+ commit_fixture "$repo" "Initial destination fixture"
+}
+
+dirty_tracked_destination_skill() {
+ local repo="$1"
+
+ cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Locally modified fixture content.
+EOF
+}
+
+write_synced_destination_fixture() {
+ local repo="$1"
+
+ mkdir -p \
+ "$repo/plugins/superpowers/.codex-plugin" \
+ "$repo/plugins/superpowers/.private-journal" \
+ "$repo/plugins/superpowers/assets" \
+ "$repo/plugins/superpowers/skills/example"
+
+ cat > "$repo/plugins/superpowers/.codex-plugin/plugin.json" < "$repo/plugins/superpowers/assets/superpowers-small.svg" <<'EOF'
+
+EOF
+
+ printf 'png fixture\n' > "$repo/plugins/superpowers/assets/app-icon.png"
+
+ cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Fixture content.
+EOF
+
+ printf 'tracked keep\n' > "$repo/plugins/superpowers/.private-journal/keep.txt"
+
+ git -C "$repo" add \
+ plugins/superpowers/.codex-plugin/plugin.json \
+ plugins/superpowers/assets/app-icon.png \
+ plugins/superpowers/assets/superpowers-small.svg \
+ plugins/superpowers/skills/example/SKILL.md \
+ plugins/superpowers/.private-journal/keep.txt
+
+ commit_fixture "$repo" "Initial synced destination fixture"
+}
+
+write_stale_ignored_destination_fixture() {
+ local repo="$1"
+
+ mkdir -p "$repo/plugins/superpowers/.private-journal"
+ printf 'fixture keep\n' > "$repo/plugins/superpowers/.fixture-keep"
+ printf 'stale ignored leak\n' > "$repo/plugins/superpowers/.private-journal/leak.txt"
+ git -C "$repo" add plugins/superpowers/.fixture-keep
+
+ commit_fixture "$repo" "Initial stale ignored destination fixture"
+}
+
+write_fake_gh() {
+ local bin_dir="$1"
+
+ mkdir -p "$bin_dir"
+
+ cat > "$bin_dir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "${1:-}" == "auth" && "${2:-}" == "status" ]]; then
+ exit 0
+fi
+
+echo "unexpected gh invocation: $*" >&2
+exit 1
+EOF
+
+ chmod +x "$bin_dir/gh"
+}
+
+run_preview() {
+ local upstream="$1"
+ local dest="$2"
+ local fake_bin="$3"
+
+ PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1
+}
+
+run_bootstrap_preview() {
+ local upstream="$1"
+ local dest="$2"
+ local fake_bin="$3"
+
+ PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --bootstrap --local "$dest" 2>&1
+}
+
+run_preview_without_manifest() {
+ local upstream="$1"
+ local dest="$2"
+ local fake_bin="$3"
+
+ rm -f "$upstream/.codex-plugin/plugin.json"
+ PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1
+}
+
+run_preview_with_stale_ignored_destination() {
+ local upstream="$1"
+ local dest="$2"
+ local fake_bin="$3"
+
+ PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1
+}
+
+run_apply() {
+ local upstream="$1"
+ local dest="$2"
+ local fake_bin="$3"
+
+ PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -y --local "$dest" 2>&1
+}
+
+run_help() {
+ local upstream="$1"
+ local fake_bin="$2"
+
+ PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" --help 2>&1
+}
+
+write_bootstrap_destination_fixture() {
+ local repo="$1"
+
+ printf 'bootstrap fixture\n' > "$repo/README.md"
+ git -C "$repo" add README.md
+
+ commit_fixture "$repo" "Initial bootstrap destination fixture"
+}
+
+main() {
+ local upstream
+ local mixed_only_upstream
+ local dest
+ local dest_branch
+ local mixed_only_dest
+ local stale_dest
+ local dirty_apply_dest
+ local dirty_apply_dest_branch
+ local noop_apply_dest
+ local noop_apply_dest_branch
+ local fake_bin
+ local bootstrap_dest
+ local bootstrap_dest_branch
+ local preview_status
+ local preview_output
+ local preview_section
+ local bootstrap_status
+ local bootstrap_output
+ local missing_manifest_status
+ local missing_manifest_output
+ local mixed_only_status
+ local mixed_only_output
+ local stale_preview_status
+ local stale_preview_output
+ local stale_preview_section
+ local dirty_apply_status
+ local dirty_apply_output
+ local noop_apply_status
+ local noop_apply_output
+ local help_output
+ local script_source
+ local dirty_skill_path
+
+ echo "=== Test: sync-to-codex-plugin dry-run regression ==="
+
+ TEST_ROOT="$(mktemp -d)"
+ trap cleanup EXIT
+
+ upstream="$TEST_ROOT/upstream"
+ mixed_only_upstream="$TEST_ROOT/mixed-only-upstream"
+ dest="$TEST_ROOT/destination"
+ mixed_only_dest="$TEST_ROOT/mixed-only-destination"
+ stale_dest="$TEST_ROOT/stale-destination"
+ dirty_apply_dest="$TEST_ROOT/dirty-apply-destination"
+ dirty_apply_dest_branch="fixture/dirty-apply-target"
+ noop_apply_dest="$TEST_ROOT/noop-apply-destination"
+ noop_apply_dest_branch="fixture/noop-apply-target"
+ bootstrap_dest="$TEST_ROOT/bootstrap-destination"
+ dest_branch="fixture/preview-target"
+ bootstrap_dest_branch="fixture/bootstrap-preview-target"
+ fake_bin="$TEST_ROOT/bin"
+
+ init_repo "$upstream"
+ write_upstream_fixture "$upstream"
+
+ init_repo "$mixed_only_upstream"
+ write_upstream_fixture "$mixed_only_upstream" 0
+
+ init_repo "$dest"
+ write_destination_fixture "$dest"
+ checkout_fixture_branch "$dest" "$dest_branch"
+ dirty_tracked_destination_skill "$dest"
+
+ init_repo "$mixed_only_dest"
+ write_destination_fixture "$mixed_only_dest"
+
+ init_repo "$stale_dest"
+ write_stale_ignored_destination_fixture "$stale_dest"
+
+ init_repo "$dirty_apply_dest"
+ write_synced_destination_fixture "$dirty_apply_dest"
+ checkout_fixture_branch "$dirty_apply_dest" "$dirty_apply_dest_branch"
+ dirty_tracked_destination_skill "$dirty_apply_dest"
+
+ init_repo "$noop_apply_dest"
+ write_synced_destination_fixture "$noop_apply_dest"
+ checkout_fixture_branch "$noop_apply_dest" "$noop_apply_dest_branch"
+
+ init_repo "$bootstrap_dest"
+ write_bootstrap_destination_fixture "$bootstrap_dest"
+ checkout_fixture_branch "$bootstrap_dest" "$bootstrap_dest_branch"
+
+ write_fake_gh "$fake_bin"
+
+ # This regression test is about dry-run content, so capture the preview
+ # output even if the current script exits nonzero in --local mode.
+ set +e
+ preview_output="$(run_preview "$upstream" "$dest" "$fake_bin")"
+ preview_status=$?
+ bootstrap_output="$(run_bootstrap_preview "$upstream" "$bootstrap_dest" "$fake_bin")"
+ bootstrap_status=$?
+ mixed_only_output="$(run_preview "$mixed_only_upstream" "$mixed_only_dest" "$fake_bin")"
+ mixed_only_status=$?
+ stale_preview_output="$(run_preview_with_stale_ignored_destination "$upstream" "$stale_dest" "$fake_bin")"
+ stale_preview_status=$?
+ dirty_apply_output="$(run_apply "$upstream" "$dirty_apply_dest" "$fake_bin")"
+ dirty_apply_status=$?
+ noop_apply_output="$(run_apply "$upstream" "$noop_apply_dest" "$fake_bin")"
+ noop_apply_status=$?
+ missing_manifest_output="$(run_preview_without_manifest "$upstream" "$dest" "$fake_bin")"
+ missing_manifest_status=$?
+ set -e
+ help_output="$(run_help "$upstream" "$fake_bin")"
+ script_source="$(cat "$upstream/scripts/sync-to-codex-plugin.sh")"
+ preview_section="$(printf '%s\n' "$preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')"
+ stale_preview_section="$(printf '%s\n' "$stale_preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')"
+ dirty_skill_path="$dirty_apply_dest/plugins/superpowers/skills/example/SKILL.md"
+
+ echo ""
+ echo "Preview assertions..."
+ assert_equals "$preview_status" "0" "Preview exits successfully"
+ assert_contains "$preview_output" "Version: $MANIFEST_VERSION" "Preview uses manifest version"
+ assert_not_contains "$preview_output" "Version: $PACKAGE_VERSION" "Preview does not use package.json version"
+ assert_contains "$preview_section" ".codex-plugin/plugin.json" "Preview includes manifest path"
+ assert_contains "$preview_section" "assets/superpowers-small.svg" "Preview includes SVG asset"
+ assert_contains "$preview_section" "assets/app-icon.png" "Preview includes PNG asset"
+ assert_contains "$preview_section" ".private-journal/keep.txt" "Preview includes tracked ignored file"
+ assert_not_contains "$preview_section" ".private-journal/leak.txt" "Preview excludes ignored untracked file"
+ assert_not_contains "$preview_section" "ignored-cache/" "Preview excludes pure ignored directories"
+ assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note"
+ assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note"
+ assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file"
+ assert_current_branch "$dest" "$dest_branch" "Preview leaves destination checkout on its original branch"
+ assert_branch_absent "$dest" "sync/superpowers-*" "Preview does not create sync branch in destination checkout"
+
+ echo ""
+ echo "Mixed-directory assertions..."
+ assert_equals "$mixed_only_status" "0" "Mixed ignored directory preview exits successfully under /bin/bash"
+ assert_contains "$mixed_only_output" ".private-journal/keep.txt" "Mixed ignored directory preview still includes tracked ignored file"
+ assert_not_contains "$mixed_only_output" "ignored-cache/" "Mixed ignored directory preview has no pure ignored directory fixture"
+
+ echo ""
+ echo "Convergence assertions..."
+ assert_equals "$stale_preview_status" "0" "Stale ignored destination preview exits successfully"
+ assert_matches "$stale_preview_section" "\\*deleting +\\.private-journal/leak\\.txt" "Preview deletes stale ignored destination file"
+
+ echo ""
+ echo "Bootstrap assertions..."
+ assert_equals "$bootstrap_status" "0" "Bootstrap preview exits successfully"
+ assert_contains "$bootstrap_output" "Mode: BOOTSTRAP (creating plugins/superpowers/ when absent)" "Bootstrap preview describes directory creation"
+ assert_not_contains "$bootstrap_output" "Assets:" "Bootstrap preview omits external assets path"
+ assert_contains "$bootstrap_output" "Dry run only. Nothing was changed or pushed." "Bootstrap preview remains dry-run only"
+ assert_path_absent "$bootstrap_dest/plugins/superpowers" "Bootstrap preview does not create destination plugin directory"
+ assert_current_branch "$bootstrap_dest" "$bootstrap_dest_branch" "Bootstrap preview leaves destination checkout on its original branch"
+ assert_branch_absent "$bootstrap_dest" "bootstrap/superpowers-*" "Bootstrap preview does not create bootstrap branch in destination checkout"
+
+ echo ""
+ echo "Apply assertions..."
+ assert_equals "$dirty_apply_status" "1" "Dirty local apply exits with failure"
+ assert_contains "$dirty_apply_output" "ERROR: local checkout has uncommitted changes under 'plugins/superpowers'" "Dirty local apply reports protected destination path"
+ assert_current_branch "$dirty_apply_dest" "$dirty_apply_dest_branch" "Dirty local apply leaves destination checkout on its original branch"
+ assert_branch_absent "$dirty_apply_dest" "sync/superpowers-*" "Dirty local apply does not create sync branch in destination checkout"
+ assert_file_equals "$dirty_skill_path" "# Example Skill
+
+Locally modified fixture content." "Dirty local apply preserves tracked working-tree file content"
+ assert_equals "$noop_apply_status" "0" "Clean no-op local apply exits successfully"
+ assert_contains "$noop_apply_output" "No changes — embedded plugin was already in sync with upstream" "Clean no-op local apply reports no changes"
+ assert_current_branch "$noop_apply_dest" "$noop_apply_dest_branch" "Clean no-op local apply leaves destination checkout on its original branch"
+ assert_branch_absent "$noop_apply_dest" "sync/superpowers-*" "Clean no-op local apply does not create sync branch in destination checkout"
+
+ echo ""
+ echo "Missing manifest assertions..."
+ assert_equals "$missing_manifest_status" "1" "Missing manifest exits with failure"
+ assert_contains "$missing_manifest_output" "ERROR: committed Codex manifest missing at" "Missing manifest reports committed manifest path"
+
+ echo ""
+ echo "Help assertions..."
+ assert_not_contains "$help_output" "--assets-src" "Help omits --assets-src"
+
+ echo ""
+ echo "Source assertions..."
+ assert_not_contains "$script_source" "regenerated inline" "Source drops regenerated inline phrasing"
+ assert_not_contains "$script_source" "Brand Assets directory" "Source drops Brand Assets directory phrasing"
+ assert_not_contains "$script_source" "--assets-src" "Source drops --assets-src"
+
+ if [[ $FAILURES -ne 0 ]]; then
+ echo ""
+ echo "FAILED: $FAILURES assertion(s) failed."
+ exit 1
+ fi
+
+ echo ""
+ echo "PASS"
+}
+
+main "$@"