package-codex-plugin.sh 10 KB

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