package-codex-plugin.sh 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. #!/usr/bin/env bash
  2. #
  3. # Package the Superpowers Codex plugin as a rootless .tar.gz for portal upload.
  4. #
  5. # The Codex portal artifact differs from the old openai/plugins sync flow:
  6. # it is a standalone archive, but it still needs the OpenAI-owned
  7. # skills/*/agents/openai.yaml metadata that used to be preserved from the
  8. # destination plugin repo. Seed that metadata from a prior official package.
  9. set -euo pipefail
  10. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  11. REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
  12. REF="HEAD"
  13. OUTPUT=""
  14. METADATA_SOURCE=""
  15. ALLOW_DIRTY=0
  16. KEEP_STAGE=0
  17. usage() {
  18. cat <<'EOF'
  19. Usage:
  20. scripts/package-codex-plugin.sh [options]
  21. Options:
  22. --output PATH Write archive to PATH.
  23. Default: ../_tmp/sup-codex-packaging/superpowers-VERSION.tar.gz
  24. --metadata-source PATH Prior official package directory or .tar.gz used to
  25. seed skills/*/agents/openai.yaml.
  26. Default: ../_tmp/sup-codex-packaging/superpowers,
  27. falling back to ../_tmp/sup-codex-packaging/superpowers.tar.gz
  28. --ref REF Git ref to package. Default: HEAD.
  29. --allow-dirty Permit a dirty working tree. The archive still uses --ref.
  30. --keep-stage Print and keep the temporary staging directory.
  31. -h, --help Show this help.
  32. The archive is rootless: .codex-plugin/, assets/, skills/, README.md, LICENSE,
  33. and CODE_OF_CONDUCT.md sit at the tar root. Source-only repo files, hooks, tests,
  34. docs, and other harness manifests are intentionally not shipped.
  35. EOF
  36. }
  37. die() {
  38. echo "ERROR: $*" >&2
  39. exit 1
  40. }
  41. while [[ $# -gt 0 ]]; do
  42. case "$1" in
  43. --output)
  44. [[ $# -ge 2 ]] || die "--output requires a path"
  45. OUTPUT="$2"
  46. shift 2
  47. ;;
  48. --metadata-source)
  49. [[ $# -ge 2 ]] || die "--metadata-source requires a path"
  50. METADATA_SOURCE="$2"
  51. shift 2
  52. ;;
  53. --ref)
  54. [[ $# -ge 2 ]] || die "--ref requires a value"
  55. REF="$2"
  56. shift 2
  57. ;;
  58. --allow-dirty)
  59. ALLOW_DIRTY=1
  60. shift
  61. ;;
  62. --keep-stage)
  63. KEEP_STAGE=1
  64. shift
  65. ;;
  66. -h|--help)
  67. usage
  68. exit 0
  69. ;;
  70. *)
  71. echo "Unknown arg: $1" >&2
  72. usage >&2
  73. exit 2
  74. ;;
  75. esac
  76. done
  77. command -v git >/dev/null || die "git not found in PATH"
  78. command -v jq >/dev/null || die "jq not found in PATH"
  79. command -v tar >/dev/null || die "tar not found in PATH"
  80. command -v gzip >/dev/null || die "gzip not found in PATH"
  81. command -v shasum >/dev/null || die "shasum not found in PATH"
  82. [[ -d "$REPO_ROOT/.git" ]] || die "repo root is not a git checkout: $REPO_ROOT"
  83. git -C "$REPO_ROOT" rev-parse --verify "$REF^{commit}" >/dev/null ||
  84. die "git ref does not resolve to a commit: $REF"
  85. if [[ "$ALLOW_DIRTY" -ne 1 ]]; then
  86. dirty_status="$(git -C "$REPO_ROOT" status --porcelain --untracked-files=all)"
  87. if [[ -n "$dirty_status" ]]; then
  88. echo "Working tree has uncommitted changes:" >&2
  89. printf '%s\n' "$dirty_status" | sed 's/^/ /' >&2
  90. die "commit or stash changes first, or pass --allow-dirty to package $REF anyway"
  91. fi
  92. fi
  93. if [[ -z "$METADATA_SOURCE" ]]; then
  94. if [[ -d "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers" ]]; then
  95. METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers"
  96. elif [[ -f "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz" ]]; then
  97. METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz"
  98. else
  99. die "no metadata source found; pass --metadata-source <prior package dir or tar.gz>"
  100. fi
  101. fi
  102. WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/superpowers-codex-package.XXXXXX")"
  103. STAGE="$WORK_DIR/payload"
  104. METADATA_WORK="$WORK_DIR/metadata"
  105. TAR_LIST="$WORK_DIR/tar-list"
  106. cleanup() {
  107. if [[ "$KEEP_STAGE" -eq 1 ]]; then
  108. echo "Keeping staging directory: $WORK_DIR" >&2
  109. else
  110. rm -rf "$WORK_DIR"
  111. fi
  112. }
  113. trap cleanup EXIT
  114. mkdir -p "$STAGE" "$METADATA_WORK"
  115. metadata_root_from_dir() {
  116. local candidate="$1"
  117. local nested
  118. if [[ -d "$candidate/skills" ]]; then
  119. printf '%s\n' "$candidate"
  120. return 0
  121. fi
  122. nested="$(find "$candidate" -mindepth 2 -maxdepth 2 -type d -name skills -print -quit)"
  123. if [[ -n "$nested" ]]; then
  124. dirname "$nested"
  125. return 0
  126. fi
  127. return 1
  128. }
  129. prepare_metadata_root() {
  130. local source="$1"
  131. local root
  132. if [[ -d "$source" ]]; then
  133. root="$(cd "$source" && pwd)"
  134. elif [[ -f "$source" ]]; then
  135. case "$source" in
  136. *.tar.gz|*.tgz)
  137. tar -xzf "$source" -C "$METADATA_WORK"
  138. root="$METADATA_WORK"
  139. ;;
  140. *)
  141. die "metadata source must be a directory or .tar.gz: $source"
  142. ;;
  143. esac
  144. else
  145. die "metadata source does not exist: $source"
  146. fi
  147. metadata_root_from_dir "$root" ||
  148. die "metadata source does not contain a skills/ directory: $source"
  149. }
  150. METADATA_ROOT="$(prepare_metadata_root "$METADATA_SOURCE")"
  151. git -C "$REPO_ROOT" archive --format=tar "$REF" -- \
  152. .codex-plugin \
  153. CODE_OF_CONDUCT.md \
  154. LICENSE \
  155. README.md \
  156. assets \
  157. skills \
  158. | tar -xf - -C "$STAGE"
  159. VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")"
  160. [[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json"
  161. if jq -e 'has("hooks")' "$STAGE/.codex-plugin/plugin.json" >/dev/null; then
  162. die "Codex manifest must not declare hooks for the portal package"
  163. fi
  164. if [[ -z "$OUTPUT" ]]; then
  165. OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.tar.gz"
  166. fi
  167. mkdir -p "$(dirname "$OUTPUT")"
  168. OUTPUT="$(cd "$(dirname "$OUTPUT")" && pwd)/$(basename "$OUTPUT")"
  169. missing_metadata=0
  170. while IFS= read -r skill_dir; do
  171. skill_name="${skill_dir##*/}"
  172. metadata_file="$METADATA_ROOT/skills/$skill_name/agents/openai.yaml"
  173. if [[ ! -f "$metadata_file" ]]; then
  174. echo "Missing OpenAI agent metadata for skill: $skill_name" >&2
  175. missing_metadata=1
  176. continue
  177. fi
  178. mkdir -p "$skill_dir/agents"
  179. cp "$metadata_file" "$skill_dir/agents/openai.yaml"
  180. done < <(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d -print | sort)
  181. if [[ "$missing_metadata" -ne 0 ]]; then
  182. die "metadata source is incomplete"
  183. fi
  184. skill_count="$(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
  185. metadata_count="$(find "$STAGE/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')"
  186. [[ "$skill_count" == "$metadata_count" ]] ||
  187. die "metadata count mismatch: $metadata_count metadata files for $skill_count skills"
  188. # Match the prior official archive's deterministic tar entry metadata.
  189. TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} +
  190. (
  191. cd "$STAGE"
  192. {
  193. find . -mindepth 1 -type d | sed 's#^\./##' | LC_ALL=C sort
  194. find . -mindepth 1 -type f | sed 's#^\./##' | LC_ALL=C sort
  195. } >"$TAR_LIST"
  196. rm -f "$OUTPUT"
  197. COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$TAR_LIST" |
  198. gzip -9n >"$OUTPUT"
  199. )
  200. if command -v xattr >/dev/null 2>&1; then
  201. xattr -c "$OUTPUT" 2>/dev/null || true
  202. fi
  203. unexpected_paths="$(
  204. tar -tzf "$OUTPUT" |
  205. 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
  206. )"
  207. if [[ -n "$unexpected_paths" ]]; then
  208. printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2
  209. die "archive contains source-only paths"
  210. fi
  211. entry_count="$(tar -tzf "$OUTPUT" | wc -l | tr -d ' ')"
  212. checksum="$(shasum -a 256 "$OUTPUT" | awk '{print $1}')"
  213. echo "Archive: $OUTPUT"
  214. echo "Version: $VERSION"
  215. echo "Entries: $entry_count"
  216. echo "Skills: $skill_count"
  217. echo "SHA-256: $checksum"