1
0

package-codex-plugin.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. #!/usr/bin/env bash
  2. #
  3. # Package the Superpowers Codex plugin as a rootless archive 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. FORMAT=""
  15. METADATA_SOURCE=""
  16. ALLOW_DIRTY=0
  17. KEEP_STAGE=0
  18. usage() {
  19. cat <<'EOF'
  20. Usage:
  21. scripts/package-codex-plugin.sh [options]
  22. Options:
  23. --output PATH Write archive to PATH.
  24. Default: ../_tmp/sup-codex-packaging/superpowers-VERSION.zip
  25. --format FORMAT Archive format: zip or tar.gz. Default: zip.
  26. If --output ends in .zip, .tar.gz, or .tgz, that
  27. extension is used when --format is omitted.
  28. --metadata-source PATH Prior official package directory, .zip, or .tar.gz used to
  29. seed skills/*/agents/openai.yaml.
  30. Default: ../_tmp/sup-codex-packaging/superpowers,
  31. falling back to superpowers.zip, then superpowers.tar.gz
  32. --ref REF Git ref to package. Default: HEAD.
  33. --allow-dirty Permit a dirty working tree. The archive still uses --ref.
  34. --keep-stage Print and keep the temporary staging directory.
  35. -h, --help Show this help.
  36. The archive is rootless: .codex-plugin/, assets/, skills/, README.md, LICENSE,
  37. and CODE_OF_CONDUCT.md sit at the archive root. Source-only repo files, hooks, tests,
  38. docs, and other harness manifests are intentionally not shipped.
  39. EOF
  40. }
  41. die() {
  42. echo "ERROR: $*" >&2
  43. exit 1
  44. }
  45. while [[ $# -gt 0 ]]; do
  46. case "$1" in
  47. --output)
  48. [[ $# -ge 2 ]] || die "--output requires a path"
  49. OUTPUT="$2"
  50. shift 2
  51. ;;
  52. --format)
  53. [[ $# -ge 2 ]] || die "--format requires a value"
  54. case "$2" in
  55. zip)
  56. FORMAT="zip"
  57. ;;
  58. tar.gz|tgz)
  59. FORMAT="tar.gz"
  60. ;;
  61. *)
  62. die "--format must be zip or tar.gz"
  63. ;;
  64. esac
  65. shift 2
  66. ;;
  67. --metadata-source)
  68. [[ $# -ge 2 ]] || die "--metadata-source requires a path"
  69. METADATA_SOURCE="$2"
  70. shift 2
  71. ;;
  72. --ref)
  73. [[ $# -ge 2 ]] || die "--ref requires a value"
  74. REF="$2"
  75. shift 2
  76. ;;
  77. --allow-dirty)
  78. ALLOW_DIRTY=1
  79. shift
  80. ;;
  81. --keep-stage)
  82. KEEP_STAGE=1
  83. shift
  84. ;;
  85. -h|--help)
  86. usage
  87. exit 0
  88. ;;
  89. *)
  90. echo "Unknown arg: $1" >&2
  91. usage >&2
  92. exit 2
  93. ;;
  94. esac
  95. done
  96. infer_format_from_output() {
  97. local output_path="$1"
  98. case "$output_path" in
  99. *.tar.gz|*.tgz)
  100. printf '%s\n' "tar.gz"
  101. ;;
  102. *.zip)
  103. printf '%s\n' "zip"
  104. ;;
  105. *)
  106. return 1
  107. ;;
  108. esac
  109. }
  110. if [[ -z "$FORMAT" ]]; then
  111. FORMAT="$(infer_format_from_output "$OUTPUT" || true)"
  112. if [[ -z "$FORMAT" ]]; then
  113. FORMAT="zip"
  114. fi
  115. else
  116. output_format="$(infer_format_from_output "$OUTPUT" || true)"
  117. if [[ -n "$output_format" && "$output_format" != "$FORMAT" ]]; then
  118. die "--output extension does not match --format $FORMAT: $OUTPUT"
  119. fi
  120. fi
  121. command -v git >/dev/null || die "git not found in PATH"
  122. command -v jq >/dev/null || die "jq not found in PATH"
  123. command -v tar >/dev/null || die "tar not found in PATH"
  124. command -v gzip >/dev/null || die "gzip not found in PATH"
  125. command -v shasum >/dev/null || die "shasum not found in PATH"
  126. if [[ "$FORMAT" == "zip" ]]; then
  127. command -v zip >/dev/null || die "zip not found in PATH"
  128. command -v unzip >/dev/null || die "unzip not found in PATH"
  129. fi
  130. [[ -d "$REPO_ROOT/.git" ]] || die "repo root is not a git checkout: $REPO_ROOT"
  131. git -C "$REPO_ROOT" rev-parse --verify "$REF^{commit}" >/dev/null ||
  132. die "git ref does not resolve to a commit: $REF"
  133. if [[ "$ALLOW_DIRTY" -ne 1 ]]; then
  134. dirty_status="$(git -C "$REPO_ROOT" status --porcelain --untracked-files=all)"
  135. if [[ -n "$dirty_status" ]]; then
  136. echo "Working tree has uncommitted changes:" >&2
  137. printf '%s\n' "$dirty_status" | sed 's/^/ /' >&2
  138. die "commit or stash changes first, or pass --allow-dirty to package $REF anyway"
  139. fi
  140. fi
  141. if [[ -z "$METADATA_SOURCE" ]]; then
  142. if [[ -d "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers" ]]; then
  143. METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers"
  144. elif [[ -f "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.zip" ]]; then
  145. METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.zip"
  146. elif [[ -f "$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz" ]]; then
  147. METADATA_SOURCE="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers.tar.gz"
  148. else
  149. die "no metadata source found; pass --metadata-source <prior package dir, zip, or tar.gz>"
  150. fi
  151. fi
  152. WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/superpowers-codex-package.XXXXXX")"
  153. STAGE="$WORK_DIR/payload"
  154. METADATA_WORK="$WORK_DIR/metadata"
  155. ARCHIVE_LIST="$WORK_DIR/archive-list"
  156. cleanup() {
  157. if [[ "$KEEP_STAGE" -eq 1 ]]; then
  158. echo "Keeping staging directory: $WORK_DIR" >&2
  159. else
  160. rm -rf "$WORK_DIR"
  161. fi
  162. }
  163. trap cleanup EXIT
  164. mkdir -p "$STAGE" "$METADATA_WORK"
  165. metadata_root_from_dir() {
  166. local candidate="$1"
  167. local nested
  168. if [[ -d "$candidate/skills" ]]; then
  169. printf '%s\n' "$candidate"
  170. return 0
  171. fi
  172. nested="$(find "$candidate" -mindepth 2 -maxdepth 2 -type d -name skills -print -quit)"
  173. if [[ -n "$nested" ]]; then
  174. dirname "$nested"
  175. return 0
  176. fi
  177. return 1
  178. }
  179. prepare_metadata_root() {
  180. local source="$1"
  181. local root
  182. if [[ -d "$source" ]]; then
  183. root="$(cd "$source" && pwd)"
  184. elif [[ -f "$source" ]]; then
  185. case "$source" in
  186. *.tar.gz|*.tgz)
  187. tar -xzf "$source" -C "$METADATA_WORK"
  188. root="$METADATA_WORK"
  189. ;;
  190. *.zip)
  191. command -v unzip >/dev/null || die "unzip not found in PATH"
  192. unzip -q "$source" -d "$METADATA_WORK"
  193. root="$METADATA_WORK"
  194. ;;
  195. *)
  196. die "metadata source must be a directory, .zip, or .tar.gz: $source"
  197. ;;
  198. esac
  199. else
  200. die "metadata source does not exist: $source"
  201. fi
  202. metadata_root_from_dir "$root" ||
  203. die "metadata source does not contain a skills/ directory: $source"
  204. }
  205. METADATA_ROOT="$(prepare_metadata_root "$METADATA_SOURCE")"
  206. # Pin tar.umask and extract with -p so staged modes are canonical 755/644
  207. # regardless of the builder's git config or process umask.
  208. git -C "$REPO_ROOT" -c tar.umask=0022 archive --format=tar "$REF" -- \
  209. .codex-plugin \
  210. CODE_OF_CONDUCT.md \
  211. LICENSE \
  212. README.md \
  213. assets \
  214. skills \
  215. | tar -xpf - -C "$STAGE"
  216. VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")"
  217. [[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json"
  218. if [[ -z "$OUTPUT" ]]; then
  219. case "$FORMAT" in
  220. zip)
  221. OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.zip"
  222. ;;
  223. tar.gz)
  224. OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.tar.gz"
  225. ;;
  226. esac
  227. fi
  228. mkdir -p "$(dirname "$OUTPUT")"
  229. OUTPUT="$(cd "$(dirname "$OUTPUT")" && pwd)/$(basename "$OUTPUT")"
  230. missing_metadata=0
  231. while IFS= read -r skill_dir; do
  232. skill_name="${skill_dir##*/}"
  233. metadata_file="$METADATA_ROOT/skills/$skill_name/agents/openai.yaml"
  234. if [[ ! -f "$metadata_file" ]]; then
  235. echo "Missing OpenAI agent metadata for skill: $skill_name" >&2
  236. missing_metadata=1
  237. continue
  238. fi
  239. mkdir -p "$skill_dir/agents"
  240. cp "$metadata_file" "$skill_dir/agents/openai.yaml"
  241. done < <(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d -print | sort)
  242. if [[ "$missing_metadata" -ne 0 ]]; then
  243. die "metadata source is incomplete"
  244. fi
  245. skill_count="$(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
  246. metadata_count="$(find "$STAGE/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')"
  247. [[ "$skill_count" == "$metadata_count" ]] ||
  248. die "metadata count mismatch: $metadata_count metadata files for $skill_count skills"
  249. (
  250. cd "$STAGE"
  251. {
  252. find . -mindepth 1 -type d | sed 's#^\./##' | LC_ALL=C sort
  253. find . -mindepth 1 -type f | sed 's#^\./##' | LC_ALL=C sort
  254. } >"$ARCHIVE_LIST"
  255. )
  256. case "$FORMAT" in
  257. zip)
  258. # ZIP cannot represent dates earlier than 1980.
  259. TZ=UTC find "$STAGE" -exec touch -t 198001010000 {} +
  260. (
  261. cd "$STAGE"
  262. rm -f "$OUTPUT"
  263. COPYFILE_DISABLE=1 zip -X -q - -@ <"$ARCHIVE_LIST" >"$OUTPUT"
  264. )
  265. ;;
  266. tar.gz)
  267. # Match the prior official archive's deterministic tar entry metadata:
  268. # ustar entries with uid/gid 0 and empty uname/gname. GNU tar and bsdtar
  269. # (macOS) spell those flags differently.
  270. if tar --version 2>/dev/null | grep -q 'GNU tar'; then
  271. TAR_METADATA_FLAGS=(--owner=:0 --group=:0 --numeric-owner)
  272. else
  273. TAR_METADATA_FLAGS=(--uid 0 --gid 0 --uname '' --gname '')
  274. fi
  275. TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} +
  276. (
  277. cd "$STAGE"
  278. rm -f "$OUTPUT"
  279. COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar "${TAR_METADATA_FLAGS[@]}" -T "$ARCHIVE_LIST" |
  280. gzip -9n >"$OUTPUT"
  281. )
  282. ;;
  283. esac
  284. if command -v xattr >/dev/null 2>&1; then
  285. xattr -c "$OUTPUT" 2>/dev/null || true
  286. fi
  287. case "$FORMAT" in
  288. zip)
  289. archive_paths="$(unzip -Z1 "$OUTPUT" | sed 's#/$##')"
  290. ;;
  291. tar.gz)
  292. archive_paths="$(tar -tzf "$OUTPUT")"
  293. ;;
  294. esac
  295. unexpected_paths="$(
  296. printf '%s\n' "$archive_paths" |
  297. 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
  298. )"
  299. if [[ -n "$unexpected_paths" ]]; then
  300. printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2
  301. die "archive contains source-only paths"
  302. fi
  303. entry_count="$(printf '%s\n' "$archive_paths" | wc -l | tr -d ' ')"
  304. checksum="$(shasum -a 256 "$OUTPUT" | awk '{print $1}')"
  305. echo "Archive: $OUTPUT"
  306. echo "Format: $FORMAT"
  307. echo "Version: $VERSION"
  308. echo "Entries: $entry_count"
  309. echo "Skills: $skill_count"
  310. echo "SHA-256: $checksum"