sync-to-codex-plugin.sh 16 KB

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