mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(web): add pet companion with Codex hatch-pet integration Introduces a customizable floating pet companion (overlay + entry-view rail + composer menu + dedicated Settings → Pets section) that supports built-in pets, user customization (glyph/image/spritesheet), and one-click adoption of pets packaged by the upstream Codex `hatch-pet` skill via a new `/api/codex-pets` daemon endpoint. Vendors the unmodified `hatch-pet` skill under `skills/hatch-pet/` and adds i18n strings across all locales. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(scripts): sync community Codex pets from public catalogs Adds `pnpm sync:community-pets` which fetches all pets from codex-pet-share.pages.dev (paginated Supabase Functions API) and j20.nz/hatchery (single-shot JSON), then writes each one as `<id>/pet.json` + `<id>/spritesheet.webp` under `\${CODEX_HOME:-\$HOME/.codex}/pets/`. The existing daemon `codex-pets` registry already scans that folder, so synced pets appear under Settings → Pets → Recently hatched and adopt with one click — no manual upload. Supports --source/--out/--force/--limit flags and validates magic bytes so HTML error pages never end up masquerading as `.webp` files. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(daemon): tighten codex-pets validation and document vendoring - sanitizeId now rejects ids that still contain `..` after collapsing, closing a defensive gap on the path-traversal guard for the `/api/codex-pets/:id/spritesheet` route. - listCodexPets emits the sanitised folder name as the public id so the download route resolves directly against the on-disk folder, even when `manifest.id` differs (manual drops, sanitiser-touched manifests). - Drop `@ts-nocheck` from `codex-pets.ts`; module is now strict-typed with explicit interfaces, an unknown-narrowed JSON.parse path, and a `pickString` helper guarding manifest fields one by one. - Restrict the spritesheet response CORS header to sandboxed-iframe callers (Origin: null) instead of unconditional `*`, matching the existing raw-file route pattern. Same-origin web traffic does not need the header (web proxies `/api/*` through the daemon). - Add `skills/hatch-pet/README.md` explaining the vendoring trade-off, provenance, and re-sync procedure. - Add `docs/codex-pets.md` covering where pets live, how to populate the registry without Codex installed, and the manifest contract. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(i18n): add pet.* keys to Hungarian locale Hungarian locale was added on main after this branch diverged, so the new pet.* dictionary keys never landed there and tsc -b reports hu's Dict as incomplete once main is merged in. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * feat(web): atlas-driven pet animations + bundled community pets Builds on the existing pet companion (#296) with a richer animation loop, a curated set of community pets that ship with the repo, and a one-click sync into ~/.codex/pets/. - Atlas-mode rendering: PetSpriteFace can now play the full Codex 8x9 sprite atlas and swap rows from a JS-driven frame index. PetOverlay classifies pointer interactions (idle / hover / drag-direction / long-idle waiting) and maps them to the matching atlas row, so the pet waves on hover, runs on drag, and falls into a waiting pose after 6s of stillness. Single-strip pets keep their existing CSS steps() animation, with the steps timing fixed to jump-none so frame cells line up on cell boundaries. - Atlas adoption: PetSettings exposes both "Use full atlas (animated)" and "Freeze to this row" — full mode keeps every row for the interaction state machine, single-row mode crops one strip via the existing canvas helper. New prepareCodexAtlas downscales the atlas to a localStorage-friendly PNG while preserving the grid layout. - Settings tabs: pet sources are now split into Built-in / Custom / Community tabs so each origin gets its own dedicated surface. - Bundled pets: scripts/bake-community-pets.ts seeds a curated set (clippit, dario, nyako-shigure, slavik, trump, tux, yelling-dario, yorha-sit-2b) into assets/community-pets/. The daemon scans this alongside the user's ~/.codex/pets/ root, with user pets winning when ids collide. CodexPetSummary gains a `bundled` flag so the UI can tag those cards with a "Bundled" pill. - One-click community sync: daemon-side port of sync-community-pets exposed via POST /api/codex-pets/sync. Returns the same wrote/skipped/failed/total summary the CLI prints. Web Pet settings surface this as a "Download community pets" button under the Community tab. - Avatar dropdown + hide rail: EntryView's avatar button is now a small menu (mirrors the project-view AvatarMenu) with toggles for hiding/showing the pet rail and opening Settings. PetRail gets a matching × button for the same hide flow. - Locales: 7 new pet.* keys for tabs, sync, hide/show, atlas full mode, and the Bundled pill — translated into all 13 supported locales. Typechecks pass across all workspace packages; daemon + web vitest suites stay green. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): bundled-pets built-in tab, ambient atlas animations, and community sync button The Built-in tab now sources its catalog from the bundled spritesheets at `assets/community-pets/` instead of the eight emoji placeholders that felt boring next to the Codex hatch-pet atlases. - Daemon: `listCodexPets` flags `bundled: true` by curated-set membership in `assets/community-pets/`, not by which folder the sprite happened to be read from. Previously a fully-synced user inbox preempted every bundled id and left the tab empty. - Settings → Pets → Built-in renders the same sprite-card grid as Community, filtered by `bundled: true`, and reuses the existing `adoptCodexPet` flow. Community tab filters to non-bundled so the curated set never appears twice. - Community tab gains the long-promised "Download community pets" trigger that calls `/api/codex-pets/sync` and shows an inline status line for the run summary. Strings already existed in every locale; we just plumbed the button. - `PetOverlay` gets ambient atlas-row choreography — while idle, the overlay occasionally swaps `idle` for a random non-idle row (wave / hop / look) so the pet doesn't feel frozen. User gestures cancel the beat and take over instantly. `pickAmbientRow` lives next to `pickAtlasRow` so both row pickers share the fallback discipline. - One-shot `migrateCustomPetAtlas` heals configs adopted before the overlay learned row switching by re-downloading the full spritesheet so hover / drag / ambient variety light up on next launch. - `BUILT_IN_PETS` is now an empty array (the type stays for backwards compat); legacy configs whose `petId` still points at an emoji id (`mochi`, `pixel`, …) fall back to the user's custom slot in `resolveActivePet` so the overlay never renders blank. - i18n: refresh `pet.tabBuiltInHint` (drop "emoji companions") and add `pet.builtInEmpty` across all locales. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
// Codex hatch-pet registry. Lists pets that the upstream `hatch-pet`
|
|
// skill packages under `${CODEX_HOME:-$HOME/.codex}/pets/<id>/` and the
|
|
// curated set bundled with this repo under `assets/community-pets/<id>/`.
|
|
//
|
|
// On-disk shape (per the hatch-pet `references/codex-pet-contract.md`):
|
|
//
|
|
// <root>/<id>/
|
|
// pet.json # { id, displayName, description, spritesheetPath }
|
|
// spritesheet.webp # 1536x1872 8x9 atlas (or .png / .gif fallback)
|
|
//
|
|
// We scan both folders lazily on every list request — there are only a
|
|
// handful of pets in either location, and watching the filesystem would
|
|
// add a daemon-side dependency that doesn't pay off here. When the same
|
|
// pet id exists in both, the user's local copy wins so re-baking a
|
|
// bundled pet locally is a supported workflow.
|
|
|
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
import type { Dirent } from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
|
|
// Pre-scanned set of ids that live under the bundled `assets/community-pets/`
|
|
// root. We resolve the `bundled` flag against this set rather than against
|
|
// "which folder did we end up reading from", so a pet that exists in BOTH
|
|
// the bundled root and the user's `~/.codex/pets/` still surfaces as
|
|
// bundled (the sprite content can still come from the user's local copy
|
|
// — only the flag is determined by the curated set membership).
|
|
type BundledIdSet = Set<string>;
|
|
|
|
async function readBundledIds(root: string): Promise<BundledIdSet> {
|
|
const ids: BundledIdSet = new Set();
|
|
let entries: Dirent[] = [];
|
|
try {
|
|
entries = await readdir(root, { withFileTypes: true, encoding: 'utf8' });
|
|
} catch {
|
|
return ids;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const safeFolderId = sanitizeId(entry.name);
|
|
if (!safeFolderId) continue;
|
|
ids.add(safeFolderId);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
export interface CodexPetSummaryRecord {
|
|
id: string;
|
|
displayName: string;
|
|
description: string;
|
|
spritesheetUrl: string;
|
|
spritesheetExt: string;
|
|
hatchedAt: number;
|
|
// True when the pet was found in the bundled `assets/community-pets/`
|
|
// folder rather than the user's `~/.codex/pets/`. Surfaced so the UI
|
|
// can render a "Bundled" pill and skip prompting the user to sync
|
|
// pets that already ship with the app.
|
|
bundled?: boolean;
|
|
}
|
|
|
|
export interface CodexPetListResult {
|
|
pets: CodexPetSummaryRecord[];
|
|
rootDir: string;
|
|
}
|
|
|
|
interface PetManifest {
|
|
id?: unknown;
|
|
displayName?: unknown;
|
|
description?: unknown;
|
|
spritesheetPath?: unknown;
|
|
}
|
|
|
|
interface SpritesheetPick {
|
|
absPath: string;
|
|
ext: string;
|
|
}
|
|
|
|
export function resolveCodexPetsRoot(): string {
|
|
const home = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
|
|
return path.join(home, 'pets');
|
|
}
|
|
|
|
const SPRITESHEET_NAMES = [
|
|
'spritesheet.webp',
|
|
'spritesheet.png',
|
|
'spritesheet.gif',
|
|
] as const;
|
|
|
|
// Scan a single root and append summaries to `out`. Pets already in
|
|
// `seenIds` are skipped — the user-root scan can therefore preempt a
|
|
// bundled pet of the same id without the bundled scan re-emitting a
|
|
// duplicate entry with a conflicting `bundled` flag.
|
|
//
|
|
// `bundledIds` lets us tag a pet as part of the curated set even when
|
|
// the sprite content was read from the user's local `~/.codex/pets/`
|
|
// copy. Without this, a user who synced every community pet via
|
|
// `pnpm sync:community-pets` would always preempt the bundled scan
|
|
// and the "Built-in" tab would render empty.
|
|
async function scanRoot(
|
|
root: string,
|
|
baseUrl: string,
|
|
bundledFallback: boolean,
|
|
bundledIds: BundledIdSet,
|
|
out: CodexPetSummaryRecord[],
|
|
seenIds: Set<string>,
|
|
): Promise<void> {
|
|
let entries: Dirent[] = [];
|
|
try {
|
|
entries = await readdir(root, { withFileTypes: true, encoding: 'utf8' });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
// The folder name is the on-disk identity for the pet — the
|
|
// `/api/codex-pets/:id/spritesheet` route resolves directly against
|
|
// it, so we use the sanitised folder name as the public id even
|
|
// when the manifest declares a different `id`. Mirroring the two
|
|
// would let a manifest typo (or a pet whose sanitised id differs
|
|
// from the folder name) silently 404 the download route.
|
|
const safeFolderId = sanitizeId(entry.name);
|
|
if (!safeFolderId) continue;
|
|
if (seenIds.has(safeFolderId)) continue;
|
|
const dir = path.join(root, entry.name);
|
|
const manifestPath = path.join(dir, 'pet.json');
|
|
let manifest: PetManifest = {};
|
|
try {
|
|
const raw = await readFile(manifestPath, 'utf8');
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
manifest = parsed as PetManifest;
|
|
}
|
|
} catch {
|
|
// Manifest is optional — fall back to folder name for the
|
|
// display name so manually-dropped pets still appear.
|
|
}
|
|
const sheet = await pickSpritesheet(dir, manifest);
|
|
if (!sheet) continue;
|
|
let mtimeMs = 0;
|
|
try {
|
|
const st = await stat(sheet.absPath);
|
|
mtimeMs = st.mtimeMs;
|
|
} catch {
|
|
// ignore — listing should not fail on a transient stat error.
|
|
}
|
|
seenIds.add(safeFolderId);
|
|
const displayName = pickString(manifest.displayName) ?? prettyName(entry.name);
|
|
const description = pickString(manifest.description) ?? '';
|
|
const spritesheetUrl = `${baseUrl}/api/codex-pets/${encodeURIComponent(safeFolderId)}/spritesheet`;
|
|
// Curated-set membership wins over the source-folder default — a
|
|
// pet read from the user's `~/.codex/pets/` is still bundled if its
|
|
// id is part of `assets/community-pets/`.
|
|
const bundled = bundledIds.has(safeFolderId) ? true : bundledFallback;
|
|
out.push({
|
|
id: safeFolderId,
|
|
displayName,
|
|
description,
|
|
spritesheetUrl,
|
|
spritesheetExt: sheet.ext,
|
|
hatchedAt: Math.floor(mtimeMs),
|
|
bundled,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function listCodexPets(
|
|
options: { baseUrl?: string; bundledRoot?: string } = {},
|
|
): Promise<CodexPetListResult> {
|
|
const baseUrl = options.baseUrl ?? '';
|
|
const userRoot = resolveCodexPetsRoot();
|
|
const out: CodexPetSummaryRecord[] = [];
|
|
const seen = new Set<string>();
|
|
// Resolve the curated set membership up front so the user-root scan
|
|
// can stamp `bundled: true` on any local re-bake, and so the
|
|
// bundled-root scan only adds the curated pets the user has not
|
|
// already shadowed.
|
|
const bundledIds = options.bundledRoot
|
|
? await readBundledIds(options.bundledRoot)
|
|
: new Set<string>();
|
|
// User pets first so a locally re-baked copy preempts the bundled
|
|
// one (same id ⇒ user wins for sprite content).
|
|
await scanRoot(userRoot, baseUrl, false, bundledIds, out, seen);
|
|
if (options.bundledRoot) {
|
|
await scanRoot(options.bundledRoot, baseUrl, true, bundledIds, out, seen);
|
|
}
|
|
// Newest-first across both origins. Sorting by mtime keeps the
|
|
// "recently hatched" framing in the UI honest — a bundled pet from
|
|
// 2024 still sinks below a fresh user-hatched pet from this morning.
|
|
out.sort((a, b) => b.hatchedAt - a.hatchedAt);
|
|
return { pets: out, rootDir: userRoot };
|
|
}
|
|
|
|
// Returns { absPath, ext } for the resolved spritesheet of a given pet
|
|
// id, or null if the pet folder / sheet is missing. Used by the
|
|
// `/api/codex-pets/:id/spritesheet` route to safely serve the file —
|
|
// the id is sanitised on both sides so users cannot path-escape into
|
|
// arbitrary folders under their home directory or the bundled assets.
|
|
export async function readCodexPetSpritesheet(
|
|
id: string,
|
|
options: { bundledRoot?: string } = {},
|
|
): Promise<SpritesheetPick | null> {
|
|
const safeId = sanitizeId(id);
|
|
if (!safeId) return null;
|
|
const roots: string[] = [resolveCodexPetsRoot()];
|
|
if (options.bundledRoot) roots.push(options.bundledRoot);
|
|
for (const root of roots) {
|
|
const dir = path.join(root, safeId);
|
|
// Re-resolve the manifest so a manifest-declared spritesheetPath wins
|
|
// when it differs from our default name (matches the hatch-pet
|
|
// contract).
|
|
let manifest: PetManifest = {};
|
|
try {
|
|
const raw = await readFile(path.join(dir, 'pet.json'), 'utf8');
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
manifest = parsed as PetManifest;
|
|
}
|
|
} catch {
|
|
// ignore; pickSpritesheet falls back to the canonical names.
|
|
}
|
|
const sheet = await pickSpritesheet(dir, manifest);
|
|
if (sheet) return sheet;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function pickSpritesheet(dir: string, manifest: PetManifest): Promise<SpritesheetPick | null> {
|
|
const candidates: string[] = [];
|
|
const declaredPath = pickString(manifest.spritesheetPath);
|
|
if (declaredPath) {
|
|
// Resolve manifest path relative to the pet folder, then ensure it
|
|
// does not escape that folder.
|
|
const abs = path.resolve(dir, declaredPath);
|
|
if (abs.startsWith(dir + path.sep) || abs === dir) {
|
|
candidates.push(abs);
|
|
}
|
|
}
|
|
for (const name of SPRITESHEET_NAMES) {
|
|
candidates.push(path.join(dir, name));
|
|
}
|
|
for (const abs of candidates) {
|
|
try {
|
|
const st = await stat(abs);
|
|
if (!st.isFile()) continue;
|
|
return { absPath: abs, ext: path.extname(abs).slice(1).toLowerCase() || 'png' };
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Strip anything that might let a request path-escape, then collapse
|
|
// runs of dots and reject any that still contain `..` after trimming —
|
|
// the daemon serves these ids straight into a `path.join`, and a value
|
|
// like `foo..bar` would otherwise be interpreted as `foo/../bar`.
|
|
// Mirrors the pet folder names produced by the upstream skill
|
|
// (lowercase + hyphens), but also accepts alphanumerics + a small set
|
|
// of safe punctuation to handle pets that users authored manually.
|
|
function sanitizeId(value: unknown): string {
|
|
const collapsed = String(value ?? '')
|
|
.replace(/[^a-zA-Z0-9._-]/g, '')
|
|
.replace(/\.+/g, '.')
|
|
.replace(/^[._-]+|[._-]+$/g, '')
|
|
.slice(0, 80);
|
|
if (collapsed.includes('..')) return '';
|
|
return collapsed;
|
|
}
|
|
|
|
function pickString(value: unknown): string | undefined {
|
|
if (typeof value !== 'string') return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function prettyName(folder: string): string {
|
|
return folder.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|