diff --git a/skills/.curated/pet-creator/LICENSE.txt b/skills/.curated/pet-creator/LICENSE.txt new file mode 100644 index 0000000..13e25df --- /dev/null +++ b/skills/.curated/pet-creator/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/skills/.curated/pet-creator/SKILL.md b/skills/.curated/pet-creator/SKILL.md new file mode 100644 index 0000000..eac8abf --- /dev/null +++ b/skills/.curated/pet-creator/SKILL.md @@ -0,0 +1,216 @@ +--- +name: pet-creator +description: Create or edit Codex desktop pet packs that install cleanly, preview immediately, preserve required runtime semantics, and embody Codex as a charming animated desktop companion rather than a dry status indicator. +shortDescription: Create or edit Codex pet packs +--- + +# Pet Creator + +Use this skill when the user wants to create, customize, or revise a Codex desktop pet. + +Your job is to produce a valid pet pack draft that the desktop app can preview immediately. + +Your deeper job is to create a pet that **embodies Codex as a living character**. A great pet should make Codex feel warmer, cuter, more expressive, and more companion-like rather than dry, mechanical, or purely utilitarian. + +Animation is a core requirement, not an optional polish pass. The pet must feel alive in every runtime state. + +A technically valid pack is not enough. If the pack feels emotionally flat, generic, or soulless, it is not done yet. + +## Product Intent + +The pet is not merely a decorative sticker or a functional status indicator. It is the emotional embodiment of Codex in the desktop app. + +A strong pet pack should: + +- make Codex feel warm, alive, charming, and companion-like +- give users something they can feel affection for +- communicate runtime state clearly without losing personality +- help shift perception of Codex away from cold or dry utility and toward a more lovable, memorable product identity + +The four runtime states are not just system states. They are moments in a character performance. The pet should feel like the same recognizable little being across all four states, with a clear emotional arc. + +If the output is valid but feels like generic loading art, abstract motion graphics, or unrelated sprite strips rather than a living companion, revise it. + +## What You Must Produce + +Every installed pack must end up with this shape: + +- `manifest.json` +- `thumbnail.png` +- `states/idle.png` +- `states/working.png` +- `states/needsUserInput.png` +- `states/ready.png` + +Supported runtime state asset format: + +- transparent PNG sprite strips for all four states + +Do not default to static state art. Every state, including `idle`, must have at least two frames. `idle` must be the calmest loop, but it still needs visible motion so the pet feels expressive and alive. + +Draft packs must live under the OS temp directory, for example `/tmp/codex-pets//`, so the OS can clean them up automatically. + +This is a hard requirement for preview and install. Do not use a workspace path, repo path, or home-directory path for the draft pack, and do not emit a preview directive with a non-`/tmp` `packPath`. If you created the files somewhere else first, copy them into `/tmp/codex-pets//`, validate that `/tmp` copy, and use that `/tmp` path in the directive. + +## Character and Quality Bar + +The pet should read as **one coherent character**, not four loosely related assets. + +Aim for: + +- a strong, recognizable silhouette +- one consistent visual language across all states +- one stable temperament or personality +- immediately readable emotional changes across states +- motion that feels characterful, not merely decorative +- charm and delight at small desktop sizes + +The pet should feel expressive even without text or explanation. + +Good packs feel like: + +- “this is my little Codex” +- “I can tell what it is feeling and doing” +- “this has personality and presence” + +Avoid: + +- sterile or generic motion +- abstract loader-like animation with no character +- four states that feel unrelated +- overcomplicated detail that becomes unreadable at small sizes +- harsh, noisy, frantic, or annoying attention cues +- static or nearly static idle loops + +## Required State Semantics + +The user can steer the overall style freely. Do not force a particular aesthetic. + +You must preserve the meaning of the four states: + +- `idle`: least motion, calm/default pose, but still animated +- `working`: purposeful motion, more active than idle +- `ready`: positive completion cue, more energy than working +- `needsUserInput`: highest-attention state, most noticeable + +These states should form a clear expressive ladder: + +1. `idle` = present, calm, alive, endearing +2. `working` = focused, purposeful, engaged +3. `ready` = satisfied, rewarding, celebratory +4. `needsUserInput` = noticeable, attention-seeking, urgent in a charming way + +If the user asks for something that would blur those roles, keep the style request but preserve the state ordering above. + +`needsUserInput` should be the most noticeable state, but it should still feel like the same lovable character rather than an alarm. + +`ready` should feel rewarding and pleasant, like a tiny moment of success. + +## Manifest Guidance + +Keep the manifest tight and valid. Do not invent extra runtime fields unless they are needed. + +For a new pack: + +- set `revision` to `1` +- write `renderWidthPx` and `renderHeightPx` to match the animation dimensions +- do not cap the pet to a square size unless the design genuinely wants it + +For an edit: + +- preserve the existing `id` unless the user explicitly asks for a variant +- increment the existing installed pack's `revision` by `1` + +## Workflow + +1. Determine whether this is a new pack or an edit to an existing pack. + +2. If this is a new pack, first decide on a clear character concept that can support all four runtime states while remaining visually coherent and emotionally expressive. + +3. For a new pack, create a draft directory under `/tmp`. The draft directory itself must be under `/tmp`, not in the current workspace. + +4. For an edit, inspect the existing installed pack first and keep the same `id` unless the user explicitly asks for a variant. + +5. For a new pack, set `revision` to `1` in `manifest.json`. For an edit, increment the existing installed pack's `revision` by `1` in the draft manifest. + +6. Use the available image generation flow, such as `$imagegen`, to create a sprite-sheet concept image. Ask for four rows in this exact order: `idle`, `working`, `needsUserInput`, `ready`. Each row should contain the same number of sequential animation frames on a plain, easily removable background. + +7. Run the normalizer from this skill directory to build a transparent PNG sprite-strip pack: + +```bash +python3 scripts/normalize-pet-sprite-pack.py /path/to/source-sprite-sheet.png /tmp/codex-pets/your-pack-id --pack-id your-pack-id --name "Your Pack Name" +``` + +8. If the normalizer reports the wrong row/frame counts or leaves background artifacts, regenerate the source sheet or tune `--component-threshold`, `--large-component-area`, `--row-tolerance`, or crop margins. Do not hand-install unnormalized generated images. + +9. Before validating, do a quality check: + - Are these clearly the same character in all four states? + - Is `idle` visibly alive, not a duplicate static frame? + - Does each state read immediately at small size? + - Does the pack feel charming and emotionally legible, not merely compliant? + - Does `ready` feel rewarding? + - Does `needsUserInput` attract attention without becoming unpleasant? + + If not, revise before validating. + +10. Validate the `/tmp` draft, but do not install it yourself: + +```bash +node ./scripts/install-pet-pack.mjs --validate-only /tmp/codex-pets/your-pack-id +``` + +11. After validation succeeds, verify the directive `packPath` starts with `/tmp/`, then: + - briefly explain the character concept + - briefly explain the four states you created and the emotional progression between them + - emit exactly one preview directive for the draft you just created or edited: + +```md +::pet-pack-preview{packId="your-pack-id" name="Your Pack Name" packPath="/tmp/codex-pets/your-pack-id" revision="1" initialState="idle"} +``` + +12. After the preview directive, tell the user: + +- click `Install pet` or `Update pet` in the card to save it into Codex +- run `/pet` in any thread to pop out a pet +- right click a popped-out pet to change the pet for that window + +## Editing Existing Packs + +When the user wants to revise a pet: + +- inspect the installed pack under `~/.codex/pets/packs//` +- write the revised draft under `/tmp`, not in the workspace and not directly into the installed pack directory +- preserve the `id` +- increment `revision` by `1` +- update the changed sprite strips, keeping all four states animated +- even when revising, `idle` must remain a multi-frame animation unless the user explicitly asks to experiment with breaking the runtime contract +- preserve the core character identity unless the user explicitly wants a redesign +- re-run the installer script in `--validate-only` mode against the revised draft directory + +When editing, avoid accidental drift where the revised pack becomes a different character. Update what changed while preserving recognizability. + +## Creative Guidance + +Favor pets that feel: + +- cute +- expressive +- memorable +- readable at a glance +- emotionally clear +- alive even in still moments + +The user can choose any style, but the resulting character should still feel like a desktop companion users would enjoy keeping around. + +When in doubt, prioritize: + +1. coherent character identity +2. state readability +3. charm +4. animation polish +5. ornamental detail + +## References + +- Pack format: [`references/pet-pack-format.md`](./references/pet-pack-format.md) +- State semantics: [`references/state-semantics.md`](./references/state-semantics.md) diff --git a/skills/.curated/pet-creator/agents/openai.yaml b/skills/.curated/pet-creator/agents/openai.yaml new file mode 100644 index 0000000..22935e7 --- /dev/null +++ b/skills/.curated/pet-creator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Pet Creator" + short_description: "Create animated Codex desktop pets" + default_prompt: "Use $pet-creator to create or revise an animated Codex desktop pet pack." diff --git a/skills/.curated/pet-creator/assets/template-pack/manifest.json b/skills/.curated/pet-creator/assets/template-pack/manifest.json new file mode 100644 index 0000000..490639c --- /dev/null +++ b/skills/.curated/pet-creator/assets/template-pack/manifest.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "id": "template-pet", + "name": "Template Pet", + "revision": 1, + "renderWidthPx": 96, + "renderHeightPx": 96, + "thumbnail": "thumbnail.png", + "states": { + "idle": { + "path": "states/idle.png", + "frameCount": 4, + "frameDurationMs": 240 + }, + "working": { + "path": "states/working.png", + "frameCount": 4, + "frameDurationMs": 160 + }, + "needsUserInput": { + "path": "states/needsUserInput.png", + "frameCount": 4, + "frameDurationMs": 120 + }, + "ready": { + "path": "states/ready.png", + "frameCount": 4, + "frameDurationMs": 160 + } + } +} diff --git a/skills/.curated/pet-creator/assets/template-pack/states/idle.png b/skills/.curated/pet-creator/assets/template-pack/states/idle.png new file mode 100644 index 0000000..5ba4a1d Binary files /dev/null and b/skills/.curated/pet-creator/assets/template-pack/states/idle.png differ diff --git a/skills/.curated/pet-creator/assets/template-pack/states/needsUserInput.png b/skills/.curated/pet-creator/assets/template-pack/states/needsUserInput.png new file mode 100644 index 0000000..26d9f1e Binary files /dev/null and b/skills/.curated/pet-creator/assets/template-pack/states/needsUserInput.png differ diff --git a/skills/.curated/pet-creator/assets/template-pack/states/ready.png b/skills/.curated/pet-creator/assets/template-pack/states/ready.png new file mode 100644 index 0000000..8571b4e Binary files /dev/null and b/skills/.curated/pet-creator/assets/template-pack/states/ready.png differ diff --git a/skills/.curated/pet-creator/assets/template-pack/states/working.png b/skills/.curated/pet-creator/assets/template-pack/states/working.png new file mode 100644 index 0000000..1fba736 Binary files /dev/null and b/skills/.curated/pet-creator/assets/template-pack/states/working.png differ diff --git a/skills/.curated/pet-creator/assets/template-pack/thumbnail.png b/skills/.curated/pet-creator/assets/template-pack/thumbnail.png new file mode 100644 index 0000000..fb12eff Binary files /dev/null and b/skills/.curated/pet-creator/assets/template-pack/thumbnail.png differ diff --git a/skills/.curated/pet-creator/references/pet-pack-format.md b/skills/.curated/pet-creator/references/pet-pack-format.md new file mode 100644 index 0000000..ccab5d9 --- /dev/null +++ b/skills/.curated/pet-creator/references/pet-pack-format.md @@ -0,0 +1,70 @@ +# Pet Pack Format + +Runtime pack layout: + +- `manifest.json` +- `thumbnail.png` +- `states/idle.png` +- `states/working.png` +- `states/needsUserInput.png` +- `states/ready.png` + +Preferred runtime layout: + +- transparent PNG thumbnail +- transparent PNG sprite strips for all four states +- each sprite strip is one horizontal row of frames + +`manifest.json` schema: + +```json +{ + "schemaVersion": 1, + "id": "kebab-case-pack-id", + "name": "Display Name", + "revision": 1, + "renderWidthPx": 96, + "renderHeightPx": 96, + "thumbnail": "thumbnail.png", + "states": { + "idle": { + "path": "states/idle.png", + "frameCount": 4, + "frameDurationMs": 240 + }, + "working": { + "path": "states/working.png", + "frameCount": 4, + "frameDurationMs": 160 + }, + "needsUserInput": { + "path": "states/needsUserInput.png", + "frameCount": 4, + "frameDurationMs": 120 + }, + "ready": { + "path": "states/ready.png", + "frameCount": 4, + "frameDurationMs": 160 + } + } +} +``` + +Rules: + +- `schemaVersion` must be `1` +- `id` must be lowercase kebab-case +- `revision` must be a positive integer +- packs must set `renderWidthPx` and `renderHeightPx` to the animation size +- all asset paths must stay inside the pack directory +- all state files must exist +- thumbnails and state assets must be PNG files +- each state file must be a horizontal transparent sprite strip +- each state file width must equal `renderWidthPx * frameCount` +- each state file height must equal `renderHeightPx` +- all four states must have at least two frames, including `idle` +- for edits, increment `revision` in the draft before emitting the preview directive +- transparent backgrounds are required + +The desktop app reads the manifest, loads the state assets, and swaps between them based on thread state. diff --git a/skills/.curated/pet-creator/references/state-semantics.md b/skills/.curated/pet-creator/references/state-semantics.md new file mode 100644 index 0000000..703e3bd --- /dev/null +++ b/skills/.curated/pet-creator/references/state-semantics.md @@ -0,0 +1,33 @@ +# State Semantics + +Keep the overall character style consistent across all four states. Change pose, expression, or motion intensity, not the identity of the character. + +State expectations: + +- `idle` + - calm default pose + - least visual noise + - smallest loop + - must still be animated; never ship idle as a one-frame sticker + - if idle frames are duplicates or effectively static, the draft is not acceptable +- `working` + - purposeful motion + - visibly active but not frantic + - should read as "busy" +- `ready` + - clear success/completion cue + - more energy than working + - should read as "done" +- `needsUserInput` + - highest attention state + - strongest pose or motion + - should read as "look at me" + +When revising a pack, do not let `idle` become louder than `working`, and do not let `needsUserInput` become subtler than `ready`. + +The relative motion ordering matters: + +- `idle` is the calmest loop, not a frozen frame +- `working` should move more than `idle` +- `ready` should feel more energized than `working` +- `needsUserInput` should be the most attention-grabbing loop diff --git a/skills/.curated/pet-creator/scripts/install-pet-pack.mjs b/skills/.curated/pet-creator/scripts/install-pet-pack.mjs new file mode 100644 index 0000000..ab829a5 --- /dev/null +++ b/skills/.curated/pet-creator/scripts/install-pet-pack.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const PACK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const PNG_EXTENSION = ".png"; +const REQUIRED_STATES = ["idle", "working", "needsUserInput", "ready"]; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +async function main() { + const args = process.argv.slice(2); + const validateOnly = args[0] === "--validate-only"; + const sourceDir = validateOnly ? args[1] : args[0]; + if (!sourceDir) { + throw new Error( + "Usage: node install-pet-pack.mjs [--validate-only] /absolute/path/to/pack", + ); + } + + const resolvedSourceDir = path.resolve(sourceDir); + const manifest = await readManifest(resolvedSourceDir); + validateManifest(manifest); + + const resolvedThumbnailPath = resolvePackPath( + resolvedSourceDir, + manifest.thumbnail, + ); + const thumbnailStats = await fs.stat(resolvedThumbnailPath); + if (!thumbnailStats.isFile()) { + throw new Error(`Asset is not a file: ${manifest.thumbnail}`); + } + if (path.extname(resolvedThumbnailPath).toLowerCase() !== PNG_EXTENSION) { + throw new Error(`Thumbnail must be a PNG file: ${manifest.thumbnail}`); + } + await readPngSize(resolvedThumbnailPath); + + await Promise.all( + REQUIRED_STATES.map(async (state) => { + const stateConfig = manifest.states[state]; + const relativeAssetPath = stateConfig.path; + const resolvedAssetPath = resolvePackPath( + resolvedSourceDir, + relativeAssetPath, + ); + const stats = await fs.stat(resolvedAssetPath); + if (!stats.isFile()) { + throw new Error(`Asset is not a file: ${relativeAssetPath}`); + } + + if (path.extname(resolvedAssetPath).toLowerCase() !== PNG_EXTENSION) { + throw new Error( + `State asset must be a PNG sprite strip: ${relativeAssetPath}`, + ); + } + const size = await readPngSize(resolvedAssetPath); + if (size.width !== manifest.renderWidthPx * stateConfig.frameCount) { + throw new Error( + `State sprite width must equal renderWidthPx * frameCount: ${relativeAssetPath}`, + ); + } + if (size.height !== manifest.renderHeightPx) { + throw new Error( + `State sprite height must equal renderHeightPx: ${relativeAssetPath}`, + ); + } + }), + ); + + if (validateOnly) { + process.stdout.write( + JSON.stringify( + { + packId: manifest.id, + name: manifest.name, + revision: manifest.revision ?? 1, + draftPath: resolvedSourceDir, + valid: true, + }, + null, + 2, + ), + ); + return; + } + + const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); + const destinationRoot = path.join(codexHome, "pets", "packs"); + const destinationDir = path.join(destinationRoot, manifest.id); + + await fs.mkdir(destinationRoot, { recursive: true }); + await fs.rm(destinationDir, { recursive: true, force: true }); + await fs.cp(resolvedSourceDir, destinationDir, { recursive: true }); + + process.stdout.write( + JSON.stringify( + { + packId: manifest.id, + name: manifest.name, + revision: manifest.revision ?? 1, + installedPath: destinationDir, + }, + null, + 2, + ), + ); +} + +async function readManifest(sourceDir) { + const manifestPath = path.join(sourceDir, "manifest.json"); + const raw = await fs.readFile(manifestPath, "utf8"); + return JSON.parse(raw); +} + +function validateManifest(manifest) { + if (manifest?.schemaVersion !== 1) { + throw new Error("manifest.json must set schemaVersion to 1"); + } + if (typeof manifest.id !== "string" || !PACK_ID_PATTERN.test(manifest.id)) { + throw new Error("manifest.json id must be lowercase kebab-case"); + } + if (typeof manifest.name !== "string" || manifest.name.trim() === "") { + throw new Error("manifest.json name is required"); + } + if ( + manifest.revision != null && + (typeof manifest.revision !== "number" || + !Number.isInteger(manifest.revision) || + manifest.revision < 1) + ) { + throw new Error("manifest.json revision must be a positive integer"); + } + const hasRenderDimensions = + typeof manifest.renderWidthPx === "number" && + Number.isInteger(manifest.renderWidthPx) && + manifest.renderWidthPx > 0 && + typeof manifest.renderHeightPx === "number" && + Number.isInteger(manifest.renderHeightPx) && + manifest.renderHeightPx > 0; + if (!hasRenderDimensions) { + throw new Error( + "manifest.json must set positive integer renderWidthPx and renderHeightPx", + ); + } + if ( + typeof manifest.thumbnail !== "string" || + manifest.thumbnail.trim() === "" + ) { + throw new Error("manifest.json thumbnail is required"); + } + if (typeof manifest.states !== "object" || manifest.states == null) { + throw new Error("manifest.json states is required"); + } + for (const state of REQUIRED_STATES) { + const stateConfig = manifest.states[state]; + if (typeof stateConfig?.path !== "string") { + throw new Error(`manifest.json states.${state}.path is required`); + } + if ( + typeof stateConfig.frameCount !== "number" || + !Number.isInteger(stateConfig.frameCount) || + stateConfig.frameCount < 2 + ) { + throw new Error( + `manifest.json states.${state}.frameCount must be an integer greater than 1`, + ); + } + if ( + typeof stateConfig.frameDurationMs !== "number" || + !Number.isInteger(stateConfig.frameDurationMs) || + stateConfig.frameDurationMs < 1 + ) { + throw new Error( + `manifest.json states.${state}.frameDurationMs must be a positive integer`, + ); + } + } +} + +function resolvePackPath(packRoot, relativeAssetPath) { + const resolvedPath = path.resolve(packRoot, relativeAssetPath); + const relativeFromPack = path.relative(packRoot, resolvedPath); + if ( + relativeFromPack === "" || + relativeFromPack.startsWith("..") || + path.isAbsolute(relativeFromPack) + ) { + throw new Error(`Asset path escapes pack root: ${relativeAssetPath}`); + } + return resolvedPath; +} + +async function readPngSize(assetPath) { + const handle = await fs.open(assetPath, "r"); + try { + const header = Buffer.alloc(24); + await handle.read(header, 0, header.length, 0); + if (!header.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + throw new Error(`Asset is not a PNG file: ${assetPath}`); + } + return { + width: header.readUInt32BE(16), + height: header.readUInt32BE(20), + }; + } finally { + await handle.close(); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/skills/.curated/pet-creator/scripts/normalize-pet-sprite-pack.py b/skills/.curated/pet-creator/scripts/normalize-pet-sprite-pack.py new file mode 100644 index 0000000..61d8448 --- /dev/null +++ b/skills/.curated/pet-creator/scripts/normalize-pet-sprite-pack.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Build a Codex pet sprite pack from a generated PNG sprite sheet.""" + +from __future__ import annotations + +import argparse +import json +import struct +import zlib +from collections import deque +from pathlib import Path + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +STATES = ("idle", "working", "needsUserInput", "ready") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Extract sprite rows into a Codex pet pack draft.", + ) + parser.add_argument("source_image", type=Path) + parser.add_argument("pack_dir", type=Path) + parser.add_argument("--pack-id", required=True) + parser.add_argument("--name", required=True) + parser.add_argument("--revision", type=int, default=1) + parser.add_argument("--row-frame-counts", default="4,4,4,4") + parser.add_argument("--frame-duration-ms", default="240,160,120,160") + parser.add_argument("--cell-width", type=int, default=192) + parser.add_argument("--cell-height", type=int, default=208) + parser.add_argument("--component-threshold", type=int, default=22) + parser.add_argument("--large-component-area", type=int, default=12000) + parser.add_argument("--row-tolerance", type=int, default=120) + parser.add_argument("--crop-margin-x", type=int, default=22) + parser.add_argument("--crop-margin-top", type=int, default=2) + parser.add_argument("--crop-margin-bottom", type=int, default=12) + parser.add_argument("--paste-bottom-margin", type=int, default=4) + parser.add_argument("--alpha-zero", type=int, default=14) + parser.add_argument("--alpha-full", type=int, default=56) + return parser.parse_args() + + +def read_rgb_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int]]]]: + data = path.read_bytes() + if data[:8] != PNG_SIGNATURE: + raise ValueError(f"{path} is not a PNG file") + + width = 0 + height = 0 + bit_depth = 0 + color_type = -1 + idat_chunks: list[bytes] = [] + offset = 8 + + while offset < len(data): + length = struct.unpack(">I", data[offset : offset + 4])[0] + chunk_type = data[offset + 4 : offset + 8] + chunk_payload = data[offset + 8 : offset + 8 + length] + offset += 12 + length + + if chunk_type == b"IHDR": + width, height, bit_depth, color_type, _, _, _ = struct.unpack( + ">IIBBBBB", + chunk_payload, + ) + elif chunk_type == b"IDAT": + idat_chunks.append(chunk_payload) + elif chunk_type == b"IEND": + break + + if bit_depth != 8 or color_type not in (2, 6): + raise ValueError( + f"{path} must be an 8-bit RGB/RGBA PNG, got {(bit_depth, color_type)}", + ) + + bytes_per_pixel = 4 if color_type == 6 else 3 + stride = width * bytes_per_pixel + raw = zlib.decompress(b"".join(idat_chunks)) + previous_row = [0] * stride + source_offset = 0 + rows: list[list[int]] = [] + + for _ in range(height): + filter_type = raw[source_offset] + source_offset += 1 + row = list(raw[source_offset : source_offset + stride]) + source_offset += stride + + if filter_type == 1: + for index in range(stride): + left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + row[index] = (row[index] + left) & 255 + elif filter_type == 2: + for index in range(stride): + row[index] = (row[index] + previous_row[index]) & 255 + elif filter_type == 3: + for index in range(stride): + left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + up = previous_row[index] + row[index] = (row[index] + ((left + up) >> 1)) & 255 + elif filter_type == 4: + for index in range(stride): + left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + up = previous_row[index] + upper_left = ( + previous_row[index - bytes_per_pixel] + if index >= bytes_per_pixel + else 0 + ) + row[index] = (row[index] + paeth_predictor(left, up, upper_left)) & 255 + elif filter_type != 0: + raise ValueError(f"Unsupported PNG filter {filter_type}") + + rows.append(row) + previous_row = row + + return width, height, [ + [ + tuple(row[x * bytes_per_pixel : x * bytes_per_pixel + 3]) + for x in range(width) + ] + for row in rows + ] + + +def paeth_predictor(left: int, up: int, upper_left: int) -> int: + estimate = left + up - upper_left + left_distance = abs(estimate - left) + up_distance = abs(estimate - up) + diagonal_distance = abs(estimate - upper_left) + + if left_distance <= up_distance and left_distance <= diagonal_distance: + return left + if up_distance <= diagonal_distance: + return up + return upper_left + + +def write_rgba_png( + path: Path, + width: int, + height: int, + pixels: list[list[tuple[int, int, int, int]]], +) -> None: + raw_rows: list[bytes] = [] + for row_pixels in pixels: + raw_row = bytearray([0]) + for pixel in row_pixels: + raw_row.extend(pixel) + raw_rows.append(bytes(raw_row)) + + png = bytearray(PNG_SIGNATURE) + png.extend( + build_png_chunk( + b"IHDR", + struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0), + ), + ) + png.extend(build_png_chunk(b"IDAT", zlib.compress(b"".join(raw_rows), 9))) + png.extend(build_png_chunk(b"IEND", b"")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(png) + + +def build_png_chunk(chunk_type: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + chunk_type + + payload + + struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF) + ) + + +def color_distance_squared( + pixel: tuple[int, int, int], + background: tuple[int, int, int], +) -> int: + return sum((pixel[channel] - background[channel]) ** 2 for channel in range(3)) + + +def detect_large_components( + pixels: list[list[tuple[int, int, int]]], + background: tuple[int, int, int], + component_threshold: int, + large_component_area: int, +) -> list[tuple[int, int, int, int]]: + height = len(pixels) + width = len(pixels[0]) + threshold_sq = component_threshold * component_threshold + mask = [ + [color_distance_squared(pixels[y][x], background) > threshold_sq for x in range(width)] + for y in range(height) + ] + visited = [[False] * width for _ in range(height)] + components: list[tuple[int, int, int, int]] = [] + + for y in range(height): + for x in range(width): + if not mask[y][x] or visited[y][x]: + continue + + queue = deque([(x, y)]) + visited[y][x] = True + area = 0 + min_x = max_x = x + min_y = max_y = y + + while queue: + cx, cy = queue.popleft() + area += 1 + min_x = min(min_x, cx) + max_x = max(max_x, cx) + min_y = min(min_y, cy) + max_y = max(max_y, cy) + + for nx, ny in ( + (cx + 1, cy), + (cx - 1, cy), + (cx, cy + 1), + (cx, cy - 1), + ): + if ( + 0 <= nx < width + and 0 <= ny < height + and mask[ny][nx] + and not visited[ny][nx] + ): + visited[ny][nx] = True + queue.append((nx, ny)) + + if area >= large_component_area: + components.append((min_x, min_y, max_x, max_y)) + + return sorted(components, key=lambda box: ((box[1] + box[3]) / 2, (box[0] + box[2]) / 2)) + + +def group_components_by_row( + components: list[tuple[int, int, int, int]], + expected_row_counts: list[int], + row_tolerance: int, +) -> list[list[tuple[int, int, int, int]]]: + rows: list[list[tuple[int, int, int, int]]] = [] + centers: list[float] = [] + + for box in components: + center_y = (box[1] + box[3]) / 2 + if rows and abs(centers[-1] - center_y) < row_tolerance: + rows[-1].append(box) + centers[-1] = sum((row_box[1] + row_box[3]) / 2 for row_box in rows[-1]) / len(rows[-1]) + else: + rows.append([box]) + centers.append(center_y) + + row_counts = [len(row) for row in rows] + if row_counts != expected_row_counts: + raise ValueError( + f"Detected row counts {row_counts}, expected {expected_row_counts}. " + "Tune thresholds/crop margins for this source sheet.", + ) + + return [sorted(row, key=lambda box: (box[0] + box[2]) / 2) for row in rows] + + +def extract_frame_pixels( + pixels: list[list[tuple[int, int, int]]], + background: tuple[int, int, int], + box: tuple[int, int, int, int], + crop_margin_x: int, + crop_margin_top: int, + crop_margin_bottom: int, + alpha_zero: int, + alpha_full: int, +) -> list[list[tuple[int, int, int, int]]]: + source_height = len(pixels) + source_width = len(pixels[0]) + min_x, min_y, max_x, max_y = box + crop_left = max(0, min_x - crop_margin_x) + crop_top = max(0, min_y - crop_margin_top) + crop_right = min(source_width - 1, max_x + crop_margin_x) + crop_bottom = min(source_height - 1, max_y + crop_margin_bottom) + frame: list[list[tuple[int, int, int, int]]] = [] + + for source_y in range(crop_top, crop_bottom + 1): + frame_row: list[tuple[int, int, int, int]] = [] + for source_x in range(crop_left, crop_right + 1): + red, green, blue = pixels[source_y][source_x] + distance_sq = color_distance_squared((red, green, blue), background) + + if distance_sq <= alpha_zero * alpha_zero: + frame_row.append((0, 0, 0, 0)) + continue + + if distance_sq >= alpha_full * alpha_full: + alpha = 255 + else: + distance = distance_sq**0.5 + alpha = round( + (distance - alpha_zero) * 255 / (alpha_full - alpha_zero), + ) + + if alpha < 255: + inverse_alpha = 255 - alpha + red = unblend_channel(red, background[0], alpha, inverse_alpha) + green = unblend_channel(green, background[1], alpha, inverse_alpha) + blue = unblend_channel(blue, background[2], alpha, inverse_alpha) + + frame_row.append((red, green, blue, alpha)) + frame.append(frame_row) + + return frame + + +def unblend_channel( + source_channel: int, + background_channel: int, + alpha: int, + inverse_alpha: int, +) -> int: + return max( + 0, + min( + 255, + round((source_channel * 255 - background_channel * inverse_alpha) / alpha), + ), + ) + + +def resize_frame( + frame: list[list[tuple[int, int, int, int]]], + scale: float, +) -> list[list[tuple[int, int, int, int]]]: + source_height = len(frame) + source_width = len(frame[0]) + target_width = max(1, round(source_width * scale)) + target_height = max(1, round(source_height * scale)) + resized_frame = [[(0, 0, 0, 0)] * target_width for _ in range(target_height)] + + for target_y in range(target_height): + source_y = min(source_height - 1, int(target_y / scale)) + for target_x in range(target_width): + source_x = min(source_width - 1, int(target_x / scale)) + resized_frame[target_y][target_x] = frame[source_y][source_x] + + return resized_frame + + +def build_sprite_strip( + frames: list[list[list[tuple[int, int, int, int]]]], + cell_width: int, + cell_height: int, + paste_bottom_margin: int, +) -> list[list[tuple[int, int, int, int]]]: + max_frame_width = max(len(frame[0]) for frame in frames) + max_frame_height = max(len(frame) for frame in frames) + scale = min( + (cell_width - 16) / max_frame_width, + (cell_height - 10) / max_frame_height, + ) + strip = [[(0, 0, 0, 0)] * (len(frames) * cell_width) for _ in range(cell_height)] + + for frame_index, frame in enumerate(frames): + resized_frame = resize_frame(frame, scale) + frame_height = len(resized_frame) + frame_width = len(resized_frame[0]) + offset_x = frame_index * cell_width + (cell_width - frame_width) // 2 + offset_y = cell_height - paste_bottom_margin - frame_height + + for frame_y, frame_row in enumerate(resized_frame): + for frame_x, pixel in enumerate(frame_row): + strip[offset_y + frame_y][offset_x + frame_x] = pixel + + return strip + + +def parse_int_list(value: str, label: str) -> list[int]: + values = [int(part) for part in value.split(",") if part] + if len(values) != len(STATES) or any(item <= 0 for item in values): + raise ValueError(f"--{label} must include {len(STATES)} positive integers") + return values + + +def main() -> None: + args = parse_args() + row_frame_counts = parse_int_list(args.row_frame_counts, "row-frame-counts") + frame_duration_ms = parse_int_list(args.frame_duration_ms, "frame-duration-ms") + + _, _, pixels = read_rgb_png(args.source_image) + background = pixels[0][0] + component_rows = group_components_by_row( + detect_large_components( + pixels=pixels, + background=background, + component_threshold=args.component_threshold, + large_component_area=args.large_component_area, + ), + expected_row_counts=row_frame_counts, + row_tolerance=args.row_tolerance, + ) + frame_rows = [ + [ + extract_frame_pixels( + pixels=pixels, + background=background, + box=frame_box, + crop_margin_x=args.crop_margin_x, + crop_margin_top=args.crop_margin_top, + crop_margin_bottom=args.crop_margin_bottom, + alpha_zero=args.alpha_zero, + alpha_full=args.alpha_full, + ) + for frame_box in component_row + ] + for component_row in component_rows + ] + + states_dir = args.pack_dir / "states" + states_dir.mkdir(parents=True, exist_ok=True) + manifest_states: dict[str, dict[str, int | str]] = {} + + for state, frames, duration_ms in zip(STATES, frame_rows, frame_duration_ms): + write_rgba_png( + states_dir / f"{state}.png", + args.cell_width * len(frames), + args.cell_height, + build_sprite_strip(frames, args.cell_width, args.cell_height, args.paste_bottom_margin), + ) + manifest_states[state] = { + "path": f"states/{state}.png", + "frameCount": len(frames), + "frameDurationMs": duration_ms, + } + + write_rgba_png( + args.pack_dir / "thumbnail.png", + args.cell_width, + args.cell_height, + build_sprite_strip([frame_rows[0][0]], args.cell_width, args.cell_height, args.paste_bottom_margin), + ) + manifest = { + "schemaVersion": 1, + "id": args.pack_id, + "name": args.name, + "revision": args.revision, + "renderWidthPx": args.cell_width, + "renderHeightPx": args.cell_height, + "thumbnail": "thumbnail.png", + "states": manifest_states, + } + args.pack_dir.mkdir(parents=True, exist_ok=True) + (args.pack_dir / "manifest.json").write_text( + f"{json.dumps(manifest, indent=2)}\n", + encoding="utf-8", + ) + print(json.dumps({"packId": args.pack_id, "packPath": str(args.pack_dir)}, indent=2)) + + +if __name__ == "__main__": + main()