mimo2codex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ generate_pet.py — produce a Codex pet sprite outside Codex.
4
+
5
+ Codex's `/hatch` requires OpenAI's image generation API (gpt-image-1). MiMo
6
+ doesn't have an image gen endpoint and mimo2codex can't fake one, so when
7
+ Codex is pointed at MiMo, /hatch fails. This script generates the pet sprite
8
+ using your choice of provider, then `install_pet.sh` drops it into Codex's
9
+ pet directory.
10
+
11
+ Providers:
12
+ auto (default) — gpt-image-1 if PET_OPENAI_API_KEY/OPENAI_API_KEY is set,
13
+ otherwise falls back to pollinations (free, no key).
14
+ Pick this if you only have a MiMo key and want pet
15
+ generation to "just work".
16
+ pollinations — free, no key. Decent quality for chibi-sticker style.
17
+ gpt-image-1 — best quality, needs PET_OPENAI_API_KEY (real OpenAI key,
18
+ NOT the mimo2codex-local placeholder). Image-to-image
19
+ edit (--reference) only works with this provider.
20
+ replicate — FLUX/SDXL, needs REPLICATE_API_TOKEN. Cheap (~$0.003/img).
21
+ local-sd — local Automatic1111 / ComfyUI on http://127.0.0.1:7860, free.
22
+
23
+ Usage:
24
+ # auto mode — works with ONLY a MiMo key (no OpenAI key needed)
25
+ python3 generate_pet.py --description "chibi axolotl" --out pet.png
26
+
27
+ # explicit free path
28
+ python3 generate_pet.py --provider pollinations --description "..." --out pet.png
29
+
30
+ # best quality (needs OpenAI key, separate from MIMO_API_KEY)
31
+ export PET_OPENAI_API_KEY=sk-real-openai-key
32
+ python3 generate_pet.py --provider gpt-image-1 \\
33
+ --reference src.jpg --description "chibi axolotl" --out pet.png
34
+
35
+ # bundle of three states (idle / working / done)
36
+ python3 generate_pet.py --description "chibi axolotl" --bundle ./my-pet/
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import base64
42
+ import json
43
+ import mimetypes
44
+ import os
45
+ import sys
46
+ import time
47
+ import urllib.parse
48
+ import urllib.request
49
+ import urllib.error
50
+ from pathlib import Path
51
+
52
+ # --- prompt assembly --------------------------------------------------------
53
+
54
+ PROMPT_PREFIX = (
55
+ "Chibi sticker mascot of "
56
+ )
57
+ PROMPT_SUFFIX = (
58
+ ", front-facing, expressive face, soft cel-shading, thin clean outline, "
59
+ "transparent background, high detail, playful, single character centered, "
60
+ "1024x1024 sticker style"
61
+ )
62
+
63
+
64
+ def build_prompt(description: str, action: str | None = None) -> str:
65
+ body = description.strip().rstrip(".,;")
66
+ if action:
67
+ body += f", {action}"
68
+ return PROMPT_PREFIX + body + PROMPT_SUFFIX
69
+
70
+
71
+ # --- providers --------------------------------------------------------------
72
+
73
+ def _http_post_json(url: str, body: dict, headers: dict) -> dict:
74
+ req = urllib.request.Request(
75
+ url,
76
+ method="POST",
77
+ data=json.dumps(body).encode(),
78
+ headers={"Content-Type": "application/json", **headers},
79
+ )
80
+ try:
81
+ with urllib.request.urlopen(req, timeout=300) as resp:
82
+ return json.loads(resp.read().decode("utf-8"))
83
+ except urllib.error.HTTPError as e:
84
+ snippet = e.read().decode("utf-8", "replace")
85
+ raise SystemExit(f"HTTP {e.code} from {url}: {snippet}")
86
+
87
+
88
+ def _http_get_bytes(url: str, headers: dict | None = None) -> bytes:
89
+ req = urllib.request.Request(url, headers=headers or {})
90
+ try:
91
+ with urllib.request.urlopen(req, timeout=300) as resp:
92
+ return resp.read()
93
+ except urllib.error.HTTPError as e:
94
+ raise SystemExit(f"HTTP {e.code} fetching {url}")
95
+
96
+
97
+ def _multipart_post(
98
+ url: str, fields: dict[str, str], files: dict[str, tuple[str, bytes, str]], headers: dict
99
+ ) -> dict:
100
+ """Minimal multipart/form-data POST (for OpenAI image edits endpoint)."""
101
+ boundary = "----mimoskill" + os.urandom(8).hex()
102
+ body = bytearray()
103
+ for k, v in fields.items():
104
+ body += f"--{boundary}\r\n".encode()
105
+ body += f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode()
106
+ body += v.encode() + b"\r\n"
107
+ for k, (filename, data, mime) in files.items():
108
+ body += f"--{boundary}\r\n".encode()
109
+ body += (
110
+ f'Content-Disposition: form-data; name="{k}"; filename="{filename}"\r\n'
111
+ ).encode()
112
+ body += f"Content-Type: {mime}\r\n\r\n".encode()
113
+ body += data + b"\r\n"
114
+ body += f"--{boundary}--\r\n".encode()
115
+
116
+ req = urllib.request.Request(
117
+ url,
118
+ method="POST",
119
+ data=bytes(body),
120
+ headers={
121
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
122
+ **headers,
123
+ },
124
+ )
125
+ try:
126
+ with urllib.request.urlopen(req, timeout=600) as resp:
127
+ return json.loads(resp.read().decode("utf-8"))
128
+ except urllib.error.HTTPError as e:
129
+ snippet = e.read().decode("utf-8", "replace")
130
+ raise SystemExit(f"HTTP {e.code}: {snippet}")
131
+
132
+
133
+ def gen_gpt_image_1(prompt: str, reference: Path | None, quality: str, out: Path) -> None:
134
+ api_key = os.environ.get("PET_OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY")
135
+ if not api_key:
136
+ raise SystemExit(
137
+ "error: PET_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.\n"
138
+ " gpt-image-1 needs a real OpenAI key — the mimo2codex-local "
139
+ "placeholder won't work.\n"
140
+ " Get one at https://platform.openai.com/api-keys, or use "
141
+ "--provider pollinations for free."
142
+ )
143
+
144
+ if reference and reference.exists():
145
+ # Edit mode — preserve the reference image's likeness
146
+ url = "https://api.openai.com/v1/images/edits"
147
+ mime = mimetypes.guess_type(reference.name)[0] or "image/png"
148
+ result = _multipart_post(
149
+ url,
150
+ fields={
151
+ "model": "gpt-image-1",
152
+ "prompt": prompt,
153
+ "size": "1024x1024",
154
+ "quality": quality,
155
+ "n": "1",
156
+ },
157
+ files={"image[]": (reference.name, reference.read_bytes(), mime)},
158
+ headers={"Authorization": f"Bearer {api_key}"},
159
+ )
160
+ else:
161
+ # Pure generation
162
+ url = "https://api.openai.com/v1/images/generations"
163
+ result = _http_post_json(
164
+ url,
165
+ {
166
+ "model": "gpt-image-1",
167
+ "prompt": prompt,
168
+ "size": "1024x1024",
169
+ "quality": quality,
170
+ "n": 1,
171
+ },
172
+ headers={"Authorization": f"Bearer {api_key}"},
173
+ )
174
+
175
+ item = result["data"][0]
176
+ if "b64_json" in item:
177
+ out.write_bytes(base64.b64decode(item["b64_json"]))
178
+ elif "url" in item:
179
+ out.write_bytes(_http_get_bytes(item["url"]))
180
+ else:
181
+ raise SystemExit(f"unexpected response shape: {result}")
182
+
183
+
184
+ def gen_pollinations(prompt: str, out: Path) -> None:
185
+ # Free, no API key. Lower quality but no setup.
186
+ url = (
187
+ "https://image.pollinations.ai/prompt/"
188
+ + urllib.parse.quote(prompt)
189
+ + "?width=1024&height=1024&nologo=true&model=flux"
190
+ )
191
+ out.write_bytes(_http_get_bytes(url, headers={"User-Agent": "mimoskill/0.1"}))
192
+
193
+
194
+ def gen_replicate(prompt: str, out: Path) -> None:
195
+ token = os.environ.get("REPLICATE_API_TOKEN")
196
+ if not token:
197
+ raise SystemExit("error: REPLICATE_API_TOKEN not set")
198
+
199
+ # FLUX-Schnell is fastest & cheapest on Replicate as of writing.
200
+ create = _http_post_json(
201
+ "https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions",
202
+ {"input": {"prompt": prompt, "aspect_ratio": "1:1", "output_format": "png"}},
203
+ headers={"Authorization": f"Bearer {token}", "Prefer": "wait"},
204
+ )
205
+ output = create.get("output")
206
+ # Replicate returns either a URL or list of URLs; with Prefer:wait, the
207
+ # response should already include the final result.
208
+ if isinstance(output, list):
209
+ url = output[0]
210
+ elif isinstance(output, str):
211
+ url = output
212
+ else:
213
+ # Fall back to polling
214
+ get_url = create["urls"]["get"]
215
+ for _ in range(60):
216
+ time.sleep(1)
217
+ poll = json.loads(
218
+ _http_get_bytes(get_url, headers={"Authorization": f"Bearer {token}"})
219
+ )
220
+ if poll["status"] == "succeeded":
221
+ url = poll["output"][0] if isinstance(poll["output"], list) else poll["output"]
222
+ break
223
+ if poll["status"] in {"failed", "canceled"}:
224
+ raise SystemExit(f"replicate prediction {poll['status']}: {poll.get('error')}")
225
+ else:
226
+ raise SystemExit("replicate prediction timed out")
227
+ out.write_bytes(_http_get_bytes(url))
228
+
229
+
230
+ def gen_local_sd(prompt: str, out: Path, host: str = "http://127.0.0.1:7860") -> None:
231
+ # Targets Automatic1111's /sdapi/v1/txt2img. ComfyUI users should swap
232
+ # this out for /prompt and adapt accordingly.
233
+ result = _http_post_json(
234
+ host.rstrip("/") + "/sdapi/v1/txt2img",
235
+ {
236
+ "prompt": prompt,
237
+ "steps": 25,
238
+ "width": 1024,
239
+ "height": 1024,
240
+ "cfg_scale": 7,
241
+ "sampler_name": "Euler",
242
+ },
243
+ headers={},
244
+ )
245
+ images = result.get("images") or []
246
+ if not images:
247
+ raise SystemExit(f"local-sd returned no image: {result}")
248
+ out.write_bytes(base64.b64decode(images[0]))
249
+
250
+
251
+ PROVIDERS = {
252
+ "gpt-image-1": gen_gpt_image_1,
253
+ "pollinations": gen_pollinations,
254
+ "replicate": gen_replicate,
255
+ "local-sd": gen_local_sd,
256
+ }
257
+
258
+
259
+ def resolve_auto_provider() -> str:
260
+ """Pick the best provider that's actually usable in the current env."""
261
+ if os.environ.get("PET_OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY"):
262
+ return "gpt-image-1"
263
+ if os.environ.get("REPLICATE_API_TOKEN"):
264
+ return "replicate"
265
+ # Pollinations needs nothing — guaranteed fallback.
266
+ return "pollinations"
267
+
268
+ # --- cli --------------------------------------------------------------------
269
+
270
+ def generate_one(
271
+ provider: str, prompt: str, reference: Path | None, quality: str, out: Path
272
+ ) -> None:
273
+ out.parent.mkdir(parents=True, exist_ok=True)
274
+ fn = PROVIDERS[provider]
275
+ if provider == "gpt-image-1":
276
+ fn(prompt, reference, quality, out)
277
+ else:
278
+ # Other providers don't support reference / edit mode here; the prompt
279
+ # is the only signal. Reference is ignored with a notice.
280
+ if reference is not None:
281
+ sys.stderr.write(
282
+ f"note: provider '{provider}' doesn't support --reference; ignoring.\n"
283
+ " Only gpt-image-1 supports image-to-image edit in this script.\n"
284
+ )
285
+ fn(prompt, out)
286
+
287
+
288
+ def main() -> None:
289
+ p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
290
+ p.add_argument("--description", required=True, help="what the pet looks/acts like")
291
+ p.add_argument("--reference", type=Path, help="reference image (gpt-image-1 only)")
292
+ p.add_argument(
293
+ "--provider",
294
+ choices=["auto"] + list(PROVIDERS),
295
+ default="auto",
296
+ help="image gen backend. 'auto' picks gpt-image-1 if you have an OpenAI "
297
+ "key, else falls back to pollinations (free, no key). With only a MiMo "
298
+ "key, 'auto' will use pollinations.",
299
+ )
300
+ p.add_argument("--quality", default="medium", choices=["low", "medium", "high", "hd"])
301
+ p.add_argument("--out", type=Path, help="single-image output path (PNG)")
302
+ p.add_argument(
303
+ "--bundle",
304
+ type=Path,
305
+ help="generate idle/working/done into a directory and write a manifest.json stub",
306
+ )
307
+ args = p.parse_args()
308
+
309
+ if not args.out and not args.bundle:
310
+ sys.stderr.write("error: pass either --out FILE or --bundle DIR\n")
311
+ sys.exit(2)
312
+ if args.out and args.bundle:
313
+ sys.stderr.write("error: --out and --bundle are mutually exclusive\n")
314
+ sys.exit(2)
315
+
316
+ # Resolve --provider auto → concrete provider, with a status line so the
317
+ # user knows what they're getting.
318
+ if args.provider == "auto":
319
+ chosen = resolve_auto_provider()
320
+ if chosen == "pollinations":
321
+ sys.stderr.write(
322
+ "[provider] auto → pollinations (free, no key required).\n"
323
+ " For higher quality, set PET_OPENAI_API_KEY (real OpenAI key)\n"
324
+ " and rerun, or pass --provider replicate / local-sd.\n\n"
325
+ )
326
+ else:
327
+ sys.stderr.write(f"[provider] auto → {chosen}\n\n")
328
+ args.provider = chosen
329
+
330
+ if args.bundle:
331
+ states = {
332
+ "idle": "calm pose, hands together, soft smile",
333
+ "working": "typing on a tiny laptop, focused expression, sparkles around hands",
334
+ "done": "celebrating with arms raised, sparkles and confetti",
335
+ }
336
+ args.bundle.mkdir(parents=True, exist_ok=True)
337
+ for state, action in states.items():
338
+ out = args.bundle / f"{state}.png"
339
+ prompt = build_prompt(args.description, action)
340
+ sys.stderr.write(f"[{state}] generating → {out}\n")
341
+ generate_one(args.provider, prompt, args.reference, args.quality, out)
342
+ # Stub manifest — install_pet.sh will overwrite with the user's name
343
+ manifest = {
344
+ "version": 1,
345
+ "name": "custom-pet",
346
+ "states": {state: f"{state}.png" for state in states},
347
+ }
348
+ (args.bundle / "manifest.json").write_text(json.dumps(manifest, indent=2))
349
+ sys.stderr.write(f"\n✓ bundle written to {args.bundle}\n")
350
+ sys.stderr.write(
351
+ f" next: bash install_pet.sh --bundle {args.bundle} <pet-name>\n"
352
+ )
353
+ else:
354
+ prompt = build_prompt(args.description)
355
+ sys.stderr.write(f"generating → {args.out}\n")
356
+ sys.stderr.write(f"prompt: {prompt}\n")
357
+ generate_one(args.provider, prompt, args.reference, args.quality, args.out)
358
+ sys.stderr.write(f"\n✓ wrote {args.out}\n")
359
+ sys.stderr.write(
360
+ f" next: bash install_pet.sh {args.out} <pet-name>\n"
361
+ )
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ # install_pet.sh — drop a generated pet into Codex's pet directory.
3
+ #
4
+ # Usage:
5
+ # bash install_pet.sh <pet.png> <pet-name>
6
+ # bash install_pet.sh --bundle <dir>/ <pet-name>
7
+ #
8
+ # The Codex pet folder location depends on OS and Codex version. This script
9
+ # probes the well-known candidates and uses the first writable directory it
10
+ # finds, falling back to ~/.codex/pets/.
11
+ #
12
+ # After install, FULLY QUIT and relaunch Codex (system tray → Quit, not just
13
+ # close window). The new pet should appear in the picker.
14
+ #
15
+ set -euo pipefail
16
+
17
+ # ── colors ──────────────────────────────────────────────────────────────────
18
+ if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then
19
+ C_GRN='\033[0;32m'; C_YEL='\033[0;33m'; C_RED='\033[0;31m'
20
+ C_CYN='\033[0;36m'; C_BLD='\033[1m'; C_RST='\033[0m'
21
+ else
22
+ C_GRN=''; C_YEL=''; C_RED=''; C_CYN=''; C_BLD=''; C_RST=''
23
+ fi
24
+ step() { printf "${C_CYN}${C_BLD}==>${C_RST} %s\n" "$1"; }
25
+ ok() { printf "${C_GRN} ✓${C_RST} %s\n" "$1"; }
26
+ warn() { printf "${C_YEL} !${C_RST} %s\n" "$1"; }
27
+ err() { printf "${C_RED} ✗${C_RST} %s\n" "$1" >&2; }
28
+
29
+ # Find a working Python interpreter. On Windows, `python3` may be the
30
+ # Microsoft Store launcher stub which does nothing useful — verify each
31
+ # candidate actually runs.
32
+ detect_python() {
33
+ for c in python3 python py; do
34
+ if command -v "$c" >/dev/null 2>&1; then
35
+ if "$c" -c "import sys, json; sys.exit(0)" >/dev/null 2>&1; then
36
+ echo "$c"
37
+ return 0
38
+ fi
39
+ fi
40
+ done
41
+ return 1
42
+ }
43
+ PY=$(detect_python || true)
44
+ if [[ -z "$PY" ]]; then
45
+ err "no working Python interpreter found (tried python3, python, py)"
46
+ err "manifest generation needs Python — install Python 3 from https://python.org"
47
+ exit 1
48
+ fi
49
+
50
+ # ── args ────────────────────────────────────────────────────────────────────
51
+ BUNDLE_MODE=false
52
+ SOURCE=""
53
+ NAME=""
54
+ while [[ $# -gt 0 ]]; do
55
+ case "$1" in
56
+ --bundle)
57
+ BUNDLE_MODE=true
58
+ SOURCE="$2"
59
+ shift 2
60
+ ;;
61
+ -h|--help)
62
+ sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
63
+ exit 0
64
+ ;;
65
+ *)
66
+ if [[ -z "$SOURCE" ]]; then SOURCE="$1"
67
+ elif [[ -z "$NAME" ]]; then NAME="$1"
68
+ else err "unexpected arg: $1"; exit 2
69
+ fi
70
+ shift
71
+ ;;
72
+ esac
73
+ done
74
+
75
+ if [[ -z "$SOURCE" ]] || [[ -z "$NAME" ]]; then
76
+ err "usage: install_pet.sh <pet.png|--bundle DIR> <pet-name>"
77
+ exit 2
78
+ fi
79
+
80
+ if ! [[ -e "$SOURCE" ]]; then
81
+ err "source does not exist: $SOURCE"
82
+ exit 1
83
+ fi
84
+
85
+ # Sanitize pet name (lowercase, alnum + dash)
86
+ SAFE_NAME=$(printf '%s' "$NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')
87
+ if [[ -z "$SAFE_NAME" ]]; then
88
+ err "pet name '$NAME' yields empty slug after sanitization"
89
+ exit 1
90
+ fi
91
+
92
+ # ── locate Codex pet folder ─────────────────────────────────────────────────
93
+ step "Locating Codex pet folder"
94
+
95
+ CANDIDATES=()
96
+ case "$(uname -s)" in
97
+ Darwin)
98
+ CANDIDATES+=(
99
+ "$HOME/Library/Application Support/Codex/pets"
100
+ "$HOME/Documents/Codex/pets"
101
+ "$HOME/.codex/pets"
102
+ )
103
+ ;;
104
+ Linux)
105
+ CANDIDATES+=(
106
+ "$HOME/.config/Codex/pets"
107
+ "$HOME/.local/share/Codex/pets"
108
+ "$HOME/.codex/pets"
109
+ )
110
+ ;;
111
+ MINGW*|MSYS*|CYGWIN*)
112
+ # Windows under Git Bash
113
+ CANDIDATES+=(
114
+ "${APPDATA:-$HOME/AppData/Roaming}/Codex/pets"
115
+ "$HOME/.codex/pets"
116
+ )
117
+ ;;
118
+ *)
119
+ CANDIDATES+=("$HOME/.codex/pets")
120
+ ;;
121
+ esac
122
+
123
+ PET_DIR=""
124
+ for c in "${CANDIDATES[@]}"; do
125
+ parent=$(dirname "$c")
126
+ if [[ -d "$parent" ]]; then
127
+ PET_DIR="$c"
128
+ ok "found Codex parent at $parent — using $PET_DIR"
129
+ break
130
+ fi
131
+ done
132
+
133
+ if [[ -z "$PET_DIR" ]]; then
134
+ PET_DIR="$HOME/.codex/pets"
135
+ warn "no existing Codex directory found; defaulting to $PET_DIR"
136
+ warn "if Codex doesn't pick this up, copy the bundle to its actual pets/ folder manually"
137
+ fi
138
+
139
+ mkdir -p "$PET_DIR"
140
+
141
+ # ── install ─────────────────────────────────────────────────────────────────
142
+ TARGET="$PET_DIR/$SAFE_NAME"
143
+ if [[ -e "$TARGET" ]]; then
144
+ warn "$TARGET already exists; backing up to $TARGET.bak.$(date +%s)"
145
+ mv "$TARGET" "$TARGET.bak.$(date +%s)"
146
+ fi
147
+ mkdir -p "$TARGET"
148
+
149
+ if [[ "$BUNDLE_MODE" == true ]]; then
150
+ step "Installing bundle from $SOURCE"
151
+ if ! [[ -d "$SOURCE" ]]; then
152
+ err "--bundle expects a directory, got: $SOURCE"
153
+ exit 1
154
+ fi
155
+ # Copy bundle contents
156
+ cp -R "$SOURCE"/. "$TARGET"/
157
+ # Rewrite manifest.json with the chosen name (preserving existing states map)
158
+ if [[ -f "$TARGET/manifest.json" ]]; then
159
+ "$PY" - "$TARGET/manifest.json" "$SAFE_NAME" <<'PY'
160
+ import json, sys
161
+ path, name = sys.argv[1], sys.argv[2]
162
+ m = json.load(open(path))
163
+ m["name"] = name
164
+ m.setdefault("version", 1)
165
+ json.dump(m, open(path, "w"), indent=2)
166
+ PY
167
+ else
168
+ # Generate a manifest from PNGs found in the directory
169
+ "$PY" - "$TARGET" "$SAFE_NAME" <<'PY'
170
+ import json, os, sys
171
+ target, name = sys.argv[1], sys.argv[2]
172
+ states = {}
173
+ for fname in ("idle", "working", "done", "error"):
174
+ p = f"{fname}.png"
175
+ if os.path.exists(os.path.join(target, p)):
176
+ states[fname] = p
177
+ if "idle" not in states and states:
178
+ states["idle"] = next(iter(states.values()))
179
+ manifest = {"version": 1, "name": name, "states": states}
180
+ json.dump(manifest, open(os.path.join(target, "manifest.json"), "w"), indent=2)
181
+ PY
182
+ fi
183
+ ok "bundle installed at $TARGET"
184
+ else
185
+ step "Installing single image from $SOURCE"
186
+ cp "$SOURCE" "$TARGET/idle.png"
187
+ # Reuse the same image for all states so Codex always has something to draw
188
+ cp "$SOURCE" "$TARGET/working.png"
189
+ cp "$SOURCE" "$TARGET/done.png"
190
+ cat > "$TARGET/manifest.json" <<EOF
191
+ {
192
+ "version": 1,
193
+ "name": "$SAFE_NAME",
194
+ "states": {
195
+ "idle": "idle.png",
196
+ "working": "working.png",
197
+ "done": "done.png"
198
+ }
199
+ }
200
+ EOF
201
+ ok "installed → $TARGET/{idle,working,done}.png"
202
+ fi
203
+
204
+ # ── final instructions ─────────────────────────────────────────────────────
205
+ cat <<EOF
206
+
207
+ ${C_GRN}${C_BLD}✓ Pet installed:${C_RST} ${C_BLD}$TARGET${C_RST}
208
+
209
+ ${C_BLD}Next steps:${C_RST}
210
+ 1. ${C_CYN}Fully quit Codex${C_RST} (system tray / menu bar → Quit, not just close window)
211
+ 2. Relaunch Codex
212
+ 3. Open the pet picker (e.g. /pet command, or settings → Pets)
213
+ 4. Select "${C_BLD}$SAFE_NAME${C_RST}"
214
+
215
+ If the new pet doesn't appear:
216
+ - Confirm Codex's actual pets folder (check Codex's docs / app settings)
217
+ - Move the directory at $TARGET to that folder manually
218
+ - Make sure the manifest.json schema matches what your Codex version expects
219
+
220
+ EOF