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.
- package/AGENTS.md +124 -0
- package/LICENSE +21 -0
- package/README.md +536 -0
- package/README.zh.md +750 -0
- package/dist/cli.js +202 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +86 -0
- package/dist/config.js.map +1 -0
- package/dist/server.js +161 -0
- package/dist/server.js.map +1 -0
- package/dist/translate/reqToChat.js +381 -0
- package/dist/translate/reqToChat.js.map +1 -0
- package/dist/translate/respToResponses.js +94 -0
- package/dist/translate/respToResponses.js.map +1 -0
- package/dist/translate/streamToSse.js +352 -0
- package/dist/translate/streamToSse.js.map +1 -0
- package/dist/translate/types.js +4 -0
- package/dist/translate/types.js.map +1 -0
- package/dist/upstream/chatStream.js +56 -0
- package/dist/upstream/chatStream.js.map +1 -0
- package/dist/upstream/mimoClient.js +99 -0
- package/dist/upstream/mimoClient.js.map +1 -0
- package/dist/util/ids.js +19 -0
- package/dist/util/ids.js.map +1 -0
- package/dist/util/log.js +32 -0
- package/dist/util/log.js.map +1 -0
- package/dist/util/sse.js +61 -0
- package/dist/util/sse.js.map +1 -0
- package/mimoskill/SKILL.md +145 -0
- package/mimoskill/assets/pet_prompt_template.md +94 -0
- package/mimoskill/references/models.md +111 -0
- package/mimoskill/references/pet_workflow.md +197 -0
- package/mimoskill/scripts/generate_pet.py +365 -0
- package/mimoskill/scripts/install_pet.sh +220 -0
- package/mimoskill/scripts/mimo_chat.py +199 -0
- package/package.json +69 -0
|
@@ -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
|