diff --git a/scripts/sync-to-codex-plugin.sh b/scripts/sync-to-codex-plugin.sh index bc66c9f6..656b806f 100755 --- a/scripts/sync-to-codex-plugin.sh +++ b/scripts/sync-to-codex-plugin.sh @@ -2,27 +2,37 @@ # # sync-to-codex-plugin.sh # -# Syncs this superpowers checkout into a Codex plugin mirror directory. -# Pulls every file except the EXCLUDES list, never touches the PROTECTS list. -# Leaves changes unstaged in the destination so a human can review before committing. +# 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 files (.codex-plugin/plugin.json + agents/openai.yaml) +# inline, 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 +# identical diffs, so two back-to-back runs can verify the tool itself. # # Usage: -# ./scripts/sync-to-codex-plugin.sh # sync with confirmation -# ./scripts/sync-to-codex-plugin.sh -n # dry run, show changes only -# ./scripts/sync-to-codex-plugin.sh -y # skip confirmation prompt -# ./scripts/sync-to-codex-plugin.sh --dest /path/to/plugins/superpowers +# ./scripts/sync-to-codex-plugin.sh # full run with confirm +# ./scripts/sync-to-codex-plugin.sh -n # dry run, no clone/push/PR +# ./scripts/sync-to-codex-plugin.sh -y # skip confirmation +# ./scripts/sync-to-codex-plugin.sh --local PATH # use existing checkout +# ./scripts/sync-to-codex-plugin.sh --base BRANCH # target branch (default: main) # -# Environment: -# CODEX_PLUGIN_DEST Destination plugin path (default: sibling openai-codex-plugins checkout) +# Requires: bash, rsync, git, gh (authenticated), python3. set -euo pipefail # ============================================================================= -# Config — edit these lists as the upstream or canonical shape evolves +# Config — edit as upstream or canonical plugin shape evolves # ============================================================================= +FORK="prime-radiant-inc/openai-codex-plugins" +DEFAULT_BASE="main" +DEST_REL="plugins/superpowers" + # Paths in upstream that should NOT land in the embedded plugin. -# Rsync --exclude patterns (trailing slash = directory). +# Both the Codex-overlay files are here too — they're managed by the +# generate-overlays step, not by rsync. EXCLUDES=( # Dotfiles and infra ".claude/" @@ -38,7 +48,7 @@ EXCLUDES=( ".worktrees/" ".DS_Store" - # Root ceremony files (not part of a canonical Codex plugin) + # Root ceremony files "AGENTS.md" "CHANGELOG.md" "CLAUDE.md" @@ -56,33 +66,93 @@ EXCLUDES=( "scripts/" "tests/" "tmp/" -) -# Paths in the destination that are hand-authored Codex overlays. -# Rsync will never touch these — including when --delete would otherwise -# remove them because they don't exist in upstream. -PROTECTS=( + # Codex-overlay files — regenerated below, not synced ".codex-plugin/" "agents/openai.yaml" ) # ============================================================================= -# Paths +# Generated overlay files # ============================================================================= -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -UPSTREAM="$(cd "$SCRIPT_DIR/.." && pwd)" +# 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" < "$dest" <<'EOF' +interface: + display_name: "Superpowers" + short_description: "Planning, TDD, debugging, and delivery workflows for coding agents" + default_prompt: "Use Superpowers to brainstorm a design, write an implementation plan, run test-driven development, debug bugs systematically, or finish and ship a development branch." +EOF +} # ============================================================================= # Args # ============================================================================= -DEST="$DEFAULT_DEST" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +UPSTREAM="$(cd "$SCRIPT_DIR/.." && pwd)" +BASE="$DEFAULT_BASE" DRY_RUN=0 YES=0 +LOCAL_CHECKOUT="" usage() { sed -n 's/^# \{0,1\}//;2,20p' "$0" @@ -91,84 +161,122 @@ usage() { while [[ $# -gt 0 ]]; do case "$1" in - --dest) DEST="$2"; shift 2 ;; -n|--dry-run) DRY_RUN=1; shift ;; -y|--yes) YES=1; shift ;; + --local) LOCAL_CHECKOUT="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; -h|--help) usage 0 ;; *) echo "Unknown arg: $1" >&2; usage 2 ;; esac done # ============================================================================= -# Validate environment +# Preflight # ============================================================================= -if [[ ! -d "$UPSTREAM/.git" ]]; then - echo "ERROR: Upstream '$UPSTREAM' is not a git checkout." >&2 - exit 1 -fi +die() { echo "ERROR: $*" >&2; exit 1; } -if [[ ! -d "$DEST" ]]; then - echo "ERROR: Destination '$DEST' does not exist." >&2 - echo "Set CODEX_PLUGIN_DEST or pass --dest ." >&2 - exit 1 -fi +command -v rsync >/dev/null || die "rsync not found in PATH" +command -v git >/dev/null || die "git not found in PATH" +command -v gh >/dev/null || die "gh not found — install GitHub CLI" +command -v python3 >/dev/null || die "python3 not found in PATH" -confirm() { - local prompt="$1" - [[ $YES -eq 1 ]] && return 0 - read -rp "$prompt [y/N] " ans - [[ "$ans" == "y" || "$ans" == "Y" ]] -} +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" + +# 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" -# Check upstream branch UPSTREAM_BRANCH="$(cd "$UPSTREAM" && git branch --show-current)" UPSTREAM_SHA="$(cd "$UPSTREAM" && git rev-parse HEAD)" UPSTREAM_SHORT="$(cd "$UPSTREAM" && git rev-parse --short HEAD)" +confirm() { + [[ $YES -eq 1 ]] && return 0 + read -rp "$1 [y/N] " ans + [[ "$ans" == "y" || "$ans" == "Y" ]] +} + if [[ "$UPSTREAM_BRANCH" != "main" ]]; then - echo "WARNING: Upstream is on branch '$UPSTREAM_BRANCH', not 'main'." + echo "WARNING: upstream is on '$UPSTREAM_BRANCH', not 'main'" confirm "Sync from '$UPSTREAM_BRANCH' anyway?" || exit 1 fi -# Check upstream working tree is clean UPSTREAM_STATUS="$(cd "$UPSTREAM" && git status --porcelain)" if [[ -n "$UPSTREAM_STATUS" ]]; then - echo "WARNING: Upstream has uncommitted changes:" + echo "WARNING: upstream has uncommitted changes:" echo "$UPSTREAM_STATUS" | sed 's/^/ /' - echo "Sync will use the working-tree state, not HEAD ($UPSTREAM_SHORT)." + echo "Sync will use working-tree state, not HEAD ($UPSTREAM_SHORT)." confirm "Continue anyway?" || exit 1 fi # ============================================================================= -# Build rsync args +# Prepare destination (clone fork fresh, or use --local) +# ============================================================================= + +CLEANUP_DIR="" +cleanup() { + [[ -n "$CLEANUP_DIR" ]] && rm -rf "$CLEANUP_DIR" +} +trap cleanup EXIT + +if [[ -n "$LOCAL_CHECKOUT" ]]; then + DEST_REPO="$(cd "$LOCAL_CHECKOUT" && pwd)" + [[ -d "$DEST_REPO/.git" ]] || die "--local path '$DEST_REPO' is not a git checkout" +else + echo "Cloning $FORK..." + CLEANUP_DIR="$(mktemp -d)" + DEST_REPO="$CLEANUP_DIR/openai-codex-plugins" + gh repo clone "$FORK" "$DEST_REPO" >/dev/null +fi + +DEST="$DEST_REPO/$DEST_REL" + +# Checkout base branch +cd "$DEST_REPO" +git checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK" + +[[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — merge the bootstrap PR first, or pass --base " + +# ============================================================================= +# Create sync branch +# ============================================================================= + +TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)" +SYNC_BRANCH="sync/superpowers-${UPSTREAM_SHORT}-${TIMESTAMP}" +git checkout -q -b "$SYNC_BRANCH" + +# ============================================================================= +# Build rsync args (excludes only — no protects, overlays are regenerated) # ============================================================================= RSYNC_ARGS=(-av --delete) - -for pat in "${EXCLUDES[@]}"; do - RSYNC_ARGS+=(--exclude="$pat") -done - -for pat in "${PROTECTS[@]}"; do - RSYNC_ARGS+=(--filter="protect $pat") -done +for pat in "${EXCLUDES[@]}"; do RSYNC_ARGS+=(--exclude="$pat"); done # ============================================================================= -# Dry run first, always +# Dry run preview (always shown) # ============================================================================= echo "" echo "Upstream: $UPSTREAM ($UPSTREAM_BRANCH @ $UPSTREAM_SHORT)" -echo "Dest: $DEST" +echo "Version: $UPSTREAM_VERSION" +echo "Fork: $FORK" +echo "Base: $BASE" +echo "Branch: $SYNC_BRANCH" echo "" echo "=== Preview (rsync --dry-run) ===" rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$DEST/" echo "=== End preview ===" +echo "" +echo "Overlay files (.codex-plugin/plugin.json, agents/openai.yaml) will be" +echo "regenerated with version $UPSTREAM_VERSION regardless of rsync output." if [[ $DRY_RUN -eq 1 ]]; then echo "" - echo "Dry run only. Nothing was changed." + echo "Dry run only. Nothing was changed or pushed." exit 0 fi @@ -177,36 +285,56 @@ fi # ============================================================================= echo "" -confirm "Apply these changes?" || { echo "Aborted."; exit 1; } +confirm "Apply changes, push branch, and open PR?" || { echo "Aborted."; exit 1; } echo "" -echo "Syncing..." +echo "Syncing upstream content..." rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/" -echo "Done." -echo "" -# ============================================================================= -# Report -# ============================================================================= +echo "Regenerating overlay files..." +generate_plugin_json "$DEST/.codex-plugin/plugin.json" "$UPSTREAM_VERSION" +generate_agents_openai_yaml "$DEST/agents/openai.yaml" -DEST_GIT_ROOT="$(cd "$DEST" && git rev-parse --show-toplevel 2>/dev/null || echo "")" -if [[ -n "$DEST_GIT_ROOT" ]]; then - DEST_REL="${DEST#$DEST_GIT_ROOT/}" - CHANGES="$(cd "$DEST_GIT_ROOT" && git status --porcelain "$DEST_REL")" - if [[ -z "$CHANGES" ]]; then - echo "No changes — destination was already in sync with upstream $UPSTREAM_SHORT." - exit 0 - fi - - echo "Changes pending review:" - echo "$CHANGES" | sed 's/^/ /' - echo "" - echo "Upstream SHA: $UPSTREAM_SHA" - echo "" - echo "Suggested commit message:" - echo " sync superpowers from upstream main @ $UPSTREAM_SHORT" - echo "" - echo "Review with: git -C $DEST_GIT_ROOT diff -- $DEST_REL" -else - echo "Destination is not a git checkout — cannot report changes." +# Bail early if nothing actually changed +cd "$DEST_REPO" +if [[ -z "$(git status --porcelain "$DEST_REL")" ]]; then + echo "No changes — embedded plugin was already in sync with upstream $UPSTREAM_SHORT (v$UPSTREAM_VERSION)." + exit 0 fi + +# ============================================================================= +# Commit, push, open PR +# ============================================================================= + +git add "$DEST_REL" +git commit --quiet -m "sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT + +Automated sync via scripts/sync-to-codex-plugin.sh +Upstream: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA +Branch: $SYNC_BRANCH" + +echo "Pushing $SYNC_BRANCH to $FORK..." +git push -u origin "$SYNC_BRANCH" --quiet + +PR_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT" +PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION). + +Run via: \`scripts/sync-to-codex-plugin.sh\` +Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA + +Running the sync tool again against the same upstream SHA should produce a PR with an identical diff — use that to verify the tool is behaving." + +echo "Opening PR..." +PR_URL="$(gh pr create \ + --repo "$FORK" \ + --base "$BASE" \ + --head "$SYNC_BRANCH" \ + --title "$PR_TITLE" \ + --body "$PR_BODY")" + +PR_NUM="${PR_URL##*/}" +DIFF_URL="https://github.com/$FORK/pull/$PR_NUM/files" + +echo "" +echo "PR opened: $PR_URL" +echo "Diff view: $DIFF_URL"