sync-to-codex-plugin.sh 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. #!/usr/bin/env bash
  2. #
  3. # sync-to-codex-plugin.sh
  4. #
  5. # Sync this superpowers checkout → prime-radiant-inc/openai-codex-plugins.
  6. # Clones the fork fresh into a temp dir, rsyncs tracked upstream plugin content
  7. # (including committed Codex files under .codex-plugin/ and assets/), commits,
  8. # pushes a sync branch, and opens a PR.
  9. # Path/user agnostic — auto-detects upstream from script location.
  10. #
  11. # Deterministic: running twice against the same upstream SHA produces PRs with
  12. # identical diffs, so two back-to-back runs can verify the tool itself.
  13. #
  14. # Usage:
  15. # ./scripts/sync-to-codex-plugin.sh # full run
  16. # ./scripts/sync-to-codex-plugin.sh -n # dry run
  17. # ./scripts/sync-to-codex-plugin.sh -y # skip confirm
  18. # ./scripts/sync-to-codex-plugin.sh --local PATH # existing checkout
  19. # ./scripts/sync-to-codex-plugin.sh --base BRANCH # default: main
  20. # ./scripts/sync-to-codex-plugin.sh --bootstrap # create plugin dir if missing
  21. #
  22. # Bootstrap mode: skips the "plugin must exist on base" requirement and creates
  23. # plugins/superpowers/ when absent, then copies the tracked plugin files from
  24. # upstream just like a normal sync.
  25. #
  26. # Requires: bash, rsync, git, gh (authenticated), python3.
  27. set -euo pipefail
  28. # =============================================================================
  29. # Config — edit as upstream or canonical plugin shape evolves
  30. # =============================================================================
  31. FORK="prime-radiant-inc/openai-codex-plugins"
  32. DEFAULT_BASE="main"
  33. DEST_REL="plugins/superpowers"
  34. # Paths in upstream that should NOT land in the embedded plugin.
  35. # All patterns use a leading "/" to anchor them to the source root.
  36. # Unanchored patterns like "scripts/" would match any directory named
  37. # "scripts" at any depth — including legitimate nested dirs like
  38. # skills/brainstorming/scripts/. Anchoring prevents that.
  39. # (.DS_Store is intentionally unanchored — Finder creates them everywhere.)
  40. EXCLUDES=(
  41. # Dotfiles and infra — top-level only
  42. "/.claude/"
  43. "/.claude-plugin/"
  44. "/.codex/"
  45. "/.cursor-plugin/"
  46. "/.git/"
  47. "/.gitattributes"
  48. "/.github/"
  49. "/.gitignore"
  50. "/.opencode/"
  51. "/.version-bump.json"
  52. "/.worktrees/"
  53. ".DS_Store"
  54. # Root ceremony files
  55. "/AGENTS.md"
  56. "/CHANGELOG.md"
  57. "/CLAUDE.md"
  58. "/GEMINI.md"
  59. "/RELEASE-NOTES.md"
  60. "/gemini-extension.json"
  61. "/package.json"
  62. # Directories not shipped by canonical Codex plugins
  63. "/commands/"
  64. "/docs/"
  65. "/hooks/"
  66. "/lib/"
  67. "/scripts/"
  68. "/tests/"
  69. "/tmp/"
  70. )
  71. # =============================================================================
  72. # Ignored-path helpers
  73. # =============================================================================
  74. IGNORED_DIR_EXCLUDES=()
  75. path_has_directory_exclude() {
  76. local path="$1"
  77. local dir
  78. if [[ ${#IGNORED_DIR_EXCLUDES[@]} -eq 0 ]]; then
  79. return 1
  80. fi
  81. for dir in "${IGNORED_DIR_EXCLUDES[@]}"; do
  82. [[ "$path" == "$dir"* ]] && return 0
  83. done
  84. return 1
  85. }
  86. ignored_directory_has_tracked_descendants() {
  87. local path="$1"
  88. [[ -n "$(git -C "$UPSTREAM" ls-files --cached -- "$path/")" ]]
  89. }
  90. append_git_ignored_directory_excludes() {
  91. local path
  92. local lookup_path
  93. while IFS= read -r -d '' path; do
  94. [[ "$path" == */ ]] || continue
  95. lookup_path="${path%/}"
  96. if ! ignored_directory_has_tracked_descendants "$lookup_path"; then
  97. IGNORED_DIR_EXCLUDES+=("$path")
  98. RSYNC_ARGS+=(--exclude="/$path")
  99. fi
  100. done < <(git -C "$UPSTREAM" ls-files --others --ignored --exclude-standard --directory -z)
  101. }
  102. append_git_ignored_file_excludes() {
  103. local path
  104. while IFS= read -r -d '' path; do
  105. path_has_directory_exclude "$path" && continue
  106. RSYNC_ARGS+=(--exclude="/$path")
  107. done < <(git -C "$UPSTREAM" ls-files --others --ignored --exclude-standard -z)
  108. }
  109. # =============================================================================
  110. # Args
  111. # =============================================================================
  112. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  113. UPSTREAM="$(cd "$SCRIPT_DIR/.." && pwd)"
  114. BASE="$DEFAULT_BASE"
  115. DRY_RUN=0
  116. YES=0
  117. LOCAL_CHECKOUT=""
  118. BOOTSTRAP=0
  119. usage() {
  120. sed -n '/^# Usage:/,/^# Requires:/s/^# \{0,1\}//p' "$0"
  121. exit "${1:-0}"
  122. }
  123. while [[ $# -gt 0 ]]; do
  124. case "$1" in
  125. -n|--dry-run) DRY_RUN=1; shift ;;
  126. -y|--yes) YES=1; shift ;;
  127. --local) LOCAL_CHECKOUT="$2"; shift 2 ;;
  128. --base) BASE="$2"; shift 2 ;;
  129. --bootstrap) BOOTSTRAP=1; shift ;;
  130. -h|--help) usage 0 ;;
  131. *) echo "Unknown arg: $1" >&2; usage 2 ;;
  132. esac
  133. done
  134. # =============================================================================
  135. # Preflight
  136. # =============================================================================
  137. die() { echo "ERROR: $*" >&2; exit 1; }
  138. command -v rsync >/dev/null || die "rsync not found in PATH"
  139. command -v git >/dev/null || die "git not found in PATH"
  140. command -v gh >/dev/null || die "gh not found — install GitHub CLI"
  141. command -v python3 >/dev/null || die "python3 not found in PATH"
  142. gh auth status >/dev/null 2>&1 || die "gh not authenticated — run 'gh auth login'"
  143. [[ -d "$UPSTREAM/.git" ]] || die "upstream '$UPSTREAM' is not a git checkout"
  144. [[ -f "$UPSTREAM/.codex-plugin/plugin.json" ]] || die "committed Codex manifest missing at $UPSTREAM/.codex-plugin/plugin.json"
  145. # Read the upstream version from the committed Codex manifest.
  146. UPSTREAM_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$UPSTREAM/.codex-plugin/plugin.json")"
  147. [[ -n "$UPSTREAM_VERSION" ]] || die "could not read 'version' from committed Codex manifest"
  148. UPSTREAM_BRANCH="$(cd "$UPSTREAM" && git branch --show-current)"
  149. UPSTREAM_SHA="$(cd "$UPSTREAM" && git rev-parse HEAD)"
  150. UPSTREAM_SHORT="$(cd "$UPSTREAM" && git rev-parse --short HEAD)"
  151. confirm() {
  152. [[ $YES -eq 1 ]] && return 0
  153. read -rp "$1 [y/N] " ans
  154. [[ "$ans" == "y" || "$ans" == "Y" ]]
  155. }
  156. if [[ "$UPSTREAM_BRANCH" != "main" ]]; then
  157. echo "WARNING: upstream is on '$UPSTREAM_BRANCH', not 'main'"
  158. confirm "Sync from '$UPSTREAM_BRANCH' anyway?" || exit 1
  159. fi
  160. UPSTREAM_STATUS="$(cd "$UPSTREAM" && git status --porcelain)"
  161. if [[ -n "$UPSTREAM_STATUS" ]]; then
  162. echo "WARNING: upstream has uncommitted changes:"
  163. echo "$UPSTREAM_STATUS" | sed 's/^/ /'
  164. echo "Sync will use working-tree state, not HEAD ($UPSTREAM_SHORT)."
  165. confirm "Continue anyway?" || exit 1
  166. fi
  167. # =============================================================================
  168. # Prepare destination (clone fork fresh, or use --local)
  169. # =============================================================================
  170. CLEANUP_DIR=""
  171. cleanup() {
  172. if [[ -n "$CLEANUP_DIR" ]]; then
  173. rm -rf "$CLEANUP_DIR"
  174. fi
  175. }
  176. trap cleanup EXIT
  177. if [[ -n "$LOCAL_CHECKOUT" ]]; then
  178. DEST_REPO="$(cd "$LOCAL_CHECKOUT" && pwd)"
  179. [[ -d "$DEST_REPO/.git" ]] || die "--local path '$DEST_REPO' is not a git checkout"
  180. else
  181. echo "Cloning $FORK..."
  182. CLEANUP_DIR="$(mktemp -d)"
  183. DEST_REPO="$CLEANUP_DIR/openai-codex-plugins"
  184. gh repo clone "$FORK" "$DEST_REPO" >/dev/null
  185. fi
  186. DEST="$DEST_REPO/$DEST_REL"
  187. PREVIEW_REPO="$DEST_REPO"
  188. PREVIEW_DEST="$DEST"
  189. overlay_destination_paths() {
  190. local repo="$1"
  191. local path
  192. local source_path
  193. local preview_path
  194. while IFS= read -r -d '' path; do
  195. source_path="$repo/$path"
  196. preview_path="$PREVIEW_REPO/$path"
  197. if [[ -e "$source_path" ]]; then
  198. mkdir -p "$(dirname "$preview_path")"
  199. cp -R "$source_path" "$preview_path"
  200. else
  201. rm -rf "$preview_path"
  202. fi
  203. done
  204. }
  205. copy_local_destination_overlay() {
  206. overlay_destination_paths "$DEST_REPO" < <(
  207. git -C "$DEST_REPO" diff --name-only -z -- "$DEST_REL"
  208. )
  209. overlay_destination_paths "$DEST_REPO" < <(
  210. git -C "$DEST_REPO" diff --cached --name-only -z -- "$DEST_REL"
  211. )
  212. overlay_destination_paths "$DEST_REPO" < <(
  213. git -C "$DEST_REPO" ls-files --others --exclude-standard -z -- "$DEST_REL"
  214. )
  215. overlay_destination_paths "$DEST_REPO" < <(
  216. git -C "$DEST_REPO" ls-files --others --ignored --exclude-standard -z -- "$DEST_REL"
  217. )
  218. }
  219. local_checkout_has_uncommitted_destination_changes() {
  220. [[ -n "$(git -C "$DEST_REPO" status --porcelain=1 --untracked-files=all --ignored=matching -- "$DEST_REL")" ]]
  221. }
  222. prepare_preview_checkout() {
  223. if [[ -n "$LOCAL_CHECKOUT" ]]; then
  224. [[ -n "$CLEANUP_DIR" ]] || CLEANUP_DIR="$(mktemp -d)"
  225. PREVIEW_REPO="$CLEANUP_DIR/preview"
  226. git clone -q --no-local "$DEST_REPO" "$PREVIEW_REPO"
  227. PREVIEW_DEST="$PREVIEW_REPO/$DEST_REL"
  228. fi
  229. git -C "$PREVIEW_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
  230. if [[ -n "$LOCAL_CHECKOUT" ]]; then
  231. copy_local_destination_overlay
  232. fi
  233. if [[ $BOOTSTRAP -ne 1 ]]; then
  234. [[ -d "$PREVIEW_DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base <branch>"
  235. fi
  236. }
  237. prepare_apply_checkout() {
  238. git -C "$DEST_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
  239. if [[ $BOOTSTRAP -ne 1 ]]; then
  240. [[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base <branch>"
  241. fi
  242. }
  243. apply_to_preview_checkout() {
  244. if [[ $BOOTSTRAP -eq 1 ]]; then
  245. mkdir -p "$PREVIEW_DEST"
  246. fi
  247. rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$PREVIEW_DEST/"
  248. }
  249. preview_checkout_has_changes() {
  250. [[ -n "$(git -C "$PREVIEW_REPO" status --porcelain "$DEST_REL")" ]]
  251. }
  252. prepare_preview_checkout
  253. TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
  254. if [[ $BOOTSTRAP -eq 1 ]]; then
  255. SYNC_BRANCH="bootstrap/superpowers-${UPSTREAM_SHORT}-${TIMESTAMP}"
  256. else
  257. SYNC_BRANCH="sync/superpowers-${UPSTREAM_SHORT}-${TIMESTAMP}"
  258. fi
  259. # =============================================================================
  260. # Build rsync args
  261. # =============================================================================
  262. RSYNC_ARGS=(-av --delete --delete-excluded)
  263. for pat in "${EXCLUDES[@]}"; do RSYNC_ARGS+=(--exclude="$pat"); done
  264. append_git_ignored_directory_excludes
  265. append_git_ignored_file_excludes
  266. # =============================================================================
  267. # Dry run preview (always shown)
  268. # =============================================================================
  269. echo ""
  270. echo "Upstream: $UPSTREAM ($UPSTREAM_BRANCH @ $UPSTREAM_SHORT)"
  271. echo "Version: $UPSTREAM_VERSION"
  272. echo "Fork: $FORK"
  273. echo "Base: $BASE"
  274. echo "Branch: $SYNC_BRANCH"
  275. if [[ $BOOTSTRAP -eq 1 ]]; then
  276. echo "Mode: BOOTSTRAP (creating plugins/superpowers/ when absent)"
  277. fi
  278. echo ""
  279. echo "=== Preview (rsync --dry-run) ==="
  280. rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$PREVIEW_DEST/"
  281. echo "=== End preview ==="
  282. echo ""
  283. if [[ $DRY_RUN -eq 1 ]]; then
  284. echo ""
  285. echo "Dry run only. Nothing was changed or pushed."
  286. exit 0
  287. fi
  288. # =============================================================================
  289. # Apply
  290. # =============================================================================
  291. echo ""
  292. confirm "Apply changes, push branch, and open PR?" || { echo "Aborted."; exit 1; }
  293. echo ""
  294. if [[ -n "$LOCAL_CHECKOUT" ]]; then
  295. if local_checkout_has_uncommitted_destination_changes; then
  296. die "local checkout has uncommitted changes under '$DEST_REL' — commit, stash, or discard them before syncing"
  297. fi
  298. apply_to_preview_checkout
  299. if ! preview_checkout_has_changes; then
  300. echo "No changes — embedded plugin was already in sync with upstream $UPSTREAM_SHORT (v$UPSTREAM_VERSION)."
  301. exit 0
  302. fi
  303. fi
  304. prepare_apply_checkout
  305. cd "$DEST_REPO"
  306. git checkout -q -b "$SYNC_BRANCH"
  307. echo "Syncing upstream content..."
  308. if [[ $BOOTSTRAP -eq 1 ]]; then
  309. mkdir -p "$DEST"
  310. fi
  311. rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/"
  312. # Bail early if nothing actually changed
  313. cd "$DEST_REPO"
  314. if [[ -z "$(git status --porcelain "$DEST_REL")" ]]; then
  315. echo "No changes — embedded plugin was already in sync with upstream $UPSTREAM_SHORT (v$UPSTREAM_VERSION)."
  316. exit 0
  317. fi
  318. # =============================================================================
  319. # Commit, push, open PR
  320. # =============================================================================
  321. git add "$DEST_REL"
  322. if [[ $BOOTSTRAP -eq 1 ]]; then
  323. COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
  324. PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
  325. Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\` and \`assets/\`.
  326. Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\`
  327. Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
  328. This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files."
  329. else
  330. COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
  331. PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
  332. Copies the tracked plugin files from upstream, including the committed Codex manifest and assets.
  333. Run via: \`scripts/sync-to-codex-plugin.sh\`
  334. Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
  335. 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."
  336. fi
  337. git commit --quiet -m "$COMMIT_TITLE
  338. Automated sync via scripts/sync-to-codex-plugin.sh
  339. Upstream: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
  340. Branch: $SYNC_BRANCH"
  341. echo "Pushing $SYNC_BRANCH to $FORK..."
  342. git push -u origin "$SYNC_BRANCH" --quiet
  343. echo "Opening PR..."
  344. PR_URL="$(gh pr create \
  345. --repo "$FORK" \
  346. --base "$BASE" \
  347. --head "$SYNC_BRANCH" \
  348. --title "$COMMIT_TITLE" \
  349. --body "$PR_BODY")"
  350. PR_NUM="${PR_URL##*/}"
  351. DIFF_URL="https://github.com/$FORK/pull/$PR_NUM/files"
  352. echo ""
  353. echo "PR opened: $PR_URL"
  354. echo "Diff view: $DIFF_URL"