package-codex-plugin.sh 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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. git -C "$REPO_ROOT" archive --format=tar "$REF" -- \
  207. .codex-plugin \
  208. CODE_OF_CONDUCT.md \
  209. LICENSE \
  210. README.md \
  211. assets \
  212. skills \
  213. | tar -xf - -C "$STAGE"
  214. VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")"
  215. [[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json"
  216. if [[ -z "$OUTPUT" ]]; then
  217. case "$FORMAT" in
  218. zip)
  219. OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.zip"
  220. ;;
  221. tar.gz)
  222. OUTPUT="$REPO_ROOT/../_tmp/sup-codex-packaging/superpowers-$VERSION.tar.gz"
  223. ;;
  224. esac
  225. fi
  226. mkdir -p "$(dirname "$OUTPUT")"
  227. OUTPUT="$(cd "$(dirname "$OUTPUT")" && pwd)/$(basename "$OUTPUT")"
  228. missing_metadata=0
  229. while IFS= read -r skill_dir; do
  230. skill_name="${skill_dir##*/}"
  231. metadata_file="$METADATA_ROOT/skills/$skill_name/agents/openai.yaml"
  232. if [[ ! -f "$metadata_file" ]]; then
  233. echo "Missing OpenAI agent metadata for skill: $skill_name" >&2
  234. missing_metadata=1
  235. continue
  236. fi
  237. mkdir -p "$skill_dir/agents"
  238. cp "$metadata_file" "$skill_dir/agents/openai.yaml"
  239. done < <(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d -print | sort)
  240. if [[ "$missing_metadata" -ne 0 ]]; then
  241. die "metadata source is incomplete"
  242. fi
  243. skill_count="$(find "$STAGE/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
  244. metadata_count="$(find "$STAGE/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')"
  245. [[ "$skill_count" == "$metadata_count" ]] ||
  246. die "metadata count mismatch: $metadata_count metadata files for $skill_count skills"
  247. (
  248. cd "$STAGE"
  249. {
  250. find . -mindepth 1 -type d | sed 's#^\./##' | LC_ALL=C sort
  251. find . -mindepth 1 -type f | sed 's#^\./##' | LC_ALL=C sort
  252. } >"$ARCHIVE_LIST"
  253. )
  254. case "$FORMAT" in
  255. zip)
  256. # ZIP cannot represent dates earlier than 1980.
  257. TZ=UTC find "$STAGE" -exec touch -t 198001010000 {} +
  258. (
  259. cd "$STAGE"
  260. rm -f "$OUTPUT"
  261. COPYFILE_DISABLE=1 zip -X -q - -@ <"$ARCHIVE_LIST" >"$OUTPUT"
  262. )
  263. ;;
  264. tar.gz)
  265. # Match the prior official archive's deterministic tar entry metadata.
  266. TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} +
  267. (
  268. cd "$STAGE"
  269. rm -f "$OUTPUT"
  270. COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$ARCHIVE_LIST" |
  271. gzip -9n >"$OUTPUT"
  272. )
  273. ;;
  274. esac
  275. if command -v xattr >/dev/null 2>&1; then
  276. xattr -c "$OUTPUT" 2>/dev/null || true
  277. fi
  278. case "$FORMAT" in
  279. zip)
  280. archive_paths="$(unzip -Z1 "$OUTPUT" | sed 's#/$##')"
  281. ;;
  282. tar.gz)
  283. archive_paths="$(tar -tzf "$OUTPUT")"
  284. ;;
  285. esac
  286. unexpected_paths="$(
  287. printf '%s\n' "$archive_paths" |
  288. 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
  289. )"
  290. if [[ -n "$unexpected_paths" ]]; then
  291. printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2
  292. die "archive contains source-only paths"
  293. fi
  294. entry_count="$(printf '%s\n' "$archive_paths" | wc -l | tr -d ' ')"
  295. checksum="$(shasum -a 256 "$OUTPUT" | awk '{print $1}')"
  296. echo "Archive: $OUTPUT"
  297. echo "Format: $FORMAT"
  298. echo "Version: $VERSION"
  299. echo "Entries: $entry_count"
  300. echo "Skills: $skill_count"
  301. echo "SHA-256: $checksum"