Skill: 632657122/avatar-image-generator (version 1.0.1)

--- name: avatar-image-generator description: Generate avatars, profile pictures, PFPs, social media headshots, gaming avatars, and portrait icons. Use when the user asks for an avatar, profile picture, pfp, portrait icon, or personal brand headshot. version: 0.5.0 metadata: { "pattern": ["generator", "pipeline"], "openclaw": { "emoji": "🙂", "primaryEnv": "IMAGE_GEN_API_KEY", "requires": { "env": ["IMAGE_GEN_API_KEY"], "anyBins": ["bun", "npx"], "bins": ["node", "npm"] } } } --- # Avatar Image Generator (`avatar-image-generator`) This skill is optimized for profile-picture style images that still read clearly at small sizes. This skill keeps the same single-gateway runtime, readiness gate, model-selection flow, and CLI behavior as `image-generation`, but narrows the briefing and prompt construction for **avatar image generator** work. ## Safety & Scope - **Network**: This skill calls the WeryAI gateway over HTTPS (`https://api.weryai.com`). - **Auth**: Uses `IMAGE_GEN_API_KEY`. The key is never printed. It may be persisted **only** when you explicitly run `npm run setup -- --persist-api-key`. - **Reference images**: Must be public URLs (`https://` recommended). `http://` may work but is insecure. Local file paths and `data:` URLs are rejected. - **No arbitrary shell**: The generation runtime does not execute arbitrary shell commands. - **Files written**: Output images and optional local config under `.image-skills/avatar-image-generator/` (project) and/or `~/.image-skills/avatar-image-generator/` (home). ## Use Cases - social profile pictures - creator or founder avatars - gaming profile icons - stylized self-portrait directions - team member profile images ## First Trigger Rules Before the first generation run in a new project or environment: 1. Run `npm run ensure-ready -- --project . --workflow <workflow>` 2. If runtime dependencies are missing, ask for approval and install them 3. If `IMAGE_GEN_API_KEY` is missing, offer to configure it now 4. If no model is configured yet, initialize **Nano Banana 2** (`GEMINI_3_1_FLASH_IMAGE`) as the default Do not ask the user to edit config files manually. Treat API keys as secrets and never echo them back. ## Clarify These Decisions Ask **one question at a time**. Prioritize: 1. who or what the avatar represents 2. realistic, illustrated, anime, or stylized look 3. mood or personality: friendly, confident, mysterious, playful, etc. 4. background preference: plain color, gradient, glow, or environmental hint 5. crop preference: face only, bust portrait, or shoulder-up ## Recommended Defaults - aspect ratio: `1:1` - recommended style: `photoreal`, `editorial`, or `anime` depending on request - composition: centered face, strong eye contact, clean silhouette - background: simple and contrast-friendly for small-size display ## Prompt Blueprint Build the prompt in this order: 1. Identity: person, character, brand mascot, or abstract persona. 2. Look: realistic, illustrated, anime, cartoon, cinematic, etc. 3. Crop: face only, shoulder-up, or bust portrait. 4. Expression + lighting: approachable, intense, premium, dramatic, soft, etc. 5. Background: simple color, gradient, soft blur, glow halo, or minimal context. Use one clean prompt direction at a time instead of mixing many competing ideas. ## Prompt Rules - Optimize for instant recognition at thumbnail size. - Prefer one dominant face or subject; avoid crowded scenes. - If the user wants a personal-brand avatar, bias toward clean lighting and controlled background. - If the avatar is fictional, lock hairstyle, outfit cue, and color identity clearly in the prompt. ## Avoid - busy environments behind the face - multiple people in one avatar unless explicitly requested - low-contrast face/background combinations - tiny accessories that disappear at small size ## Workflow 1. Run the readiness gate and resolve `IMAGE_GEN_API_KEY` 2. Clarify the scenario-specific decisions above 3. Build a single strong prompt from the blueprint 4. Choose a recommended style only if it helps the request 5. Generate the image 6. If the user wants variations, change one major variable at a time and re-generate ## Script `{baseDir}` is the directory containing this file. `${BUN_X}` is either `bun` or `npx -y bun`. | Path | Purpose | | --- | --- | | `{baseDir}/scripts/main.ts` | the only execution entrypoint | ## Usage Examples ```bash # examples only; M should be chosen by the user or resolved by the agent M=<chosen model key> ${BUN_X} {baseDir}/scripts/main.ts --prompt "clean creator avatar for a tech founder, shoulder-up portrait, confident expression, soft studio lighting, dark blue gradient background, highly recognizable at small size" --style editorial --image avatar.png --ar 1:1 -m "$M" ${BUN_X} {baseDir}/scripts/main.ts --prompt "anime gaming avatar, silver hair, red eyes, black cyber jacket, neon rim light, centered face, dark purple glow background, strong icon readability" --style anime --image avatar-anime.png --ar 1:1 -m "$M" ``` ## Delivery Rules - Tell the user what you are generating and which model is being used before you start - Show the image directly when it is ready; do not reply with only a filename - If the user asks for revisions, only change the necessary direction instead of restarting everything - If the request is underspecified, use the clarification order above before writing the final prompt ## References - [references/config/first-time-setup.md](references/config/first-time-setup.md) - [references/config/preferences-schema.md](references/config/preferences-schema.md) - [references/config/model-registry-schema.md](references/config/model-registry-schema.md) - [references/style-presets.md](references/style-presets.md) - [references/weryai-platform.md](references/weryai-platform.md)

path: AGENTS.md
content: # avatar-image-generator

Generate avatars, profile pictures, PFPs, social media headshots, gaming avatars, and portrait icons. Use when the user asks for an avatar, profile picture, pfp, portrait icon, or personal brand headshot.

Default model: `Nano Banana 2` (`GEMINI_3_1_FLASH_IMAGE`). Tell the user this when the skill is first connected, and remind them it can be switched later if needed.

Default aspect ratio: `1:1`.

Prompt priorities:
- Clarify these decisions one at a time: who or what the avatar represents, realistic, illustrated, anime, or stylized look, mood or personality: friendly, confident, mysterious, playful, etc., background preference: plain color, gradient, glow, or environmental hint, crop preference: face only, bust portrait, or shoulder-up.
- Bias toward: Optimize for instant recognition at thumbnail size.
- Avoid: busy environments behind the face

Delivery rules:
- When an image or image set is ready, send/display the actual image output to the user immediately.
- Never stop at a filename or local file path alone. If the environment supports file sending, send the file. If it supports inline rendering, render inline. Otherwise provide a usable download URL.

For full instructions, workflow, and commands, see [SKILL.md](SKILL.md).


path: eval.yaml
content: version: "1"
defaults:
  trials: 3
  timeout: 300
  threshold: 0.8

trigger_tests:
  - id: explicit-avatar-image-generator
    should_trigger: true
    prompt: "Use the avatar-image-generator skill to generate a avatar image generator."
  - id: implicit-avatar-image-generator
    should_trigger: true
    prompt: "Social profile pictures for me."
  - id: negative-control-avatar-image-generator
    should_trigger: false
    prompt: "Turn this article into a five-card RedNote series with prompt files and batch generation."

outcome_tests:
  - id: file-structure
    type: deterministic
    checks:
      - file_exists: "SKILL.md"
      - file_exists: "package.json"
      - file_exists: "scripts/main.ts"
      - file_exists: "references/config/first-time-setup.md"


path: package.json
content: {
  "name": "avatar-image-generator",
  "private": true,
  "type": "module",
  "description": "Standalone avatar-image-generator skill package",
  "scripts": {
    "bootstrap": "node scripts/bootstrap.mjs",
    "doctor": "node scripts/doctor.mjs",
    "ensure-ready": "node scripts/ensure-ready.mjs",
    "setup": "node scripts/setup.mjs",
    "discover-image-models": "node scripts/discover-models.mjs",
    "recommend-model": "node scripts/recommend-model.mjs",
    "build-visual-brief": "node scripts/build-visual-brief.mjs",
    "style-menu": "node scripts/style-menu.mjs",
    "smoke": "node scripts/smoke-check.mjs",
    "generate": "node ./scripts/vendor/weryai-image/run-generate.mjs scripts/main.ts",
    "test": "npm --prefix scripts test"
  }
}


path: references/config/first-time-setup.md
content: # First-Time Setup (Before Image Generation)

## 1. First Trigger: Readiness First

Before the first real generation attempt, the agent should run a readiness pass silently:

```bash
npm run ensure-ready -- --project . --workflow <workflow>
```

This should happen before model choice. The purpose is:

- detect missing local runtime dependencies
- auto-bootstrap local script dependencies when possible
- confirm whether `IMAGE_GEN_API_KEY` is already available

If local dependencies are still missing after the check, the agent should ask the user for approval to install them on the user's behalf.

If `IMAGE_GEN_API_KEY` is missing, the agent should tell the user that image generation needs an API key and offer to configure it now. After approval, the agent should persist it to `.image-skills/avatar-image-generator/.env` or run `npm run setup -- --project . --workflow <workflow> --persist-api-key` when the key is already available in env. Do not ask the user to debug local setup manually before this readiness pass.

Treat the API key as a secret:

- prefer writing it locally on the user's behalf instead of making the user edit files
- do not ask the user to paste the key into normal chat if a local write path is available
- if the user does share the key, never echo it back or include it in follow-up messages

## 2. API Key

Get a key from the current gateway console at [WeryAI API Keys](https://weryai.com/api/keys), then configure only:

- environment variable **`IMAGE_GEN_API_KEY`** (recommended), or
- project/user `.image-skills/avatar-image-generator/.env` with:
  `IMAGE_GEN_API_KEY=<your Bearer token>`

Preferred agent path:

```bash
IMAGE_GEN_API_KEY=<secret> npm run setup -- --project . --workflow <workflow> --persist-api-key
```

## 3. Default Model Initialization

When no default model is configured yet, the **agent** should initialize the workspace with **Nano Banana 2** and clearly tell the user they can switch later at any time:

1. Start from the bundled starter registry (silent, no user involvement). If the agent needs a fresher list, refresh it from docs:

```bash
npm run discover-image-models -- --out .image-skills/avatar-image-generator/MODELS.json
```

2. Write the default config immediately:

```bash
mkdir -p .image-skills/avatar-image-generator
cat > .image-skills/avatar-image-generator/EXTEND.md << 'EOF'
---
version: 1
default_model: "GEMINI_3_1_FLASH_IMAGE"
default_quality: 2k
default_aspect_ratio: "1:1"
batch:
  max_workers: 4
---
EOF
```

3. Tell the user what happened:

> All set — I'll use **Nano Banana 2** as the default model for this workspace.
> If you want, I can switch models anytime later for different quality, style, or text-layout needs.

4. If the user wants a different model right now, run recommendation for the user's workflow:

```bash
npm run recommend-model -- --workflow <workflow> --role <role> --json
```

```bash
npm run recommend-model -- --workflow <workflow> --role <role> --json
```

5. Present alternatives only when needed:

> You're currently set to **Nano Banana 2** by default. If you'd like a better fit for this task, here are other strong options:
> 1. **GPT Image 1.5** — balanced and reliable; good for general images and text-heavy layouts
> 2. **Recraft V4 Pro** — stronger for infographic-style visuals and cleaner layout control
> 3. **WeryAI Image 2.0** — high-quality general image model, better for cover-style visuals
>
> Pick one if you want to switch now — otherwise I can keep Nano Banana 2. You can switch anytime.

6. If the user switches, update `EXTEND.md` and confirm:

> Updated — you're now defaulting to **GPT Image 1.5** instead of **Nano Banana 2**. Let me know anytime if you want to switch again or adjust defaults.

The agent **must not** ask the user to manually edit `EXTEND.md` or figure out the file format. The entire flow should feel like a guided conversation, not a config tutorial.

If a requested model is missing or looks stale, the agent should first refresh the local registry from WeryAI docs before asking the user for any platform-side details.

If the user later asks to switch models, the agent should update `EXTEND.md` in place.

## 4. Verify

```bash
npx -y bun scripts/main.ts --prompt "test" --image ./out/test.png --ar 1:1 --dry-run
```

Use `--dry-run` first to verify the request body is correct without consuming API quota. If the dry-run looks good, run without `--dry-run` to confirm end-to-end.

## 5. If the Request Is Still Vague

If the user's visual direction is underspecified, generate a brief before prompt writing:

```bash
npm run build-visual-brief -- --workflow cover --topic "Habit systems"
```

Use its question menu to guide the conversation. See `SKILL.md` § "If The Request Is Underspecified" when this file is bundled with the skill.


path: references/config/model-registry-schema.md
content: # `MODELS.json` Schema (`avatar-image-generator`)

This optional registry helps the skill recommend a model based on workflow needs instead of treating model choice as an unstructured string.

Recommended file paths, in priority order:

- `<project>/.image-skills/avatar-image-generator/MODELS.json`
- `~/.image-skills/avatar-image-generator/MODELS.json`
- `<skill>/references/config/starter-models.json` (bundled starter snapshot from current WeryAI docs)

## Example

```json
{
  "version": 1,
  "models": [
    {
      "id": "gateway-model-fast",
      "label": "Fast general model",
      "modalities": ["text-to-image"],
      "best_for": ["general", "cover", "rednote"],
      "best_for_roles": ["rednote-cover"],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": ["editorial", "flat-illustration", "speed"],
      "notes": "Good for quick visual exploration."
    },
    {
      "id": "gateway-model-pro",
      "label": "High quality ref-capable model",
      "modalities": ["text-to-image", "image-to-image"],
      "best_for": ["cover", "comic", "article"],
      "best_for_roles": ["cover-hero", "comic-page", "comic-character-sheet"],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "high",
      "strengths": ["photoreal", "editorial", "comic"],
      "notes": "Best when consistency and image-to-image control matter."
    },
    {
      "id": "gateway-model-diagram",
      "label": "Diagram and infographic model",
      "modalities": ["text-to-image"],
      "best_for": ["infographic", "article"],
      "best_for_roles": ["infographic-dense", "article-framework", "article-flowchart"],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": ["diagram", "infographic", "labels"],
      "notes": "Prefer this for dense, label-heavy visuals."
    }
  ]
}
```

## Fields

| Field | Type | Description |
| --- | --- | --- |
| `version` | int | reserved, currently `1` |
| `models[]` | array | list of locally approved gateway model entries |
| `models[].id` | string | exact gateway `model` key |
| `models[].label` | string | human-readable short label |
| `models[].modalities` | string[] | e.g. `text-to-image`, `image-to-image` |
| `models[].best_for` | string[] | workflow hints such as `cover`, `rednote`, `infographic`, `comic`, `article`, `general` |
| `models[].best_for_roles` | string[] | optional sub-task hints such as `cover-hero`, `cover-typography-space`, `rednote-text-card`, `infographic-dense`, `comic-page`, `comic-character-sheet`, `article-framework`, `article-flowchart` |
| `models[].supports_ref` | bool | whether the model is dependable for reference-image workflows |
| `models[].supports_text_heavy` | bool | whether the model is dependable for label-heavy or diagram-heavy outputs |
| `models[].quality_tier` | string | `fast`, `balanced`, or `high` |
| `models[].strengths` | string[] | optional style or task hints such as `editorial`, `diagram`, `comic`, `photoreal` |
| `models[].notes` | string | optional short operational note |

## Recommendation CLI

After creating the registry, run:

```bash
npm run recommend-model -- --workflow comic --need-ref --quality high --style comic
npm run recommend-model -- --workflow comic --role comic-page
```

Use `--json` when the output should be consumed by another script.

This skill already ships with a bundled starter registry. To refresh it for a project from live docs:

```bash
npm run discover-image-models -- --out .image-skills/avatar-image-generator/MODELS.json
```


path: references/config/preferences-schema.md
content: # `EXTEND.md` Schema (`avatar-image-generator`)

Configuration file paths, in priority order:

- `<project>/.image-skills/avatar-image-generator/EXTEND.md`
- `~/.image-skills/avatar-image-generator/EXTEND.md`

Optional model registry paths:

- `<project>/.image-skills/avatar-image-generator/MODELS.json`
- `~/.image-skills/avatar-image-generator/MODELS.json`

Field-to-gateway mapping is described in [weryai-platform.md](../weryai-platform.md).

The file is Markdown with YAML front matter at the top.

## Example

```yaml
---
version: 1
default_model: <model key chosen from the gateway docs>
default_quality: 2k
default_aspect_ratio: "16:9"
# default_resolution: 2k
# default_use_web_search: false
# default_caller_id: 12345
batch:
  max_workers: 10
---
```

## Fields

| Field | Type | Description |
| --- | --- | --- |
| `version` | int | reserved, currently `1` |
| `default_model` | string | gateway `model` key; initialization should default to `GEMINI_3_1_FLASH_IMAGE` (Nano Banana 2) unless the user has already chosen another model |
| `default_quality` | `normal` \| `2k` | maps to `resolution`, unless overridden by `--resolution` |
| `default_aspect_ratio` | string | such as `1:1`; overridden by `--ar` |
| `default_resolution` | string | such as `2k`; common values are `1k`, `2k`, and `4k` |
| `default_use_web_search` | bool | default `use_web_search` behavior; can still be forced on by `--use-web-search` |
| `default_caller_id` | int | default `caller_id`; can be overridden by `--caller-id` |
| `batch.max_workers` | int | maximum batch concurrency; can be overridden by `BASE_IMAGE_MAX_WORKERS` |

`MODELS.json` is separate from `EXTEND.md`. Use it when this skill should rank multiple valid model keys by workflow or by narrower roles such as `comic-page` or `article-framework`. See [model-registry-schema.md](model-registry-schema.md).


path: references/config/starter-models.json
content: {
  "version": 1,
  "generated_at": "2026-03-20T10:35:24.736Z",
  "source": "bundled-weryai-starter",
  "docs": {
    "text_to_image": "https://docs.weryai.com/api-reference/avatar-image-generator/submit-text-to-image-task",
    "image_to_image": "https://docs.weryai.com/api-reference/avatar-image-generator/submit-image-to-image-task"
  },
  "models": [
    {
      "id": "CHATBOT_GEMINI_3_PRO_IMAGE_PREVIEW",
      "label": "Nano Banana Pro",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "speed"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "DREAMINA",
      "label": "Dreamina 3.1",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "DREAMINA_3",
      "label": "Dreamina 3.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "DREAMINA_4",
      "label": "Dreamina 4.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "FLUX",
      "label": "Flux",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GEMINI_2_5_FLASH",
      "label": "Nano-banana",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "speed"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GEMINI_3_1_FLASH_IMAGE",
      "label": "Nano Banana 2",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "speed"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GPT_IMAGE_1_5",
      "label": "GPT Image 1.5",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "labels"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GPT_IMAGE_1_MINI",
      "label": "Gpt Image Mini",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": true,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "labels"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GROK_IMAGINE_IMAGE",
      "label": "Grok 2",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "IMAGE4",
      "label": "Imagen4",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "QWEN",
      "label": "Qwen Image",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "labels"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4",
      "label": "Recraft V4",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4_PRO",
      "label": "Recraft V4 Pro",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "high",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4_PRO_TO_VECTOR",
      "label": "Recraft V4 Pro Vector",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "high",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4_TO_VECTOR",
      "label": "Recraft V4 Vector",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM",
      "label": "Seedream 4.0",
      "modalities": [
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM_4",
      "label": "Seedream 4.0",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM_4_5",
      "label": "Seedream 4.5",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM_5_0_LITE",
      "label": "Seedream 5.0 lite",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WAN_2_5",
      "label": "Wan2.5",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WAN_2_6",
      "label": "Wan2.6",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WERYAI_IMAGE_1_0",
      "label": "WeryAI Image 1.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WERYAI_IMAGE_2_0",
      "label": "WeryAI Image 2.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "high",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    }
  ]
}


path: references/style-presets.md
content: # Style Presets (`avatar-image-generator`)

These presets are **prompt-level style presets**, not native gateway API enums. Each preset appends a style description to the original prompt so the agent and the user can reuse common visual directions more consistently.

Useful when:

- the user only knows a rough direction such as "photoreal", "manga", "poster", or "infographic"
- the same visual language should be reused across a batch
- `--dry-run` is being used to inspect the final assembled request body

Not ideal when:

- the user already provided a very explicit and sufficiently detailed style prompt
- the workflow must rely on model-specific private style tokens

## Available Presets

| preset | User Might Say | Best For | Injected Direction |
| --- | --- | --- | --- |
| `photoreal` | "realistic", "photo-like", "natural" | realistic people, products, environments | photorealistic, natural lighting, realistic texture, high detail |
| `cinematic` | "cinematic", "movie feel", "moody" | cinematic posters, moody scenes | cinematic composition, dramatic lighting, film still, moody atmosphere |
| `anime` | "anime", "colorful cartoon" | anime characters, light-novel covers | anime style, clean linework, vibrant colors, expressive character design |
| `manga` | "manga", "black and white comic" | comic panels, B&W storytelling | manga style, dynamic ink lines, screentone shading, black and white emphasis |
| `watercolor` | "watercolor", "hand-drawn", "soft" | soft illustration, book-style imagery | watercolor illustration, soft brush texture, paper grain, gentle palette |
| `flat-illustration` | "flat", "minimal", "notion-style", "clean" | product illustration, knowledge cards | flat illustration, simplified shapes, clean vector feel, limited palette |
| `3d-render` | "3D", "rendered" | 3D characters, product rendering | 3d render, volumetric lighting, material detail, polished surfaces |
| `poster` | "poster", "bold", "high impact" | promo images, event pages, covers | poster design, bold focal point, strong typography space, high contrast |
| `editorial` | "magazine", "premium", "elegant layout" | magazine-like, brand-oriented visuals | editorial illustration, refined composition, premium layout feel, restrained palette |
| `infographic` | "infographic", "chart", "data visual" | infographics, knowledge cards | infographic style, clear hierarchy, icon-friendly layout, data visualization feel |
| `chalk` | "chalkboard", "hand-written" | chalkboard, hand-drawn tutorials | chalk drawing, textured strokes, blackboard feel, hand-drawn annotations |
| `ink-brush` | "ink painting", "brush", "East Asian" | ink-painting, East Asian aesthetics | ink brush painting, expressive brushwork, elegant negative space, East Asian aesthetic |

When the user describes a style in natural language, match their description to the closest preset. If unsure between two presets, briefly describe both and ask which feels closer. Run `npm run style-menu` from this skill directory to see the full preset list with localized labels.

## Usage

```bash
${BUN_X} {baseDir}/scripts/main.ts --prompt "a cat" --style photoreal --image out.png -m "<model>"
${BUN_X} {baseDir}/scripts/main.ts --prompt "AI trend summary" --style infographic --image out.png -m "<model>"
${BUN_X} {baseDir}/scripts/main.ts --prompt "girl standing on a rainy night street" --style cinematic --dry-run -m "<model>"
```

## Notes

- `--style` only adds style direction. It does not replace `model`.
- The style preset participates in prompt length validation because it is appended to the final request prompt.
- If the user's original prompt is already clear and detailed, respect that first. The preset is only a lightweight enhancement.


path: references/weryai-platform.md
content: # Gateway Alignment Notes (Current Gateway: WeryAI)

This skill follows a single-gateway architecture, and the current gateway happens to be WeryAI. The HTTP contract below follows the public **Image Generation** documentation. If the gateway changes later, this file must be updated as well.

## Base URL and Authentication

- **Base URL**: `https://api.weryai.com`
- **Header**: `Authorization: Bearer <API_KEY>` using environment variable `IMAGE_GEN_API_KEY`

## Async Endpoints

| Capability | Method | Path |
| --- | --- | --- |
| text-to-image | POST | `/v1/generation/text-to-image` |
| image-to-image | POST | `/v1/generation/image-to-image` |
| task status | GET | `/v1/generation/{taskId}/status` |

Successful business responses return **`success: true`** (or `status: 200`). Failed business responses return `success: false` with a business status code such as `1002`. On success, `data.task_ids` is a string array and polling uses the returned `task_id`.

## `task_status` Values

Follow the lowercase OpenAPI enum:

- `waiting`
- `processing`
- `succeed` -> `data.images` contains image URLs
- `failed` -> `data.msg` explains the failure

Do not rely on uppercase aliases such as `SUCCESS` or `FAILED`. If examples and OpenAPI disagree, trust the OpenAPI definition.

## Result Image Download

After `succeed`, the platform usually returns **download URLs** (often behind a CDN). Compared with APIs that return raw bytes directly, this introduces an extra network hop and can fail because the link is cold, temporarily unavailable, or subject to different authentication behavior.

The CLI handles each result image with:

- a request timeout (default: 120s)
- up to 4 attempts with exponential backoff
- an unauthenticated `GET` first, then a retry with the same Bearer token on `401/403`
- limited retries for `5xx`, `429`, `408`, and even `404` when the asset is not ready yet
- minimum payload validation to avoid saving empty responses or error pages as images

If download still fails, use the returned image URL manually and inspect the request with `task_id` plus [call history](https://weryai.com/api/history).

**Billing behavior**: once the platform-side task is already `succeed`, the CLI will **not** resubmit the generation request just because the file download failed. It only retries the HTTP file download layer, which avoids accidental double billing.

## Text-to-Image Request Body

Important fields:

| Field | Notes |
| --- | --- |
| `model` | required; see the model table for that endpoint |
| `prompt` | required; positive prompt, subject to documented length limits |
| `aspect_ratio` | required; e.g. `16:9`, `1:1` |
| `negative_prompt` | optional |
| `image_number` | optional, default 1 |
| `resolution` | optional; common values include `1k`, `2k`, `4k`, depending on model support |
| `use_web_search` | optional, default `false` |
| `webhook_url` | optional |
| `caller_id` | optional, int64, for business correlation |

## Image-to-Image Request Body

The image-to-image endpoint adds:

| Field | Notes |
| --- | --- |
| `images` | required string array. The docs show public HTTPS URLs, but the CLI can encode local files as `data:image/...;base64,...` and send them through the same field. |

The supported `model` set for image-to-image may differ from text-to-image. Always check the corresponding endpoint documentation.

## Common Business Status Codes

Examples from the documentation:

- `0` -> success
- `1001` -> parameter error
- `1002` -> authentication failure
- `1003` -> resource not found, such as an invalid task id

The script raises an error when the business response is not successful. For troubleshooting, compare the request body and response with [call history](https://weryai.com/api/history).

## Official Documentation Entry Points

- [API introduction](https://docs.weryai.com/en)
- [text-to-image](https://docs.weryai.com/api-reference/avatar-image-generator/submit-text-to-image-task)
- [image-to-image](https://docs.weryai.com/api-reference/avatar-image-generator/submit-image-to-image-task)
- [query task status](https://docs.weryai.com/api-reference/tasks/query-task-details)
- [documentation index `llms.txt`](https://docs.weryai.com/llms.txt)

The script validates `prompt` and `negative_prompt` lengths before making requests, following the OpenAPI `maxLength` values. The current implementation uses `prompt <= 2000` and `negative_prompt <= 1000`; update `scripts/main.ts` if the upstream contract changes.


path: scripts/bootstrap.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
const SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);

function printHelp() {
  console.log(`Usage:
  node scripts/bootstrap.mjs [options]

Options:
  --dir <path>     Skill root directory (default: current skill root)
  --dry-run        Print install targets without running npm install
  --json           Print JSON output
  -h, --help       Show help`);
}

function parseArgs(argv) {
  const args = {
    dir: null,
    dryRun: false,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--dir") args.dir = argv[++i] ?? null;
    else if (current === "--dry-run") args.dryRun = true;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }
  return args;
}

function readJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

function countInstallableDependencies(pkg) {
  const sections = ["dependencies", "optionalDependencies", "devDependencies"];
  return sections.reduce((total, field) => total + Object.keys(pkg[field] ?? {}).length, 0);
}

function collectPackageJsonPaths(rootDir) {
  const results = [];
  function visit(currentDir) {
    for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
      if (entry.isDirectory()) {
        if (SKIP_DIRS.has(entry.name)) continue;
        visit(path.join(currentDir, entry.name));
        continue;
      }
      if (entry.isFile() && entry.name === "package.json") {
        results.push(path.join(currentDir, entry.name));
      }
    }
  }
  visit(rootDir);
  return results;
}

export function discoverBootstrapTargets(rootDir) {
  const suiteRoot = path.resolve(rootDir);
  return collectPackageJsonPaths(suiteRoot)
    .map((packageJsonPath) => {
      const pkg = readJson(packageJsonPath);
      const dependencyCount = countInstallableDependencies(pkg);
      return {
        dir: path.dirname(packageJsonPath),
        relativeDir: path.relative(suiteRoot, path.dirname(packageJsonPath)) || ".",
        packageName: pkg.name ?? path.basename(path.dirname(packageJsonPath)),
        dependencyCount,
      };
    })
    .filter((target) => target.dependencyCount > 0)
    .sort((a, b) => a.relativeDir.localeCompare(b.relativeDir));
}

function installTarget(target) {
  const result = spawnSync(npmCmd, ["install"], {
    cwd: target.dir,
    stdio: "inherit",
  });
  return {
    ...target,
    ok: result.status === 0,
    exitCode: result.status ?? 1,
  };
}

export function bootstrapSuite({ dir, dryRun = false }) {
  const suiteRoot = path.resolve(dir);
  const targets = discoverBootstrapTargets(suiteRoot);
  const summary = {
    ok: true,
    dir: suiteRoot,
    dryRun,
    targetCount: targets.length,
    targets,
    installed: [],
  };

  if (dryRun || targets.length === 0) {
    return summary;
  }

  for (const target of targets) {
    const installed = installTarget(target);
    summary.installed.push(installed);
    if (!installed.ok) {
      summary.ok = false;
      break;
    }
  }
  return summary;
}

function formatText(summary) {
  const lines = [];
  lines.push(`Skill root: ${summary.dir}`);
  lines.push(`Dry run: ${summary.dryRun ? "yes" : "no"}`);
  lines.push(`Install targets: ${summary.targetCount}`);
  if (summary.targets.length) {
    lines.push("Targets:");
    for (const target of summary.targets) {
      lines.push(`- ${target.relativeDir} (${target.dependencyCount} dependencies)`);
    }
  }
  if (!summary.dryRun && summary.installed.length) {
    lines.push("Install results:");
    for (const target of summary.installed) {
      lines.push(`- ${target.relativeDir}: ${target.ok ? "ok" : `failed (${target.exitCode})`}`);
    }
  }
  return lines.join("\n");
}

const defaultDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = bootstrapSuite({ dir: args.dir ?? defaultDir, dryRun: args.dryRun });
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else console.log(formatText(summary));
    if (!summary.ok) process.exitCode = 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/build-visual-brief.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const VALID_WORKFLOWS = new Set(["general", "cover", "rednote", "infographic", "comic", "article"]);

const FIELD_OPTIONS = {
  styleFamily: ["photoreal", "editorial", "flat-illustration", "poster", "manga", "watercolor", "infographic"],
  palette: ["cool", "warm", "neutral", "high-contrast", "pastel", "dark-cinematic"],
  lighting: ["soft", "natural", "dramatic", "studio", "flat", "high-key"],
  composition: ["single-hero", "balanced", "layered-depth", "grid", "bento", "panel-storytelling"],
  shot: ["close-up", "medium", "wide", "top-down", "isometric", "straight-on"],
  textDensity: ["none", "title-only", "light-labels", "text-rich"],
  aspect: ["1:1", "3:4", "4:3", "16:9", "9:16"],
  referenceMode: ["none", "style-reference", "character-reference", "layout-reference"],
};

const WORKFLOW_DEFAULTS = {
  general: {
    styleFamily: "editorial",
    composition: "balanced",
    shot: "medium",
    aspect: "1:1",
    textDensity: "none",
  },
  cover: {
    styleFamily: "editorial",
    composition: "single-hero",
    shot: "medium",
    aspect: "16:9",
    textDensity: "title-only",
  },
  rednote: {
    styleFamily: "flat-illustration",
    composition: "bento",
    shot: "straight-on",
    aspect: "3:4",
    textDensity: "light-labels",
  },
  infographic: {
    styleFamily: "infographic",
    composition: "grid",
    shot: "straight-on",
    aspect: "3:4",
    textDensity: "text-rich",
  },
  comic: {
    styleFamily: "manga",
    composition: "panel-storytelling",
    shot: "medium",
    aspect: "3:4",
    textDensity: "light-labels",
  },
  article: {
    styleFamily: "editorial",
    composition: "balanced",
    shot: "wide",
    aspect: "16:9",
    textDensity: "light-labels",
  },
};

function printHelp() {
  console.log(`Usage:
  node scripts/build-visual-brief.mjs --workflow <name> --topic <text> [options]

Options:
  --workflow <name>       general | cover | rednote | infographic | comic | article
  --topic <text>          Core topic or request
  --subject <text>        Main subject
  --goal <text>           Intended effect or communication goal
  --style-family <name>   Visual family
  --palette <name>        Color temperature / palette direction
  --lighting <name>       Lighting direction
  --composition <name>    Composition pattern
  --shot <name>           Shot language
  --aspect <ratio>        Aspect ratio
  --lang <code>           On-image text language when needed
  --text-density <name>   none | title-only | light-labels | text-rich
  --reference-mode <name> none | style-reference | character-reference | layout-reference
  --output <path>         Write a markdown brief to this path
  --json                  Print JSON output
  -h, --help              Show help`);
}

function parseArgs(argv) {
  const args = {
    workflow: "general",
    topic: null,
    subject: null,
    goal: null,
    styleFamily: null,
    palette: null,
    lighting: null,
    composition: null,
    shot: null,
    aspect: null,
    language: "en",
    textDensity: null,
    referenceMode: null,
    output: null,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--workflow") args.workflow = argv[++i] ?? args.workflow;
    else if (current === "--topic") args.topic = argv[++i] ?? null;
    else if (current === "--subject") args.subject = argv[++i] ?? null;
    else if (current === "--goal") args.goal = argv[++i] ?? null;
    else if (current === "--style-family") args.styleFamily = argv[++i] ?? null;
    else if (current === "--palette") args.palette = argv[++i] ?? null;
    else if (current === "--lighting") args.lighting = argv[++i] ?? null;
    else if (current === "--composition") args.composition = argv[++i] ?? null;
    else if (current === "--shot") args.shot = argv[++i] ?? null;
    else if (current === "--aspect") args.aspect = argv[++i] ?? null;
    else if (current === "--lang") args.language = argv[++i] ?? args.language;
    else if (current === "--text-density") args.textDensity = argv[++i] ?? null;
    else if (current === "--reference-mode") args.referenceMode = argv[++i] ?? null;
    else if (current === "--output") args.output = argv[++i] ?? null;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }

  if (!VALID_WORKFLOWS.has(args.workflow)) throw new Error(`Invalid workflow: ${args.workflow}`);
  if (!args.topic?.trim()) throw new Error("--topic is required");
  return args;
}

const QUESTIONS_I18N = {
  en: {
    subject:       { prompt: "What is the main visual subject?", options: ["person", "product", "scene", "diagram/structure", "abstract concept"], recommended: "scene" },
    goal:          { prompt: "What is the primary purpose of this image?", options: ["attract clicks", "explain a concept", "set the mood", "highlight contrast", "drive action"], recommended: "explain a concept" },
    styleFamily:   { prompt: "More photorealistic or illustration?" },
    palette:       { prompt: "What color temperature / palette direction?" },
    lighting:      { prompt: "What lighting or atmosphere?" },
    composition:   { prompt: "What composition pattern works best?" },
    shot:          { prompt: "What shot language fits best?" },
    aspect:        { prompt: "What aspect ratio?" },
    textDensity:   { prompt: "How much text should appear on the image?" },
    referenceMode: { prompt: "Do you have a reference image?" },
  },
  zh: {
    subject:       { prompt: "主视觉主体是什么？", options: ["人物", "产品", "场景", "图表/结构", "抽象概念"], recommended: "场景" },
    goal:          { prompt: "这张图最重要的任务是什么？", options: ["吸引点击", "解释概念", "建立氛围", "突出对比", "引导行动"], recommended: "解释概念" },
    styleFamily:   { prompt: "更偏写实还是插画？" },
    palette:       { prompt: "色彩温度想要什么方向？" },
    lighting:      { prompt: "光线/气氛更偏哪种？" },
    composition:   { prompt: "构图组织更适合哪种？" },
    shot:          { prompt: "镜头语言更适合哪种？" },
    aspect:        { prompt: "最终比例是什么？" },
    textDensity:   { prompt: "画面里文字密度要多高？" },
    referenceMode: { prompt: "是否有参考图？" },
  },
};

function question(id, prompt, options, recommended) {
  return { id, prompt, options, recommended };
}

function getI18nQuestion(lang, id) {
  const table = QUESTIONS_I18N[lang] ?? QUESTIONS_I18N.en;
  return table[id] ?? QUESTIONS_I18N.en[id];
}

export function buildVisualBrief(input) {
  const lang = (input.language?.trim() || "en").startsWith("zh") ? "zh" : "en";
  const defaults = WORKFLOW_DEFAULTS[input.workflow] ?? WORKFLOW_DEFAULTS.general;
  const brief = {
    workflow: input.workflow,
    topic: input.topic.trim(),
    subject: input.subject?.trim() || null,
    goal: input.goal?.trim() || null,
    styleFamily: input.styleFamily?.trim() || defaults.styleFamily,
    palette: input.palette?.trim() || null,
    lighting: input.lighting?.trim() || null,
    composition: input.composition?.trim() || defaults.composition,
    shot: input.shot?.trim() || defaults.shot,
    aspect: input.aspect?.trim() || defaults.aspect,
    language: input.language?.trim() || "en",
    textDensity: input.textDensity?.trim() || defaults.textDensity,
    referenceMode: input.referenceMode?.trim() || "none",
  };

  const missingFields = [];
  if (!brief.subject) missingFields.push("subject");
  if (!brief.goal) missingFields.push("goal");
  if (!input.palette) missingFields.push("palette");
  if (!input.lighting) missingFields.push("lighting");

  const q = (id, fallbackOptions, fallbackRecommended) => {
    const i18n = getI18nQuestion(lang, id);
    return question(id, i18n.prompt, i18n.options ?? fallbackOptions, i18n.recommended ?? fallbackRecommended);
  };

  const questions = [];
  if (!brief.subject) questions.push(q("subject"));
  if (!brief.goal) questions.push(q("goal"));
  if (!input.styleFamily) questions.push(q("styleFamily", FIELD_OPTIONS.styleFamily, defaults.styleFamily));
  if (!input.palette) questions.push(q("palette", FIELD_OPTIONS.palette, "neutral"));
  if (!input.lighting) questions.push(q("lighting", FIELD_OPTIONS.lighting, "soft"));
  if (!input.composition) questions.push(q("composition", FIELD_OPTIONS.composition, defaults.composition));
  if (!input.shot) questions.push(q("shot", FIELD_OPTIONS.shot, defaults.shot));
  if (!input.aspect) questions.push(q("aspect", FIELD_OPTIONS.aspect, defaults.aspect));
  if (!input.textDensity) questions.push(q("textDensity", FIELD_OPTIONS.textDensity, defaults.textDensity));
  if (!input.referenceMode) questions.push(q("referenceMode", FIELD_OPTIONS.referenceMode, "none"));

  const promptDirectives = [
    `Main topic: ${brief.topic}.`,
    `Primary subject: ${brief.subject || "<need subject>"}.`,
    `Communication goal: ${brief.goal || "<need goal>"}.`,
    `Style family: ${brief.styleFamily}.`,
    `Palette direction: ${brief.palette || "<need palette>"}.`,
    `Lighting: ${brief.lighting || "<need lighting>"}.`,
    `Composition: ${brief.composition}.`,
    `Shot language: ${brief.shot}.`,
    `Aspect ratio: ${brief.aspect}.`,
    `Language: ${brief.language}.`,
    `Text density: ${brief.textDensity}.`,
    `Reference mode: ${brief.referenceMode}.`,
  ];

  return {
    ok: missingFields.length === 0,
    brief,
    missingFields,
    questions,
    promptDirectives,
  };
}

function renderMarkdown(summary) {
  const { brief, missingFields, questions, promptDirectives } = summary;
  const lines = [
    "# Visual Brief",
    "",
    "## Resolved Brief",
    `- Workflow: ${brief.workflow}`,
    `- Topic: ${brief.topic}`,
    `- Subject: ${brief.subject ?? "<need subject>"}`,
    `- Goal: ${brief.goal ?? "<need goal>"}`,
    `- Style family: ${brief.styleFamily}`,
    `- Palette: ${brief.palette ?? "<need palette>"}`,
    `- Lighting: ${brief.lighting ?? "<need lighting>"}`,
    `- Composition: ${brief.composition}`,
    `- Shot: ${brief.shot}`,
    `- Aspect ratio: ${brief.aspect}`,
    `- Language: ${brief.language}`,
    `- Text density: ${brief.textDensity}`,
    `- Reference mode: ${brief.referenceMode}`,
    "",
    "## Missing Fields",
    ...(missingFields.length ? missingFields.map((field) => `- ${field}`) : ["- none"]),
    "",
    "## Questions To Ask",
    ...(questions.length
      ? questions.flatMap((item) => [
          `- ${item.prompt}`,
          `  options: ${item.options.join(" | ")}`,
          `  recommended: ${item.recommended}`,
        ])
      : ["- none"]),
    "",
    "## Prompt Directives",
    ...promptDirectives.map((line) => `- ${line}`),
    "",
  ];
  return `${lines.join("\n")}`;
}

function writeBrief(outputPath, summary) {
  const resolved = path.resolve(outputPath);
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
  fs.writeFileSync(resolved, renderMarkdown(summary), "utf8");
  return resolved;
}

const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = buildVisualBrief(args);
    const outputPath = args.output ? writeBrief(args.output, summary) : null;
    if (args.json) {
      console.log(JSON.stringify({ ...summary, outputPath }, null, 2));
    } else {
      console.log(renderMarkdown(summary));
      if (outputPath) console.log(`Brief written: ${outputPath}`);
    }
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/discover-models.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

// Prefer raw markdown endpoints (if exposed), but fall back to HTML pages.
const TEXT_TO_IMAGE_DOC_URLS = [
  "https://docs.weryai.com/api-reference/avatar-image-generator/submit-text-to-image-task.md",
  "https://docs.weryai.com/api-reference/avatar-image-generator/submit-text-to-image-task",
];
const IMAGE_TO_IMAGE_DOC_URLS = [
  "https://docs.weryai.com/api-reference/avatar-image-generator/submit-image-to-image-task.md",
  "https://docs.weryai.com/api-reference/avatar-image-generator/submit-image-to-image-task",
];

function printHelp() {
  console.log(`Usage:
  node scripts/discover-models.mjs [options]

Options:
  --out <path>   Write discovered MODELS.json starter to this path
  --force        Overwrite existing output file
  --json         Print JSON output
  -h, --help     Show help`);
}

function parseArgs(argv) {
  const args = {
    out: null,
    force: false,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--out") args.out = argv[++i] ?? null;
    else if (current === "--force") args.force = true;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }
  return args;
}

export function parseModelTable(markdown) {
  const lines = markdown.replace(/\r\n/g, "\n").split("\n");

  // Locate a markdown table containing a model list. Docs may tweak header wording,
  // so we infer the "key" column and the "name" column rather than matching a fixed string.
  const normalize = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
  const isTableLine = (line) => /^\s*\|/.test(line);
  const splitRow = (line) => line.split("|").map((part) => part.trim()).filter(Boolean);

  let headerIndex = -1;
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!isTableLine(line)) continue;
    const parts = splitRow(line);
    if (parts.length < 2) continue;
    const joined = normalize(parts.join(" | "));
    if (joined.includes("model") && joined.includes("key")) {
      headerIndex = i;
      break;
    }
  }
  if (headerIndex === -1) return [];

  const headerParts = splitRow(lines[headerIndex] ?? "").map(normalize);
  const keyIdx = headerParts.findIndex((h) => h === "model key" || h.endsWith(" key") || h.includes("key"));
  const nameIdx = headerParts.findIndex((h) => h === "model" || h === "model name" || h.includes("name"));
  const resolvedKeyIdx = keyIdx >= 0 ? keyIdx : 1;
  const resolvedNameIdx = nameIdx >= 0 ? nameIdx : 0;

  const rows = [];
  for (let i = headerIndex + 2; i < lines.length; i++) {
    const line = lines[i].trim();
    if (!line.startsWith("|")) break;
    const parts = line.split("|").map((part) => part.trim()).filter(Boolean);
    if (parts.length < 2) continue;
    const label = parts[resolvedNameIdx] ?? parts[0];
    const id = parts[resolvedKeyIdx] ?? parts[1];
    if (!label || !id) continue;
    rows.push({ label, id });
  }
  return rows;
}

function inferQualityTier(label, id) {
  const value = `${label} ${id}`.toLowerCase();
  if (/(mini|lite|flash)/i.test(value)) return "fast";
  if (/(pro|2\.0|5\.0|v4 pro)/i.test(value)) return "high";
  return "balanced";
}

function inferStrengths(label, id) {
  const value = `${label} ${id}`.toLowerCase();
  const strengths = new Set();
  if (/(vector|recraft)/i.test(value)) {
    strengths.add("flat-illustration");
    strengths.add("infographic");
    strengths.add("diagram");
  }
  if (/(qwen|gpt)/i.test(value)) {
    strengths.add("editorial");
    strengths.add("labels");
  }
  if (/(dreamina|flux|imagen|weryai image|seedream|wan)/i.test(value)) {
    strengths.add("editorial");
    strengths.add("photoreal");
    strengths.add("cover");
  }
  if (/(gemini|banana)/i.test(value)) {
    strengths.add("editorial");
    strengths.add("speed");
  }
  if (strengths.size === 0) strengths.add("editorial");
  return [...strengths];
}

function inferSupportsTextHeavy(label, id) {
  return /(vector|recraft|qwen|gpt|labels)/i.test(`${label} ${id}`);
}

function inferBestFor(strengths, supportsTextHeavy) {
  const bestFor = new Set(["general", "cover"]);
  if (supportsTextHeavy || strengths.includes("infographic") || strengths.includes("diagram")) {
    bestFor.add("infographic");
    bestFor.add("article");
    bestFor.add("rednote");
  }
  return [...bestFor];
}

function inferBestForRoles(bestFor, strengths, supportsRef, supportsTextHeavy) {
  const roles = new Set();
  if (bestFor.includes("cover")) roles.add("cover-hero");
  if (supportsTextHeavy) {
    roles.add("rednote-text-card");
    roles.add("infographic-dense");
    roles.add("article-framework");
    roles.add("article-flowchart");
  }
  if (strengths.includes("infographic")) roles.add("infographic-dense");
  if (supportsRef) {
    roles.add("comic-page");
    roles.add("comic-character-sheet");
  }
  return [...roles];
}

export function buildDiscoveredRegistry({ textToImageModels, imageToImageModels }) {
  const byId = new Map();

  for (const item of textToImageModels) {
    const current = byId.get(item.id) ?? {
      id: item.id,
      label: item.label,
      modalities: [],
    };
    current.modalities = [...new Set([...current.modalities, "text-to-image"])];
    byId.set(item.id, current);
  }

  for (const item of imageToImageModels) {
    const current = byId.get(item.id) ?? {
      id: item.id,
      label: item.label,
      modalities: [],
    };
    current.modalities = [...new Set([...current.modalities, "image-to-image"])];
    byId.set(item.id, current);
  }

  const models = [...byId.values()]
    .map((model) => {
      const strengths = inferStrengths(model.label, model.id);
      const supportsTextHeavy = inferSupportsTextHeavy(model.label, model.id);
      const bestFor = inferBestFor(strengths, supportsTextHeavy);
      return {
        id: model.id,
        label: model.label,
        modalities: model.modalities,
        supports_ref: model.modalities.includes("image-to-image"),
        supports_text_heavy: supportsTextHeavy,
        quality_tier: inferQualityTier(model.label, model.id),
        strengths,
        best_for: bestFor,
        best_for_roles: inferBestForRoles(bestFor, strengths, model.modalities.includes("image-to-image"), supportsTextHeavy),
        notes: "Auto-discovered from current WeryAI image generation documentation.",
      };
    })
    .sort((a, b) => a.id.localeCompare(b.id));

  return {
    version: 1,
    generated_at: new Date().toISOString(),
    source: "weryai-docs-auto-discovery",
    docs: {
      text_to_image: TEXT_TO_IMAGE_DOC_URLS[0],
      image_to_image: IMAGE_TO_IMAGE_DOC_URLS[0],
    },
    models,
  };
}

async function fetchFirstOk(urls, fetchImpl) {
  let last = null;
  for (const url of urls) {
    try {
      const res = await fetchImpl(url);
      if (res.ok) return { res, url };
      last = new Error(`HTTP ${res.status} for ${url}`);
    } catch (e) {
      last = e instanceof Error ? e : new Error(String(e));
    }
  }
  throw last ?? new Error("Failed to fetch docs");
}

export async function discoverImageModels({ fetchImpl = fetch } = {}) {
  const [text, image] = await Promise.all([
    fetchFirstOk(TEXT_TO_IMAGE_DOC_URLS, fetchImpl),
    fetchFirstOk(IMAGE_TO_IMAGE_DOC_URLS, fetchImpl),
  ]);

  const [textMarkdown, imageMarkdown] = await Promise.all([text.res.text(), image.res.text()]);
  const textToImageModels = parseModelTable(textMarkdown);
  const imageToImageModels = parseModelTable(imageMarkdown);
  if (textToImageModels.length === 0 && imageToImageModels.length === 0) {
    throw new Error("No image model table was found in current WeryAI docs");
  }

  return buildDiscoveredRegistry({ textToImageModels, imageToImageModels });
}

function writeRegistry(filePath, registry) {
  const resolved = path.resolve(filePath);
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
  fs.writeFileSync(resolved, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
  return resolved;
}

const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const registry = await discoverImageModels();

    let outputPath = null;
    let wrote = false;
    if (args.out) {
      const resolvedOut = path.resolve(args.out);
      if (fs.existsSync(resolvedOut) && !args.force) {
        outputPath = resolvedOut;
        wrote = false;
      } else {
        outputPath = writeRegistry(resolvedOut, registry);
        wrote = true;
      }
    }

    const summary = {
      ok: true,
      modelCount: registry.models.length,
      source: registry.source,
      docs: registry.docs,
      outputPath,
      wrote,
      registry,
    };
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else {
      console.log(`Discovered ${summary.modelCount} image models from WeryAI docs.`);
      if (args.out && summary.outputPath) {
        if (!summary.wrote) {
          console.log(`Registry already exists (skipped): ${summary.outputPath}`);
          console.log("Tip: pass --force to overwrite.");
        } else {
          console.log(`Wrote starter registry: ${summary.outputPath}`);
        }
      }
    }
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/doctor.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

import { discoverBootstrapTargets } from "./bootstrap.mjs";

const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
const VALID_WORKFLOWS = new Set(["general", "cover", "rednote", "infographic", "comic", "article", "compress"]);
const skillNamespace = process.env.IMAGE_SKILL_NAMESPACE?.trim() || "avatar-image-generator";

function printHelp() {
  console.log(`Usage:
  node scripts/doctor.mjs [options]

Options:
  --dir <path>       Skill root directory (default: current skill root)
  --project <path>   Project directory to inspect for .image-skills config (default: current working directory)
  --workflow <name>  Optional readiness focus: general, cover, rednote, infographic, comic, article, compress
  --json             Print JSON output
  -h, --help         Show help`);
}

function parseArgs(argv) {
  const args = {
    dir: null,
    project: null,
    workflow: null,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--dir") args.dir = argv[++i] ?? null;
    else if (current === "--project") args.project = argv[++i] ?? null;
    else if (current === "--workflow") args.workflow = argv[++i] ?? null;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }
  if (args.workflow && !VALID_WORKFLOWS.has(args.workflow)) {
    throw new Error(`Invalid --workflow: ${args.workflow}`);
  }
  return args;
}

function pathExists(filePath) {
  return !!filePath && fs.existsSync(filePath);
}

function checkCommand(command, args) {
  const result = spawnSync(command, args, { encoding: "utf8" });
  return {
    available: result.status === 0,
    version: result.status === 0 ? (result.stdout || result.stderr).trim().split("\n")[0] : null,
  };
}

function inspectToolchain() {
  const nodeStatus = checkCommand(process.execPath, ["--version"]);
  const npmStatus = checkCommand(npmCmd, ["--version"]);
  const bunStatus = checkCommand("bun", ["--version"]);
  const npxStatus = checkCommand(npxCmd, ["--version"]);
  const sipsStatus = checkCommand("sips", ["--help"]);
  const cwebpStatus = checkCommand("cwebp", ["-version"]);
  const magickStatus = checkCommand("magick", ["-version"]);
  const convertStatus = checkCommand("convert", ["-version"]);
  return [
    { id: "node", label: "Node.js", required: true, ...nodeStatus },
    { id: "npm", label: "npm", required: true, ...npmStatus },
    { id: "bun", label: "Bun", required: false, ...bunStatus },
    { id: "npx", label: "npx", required: false, ...npxStatus },
    { id: "sips", label: "sips", required: false, ...sipsStatus },
    { id: "cwebp", label: "cwebp", required: false, ...cwebpStatus },
    { id: "magick", label: "ImageMagick", required: false, ...magickStatus },
    { id: "convert", label: "convert", required: false, ...convertStatus },
  ];
}

export function inspectConfigState({ projectDir, homeDir, env }) {
  const projectRoot = path.resolve(projectDir);
  const homeRoot = homeDir ? path.resolve(homeDir) : null;
  const projectSkillDir = path.join(projectRoot, ".image-skills", skillNamespace);
  const homeSkillDir = homeRoot ? path.join(homeRoot, ".image-skills", skillNamespace) : null;

  const projectEnvPath = path.join(projectRoot, ".image-skills", skillNamespace, ".env");
  const projectEnvFile = pathExists(projectEnvPath);
  let apiKeyInProjectEnv = false;
  if (projectEnvFile) {
    try {
      const content = fs.readFileSync(projectEnvPath, "utf8");
      apiKeyInProjectEnv =
        /(^|\n)\s*IMAGE_GEN_API_KEY\s*=\s*[^\n#]+/m.test(content) ||
        /(^|\n)\s*IMAGE_GEN_API_KEY\s*=\s*[^\n#]+/m.test(content);
    } catch {
      apiKeyInProjectEnv = false;
    }
  }

  return {
    projectDir: projectRoot,
    apiKeyInEnv: Boolean(env.IMAGE_GEN_API_KEY || env.IMAGE_GEN_API_KEY),
    apiKeyInProjectEnv,
    defaultModelInEnv: Boolean(env.IMAGE_GEN_DEFAULT_MODEL),
    projectEnvFile,
    projectExtend: pathExists(path.join(projectSkillDir, "EXTEND.md")),
    projectModelRegistry: pathExists(path.join(projectSkillDir, "MODELS.json")),
    homeExtend: homeSkillDir ? pathExists(path.join(homeSkillDir, "EXTEND.md")) : false,
    homeModelRegistry: homeSkillDir ? pathExists(path.join(homeSkillDir, "MODELS.json")) : false,
  };
}

function readJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

export function inspectBootstrapDependencyState(suiteDir) {
  return discoverBootstrapTargets(suiteDir).map((target) => {
    const pkg = readJson(path.join(target.dir, "package.json"));
    const dependencyNames = [
      ...Object.keys(pkg.dependencies ?? {}),
      ...Object.keys(pkg.optionalDependencies ?? {}),
      ...Object.keys(pkg.devDependencies ?? {}),
    ];
    const missingPackages = dependencyNames.filter(
      (dependencyName) => !pathExists(path.join(target.dir, "node_modules", dependencyName, "package.json"))
    );
    return {
      ...target,
      dependencyNames,
      installed: missingPackages.length === 0,
      missingPackages,
    };
  });
}

function hasDetectedApiKey(config) {
  return config.apiKeyInEnv || config.apiKeyInProjectEnv;
}

function hasDetectedDefaultModel(config) {
  return config.defaultModelInEnv || config.projectExtend || config.homeExtend;
}

function findBootstrapTarget(bootstrapTargets, relativeDir) {
  return bootstrapTargets.find((target) => target.relativeDir === relativeDir) ?? null;
}

function toolAvailable(tools, id) {
  return Boolean(tools.find((tool) => tool.id === id)?.available);
}

function buildRootCommandHints(projectDir, workflow = null) {
  const commands = [
    `cd "${projectDir}"`,
    "npm run doctor -- --project .",
  ];
  if (workflow) commands.push(`npm run doctor -- --project . --workflow ${workflow}`);
  commands.push("npm run setup -- --project .");
  commands.push(`npm run discover-image-models -- --out .image-skills/${skillNamespace}/MODELS.json`);
  commands.push("npm run recommend-model -- --workflow cover --role cover-hero");
  return commands;
}

function defaultRoleForWorkflow(workflow) {
  switch (workflow) {
    case "cover":
      return "cover-hero";
    case "rednote":
      return "rednote-text-card";
    case "infographic":
      return "infographic-dense";
    case "comic":
      return "comic-page";
    case "article":
      return "article-framework";
    default:
      return null;
  }
}

export function inspectWorkflowReadiness({ workflow, tools, config, bootstrapTargets }) {
  if (!workflow) return null;

  const blockers = [];
  const warnings = [];
  const suggestions = [];
  const checks = [];
  const hasApiKey = hasDetectedApiKey(config);
  const hasDefaultModel = hasDetectedDefaultModel(config);
  const hasModelRegistry = config.projectModelRegistry || config.homeModelRegistry;
  const recommendedRole = defaultRoleForWorkflow(workflow);

  const mark = (label, ok, detail = null) => {
    checks.push({ label, ok, detail });
    return ok;
  };

  if (workflow === "compress") {
    const compressorOk =
      toolAvailable(tools, "sips") ||
      toolAvailable(tools, "cwebp") ||
      toolAvailable(tools, "magick") ||
      toolAvailable(tools, "convert");
    if (!mark("local-compressor-tool", compressorOk, "Need one of sips, cwebp, magick, or convert")) {
      blockers.push("No local image compression tool was detected");
      suggestions.push("Install at least one of sips, cwebp, or ImageMagick before using compress-image");
    }
  } else {
    if (!mark("gateway-api-key", hasApiKey, `Need IMAGE_GEN_API_KEY in env or .image-skills/${skillNamespace}/.env`)) {
      blockers.push("Gateway API key is not configured");
      suggestions.push(`Provide IMAGE_GEN_API_KEY, then rerun \`npm run setup -- --project . --persist-api-key\` or write .image-skills/${skillNamespace}/.env`);
    }
    if (!mark("default-model", hasDefaultModel, "Need IMAGE_GEN_DEFAULT_MODEL or EXTEND.md default_model for smooth first use")) {
      warnings.push("No default model is configured yet");
      suggestions.push("Run npm run setup -- --project . to initialize Nano Banana 2 as the default model, or set IMAGE_GEN_DEFAULT_MODEL / EXTEND.md yourself");
    }
    if (!mark("model-registry", hasModelRegistry, "MODELS.json enables workflow and role-aware model ranking")) {
      warnings.push("No MODELS.json registry was detected for workflow-aware model recommendation");
      suggestions.push("Run npm run setup -- --project . to write the bundled starter registry, or refresh from docs before model recommendation");
    }
  }

  if (workflow === "comic") {
    const comicBootstrap = findBootstrapTarget(bootstrapTargets, "comic/scripts");
    const pdfReady = comicBootstrap ? comicBootstrap.installed : true;
    if (!mark("comic-pdf-merge", pdfReady, "merge-to-pdf.ts needs local pdf-lib install")) {
      warnings.push("Comic PDF merge dependency is not installed yet");
      suggestions.push("Run npm run bootstrap in this skill directory before using comic PDF merge");
    }
  }

  const recommendationHint =
    workflow !== "compress" && recommendedRole
      ? `npm run recommend-model -- --workflow ${workflow} --role ${recommendedRole}`
      : null;
  if (recommendationHint) {
    suggestions.push(`Before generation, rank models with: ${recommendationHint}`);
  }

  return {
    workflow,
    ready: blockers.length === 0,
    recommendationHint,
    blockers,
    warnings,
    suggestions,
    checks,
  };
}

export function doctorSuite({
  suiteDir,
  projectDir = process.cwd(),
  homeDir = process.env.HOME ?? null,
  env = process.env,
  tools = null,
  workflow = null,
}) {
  const resolvedSuiteDir = path.resolve(suiteDir);
  const toolchain = tools ?? inspectToolchain();
  const config = inspectConfigState({ projectDir, homeDir, env });
  const bootstrap = inspectBootstrapDependencyState(resolvedSuiteDir);
  const warnings = [];
  const errors = [];
  const suggestions = [];

  const hasNode = toolchain.find((tool) => tool.id === "node")?.available;
  const hasNpm = toolchain.find((tool) => tool.id === "npm")?.available;
  const hasBun = toolchain.find((tool) => tool.id === "bun")?.available;
  const hasNpx = toolchain.find((tool) => tool.id === "npx")?.available;

  if (!hasNode) errors.push("Node.js is required but was not detected");
  if (!hasNpm) errors.push("npm is required but was not detected");
  if (!hasBun && !hasNpx) errors.push("Need Bun or npx so Bun-based workflow scripts can run");

  const hasApiKey = hasDetectedApiKey(config);
  if (!hasApiKey) {
    warnings.push(`IMAGE_GEN_API_KEY was not detected in the environment or project .image-skills/${skillNamespace}/.env`);
    suggestions.push(`Provide IMAGE_GEN_API_KEY, then rerun \`npm run setup -- --project . --persist-api-key\` or write .image-skills/${skillNamespace}/.env before using gateway generation`);
  }

  const hasDefaultModel = hasDetectedDefaultModel(config);
  if (!hasDefaultModel) {
    warnings.push("No default model was detected in IMAGE_GEN_DEFAULT_MODEL or EXTEND.md");
    suggestions.push("Run npm run setup -- --project . to initialize Nano Banana 2 as the default model, or set IMAGE_GEN_DEFAULT_MODEL / EXTEND.md yourself");
  }

  const pendingBootstrap = bootstrap.filter((target) => !target.installed);
  if (pendingBootstrap.length > 0) {
    warnings.push(`Optional local dependencies are not installed for ${pendingBootstrap.map((target) => target.relativeDir).join(", ")}`);
    suggestions.push("Run npm run bootstrap in this skill directory to install optional local dependencies");
  }

  const workflowReadiness = inspectWorkflowReadiness({
    workflow,
    tools: toolchain,
    config,
    bootstrapTargets: bootstrap,
  });

  return {
    ok: errors.length === 0,
    suiteDir: resolvedSuiteDir,
    projectDir: config.projectDir,
    workflow,
    behavior: {
      readOnly: true,
      writesFiles: false,
      generatedArtifacts: [],
    },
    commandHints: buildRootCommandHints(config.projectDir, workflow),
    tools: toolchain,
    config,
    bootstrap: {
      targets: bootstrap,
      pendingCount: pendingBootstrap.length,
    },
    workflowReadiness,
    warnings,
    errors,
    suggestions,
  };
}

function formatText(summary) {
  const lines = [];
  lines.push(`Skill root: ${summary.suiteDir}`);
  lines.push(`Project dir: ${summary.projectDir}`);
  lines.push(`Status: ${summary.ok ? "ok" : "failed"}`);
  lines.push("Doctor behavior: read-only check; it does not create or modify files.");
  if (summary.workflowReadiness) {
    lines.push(`Workflow focus: ${summary.workflowReadiness.workflow}`);
    lines.push(`Workflow ready: ${summary.workflowReadiness.ready ? "yes" : "no"}`);
  }
  lines.push("Toolchain:");
  for (const tool of summary.tools) {
    lines.push(`- ${tool.label}: ${tool.available ? tool.version ?? "ok" : "missing"}`);
  }
  lines.push("Gateway config:");
  lines.push(`- API key: ${summary.config.apiKeyInEnv || summary.config.projectEnvFile ? "detected" : "missing"}`);
  lines.push(`- Default model: ${summary.config.defaultModelInEnv || summary.config.projectExtend || summary.config.homeExtend ? "detected" : "missing"}`);
  lines.push("Optional dependencies:");
  if (summary.bootstrap.targets.length === 0) {
    lines.push("- none");
  } else {
    for (const target of summary.bootstrap.targets) {
      lines.push(`- ${target.relativeDir}: ${target.installed ? "installed" : `missing ${target.missingPackages.join(", ")}`}`);
    }
  }
  if (summary.errors.length) {
    lines.push("Errors:");
    for (const error of summary.errors) lines.push(`- ${error}`);
  }
  if (summary.warnings.length) {
    lines.push("Warnings:");
    for (const warning of summary.warnings) lines.push(`- ${warning}`);
  }
  if (summary.suggestions.length) {
    lines.push("Suggestions:");
    for (const suggestion of summary.suggestions) lines.push(`- ${suggestion}`);
  }
  if (summary.commandHints?.length) {
    lines.push("Suggested commands:");
    for (const command of summary.commandHints) lines.push(`- ${command}`);
  }
  if (summary.workflowReadiness) {
    if (summary.workflowReadiness.blockers.length) {
      lines.push("Workflow blockers:");
      for (const blocker of summary.workflowReadiness.blockers) lines.push(`- ${blocker}`);
    }
    if (summary.workflowReadiness.warnings.length) {
      lines.push("Workflow warnings:");
      for (const warning of summary.workflowReadiness.warnings) lines.push(`- ${warning}`);
    }
    if (summary.workflowReadiness.suggestions.length) {
      lines.push("Workflow suggestions:");
      for (const suggestion of summary.workflowReadiness.suggestions) lines.push(`- ${suggestion}`);
    }
    if (summary.workflowReadiness.recommendationHint) {
      lines.push(`Workflow model hint: ${summary.workflowReadiness.recommendationHint}`);
    }
  }
  return lines.join("\n");
}

const defaultSuiteDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = doctorSuite({
      suiteDir: args.dir ?? defaultSuiteDir,
      projectDir: args.project ?? process.cwd(),
      workflow: args.workflow,
    });
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else console.log(formatText(summary));
    if (!summary.ok) process.exitCode = 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/ensure-ready.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

import { bootstrapSuite } from "./bootstrap.mjs";
import { doctorSuite } from "./doctor.mjs";

const DEFAULT_MODEL = "GEMINI_3_1_FLASH_IMAGE";

function ensureDefaultModelConfig({ suiteDir, projectDir, dryRun }) {
  if (process.env.IMAGE_GEN_DEFAULT_MODEL) return { wrote: false, reason: "env" };
  const namespace = path.basename(path.resolve(suiteDir));
  const extendPath = path.resolve(projectDir, ".image-skills", namespace, "EXTEND.md");
  if (fs.existsSync(extendPath)) return { wrote: false, reason: "exists" };
  if (dryRun) return { wrote: false, reason: "dry-run" };
  const content = `---
version: 1
default_model: "GEMINI_3_1_FLASH_IMAGE"
default_quality: 2k
default_aspect_ratio: "1:1"
batch:
  max_workers: 4
---
`;
  fs.mkdirSync(path.dirname(extendPath), { recursive: true });
  fs.writeFileSync(extendPath, content, "utf8");
  return { wrote: true, reason: "created", path: extendPath };
}

function printHelp() {
  console.log(`Usage:
  node scripts/ensure-ready.mjs [options]

Options:
  --dir <path>       Skill root directory (default: current avatar-image-generator root)
  --project <path>   Project directory to inspect for .image-skills config (default: current working directory)
  --workflow <name>  Optional readiness focus: general, cover, rednote, infographic, comic, article, compress
  --dry-run          Report what would be installed without running bootstrap
  --json             Print JSON output
  -h, --help         Show help`);
}

function parseArgs(argv) {
  const args = {
    dir: null,
    project: null,
    workflow: null,
    dryRun: false,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--dir") args.dir = argv[++i] ?? null;
    else if (current === "--project") args.project = argv[++i] ?? null;
    else if (current === "--workflow") args.workflow = argv[++i] ?? null;
    else if (current === "--dry-run") args.dryRun = true;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }

  return args;
}

function formatText(summary) {
  const lines = [];
  lines.push(`Skill root: ${summary.suiteDir}`);
  lines.push(`Project dir: ${summary.projectDir}`);
  lines.push(`Workflow: ${summary.workflow ?? "general"}`);
  if (summary.defaultModelConfig?.wrote) lines.push(`Default model: initialized to GEMINI_3_1_FLASH_IMAGE`);
  lines.push(`Bootstrap needed: ${summary.initial.bootstrap.pendingCount > 0 ? "yes" : "no"}`);
  lines.push(`Bootstrap attempted: ${summary.bootstrap.attempted ? "yes" : "no"}`);
  lines.push(`Bootstrap changed files: ${summary.bootstrap.changed ? "yes" : "no"}`);
  lines.push(`Ready: ${summary.final.ok ? "yes" : "no"}`);

  if (summary.bootstrap.targets.length) {
    lines.push("Bootstrap targets:");
    for (const target of summary.bootstrap.targets) {
      lines.push(`- ${target.relativeDir}`);
    }
  }

  if (summary.final.errors.length) {
    lines.push("Errors:");
    for (const error of summary.final.errors) lines.push(`- ${error}`);
  }
  if (summary.final.warnings.length) {
    lines.push("Warnings:");
    for (const warning of summary.final.warnings) lines.push(`- ${warning}`);
  }
  if (summary.final.suggestions.length) {
    lines.push("Suggestions:");
    for (const suggestion of summary.final.suggestions) lines.push(`- ${suggestion}`);
  }
  return lines.join("\n");
}

export function ensureReady({
  suiteDir,
  projectDir = process.cwd(),
  homeDir = process.env.HOME ?? null,
  env = process.env,
  workflow = null,
  dryRun = false,
  doctorImpl = doctorSuite,
  bootstrapImpl = bootstrapSuite,
}) {
  const defaultModelConfig = ensureDefaultModelConfig({ suiteDir, projectDir, dryRun });

  const initial = doctorImpl({
    suiteDir,
    projectDir,
    homeDir,
    env,
    workflow,
  });
  const pendingTargets = initial.bootstrap.targets.filter((target) => !target.installed);

  let bootstrapSummary = {
    attempted: false,
    changed: false,
    dryRun,
    ok: true,
    targets: pendingTargets,
    result: null,
  };

  if (pendingTargets.length > 0) {
    bootstrapSummary = {
      attempted: true,
      changed: !dryRun,
      dryRun,
      ok: true,
      targets: pendingTargets,
      result: bootstrapImpl({ dir: suiteDir, dryRun }),
    };
    if (bootstrapSummary.result && bootstrapSummary.result.ok === false) {
      bootstrapSummary.ok = false;
    }
  }

  const final = doctorImpl({
    suiteDir,
    projectDir,
    homeDir,
    env,
    workflow,
  });

  return {
    ok: final.ok && bootstrapSummary.ok,
    suiteDir: path.resolve(suiteDir),
    projectDir: path.resolve(projectDir),
    workflow,
    initial,
    bootstrap: bootstrapSummary,
    final,
    defaultModelConfig,
  };
}

const defaultSuiteDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = ensureReady({
      suiteDir: args.dir ?? defaultSuiteDir,
      projectDir: args.project ?? process.cwd(),
      workflow: args.workflow,
      dryRun: args.dryRun,
    });
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else console.log(formatText(summary));
    if (!summary.ok) process.exitCode = 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/main.ts
content: #!/usr/bin/env bun
/**
 * Single-gateway image generation CLI (currently: WeryAI).
 * POST /v1/generation/text-to-image, image-to-image; GET .../status.
 */
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { extname, resolve, dirname } from "node:path";

const MAX_ATTEMPTS = 3;
const DEFAULT_MAX_WORKERS = 10;
const POLL_WAIT_MS = 250;

// --- Gateway HTTP（当前: WeryAI；变更以 https://docs.weryai.com 为准）---
const BASE_URL = "https://api.weryai.com";

/** OpenAPI `maxLength` for text-to-image / image-to-image request schemas. */
const WERYAI_PROMPT_MAX_LEN = 2000;
const WERYAI_NEGATIVE_PROMPT_MAX_LEN = 1000;

/** Result URLs are gateway CDN links — extra hop vs direct bytes; harden download path. */
const DOWNLOAD_MAX_ATTEMPTS = 4;
const DOWNLOAD_TIMEOUT_MS = 120_000;
const DOWNLOAD_BACKOFF_MS = 700;
const DOWNLOAD_MIN_BYTES = 32;

const STYLE_PRESETS = {
  photoreal: "photorealistic, natural lighting, realistic texture, high detail",
  cinematic: "cinematic composition, dramatic lighting, film still, moody atmosphere",
  anime: "anime style, clean linework, vibrant colors, expressive character design",
  manga: "manga style, dynamic ink lines, screentone shading, black and white emphasis",
  watercolor: "watercolor illustration, soft brush texture, paper grain, gentle palette",
  "flat-illustration": "flat illustration, simplified shapes, clean vector feel, limited palette",
  "3d-render": "3d render, volumetric lighting, material detail, polished surfaces",
  poster: "poster design, bold focal point, strong typography space, high contrast",
  editorial: "editorial illustration, refined composition, premium layout feel, restrained palette",
  infographic: "infographic style, clear hierarchy, icon-friendly layout, data visualization feel",
  chalk: "chalk drawing, textured strokes, blackboard feel, hand-drawn annotations",
  "ink-brush": "ink brush painting, expressive brushwork, elegant negative space, East Asian aesthetic",
} as const;

type StylePreset = keyof typeof STYLE_PRESETS;

const SKILL_NAMESPACE = process.env.IMAGE_SKILL_NAMESPACE?.trim() || "avatar-image-generator";
const SKILL_LABEL = process.env.IMAGE_SKILL_LABEL?.trim() || SKILL_NAMESPACE;

function getApiKey(): string {
  const k = process.env.IMAGE_GEN_API_KEY || process.env.IMAGE_GEN_API_KEY;
  if (!k?.trim()) {
    console.error(
      `Missing IMAGE_GEN_API_KEY. Get a key at https://weryai.com/api/keys, then add it to the environment or project .image-skills/${SKILL_NAMESPACE}/.env. IMAGE_GEN_API_KEY is still accepted as a compatibility alias. If you are setting up this skill for the first time, run \`npm run doctor -- --project <your-project>\` from the ${SKILL_LABEL} skill directory. Doctor is read-only: it prints readiness, exact next commands, and does not create files.`
    );
    process.exit(1);
  }
  return k.trim();
}

type TaskStatus = "waiting" | "processing" | "succeed" | "failed";

interface ApiEnvelope<T> {
  status: number;
  success?: boolean;
  desc?: string;
  message?: string;
  data?: T;
}

function isApiSuccess(json: ApiEnvelope<unknown>): boolean {
  if (typeof json.success === "boolean") return json.success;
  return json.status === 200 || json.status === 0;
}

type RequestPreview = {
  endpoint: string;
  method: "POST";
  body: Record<string, unknown>;
};

interface SubmitResult {
  batch_id?: number;
  task_ids?: string[];
}

interface TaskDetail {
  task_id?: string;
  task_status?: TaskStatus;
  msg?: string;
  images?: string[];
}

function weryaiBusinessError(json: ApiEnvelope<unknown>): Error {
  const code = json.status;
  const base = json.message ?? json.desc ?? `WeryAI business status ${code}`;
  const hints: Record<number, string> = {
    1001: "parameter error — check model, prompt length, aspect_ratio, images, resolution per docs",
    1002: "authentication — verify IMAGE_GEN_API_KEY",
    1003: "resource not found (e.g. invalid task id)",
  };
  const extra = hints[code] ? ` (${hints[code]})` : "";
  return new Error(`${base}${extra}`);
}

async function apiPost<T>(pathSuffix: string, body: Record<string, unknown>): Promise<ApiEnvelope<T>> {
  const res = await fetch(`${BASE_URL}${pathSuffix}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${getApiKey()}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  const json = (await res.json()) as ApiEnvelope<T>;
  if (!res.ok) {
    const msg = json?.message ?? json?.desc ?? res.statusText;
    throw new Error(`HTTP ${res.status}: ${msg}`);
  }
  if (!isApiSuccess(json)) throw weryaiBusinessError(json);
  return json;
}

async function apiGet<T>(pathSuffix: string): Promise<ApiEnvelope<T>> {
  const res = await fetch(`${BASE_URL}${pathSuffix}`, {
    headers: { Authorization: `Bearer ${getApiKey()}` },
  });
  const json = (await res.json()) as ApiEnvelope<T>;
  if (!res.ok) {
    const msg = json?.message ?? json?.desc ?? res.statusText;
    throw new Error(`HTTP ${res.status}: ${msg}`);
  }
  if (!isApiSuccess(json)) throw weryaiBusinessError(json);
  return json;
}

async function pollTask(
  taskId: string,
  intervalMs: number,
  timeoutMs: number,
  quiet: boolean
): Promise<TaskDetail> {
  const start = Date.now();
  let last: TaskDetail | undefined;
  while (Date.now() - start < timeoutMs) {
    const r = await apiGet<TaskDetail>(`/v1/generation/${encodeURIComponent(taskId)}/status`);
    last = r.data;
    const st = r.data?.task_status;
    if (!quiet && st) console.error(`[poll] ${taskId} → ${st}`);
    if (st === "succeed" || st === "failed") return r.data ?? {};
    await new Promise((r2) => setTimeout(r2, intervalMs));
  }
  throw new Error(`Timeout after ${timeoutMs}ms waiting for task ${taskId}. Last: ${JSON.stringify(last)}`);
}

type FetchResult = { bytes: Uint8Array; contentType: string | null };

/**
 * Pull image bytes from gateway result URL (often CDN). Mitigates transient failures, cold CDN,
 * and occasional link quirks by: timeout, retries with backoff, unauthenticated then Bearer retry.
 */
async function fetchImageBytes(url: string, quiet: boolean): Promise<FetchResult> {
  let lastErr = "";

  const transientStatus = (status: number) =>
    status === 408 ||
    status === 429 ||
    status === 502 ||
    status === 503 ||
    status === 504 ||
    (status >= 520 && status <= 599);

  const transientNetwork = (e: unknown) => {
    const name = e instanceof Error ? e.name : "";
    const msg = e instanceof Error ? e.message : String(e);
    return (
      name === "AbortError" ||
      msg.includes("timeout") ||
      msg.includes("Timeout") ||
      msg.includes("fetch failed") ||
      msg.includes("ECONNRESET") ||
      msg.includes("ETIMEDOUT") ||
      msg.includes("EAI_AGAIN")
    );
  };

  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

  for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
    let res: Response | null = null;
    let threw: unknown = null;

    for (const useAuth of [false, true] as const) {
      try {
        const init: RequestInit = { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) };
        if (useAuth) init.headers = { Authorization: `Bearer ${getApiKey()}` };
        res = await fetch(url, init);
        threw = null;
        if (res.ok) break;
        lastErr = `HTTP ${res.status}`;
        if (!useAuth && (res.status === 401 || res.status === 403)) continue;
        break;
      } catch (e) {
        threw = e;
        res = null;
        lastErr = e instanceof Error ? e.message : String(e);
        if (!useAuth) continue;
        break;
      }
    }

    if (threw) {
      if (attempt < DOWNLOAD_MAX_ATTEMPTS && transientNetwork(threw)) {
        const wait = DOWNLOAD_BACKOFF_MS * 2 ** (attempt - 1);
        if (!quiet) console.error(`[download] ${lastErr} → retry ${attempt}/${DOWNLOAD_MAX_ATTEMPTS} in ${wait}ms`);
        await sleep(wait);
        continue;
      }
      throw new Error(`Download failed (network): ${lastErr}`);
    }

    if (!res) {
      throw new Error(`Download failed: no response (${lastErr})`);
    }

    if (!res.ok) {
      const st = res.status;
      const retry =
        attempt < DOWNLOAD_MAX_ATTEMPTS &&
        (transientStatus(st) || st === 404 || st === 403);
      if (retry) {
        const wait = DOWNLOAD_BACKOFF_MS * 2 ** (attempt - 1);
        if (!quiet) console.error(`[download] HTTP ${st} → retry ${attempt}/${DOWNLOAD_MAX_ATTEMPTS} in ${wait}ms`);
        await sleep(wait);
        continue;
      }
      throw new Error(`Download HTTP ${st} for ${url.length > 96 ? url.slice(0, 96) + "…" : url}`);
    }

    let buf: Uint8Array;
    try {
      buf = new Uint8Array(await res.arrayBuffer());
    } catch (e) {
      lastErr = e instanceof Error ? e.message : String(e);
      if (attempt < DOWNLOAD_MAX_ATTEMPTS) {
        const wait = DOWNLOAD_BACKOFF_MS * 2 ** (attempt - 1);
        if (!quiet) console.error(`[download] body read error → retry in ${wait}ms`);
        await sleep(wait);
        continue;
      }
      throw e;
    }

    if (buf.byteLength < DOWNLOAD_MIN_BYTES) {
      lastErr = `payload too small (${buf.byteLength} bytes; link may be expired or not an image)`;
      if (attempt < DOWNLOAD_MAX_ATTEMPTS) {
        const wait = DOWNLOAD_BACKOFF_MS * 2 ** (attempt - 1);
        if (!quiet) console.error(`[download] ${lastErr} → retry in ${wait}ms`);
        await sleep(wait);
        continue;
      }
      throw new Error(lastErr);
    }

    return { bytes: buf, contentType: res.headers.get("content-type") };
  }

  throw new Error(`Download exhausted after ${DOWNLOAD_MAX_ATTEMPTS} attempts: ${lastErr}`);
}

function mimeForExt(ext: string): string {
  switch (ext.toLowerCase()) {
    case ".png":
      return "image/png";
    case ".webp":
      return "image/webp";
    case ".jpg":
    case ".jpeg":
      return "image/jpeg";
    default:
      return "application/octet-stream";
  }
}

function extensionForContentType(ct: string): string | null {
  const lower = (ct.split(";")[0] ?? "").trim().toLowerCase();
  switch (lower) {
    case "image/png": return ".png";
    case "image/jpeg":
    case "image/jpg": return ".jpg";
    case "image/webp": return ".webp";
    case "image/gif": return ".gif";
    case "image/avif": return ".avif";
    default: return null;
  }
}

function toImagePayloadValue(pathOrUrl: string): string {
  const t = pathOrUrl.trim();
  if (/^https?:\/\//i.test(t)) return t;
  const abs = resolve(t);
  if (!existsSync(abs)) throw new Error(`Reference image not found: ${abs}`);
  const buf = readFileSync(abs);
  const mime = mimeForExt(extname(abs));
  return `data:${mime};base64,${buf.toString("base64")}`;
}

// --- EXTEND / env ---

type Quality = "normal" | "2k";

type ExtendConfig = {
  version?: number;
  default_model?: string | null;
  default_quality?: Quality | null;
  default_aspect_ratio?: string | null;
  default_resolution?: string | null;
  default_use_web_search?: boolean | null;
  default_caller_id?: number | null;
  batch?: { max_workers?: number | null };
};

function extractYamlFrontMatter(content: string): string | null {
  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
  return match ? match[1] : null;
}

function parseExtendYaml(yaml: string): ExtendConfig {
  const config: ExtendConfig = {};
  const lines = yaml.split("\n");
  let inBatch = false;

  for (const line of lines) {
    const trimmed = line.trim();
    const indent = line.match(/^\s*/)?.[0].length ?? 0;
    if (!trimmed || trimmed.startsWith("#")) continue;

    if (trimmed.includes(":") && !trimmed.startsWith("-")) {
      const colonIdx = trimmed.indexOf(":");
      const key = trimmed.slice(0, colonIdx).trim();
      let value = trimmed.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, "");

      if (key === "version") {
        config.version = value === "null" || value === "" ? 1 : parseInt(value, 10);
      } else if (key === "default_model") {
        if (!value || value === "null") config.default_model = null;
        else config.default_model = value;
        inBatch = false;
      } else if (key === "default_quality") {
        if (value === "normal" || value === "2k") config.default_quality = value;
        else config.default_quality = null;
        inBatch = false;
      } else if (key === "default_aspect_ratio") {
        config.default_aspect_ratio = value === "null" || !value ? null : value;
        inBatch = false;
      } else if (key === "default_resolution") {
        config.default_resolution = value === "null" || !value ? null : value;
        inBatch = false;
      } else if (key === "default_use_web_search") {
        const v = value.toLowerCase();
        if (value === "null" || value === "") config.default_use_web_search = null;
        else config.default_use_web_search = v === "true" || v === "1" || v === "yes";
        inBatch = false;
      } else if (key === "default_caller_id") {
        if (value === "null" || !value) config.default_caller_id = null;
        else {
          const n = parseInt(value, 10);
          config.default_caller_id = Number.isFinite(n) ? n : null;
        }
        inBatch = false;
      } else if (key === "batch") {
        inBatch = true;
        config.batch ??= {};
      } else if (key === "max_workers" && inBatch && indent >= 2) {
        config.batch ??= {};
        config.batch.max_workers = value === "null" || !value ? null : parseInt(value, 10);
      }
    }
  }
  return config;
}

function pushProjectCandidate(
  candidates: string[],
  seen: Set<string>,
  value: string | undefined | null
): void {
  if (!value?.trim()) return;
  const resolved = path.resolve(value);
  if (seen.has(resolved)) return;
  seen.add(resolved);
  candidates.push(resolved);
}

function hasProjectScopedConfig(projectRoot: string): boolean {
  const baseDir = path.join(projectRoot, ".image-skills", SKILL_NAMESPACE);
  return existsSync(path.join(baseDir, ".env")) || existsSync(path.join(baseDir, "EXTEND.md"));
}

function resolveProjectRoot(argv: string[] = process.argv.slice(2)): string {
  const candidates: string[] = [];
  const seen = new Set<string>();

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--project") {
      const value = argv[i + 1];
      if (value && !value.startsWith("-")) {
        pushProjectCandidate(candidates, seen, value);
      }
      break;
    }
    if (current?.startsWith("--project=")) {
      pushProjectCandidate(candidates, seen, current.slice("--project=".length));
      break;
    }
  }

  pushProjectCandidate(candidates, seen, process.env.IMAGE_PROJECT_ROOT);
  pushProjectCandidate(candidates, seen, process.env.INIT_CWD);
  pushProjectCandidate(candidates, seen, process.cwd());

  return candidates.find((candidate) => hasProjectScopedConfig(candidate)) ?? candidates[0] ?? process.cwd();
}

async function loadExtendConfig(projectRoot = resolveProjectRoot()): Promise<ExtendConfig> {
  const home = homedir();
  const paths = [
    path.join(projectRoot, ".image-skills", SKILL_NAMESPACE, "EXTEND.md"),
    path.join(home, ".image-skills", SKILL_NAMESPACE, "EXTEND.md"),
  ];
  for (const p of paths) {
    try {
      const content = await readFile(p, "utf8");
      const yaml = extractYamlFrontMatter(content);
      if (!yaml) continue;
      return parseExtendYaml(yaml);
    } catch {
      continue;
    }
  }
  return {};
}

async function loadEnvFile(p: string): Promise<Record<string, string>> {
  try {
    const content = await readFile(p, "utf8");
    const env: Record<string, string> = {};
    for (const line of content.split("\n")) {
      const trimmed = line.trim();
      if (!trimmed || trimmed.startsWith("#")) continue;
      const idx = trimmed.indexOf("=");
      if (idx === -1) continue;
      const key = trimmed.slice(0, idx).trim();
      let val = trimmed.slice(idx + 1).trim();
      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
        val = val.slice(1, -1);
      }
      env[key] = val;
    }
    return env;
  } catch {
    return {};
  }
}

async function loadEnv(projectRoot = resolveProjectRoot()): Promise<void> {
  const home = homedir();
  const mergedFiles = {
    ...(await loadEnvFile(path.join(home, ".image-skills", SKILL_NAMESPACE, ".env"))),
    ...(await loadEnvFile(path.join(projectRoot, ".image-skills", SKILL_NAMESPACE, ".env"))),
  };
  for (const [k, v] of Object.entries(mergedFiles)) {
    if (!process.env[k]) process.env[k] = v;
  }
}

// --- CLI types ---

type CliArgs = {
  prompt: string | null;
  promptFiles: string[];
  imagePath: string | null;
  batchFile: string | null;
  jobs: number | null;
  model: string | null;
  style: StylePreset | null;
  aspectRatio: string | null;
  size: string | null;
  quality: Quality | null;
  imageSize: string | null;
  referenceImages: string[];
  n: number;
  resolution: string | null;
  negativePrompt: string | null;
  webhookUrl: string | null;
  pollIntervalMs: number;
  pollTimeoutMs: number;
  noDownload: boolean;
  json: boolean;
  help: boolean;
  /** WeryAI `use_web_search` */
  useWebSearch: boolean;
  /** WeryAI `caller_id` */
  callerId: number | null;
  dryRun: boolean;
};

type BatchTaskInput = {
  id?: string;
  prompt?: string | null;
  promptFiles?: string[];
  image?: string;
  model?: string | null;
  style?: StylePreset | null;
  ar?: string | null;
  size?: string | null;
  quality?: Quality | null;
  imageSize?: "1K" | "2K" | "4K" | null;
  ref?: string[];
  n?: number;
  provider?: string | null;
  webhook_url?: string;
  negative_prompt?: string;
  resolution?: string;
  use_web_search?: boolean;
  caller_id?: number;
};

type BatchFile = BatchTaskInput[] | { tasks: BatchTaskInput[]; jobs?: number | null };

function printUsage(): void {
  console.log(`Image generation (gateway: api.weryai.com)
  ${process.argv[1] ?? "main.ts"} --prompt "..." --image out.png [--ar 16:9] [--ref a.png]
  ${process.argv[1] ?? "main.ts"} --promptfiles a.md b.md --image out.png
  ${process.argv[1] ?? "main.ts"} --batchfile batch.json [--jobs 4] [--json]

Legacy subcommands:
  ... text-to-image [options]
  ... image-to-image [options]
  ... status --task-id <id>

Options:
  -p, --prompt <text>       Prompt
  --promptfiles <files...>  Concatenate prompt from files
  --image <path>            Output image path (required for single mode)
  --batchfile <path>        JSON batch (see SKILL.md)
  --jobs <n>                Parallel workers in batch mode
  -m, --model <id>          Required unless EXTEND default_model or IMAGE_GEN_DEFAULT_MODEL (else agent supplies after doc lookup)
  --style <preset>          Style preset (${Object.keys(STYLE_PRESETS).join(", ")})
  --ar <ratio>              Aspect ratio (default: EXTEND or 1:1)
  --size <WxH>              If --ar omitted, ratio may be inferred (e.g. 1024x768 → 4:3)
  --quality normal|2k      Maps to resolution 1k|2k (default: 2k)
  --imageSize 1K|2K|4K      Fine-tune resolution mapping with --quality
  --resolution 
        Pass through to API (overrides quality mapping)
  --negative-prompt <t>    Negative prompt
  --ref, --reference <paths...>  Reference images (image-to-image)
  --n <count>             image_number (default 1)
  --webhook-url <url>      Gateway webhook_url
  --use-web-search         Gateway use_web_search=true
  --caller-id <n>          Gateway caller_id (int64)
  --poll-interval-ms <n>   Default 2500
  --poll-timeout-ms <n>    Default 600000
  --project <path>        Project directory used for .image-skills env and EXTEND lookup (default: cwd)
  --no-download           Only print URLs (no file write)
  --dry-run               Print final request body (JSON), do not call API
  --json                  JSON summary / batch report
  -h, --help

Env: IMAGE_GEN_API_KEY (preferred); IMAGE_GEN_API_KEY compatible; IMAGE_GEN_DEFAULT_MODEL optional; or EXTEND / --model
Also loaded: <project>/.image-skills/${SKILL_NAMESPACE}/.env and ~/.image-skills/${SKILL_NAMESPACE}/.env
Prefs: .image-skills/${SKILL_NAMESPACE}/EXTEND.md (project or home)
`);
}

function takeMany(argv: string[], i: number): { items: string[]; next: number } {
  const items: string[] = [];
  let j = i + 1;
  while (j < argv.length) {
    const v = argv[j]!;
    if (v.startsWith("-")) break;
    items.push(v);
    j++;
  }
  return { items, next: j - 1 };
}

function normalizeStylePreset(value: string): StylePreset | null {
  const normalized = value.trim().toLowerCase().replace(/[_\s]+/g, "-");
  return Object.prototype.hasOwnProperty.call(STYLE_PRESETS, normalized)
    ? (normalized as StylePreset)
    : null;
}

function applyStylePreset(prompt: string, style: StylePreset | null | undefined): string {
  if (!style) return prompt;
  return `${prompt.trim()}\n\nStyle preset (${style}): ${STYLE_PRESETS[style]}.`;
}

function parseArgs(argv: string[]): CliArgs {
  const out: CliArgs = {
    prompt: null,
    promptFiles: [],
    imagePath: null,
    batchFile: null,
    jobs: null,
    model: null,
    style: null,
    aspectRatio: null,
    size: null,
    quality: null,
    imageSize: null,
    referenceImages: [],
    n: 1,
    resolution: null,
    negativePrompt: null,
    webhookUrl: null,
    pollIntervalMs: 2500,
    pollTimeoutMs: 600_000,
    noDownload: false,
    json: false,
    help: false,
    useWebSearch: false,
    callerId: null,
    dryRun: false,
  };
  const positional: string[] = [];

  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]!;
    if (a === "--use-web-search") {
      out.useWebSearch = true;
      continue;
    }
    if (a === "--dry-run") {
      out.dryRun = true;
      continue;
    }
    if (a === "--help" || a === "-h") {
      out.help = true;
      continue;
    }
    if (a === "--json") {
      out.json = true;
      continue;
    }
    if (a === "--no-download") {
      out.noDownload = true;
      continue;
    }
    if (a === "--project") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --project");
      continue;
    }
    if (a === "--prompt" || a === "-p") {
      const v = argv[++i];
      if (!v) throw new Error(`Missing value for ${a}`);
      out.prompt = v;
      continue;
    }
    if (a === "--promptfiles") {
      const { items, next } = takeMany(argv, i);
      if (!items.length) throw new Error("Missing files for --promptfiles");
      out.promptFiles.push(...items);
      i = next;
      continue;
    }
    if (a === "--image") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --image");
      out.imagePath = v;
      continue;
    }
    if (a === "--output" || a === "-o") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --output");
      out.imagePath = v;
      continue;
    }
    if (a === "--batchfile") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --batchfile");
      out.batchFile = v;
      continue;
    }
    if (a === "--jobs") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --jobs");
      out.jobs = parseInt(v, 10);
      if (Number.isNaN(out.jobs) || out.jobs < 1) throw new Error(`Invalid worker count: ${v}`);
      continue;
    }
    if (a === "--model" || a === "-m") {
      const v = argv[++i];
      if (!v) throw new Error(`Missing value for ${a}`);
      out.model = v;
      continue;
    }
    if (a === "--style") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --style");
      const style = normalizeStylePreset(v);
      if (!style) throw new Error(`Invalid --style: ${v}. Available: ${Object.keys(STYLE_PRESETS).join(", ")}`);
      out.style = style;
      continue;
    }
    if (a === "--ar") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --ar");
      out.aspectRatio = v;
      continue;
    }
    if (a === "--aspect-ratio") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --aspect-ratio");
      out.aspectRatio = v;
      continue;
    }
    if (a === "--size") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --size");
      out.size = v;
      continue;
    }
    if (a === "--quality") {
      const v = argv[++i];
      if (v !== "normal" && v !== "2k") throw new Error(`Invalid quality: ${v}`);
      out.quality = v;
      continue;
    }
    if (a === "--imageSize") {
      const v = argv[++i]?.toUpperCase();
      if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`);
      out.imageSize = v;
      continue;
    }
    if (a === "--ref" || a === "--reference") {
      const { items, next } = takeMany(argv, i);
      if (!items.length) throw new Error(`Missing files for ${a}`);
      out.referenceImages.push(...items);
      i = next;
      continue;
    }
    if (a === "--n") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --n");
      out.n = parseInt(v, 10);
      if (Number.isNaN(out.n) || out.n < 1) throw new Error(`Invalid count: ${v}`);
      continue;
    }
    if (a === "--resolution") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --resolution");
      out.resolution = v;
      continue;
    }
    if (a === "--negative-prompt") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --negative-prompt");
      out.negativePrompt = v;
      continue;
    }
    if (a === "--webhook-url") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --webhook-url");
      out.webhookUrl = v;
      continue;
    }
    if (a === "--caller-id") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --caller-id");
      const n = parseInt(v, 10);
      if (!Number.isFinite(n)) throw new Error(`Invalid --caller-id: ${v}`);
      out.callerId = n;
      continue;
    }
    if (a === "--poll-interval-ms") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --poll-interval-ms");
      out.pollIntervalMs = parseInt(v, 10);
      continue;
    }
    if (a === "--poll-timeout-ms") {
      const v = argv[++i];
      if (!v) throw new Error("Missing value for --poll-timeout-ms");
      out.pollTimeoutMs = parseInt(v, 10);
      continue;
    }
    if (a.startsWith("-")) throw new Error(`Unknown option: ${a}`);
    positional.push(a);
  }

  if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {
    out.prompt = positional.join(" ");
  }
  return out;
}

function mergeConfig(args: CliArgs, extend: ExtendConfig): CliArgs {
  const envModel = process.env.IMAGE_GEN_DEFAULT_MODEL?.trim() || null;
  const useWs = Boolean(args.useWebSearch) || extend.default_use_web_search === true;
  const cid = args.callerId ?? extend.default_caller_id ?? null;
  return {
    ...args,
    quality: args.quality ?? extend.default_quality ?? null,
    aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
    resolution: args.resolution ?? extend.default_resolution ?? null,
    model: args.model?.trim() || extend.default_model?.trim() || envModel || null,
    useWebSearch: useWs,
    callerId: cid !== null && cid !== undefined && Number.isFinite(cid) ? cid : null,
  };
}

function gcd(a: number, b: number): number {
  while (b) [a, b] = [b, a % b];
  return Math.abs(a);
}

function inferAspectRatioFromSize(size: string): string | null {
  const m = size.trim().match(/^(\d+)\s*[x*]\s*(\d+)$/i);
  if (!m) return null;
  let w = parseInt(m[1]!, 10);
  let h = parseInt(m[2]!, 10);
  const g = gcd(w, h);
  if (g === 0) return null;
  w /= g;
  h /= g;
  return `${w}:${h}`;
}

/**
 * `resolution` is optional; allowed values depend on the model.
 * Common gateway values: 1k, 2k, 4k. `--resolution` passes through verbatim for exact control.
 */
function resolveResolution(args: CliArgs): string | undefined {
  if (args.resolution) return args.resolution;
  const q = args.quality ?? "2k";
  const is = (args.imageSize ?? "").toUpperCase();
  if (is === "4K") return "4k";
  if (is === "1K") return "1k";
  if (is === "2K") return "2k";
  return q === "normal" ? "1k" : "2k";
}

function resolveAspectRatio(args: CliArgs): string {
  if (args.aspectRatio) return args.aspectRatio;
  if (args.size) {
    const inferred = inferAspectRatioFromSize(args.size);
    if (inferred) return inferred;
  }
  return "1:1";
}

function resolveModel(args: CliArgs): string {
  const m = args.model?.trim();
  if (!m) {
    throw new Error(
      `Missing model: need --model, EXTEND default_model, or IMAGE_GEN_DEFAULT_MODEL. Set default_model in .image-skills/${SKILL_NAMESPACE}/EXTEND.md, export IMAGE_GEN_DEFAULT_MODEL, or rerun with --model <id>. If setup is still incomplete, run \`npm run doctor -- --project <your-project>\` or \`npm run setup -- --project <your-project>\` from the ${SKILL_LABEL} skill directory. Agent should start from the bundled WeryAI starter model list, refresh from docs if needed, rank candidates, ask the user to confirm one, then rerun with --model <id>.`
    );
  }
  return m;
}

function modelLabelForReport(args: CliArgs): string {
  const m = args.model?.trim();
  return m || "(no model configured)";
}

function parsePositiveInt(v: string | undefined): number | null {
  if (!v) return null;
  const n = parseInt(v, 10);
  return Number.isFinite(n) && n > 0 ? n : null;
}

function getMaxWorkers(extend: ExtendConfig): number {
  const env = parsePositiveInt(process.env.BASE_IMAGE_MAX_WORKERS);
  const cfg = extend.batch?.max_workers;
  return Math.max(1, env ?? cfg ?? DEFAULT_MAX_WORKERS);
}

function getConcurrency(): { concurrency: number; startIntervalMs: number } {
  return {
    concurrency: parsePositiveInt(process.env.BASE_IMAGE_GEN_CONCURRENCY) ?? 2,
    startIntervalMs: parsePositiveInt(process.env.BASE_IMAGE_GEN_START_INTERVAL_MS) ?? 1600,
  };
}

async function readPromptFromFiles(files: string[]): Promise<string> {
  const parts: string[] = [];
  for (const f of files) {
    parts.push(await readFile(f, "utf8"));
  }
  return parts.join("\n\n");
}

async function readPromptFromStdin(): Promise<string | null> {
  if (process.stdin.isTTY) return null;
  try {
    const chunks: Buffer[] = [];
    for await (const chunk of process.stdin) {
      chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
    }
    const value = Buffer.concat(chunks).toString("utf8").trim();
    return value.length > 0 ? value : null;
  } catch {
    return null;
  }
}

function normalizeOutputImagePath(p: string): string {
  const full = path.resolve(p);
  const ext = path.extname(full);
  if (ext) return full;
  return `${full}.png`;
}

async function validateReferenceImages(paths: string[]): Promise<void> {
  for (const refPath of paths) {
    const t = refPath.trim();
    if (/^https?:\/\//i.test(t)) continue;
    const fullPath = path.resolve(refPath);
    try {
      await access(fullPath);
    } catch {
      throw new Error(`Reference image not found: ${fullPath}`);
    }
  }
}

function assertWeryaiPromptBounds(prompt: string, negativePrompt: string | null | undefined): void {
  const p = prompt.trim();
  if (!p) throw new Error("prompt is empty (WeryAI requires non-empty prompt)");
  if (p.length > WERYAI_PROMPT_MAX_LEN) {
    throw new Error(
      `prompt length ${p.length} exceeds WeryAI OpenAPI maxLength ${WERYAI_PROMPT_MAX_LEN}`
    );
  }
  if (negativePrompt && negativePrompt.length > WERYAI_NEGATIVE_PROMPT_MAX_LEN) {
    throw new Error(
      `negative_prompt length ${negativePrompt.length} exceeds WeryAI maxLength ${WERYAI_NEGATIVE_PROMPT_MAX_LEN}`
    );
  }
}

async function runAsyncGeneration(input: {
  prompt: string;
  model: string;
  aspectRatio: string;
  resolution?: string;
  negativePrompt?: string | null;
  imageNumber: number;
  referenceImages: string[];
  webhookUrl?: string | null;
  useWebSearch: boolean;
  callerId: number | null;
  pollIntervalMs: number;
  pollTimeoutMs: number;
  quietPoll: boolean;
  dryRun?: boolean;
}): Promise<{
  submit: SubmitResult | undefined;
  detail: TaskDetail | undefined;
  requestPreview?: RequestPreview;
  dryRun?: boolean;
}> {
  const {
    prompt,
    model,
    aspectRatio,
    resolution,
    negativePrompt,
    imageNumber,
    referenceImages,
    webhookUrl,
    useWebSearch,
    callerId,
    pollIntervalMs,
    pollTimeoutMs,
    quietPoll,
  } = input;

  const baseBody: Record<string, unknown> = {
    model,
    prompt,
    aspect_ratio: aspectRatio,
    image_number: imageNumber,
  };
  if (negativePrompt) baseBody.negative_prompt = negativePrompt;
  if (resolution) baseBody.resolution = resolution;
  if (webhookUrl) baseBody.webhook_url = webhookUrl;
  if (useWebSearch) baseBody.use_web_search = true;
  if (callerId !== null && callerId !== undefined && Number.isFinite(callerId)) {
    baseBody.caller_id = Math.trunc(callerId);
  }

  const pathSuffix =
    referenceImages.length > 0 ? "/v1/generation/image-to-image" : "/v1/generation/text-to-image";
  const body: Record<string, unknown> =
    referenceImages.length > 0
      ? { ...baseBody, images: referenceImages.map(toImagePayloadValue) }
      : baseBody;

  if (input.dryRun) {
    const preview = { ...body };
    if (Array.isArray(preview.images)) {
      preview.images = (preview.images as string[]).map((v) =>
        typeof v === "string" && v.length > 256 ? `${v.slice(0, 64)}…[${v.length} chars]` : v
      );
    }
    return {
      submit: undefined,
      detail: undefined,
      dryRun: true,
      requestPreview: {
        endpoint: `${BASE_URL}${pathSuffix}`,
        method: "POST",
        body: preview,
      },
    };
  }

  const submit = await apiPost<SubmitResult>(pathSuffix, body);
  const taskIds = submit.data?.task_ids;
  if (!taskIds?.length) throw new Error("No task_ids in response");
  const detail = await pollTask(taskIds[0]!, pollIntervalMs, pollTimeoutMs, quietPoll);
  return { submit: submit.data, detail };
}

function isRetryable(err: unknown): boolean {
  const msg = err instanceof Error ? err.message : String(err);
  const bad = [
    "Reference image not found",
    "Missing ",
    "Invalid ",
    "HTTP 401",
    "HTTP 400",
    "HTTP 403",
    "WeryAI business status",
    "WeryAI task failed",
    "exceeds WeryAI",
    "parameter error",
    "authentication",
    "API status",
    "Parameter",
    "required",
  ];
  return !bad.some((b) => msg.includes(b));
}

async function saveResultImages(
  images: string[],
  outputPath: string,
  noDownload: boolean,
  quietLogs: boolean
): Promise<string[]> {
  if (noDownload || !images.length) return [];
  const userExt = path.extname(path.resolve(outputPath));
  const resolved = normalizeOutputImagePath(outputPath);
  const dir = path.dirname(resolved);
  const fallbackExt = path.extname(resolved) || ".png";
  const base = path.basename(resolved, fallbackExt);
  const saved: string[] = [];
  for (let i = 0; i < images.length; i++) {
    const { bytes, contentType } = await fetchImageBytes(images[i]!, quietLogs);
    let ext = fallbackExt;
    if (!userExt && contentType) {
      ext = extensionForContentType(contentType) ?? fallbackExt;
    }
    const target =
      images.length === 1 ? path.join(dir, `${base}${ext}`) : path.join(dir, `${base}-${i + 1}${ext}`);
    await mkdir(path.dirname(target), { recursive: true });
    await writeFile(target, bytes);
    saved.push(target);
    if (!quietLogs) console.error(`Saved: ${target}`);
  }
  return saved;
}

type GenResult = {
  success: boolean;
  dryRun?: boolean;
  outputPath: string;
  model: string;
  attempts: number;
  error: string | null;
  images?: string[];
  submit?: SubmitResult;
  detail?: TaskDetail;
  requestPreview?: RequestPreview;
};

async function generateOne(
  label: string,
  prompt: string,
  args: CliArgs,
  quietPoll: boolean
): Promise<GenResult> {
  let outputPath = args.imagePath ? normalizeOutputImagePath(args.imagePath) : "";
  const model = resolveModel(args);
  const styledPrompt = applyStylePreset(prompt, args.style);
  const aspectRatio = resolveAspectRatio(args);
  const resolution = resolveResolution(args);

  console.error(`Using model ${model} (${label})`);
  console.error(`Switch model: --model <id> | EXTEND.md default_model | IMAGE_GEN_DEFAULT_MODEL`);

  let attempts = 0;
  while (attempts < MAX_ATTEMPTS) {
    attempts++;
    try {
      assertWeryaiPromptBounds(styledPrompt, args.negativePrompt);
      if (args.referenceImages.length > 0) await validateReferenceImages(args.referenceImages);
      const { submit, detail, dryRun, requestPreview } = await runAsyncGeneration({
        prompt: styledPrompt,
        model,
        aspectRatio,
        resolution,
        negativePrompt: args.negativePrompt,
        imageNumber: args.n,
        referenceImages: args.referenceImages,
        webhookUrl: args.webhookUrl,
        useWebSearch: args.useWebSearch,
        callerId: args.callerId,
        pollIntervalMs: args.pollIntervalMs,
        pollTimeoutMs: args.pollTimeoutMs,
        quietPoll,
        dryRun: args.dryRun,
      });
      if (dryRun) {
        return {
          success: true,
          dryRun: true,
          outputPath: "",
          model,
          attempts,
          error: null,
          submit,
          detail,
          requestPreview,
        };
      }
      if (!detail) {
        throw new Error("Gateway returned no task detail");
      }
      if (detail.task_status === "failed") {
        throw new Error(
          detail.msg ? `WeryAI task failed: ${detail.msg}` : "WeryAI task failed (see data.msg)"
        );
      }
      const images = detail.images ?? [];
      if (outputPath && !args.noDownload) {
        try {
          const savedPaths = await saveResultImages(images, args.imagePath!, args.noDownload, args.json);
          if (savedPaths.length > 0) outputPath = savedPaths[0]!;
        } catch (downloadErr) {
          const msg = downloadErr instanceof Error ? downloadErr.message : String(downloadErr);
          if (!args.json) {
            console.error(`[${label}] Generation succeeded but download failed — URLs still valid; retry download or use --no-download. ${msg}`);
          }
          return {
            success: false,
            outputPath,
            model,
            attempts,
            error: `Download after succeed: ${msg}`,
            images,
            submit,
            detail,
          };
        }
      }
      return {
        success: true,
        outputPath,
        model,
        attempts,
        error: null,
        images,
        submit,
        detail,
      };
    } catch (e) {
      const message = e instanceof Error ? e.message : String(e);
      if (attempts < MAX_ATTEMPTS && isRetryable(e)) {
        console.error(`[${label}] Attempt ${attempts}/${MAX_ATTEMPTS} failed, retrying... (${message})`);
        continue;
      }
      return { success: false, outputPath, model, attempts, error: message };
    }
  }
  return { success: false, outputPath, model, attempts: MAX_ATTEMPTS, error: "Unknown failure" };
}

// --- Batch ---

async function loadBatchTasks(batchFilePath: string): Promise<{
  tasks: BatchTaskInput[];
  jobs: number | null;
  batchDir: string;
}> {
  const resolved = path.resolve(batchFilePath);
  const content = await readFile(resolved, "utf8");
  const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile;
  const batchDir = path.dirname(resolved);
  if (Array.isArray(parsed)) return { tasks: parsed, jobs: null, batchDir };
  if (parsed && typeof parsed === "object" && Array.isArray(parsed.tasks)) {
    const jobs =
      parsed.jobs !== undefined && parsed.jobs !== null
        ? typeof parsed.jobs === "number"
          ? parsed.jobs
          : parseInt(String(parsed.jobs), 10)
        : null;
    if (parsed.jobs !== undefined && parsed.jobs !== null && (jobs === null || jobs < 1)) {
      throw new Error("Invalid batch file: jobs must be a positive integer.");
    }
    return { tasks: parsed.tasks, jobs: jobs && jobs > 0 ? jobs : null, batchDir };
  }
  throw new Error("Invalid batch file. Expected array or { tasks: [...] }.");
}

function resolveBatchPath(batchDir: string, filePath: string): string {
  return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
}

function createTaskArgs(base: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
  const refs = task.ref?.map((p) => resolveBatchPath(batchDir, p)) ?? [];
  return {
    ...base,
    prompt: task.prompt ?? null,
    promptFiles: task.promptFiles?.map((f) => resolveBatchPath(batchDir, f)) ?? [],
    imagePath: task.image ? resolveBatchPath(batchDir, task.image) : null,
    model: task.model ?? base.model,
    style: task.style ?? base.style,
    aspectRatio: task.ar ?? base.aspectRatio,
    size: task.size ?? base.size,
    quality: task.quality ?? base.quality,
    imageSize: task.imageSize ?? base.imageSize,
    referenceImages: refs,
    n: task.n ?? base.n,
    webhookUrl: task.webhook_url ?? base.webhookUrl,
    negativePrompt: task.negative_prompt ?? base.negativePrompt,
    resolution: task.resolution ?? base.resolution,
    useWebSearch: task.use_web_search !== undefined && task.use_web_search !== null ? task.use_web_search : base.useWebSearch,
    callerId: task.caller_id !== undefined && task.caller_id !== null ? task.caller_id : base.callerId,
    batchFile: null,
    jobs: base.jobs,
    json: base.json,
    help: false,
  };
}

function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
  const requested = jobs ?? Math.min(taskCount, maxWorkers);
  return Math.max(1, Math.min(requested, taskCount, maxWorkers));
}

function createGate(limit: { concurrency: number; startIntervalMs: number }) {
  const state = { active: 0, lastStartedAt: 0 };
  return async function acquire(): Promise<() => void> {
    while (true) {
      const now = Date.now();
      const cap = state.active < limit.concurrency;
      const gap = now - state.lastStartedAt >= limit.startIntervalMs;
      if (cap && gap) {
        state.active++;
        state.lastStartedAt = now;
        return () => {
          state.active = Math.max(0, state.active - 1);
        };
      }
      await new Promise((r) => setTimeout(r, POLL_WAIT_MS));
    }
  };
}

// --- Legacy subcommand parse (text-to-image / image-to-image) ---

type LegacyOpts = {
  model: string;
  prompt: string;
  aspectRatio: string;
  negativePrompt?: string;
  resolution?: string;
  imageNumber: number;
  webhookUrl?: string;
  output?: string;
  noDownload: boolean;
  pollIntervalMs: number;
  pollTimeoutMs: number;
  json: boolean;
};

function parseLegacyCommon(args: string[], startIdx: number): { opts: LegacyOpts; next: number } {
  const opts: LegacyOpts = {
    model: "",
    prompt: "",
    aspectRatio: "",
    imageNumber: 1,
    noDownload: false,
    pollIntervalMs: 2500,
    pollTimeoutMs: 600_000,
    json: false,
  };
  let i = startIdx;
  const take = (flag: string): string => {
    const v = args[++i];
    if (!v) throw new Error(`Missing value for ${flag}`);
    return v;
  };
  while (i < args.length) {
    const a = args[i]!;
    if (a === "--json") {
      opts.json = true;
      i++;
      continue;
    }
    if (a === "--no-download") {
      opts.noDownload = true;
      i++;
      continue;
    }
    if (a === "--model" || a === "-m") opts.model = take(a);
    else if (a === "--prompt") opts.prompt = take(a);
    else if (a === "--aspect-ratio") opts.aspectRatio = take(a);
    else if (a === "--negative-prompt") opts.negativePrompt = take(a);
    else if (a === "--resolution") opts.resolution = take(a);
    else if (a === "--image-number") {
      opts.imageNumber = parseInt(take(a), 10);
      if (Number.isNaN(opts.imageNumber) || opts.imageNumber < 1) throw new Error("Invalid --image-number");
    } else if (a === "--webhook-url") opts.webhookUrl = take(a);
    else if (a === "--output" || a === "-o") opts.output = take(a);
    else if (a === "--poll-interval-ms") opts.pollIntervalMs = parseInt(take(a), 10);
    else if (a === "--poll-timeout-ms") opts.pollTimeoutMs = parseInt(take(a), 10);
    else if (a.startsWith("-")) throw new Error(`Unknown option: ${a}`);
    else break;
    i++;
  }
  return { opts, next: i };
}

async function runLegacyTextToImage(args: string[]): Promise<void> {
  const extend = await loadExtendConfig();
  const { opts, next } = parseLegacyCommon(args, 0);
  if (next < args.length) throw new Error(`Unexpected arg: ${args[next]}`);
  if (!opts.prompt) throw new Error("--prompt is required");
  if (!opts.aspectRatio) throw new Error("--aspect-ratio is required");
  const cli = parseArgs([]);
  const merged: CliArgs = mergeConfig(
    {
      ...cli,
      prompt: opts.prompt,
      aspectRatio: opts.aspectRatio,
      model: opts.model || null,
      resolution: opts.resolution ?? null,
      negativePrompt: opts.negativePrompt ?? null,
      webhookUrl: opts.webhookUrl ?? null,
      n: opts.imageNumber,
      imagePath: opts.output ?? null,
      noDownload: opts.noDownload,
      pollIntervalMs: opts.pollIntervalMs,
      pollTimeoutMs: opts.pollTimeoutMs,
      json: opts.json,
      quality: opts.resolution ? null : "2k",
    },
    extend
  );
  const r = await generateOne("legacy-text", opts.prompt, merged, opts.json);
  if (!r.success) throw new Error(r.error ?? "failed");
  if (opts.json)
    console.log(JSON.stringify({ submit: r.submit, detail: r.detail, images: r.images }, null, 2));
  else console.log((r.images ?? []).join("\n"));
}

async function runLegacyImageToImage(args: string[]): Promise<void> {
  const extend = await loadExtendConfig();
  const refs: string[] = [];
  const filtered: string[] = [];
  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--ref") {
      const v = args[++i];
      if (!v) throw new Error("Missing value for --ref");
      refs.push(v);
    } else filtered.push(args[i]!);
  }
  const { opts, next } = parseLegacyCommon(filtered, 0);
  if (next < filtered.length) throw new Error(`Unexpected arg: ${filtered[next]}`);
  if (!opts.prompt) throw new Error("--prompt is required");
  if (!opts.aspectRatio) throw new Error("--aspect-ratio is required");
  if (!refs.length) throw new Error("At least one --ref required");
  const cli = parseArgs([]);
  const merged: CliArgs = mergeConfig(
    {
      ...cli,
      prompt: opts.prompt,
      aspectRatio: opts.aspectRatio,
      model: opts.model || null,
      resolution: opts.resolution ?? null,
      negativePrompt: opts.negativePrompt ?? null,
      webhookUrl: opts.webhookUrl ?? null,
      n: opts.imageNumber,
      imagePath: opts.output ?? null,
      referenceImages: refs,
      noDownload: opts.noDownload,
      pollIntervalMs: opts.pollIntervalMs,
      pollTimeoutMs: opts.pollTimeoutMs,
      json: opts.json,
      quality: opts.resolution ? null : "2k",
    },
    extend
  );
  const r = await generateOne("legacy-i2i", opts.prompt, merged, opts.json);
  if (!r.success) throw new Error(r.error ?? "failed");
  if (opts.json)
    console.log(JSON.stringify({ submit: r.submit, detail: r.detail, images: r.images }, null, 2));
  else console.log((r.images ?? []).join("\n"));
}

async function runLegacyStatus(args: string[]): Promise<void> {
  let taskId = "";
  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--task-id") taskId = args[++i] ?? "";
  }
  if (!taskId) throw new Error("--task-id is required");
  const r = await apiGet<TaskDetail>(`/v1/generation/${encodeURIComponent(taskId)}/status`);
  console.log(JSON.stringify(r, null, 2));
}

// --- main flows ---

async function runSingleMode(args: CliArgs): Promise<void> {
  let prompt: string | null = args.prompt;
  if (!prompt && args.promptFiles.length > 0) {
    prompt = await readPromptFromFiles(args.promptFiles);
  }
  if (!prompt) prompt = await readPromptFromStdin();
  if (!prompt) throw new Error("Prompt is required (--prompt, --promptfiles, or stdin)");
  if (!args.imagePath && !args.dryRun) throw new Error("--image <path> is required for single mode");
  if (args.referenceImages.length > 0) await validateReferenceImages(args.referenceImages);

  const r = await generateOne("single", prompt, args, args.json);
  if (!r.success) throw new Error(r.error ?? "Generation failed");
  if (args.dryRun) {
    console.log(
      JSON.stringify(
        {
          mode: "dry-run",
          model: r.model,
          prompt: prompt.slice(0, 200),
          request: r.requestPreview,
        },
        null,
        2
      )
    );
    return;
  }
  if (args.json) {
    console.log(
      JSON.stringify(
        {
          savedHint: r.outputPath,
          model: r.model,
          attempts: r.attempts,
          images: r.images,
          prompt: prompt.slice(0, 200),
        },
        null,
        2
      )
    );
    return;
  }
  console.log(r.outputPath);
}

async function runBatchMode(args: CliArgs, extend: ExtendConfig): Promise<void> {
  if (!args.batchFile) throw new Error("--batchfile required");
  const { tasks: inputs, jobs: batchJobs, batchDir } = await loadBatchTasks(args.batchFile);
  if (!inputs.length) throw new Error("Batch has no tasks");

  const maxW = getMaxWorkers(extend);
  const workerCount = getWorkerCount(inputs.length, args.jobs ?? batchJobs, maxW);
  const gate = createGate(getConcurrency());

  type Row = {
    id: string;
    success: boolean;
    dryRun?: boolean;
    outputPath: string;
    model: string;
    attempts: number;
    error: string | null;
    requestPreview?: RequestPreview;
  };
  const results: Row[] = new Array(inputs.length);

  if (inputs.length === 1) {
    const t = inputs[0]!;
    if (t.provider && !args.json)
      console.error(`[batch] Ignoring task.provider=${JSON.stringify(t.provider)} (single-gateway mode)`);
    const taskArgs = createTaskArgs(args, t, batchDir);
    const merged = mergeConfig(taskArgs, extend);
    if (!merged.quality) merged.quality = "2k";
    let prompt: string | null = taskArgs.prompt;
    if (!prompt && taskArgs.promptFiles.length)
      prompt = await readPromptFromFiles(taskArgs.promptFiles);
    if (!prompt) throw new Error("Task missing prompt");
    if (!taskArgs.imagePath && !args.dryRun) throw new Error("Task missing image path");
    const r = await generateOne(t.id || "task-01", prompt, merged, args.json);
    results[0] = {
      id: t.id || "task-01",
      success: r.success,
      dryRun: r.dryRun,
      outputPath: r.outputPath,
      model: r.model,
      attempts: r.attempts,
      error: r.error,
      requestPreview: r.requestPreview,
    };
  } else {
    console.error(`Batch: ${inputs.length} tasks, ${workerCount} workers.`);
    let next = 0;
    const workers = Array.from({ length: workerCount }, async () => {
      while (true) {
        const idx = next++;
        if (idx >= inputs.length) return;
        const t = inputs[idx]!;
        if (t.provider && !args.json)
          console.error(`[batch] Ignoring task.provider=${JSON.stringify(t.provider)} (single-gateway mode)`);
        const taskArgs = createTaskArgs(args, t, batchDir);
        const merged = mergeConfig(taskArgs, extend);
        if (!merged.quality) merged.quality = "2k";
        let prompt: string | null = taskArgs.prompt;
        if (!prompt && taskArgs.promptFiles.length) {
          try {
            prompt = await readPromptFromFiles(taskArgs.promptFiles);
          } catch (e) {
            results[idx] = {
              id: t.id || `task-${String(idx + 1).padStart(2, "0")}`,
              success: false,
              outputPath: "",
              model: modelLabelForReport(merged),
              attempts: 0,
              error: (e as Error).message,
            };
            continue;
          }
        }
        if (!prompt) {
          results[idx] = {
            id: t.id || `task-${String(idx + 1).padStart(2, "0")}`,
            success: false,
            outputPath: "",
            model: modelLabelForReport(merged),
            attempts: 0,
            error: "Missing prompt",
          };
          continue;
        }
        if (!taskArgs.imagePath && !args.dryRun) {
          results[idx] = {
            id: t.id || `task-${String(idx + 1).padStart(2, "0")}`,
            success: false,
            outputPath: "",
            model: modelLabelForReport(merged),
            attempts: 0,
            error: "Missing image output path",
          };
          continue;
        }
        const release = await gate();
        try {
          const r = await generateOne(t.id || `task-${String(idx + 1).padStart(2, "0")}`, prompt, merged, args.json);
          results[idx] = {
            id: t.id || `task-${String(idx + 1).padStart(2, "0")}`,
            success: r.success,
            dryRun: r.dryRun,
            outputPath: r.outputPath,
            model: r.model,
            attempts: r.attempts,
            error: r.error,
            requestPreview: r.requestPreview,
          };
        } finally {
          release();
        }
      }
    });
    await Promise.all(workers);
  }

  if (args.dryRun) {
    const failed = results.filter((x) => !x.success).length;
    if (args.json) {
      console.log(
        JSON.stringify(
          {
            mode: "batch-dry-run",
            total: results.length,
            previewed: results.length - failed,
            failed,
            results,
          },
          null,
          2
        )
      );
    } else {
      for (const row of results) {
        if (row.requestPreview) {
          console.log(JSON.stringify({ id: row.id, request: row.requestPreview }, null, 2));
        } else {
          console.error(`[dry-run] ${row.id}: ${row.error}`);
        }
      }
    }
    if (failed > 0) process.exitCode = 1;
    return;
  }

  const ok = results.filter((x) => x?.success).length;
  const fail = results.length - ok;
  console.error("");
  console.error("Batch generation summary:");
  console.error(`- Total: ${results.length}`);
  console.error(`- Succeeded: ${ok}`);
  console.error(`- Failed: ${fail}`);
  if (fail > 0) {
    console.error("Failures:");
    for (const x of results) {
      if (!x.success) console.error(`- ${x.id}: ${x.error}`);
    }
  }
  if (args.json) {
    console.log(JSON.stringify({ mode: "batch", total: results.length, succeeded: ok, failed: fail, results }, null, 2));
  }
  if (fail > 0) process.exitCode = 1;
}

async function main(): Promise<void> {
  const argv = process.argv.slice(2);
  if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
    printUsage();
    return;
  }

  await loadEnv();

  if (argv[0] === "text-to-image") {
    try {
      await runLegacyTextToImage(argv.slice(1));
    } catch (e) {
      console.error((e as Error).message);
      process.exit(1);
    }
    return;
  }
  if (argv[0] === "image-to-image") {
    try {
      await runLegacyImageToImage(argv.slice(1));
    } catch (e) {
      console.error((e as Error).message);
      process.exit(1);
    }
    return;
  }
  if (argv[0] === "status") {
    try {
      await runLegacyStatus(argv.slice(1));
    } catch (e) {
      console.error((e as Error).message);
      process.exit(1);
    }
    return;
  }

  const extend = await loadExtendConfig();
  let args = parseArgs(argv);
  if (args.help) {
    printUsage();
    return;
  }
  args = mergeConfig(args, extend);
  if (!args.quality) args.quality = "2k";

  try {
    if (args.batchFile) await runBatchMode(args, extend);
    else await runSingleMode(args);
  } catch (e) {
    console.error((e as Error).message);
    process.exit(1);
  }
}

main();


path: scripts/package.json
content: {
  "name": "avatar-image-generator-scripts",
  "private": true,
  "type": "module",
  "description": "CLI for async text-to-image and image-to-image (Bun)",
  "scripts": {
    "test": "npx -y bun test"
  }
}


path: scripts/recommend-model.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { discoverImageModels } from "./discover-models.mjs";

const homeDir = process.env.HOME ?? "";
const skillDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bundledRegistryPath = path.join(skillDir, "references", "config", "starter-models.json");
const skillNamespace = process.env.IMAGE_SKILL_NAMESPACE?.trim() || "avatar-image-generator";
const VALID_WORKFLOWS = new Set(["general", "cover", "rednote", "infographic", "comic", "article"]);
const VALID_QUALITIES = new Set(["fast", "balanced", "high"]);
const AUTO_DISCOVER = Symbol("auto-discover");
const ROLE_PROFILES = {
  "cover-hero": {
    workflow: "cover",
    needRef: false,
    textHeavy: false,
    preferredQuality: "high",
    preferredStyles: ["editorial", "cinematic", "photoreal"],
  },
  "cover-typography-space": {
    workflow: "cover",
    needRef: false,
    textHeavy: true,
    preferredQuality: "high",
    preferredStyles: ["editorial", "poster"],
  },
  "rednote-cover": {
    workflow: "rednote",
    needRef: true,
    textHeavy: true,
    preferredQuality: "balanced",
    preferredStyles: ["editorial", "flat-illustration", "anime"],
  },
  "rednote-text-card": {
    workflow: "rednote",
    needRef: true,
    textHeavy: true,
    preferredQuality: "balanced",
    preferredStyles: ["flat-illustration", "editorial", "infographic"],
  },
  "infographic-dense": {
    workflow: "infographic",
    needRef: false,
    textHeavy: true,
    preferredQuality: "high",
    preferredStyles: ["diagram", "infographic"],
  },
  "infographic-minimal": {
    workflow: "infographic",
    needRef: false,
    textHeavy: false,
    preferredQuality: "balanced",
    preferredStyles: ["editorial", "infographic"],
  },
  "comic-cover": {
    workflow: "comic",
    needRef: true,
    textHeavy: false,
    preferredQuality: "high",
    preferredStyles: ["comic", "manga", "cinematic"],
  },
  "comic-character-sheet": {
    workflow: "comic",
    needRef: true,
    textHeavy: false,
    preferredQuality: "high",
    preferredStyles: ["comic", "manga", "flat-illustration"],
  },
  "comic-page": {
    workflow: "comic",
    needRef: true,
    textHeavy: false,
    preferredQuality: "high",
    preferredStyles: ["comic", "manga", "flat-illustration"],
  },
  "article-framework": {
    workflow: "article",
    needRef: true,
    textHeavy: true,
    preferredQuality: "balanced",
    preferredStyles: ["diagram", "infographic", "editorial"],
  },
  "article-scene": {
    workflow: "article",
    needRef: true,
    textHeavy: false,
    preferredQuality: "high",
    preferredStyles: ["editorial", "watercolor", "photoreal"],
  },
  "article-flowchart": {
    workflow: "article",
    needRef: true,
    textHeavy: true,
    preferredQuality: "balanced",
    preferredStyles: ["diagram", "infographic", "flat-illustration"],
  },
};
const VALID_ROLES = new Set(Object.keys(ROLE_PROFILES));

function defaultArgs() {
  return {
    workflow: "general",
    role: null,
    needRef: false,
    textHeavy: false,
    quality: "balanced",
    style: null,
    modality: null,
    registryPath: null,
    json: false,
  };
}

function parseArgs(argv) {
  const args = defaultArgs();
  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--workflow") args.workflow = argv[++i] ?? args.workflow;
    else if (current === "--role") args.role = argv[++i] ?? null;
    else if (current === "--need-ref") args.needRef = true;
    else if (current === "--text-heavy") args.textHeavy = true;
    else if (current === "--quality") args.quality = argv[++i] ?? args.quality;
    else if (current === "--style") args.style = argv[++i] ?? null;
    else if (current === "--modality") args.modality = argv[++i] ?? null;
    else if (current === "--registry") args.registryPath = argv[++i] ?? null;
    else if (current === "--json") args.json = true;
    else if (current === "-h" || current === "--help") {
      printHelp();
      process.exit(0);
    }
  }

  if (!VALID_WORKFLOWS.has(args.workflow)) {
    throw new Error(`Invalid workflow: ${args.workflow}`);
  }
  if (args.role && !VALID_ROLES.has(args.role)) {
    throw new Error(`Invalid role: ${args.role}`);
  }
  if (!VALID_QUALITIES.has(args.quality)) {
    throw new Error(`Invalid quality: ${args.quality}`);
  }
  if (!args.modality) {
    args.modality = args.needRef ? "image-to-image" : "text-to-image";
  }
  return args;
}

function printHelp() {
  console.log(`Usage:
  node scripts/recommend-model.mjs --workflow <general|cover|rednote|infographic|comic|article> [options]

Options:
  --workflow <name>   Target workflow (default: general)
  --role <name>       Optional sub-task role such as cover-hero, comic-page, infographic-dense
  --need-ref          Prefer models that support reference images
  --text-heavy        Prefer models that handle text-heavy or label-heavy images better
  --quality <level>   fast | balanced | high (default: balanced)
  --style <name>      Optional style hint such as editorial, diagram, comic, photoreal
  --modality <name>   text-to-image | image-to-image (default inferred from --need-ref)
  --registry <path>   Explicit MODELS.json path
  --json              Print JSON output
  -h, --help          Show help`);
}

export function getRoleProfile(role) {
  return role ? ROLE_PROFILES[role] ?? null : null;
}

export function resolveRecommendationOptions(args) {
  const roleProfile = getRoleProfile(args.role);
  const preferredStyles = new Set();
  if (args.style) preferredStyles.add(args.style);
  for (const style of roleProfile?.preferredStyles ?? []) preferredStyles.add(style);

  return {
    ...args,
    workflow: args.workflow === "general" && roleProfile?.workflow ? roleProfile.workflow : args.workflow,
    needRef: Boolean(args.needRef || roleProfile?.needRef),
    textHeavy: Boolean(args.textHeavy || roleProfile?.textHeavy),
    preferredQuality: roleProfile?.preferredQuality ?? null,
    preferredStyles: [...preferredStyles],
    roleProfile,
  };
}

export function parseSimpleFrontmatter(markdown) {
  const match = markdown.match(/^---\n([\s\S]*?)\n---/);
  if (!match) return {};
  const out = {};
  for (const line of match[1].split("\n")) {
    const idx = line.indexOf(":");
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const value = line.slice(idx + 1).trim().replace(/^"(.*)"$/, "$1");
    out[key] = value;
  }
  return out;
}

export function candidateRegistryPaths(cwd, overridePath = null) {
  if (overridePath) return [path.resolve(cwd, overridePath)];
  return [
    path.join(cwd, ".image-skills", skillNamespace, "MODELS.json"),
    homeDir ? path.join(homeDir, ".image-skills", skillNamespace, "MODELS.json") : null,
    bundledRegistryPath,
  ].filter(Boolean);
}

function candidateExtendPaths(cwd) {
  return [
    path.join(cwd, ".image-skills", skillNamespace, "EXTEND.md"),
    homeDir ? path.join(homeDir, ".image-skills", skillNamespace, "EXTEND.md") : null,
  ].filter(Boolean);
}

function readFirstExisting(paths) {
  for (const filePath of paths) {
    if (fs.existsSync(filePath)) return filePath;
  }
  return null;
}

export function loadDefaultModel(cwd) {
  const envModel = process.env.IMAGE_GEN_DEFAULT_MODEL?.trim();
  if (envModel) return { model: envModel, source: "IMAGE_GEN_DEFAULT_MODEL" };

  const extendPath = readFirstExisting(candidateExtendPaths(cwd));
  if (!extendPath) return { model: null, source: null };
  const parsed = parseSimpleFrontmatter(fs.readFileSync(extendPath, "utf8"));
  const model = typeof parsed.default_model === "string" ? parsed.default_model.trim() : "";
  if (!model) return { model: null, source: null };
  return { model, source: path.relative(cwd, extendPath) || "EXTEND.md" };
}

export function loadRegistry(cwd, overridePath = null) {
  const registryPath = readFirstExisting(candidateRegistryPaths(cwd, overridePath));
  if (!registryPath) {
    return { registry: null, path: null };
  }
  const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8"));
  if (!parsed || !Array.isArray(parsed.models)) {
    throw new Error(`Invalid model registry: ${registryPath}`);
  }
  return { registry: parsed, path: registryPath };
}

function registrySourceForPath(registryPath) {
  if (!registryPath) return null;
  return path.resolve(registryPath) === path.resolve(bundledRegistryPath) ? "bundled-starter" : "local-registry";
}

export function scoreModel(model, options, defaultModel) {
  const reasons = [];
  let score = 0;

  const modalities = Array.isArray(model.modalities) ? model.modalities : [];
  if (options.modality && modalities.length > 0) {
    if (modalities.includes(options.modality)) {
      score += 3;
      reasons.push(`supports ${options.modality}`);
    } else {
      score -= 6;
      reasons.push(`does not list ${options.modality}`);
    }
  }

  if (options.workflow && Array.isArray(model.best_for)) {
    if (model.best_for.includes(options.workflow)) {
      score += 5;
      reasons.push(`matched workflow ${options.workflow}`);
    } else if (options.workflow !== "general") {
      score -= 1;
    }
  }

  if (options.role) {
    if (Array.isArray(model.best_for_roles) && model.best_for_roles.includes(options.role)) {
      score += 6;
      reasons.push(`matched role ${options.role}`);
    } else if (options.role) {
      score -= 1;
    }
  }

  if (options.needRef) {
    if (model.supports_ref) {
      score += 4;
      reasons.push("supports reference images");
    } else {
      score -= 8;
      reasons.push("missing reference-image support");
    }
  }

  if (options.textHeavy) {
    if (model.supports_text_heavy) {
      score += 4;
      reasons.push("handles text-heavy layouts");
    } else {
      score -= 3;
      reasons.push("not marked for text-heavy layouts");
    }
  }

  if (options.quality && model.quality_tier === options.quality) {
    score += 3;
    reasons.push(`matched quality tier ${options.quality}`);
  }

  if (Array.isArray(model.strengths)) {
    for (const style of options.preferredStyles ?? []) {
      if (model.strengths.includes(style)) {
        score += style === options.style ? 3 : 2;
        reasons.push(`matched style strength ${style}`);
      }
    }
  }

  if (options.preferredQuality && model.quality_tier === options.preferredQuality && options.quality !== options.preferredQuality) {
    score += 1;
    reasons.push(`fits preferred role quality ${options.preferredQuality}`);
  }

  if (defaultModel && model.id === defaultModel) {
    score += 2;
    reasons.push("matches configured default model");
  }

  return { score, reasons };
}

export function recommendModels(registry, options, defaultModel = null) {
  const resolvedOptions = resolveRecommendationOptions(options);
  return registry.models
    .map((model) => {
      const scored = scoreModel(model, resolvedOptions, defaultModel);
      return {
        id: model.id,
        label: model.label ?? model.id,
        score: scored.score,
        reasons: scored.reasons,
        quality_tier: model.quality_tier ?? null,
        supports_ref: Boolean(model.supports_ref),
        supports_text_heavy: Boolean(model.supports_text_heavy),
        best_for: Array.isArray(model.best_for) ? model.best_for : [],
        best_for_roles: Array.isArray(model.best_for_roles) ? model.best_for_roles : [],
        notes: model.notes ?? null,
      };
    })
    .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
}

export async function buildRecommendationSummary({ cwd, args, autoDiscoveredRegistry = AUTO_DISCOVER }) {
  const resolvedArgs = resolveRecommendationOptions(args);
  const { registry, path: registryPath } = loadRegistry(cwd, args.registryPath);
  const { model: defaultModel, source: defaultSource } = loadDefaultModel(cwd);
  const registrySource = registrySourceForPath(registryPath);
  const discoveredRegistry = registry
    ? null
    : autoDiscoveredRegistry === AUTO_DISCOVER
      ? await discoverImageModels().catch(() => null)
      : autoDiscoveredRegistry;

  if (!registry && !discoveredRegistry) {
    return {
      ok: false,
      workflow: resolvedArgs.workflow,
      requestedWorkflow: args.workflow,
      role: args.role,
      registryPath: null,
      defaultModel,
      defaultModelSource: defaultSource,
      recommendations: defaultModel
        ? [
            {
              id: defaultModel,
              label: defaultModel,
              score: 1,
              reasons: ["no model registry found; falling back to configured default model"],
            },
          ]
        : [],
      notes: [
        "No MODELS.json registry was found.",
        `Create .image-skills/${skillNamespace}/MODELS.json or run \`npm run discover-image-models -- --out .image-skills/${skillNamespace}/MODELS.json\` to let this skill rank models by capability.`,
      ],
    };
  }

  const activeRegistry = registry ?? discoveredRegistry;
  const activeRegistryPath = registryPath ?? "auto-discovered from WeryAI docs";

  return {
    ok: true,
    workflow: resolvedArgs.workflow,
    requestedWorkflow: args.workflow,
    role: args.role,
    registryPath: activeRegistryPath,
    defaultModel,
    defaultModelSource: defaultSource,
    registrySource: registry ? registrySource : "auto-discovered",
    resolvedNeeds: {
      needRef: resolvedArgs.needRef,
      textHeavy: resolvedArgs.textHeavy,
      preferredQuality: resolvedArgs.preferredQuality,
      preferredStyles: resolvedArgs.preferredStyles,
    },
    recommendations: recommendModels(activeRegistry, resolvedArgs, defaultModel).slice(0, 5),
    notes: [
      resolvedArgs.needRef ? "Reference-image support was weighted heavily." : "Reference-image support was not required.",
      resolvedArgs.textHeavy ? "Text-heavy handling was weighted heavily." : "Text-heavy handling was not required.",
      args.role ? `Role profile ${args.role} refined the recommendation.` : "No sub-task role profile was applied.",
      registrySource === "bundled-starter"
        ? "Using the bundled WeryAI starter model registry shipped with this skill."
        : registry
          ? "Using local MODELS.json registry."
          : "Fell back to auto-discovered WeryAI image model list.",
    ],
  };
}

function formatText(summary) {
  const lines = [];
  lines.push(`Workflow: ${summary.workflow}`);
  if (summary.role) lines.push(`Role: ${summary.role}`);
  if (summary.registryPath) lines.push(`Registry: ${summary.registryPath}`);
  if (summary.defaultModel) lines.push(`Configured default: ${summary.defaultModel} (${summary.defaultModelSource})`);
  if (!summary.recommendations.length) {
    lines.push("No concrete model recommendation is available yet.");
  } else {
    lines.push("Recommended models:");
    for (const item of summary.recommendations) {
      lines.push(`- ${item.id} (score ${item.score})`);
      if (item.reasons?.length) lines.push(`  reasons: ${item.reasons.join("; ")}`);
    }
  }
  for (const note of summary.notes ?? []) lines.push(`note: ${note}`);
  return lines.join("\n");
}

const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const cwd = process.cwd();
    const args = parseArgs(process.argv.slice(2));
    const summary = await buildRecommendationSummary({ cwd, args });
    if (args.json) {
      console.log(JSON.stringify(summary, null, 2));
    } else {
      console.log(formatText(summary));
    }
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/setup.mjs
content: 
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const defaultSkillDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bundledModelsPath = path.join(defaultSkillDir, "references", "config", "starter-models.json");
const skillNamespace = process.env.IMAGE_SKILL_NAMESPACE?.trim() || "avatar-image-generator";
const defaultConfigDir = `.image-skills/${skillNamespace}`;
const defaultEnvPath = `${defaultConfigDir}/.env`;
const defaultExtendPath = `${defaultConfigDir}/EXTEND.md`;
const defaultStarterModel = {
  id: "GEMINI_3_1_FLASH_IMAGE",
  label: "Nano Banana 2",
};

function printHelp() {
  console.log(`Usage:
  node scripts/setup.mjs [options]

Options:
  --project <path>      Project directory to write/read .image-skills config (default: cwd)
  --workflow <name>     Optional focus: general|cover|rednote|infographic|comic|article|compress
  --force-models        Overwrite MODELS.json if it already exists
  --api-key <secret>    Write IMAGE_GEN_API_KEY into local skill config
  --persist-api-key     Persist current IMAGE_GEN_API_KEY from env into local skill config
  --scope <target>      Where to write the local .env: project or home (default: project)
  --json                Print JSON output
  -h, --help            Show help

What this does:
  1) Optionally writes IMAGE_GEN_API_KEY into the local skill .env
  2) Runs doctor (read-only)
  3) Ensures a MODELS.json exists (writes the bundled starter if missing)
  4) Initializes EXTEND.md with Nano Banana 2 if no default model exists yet
  5) Prints a recommend-model command for your workflow

The API key is never echoed back in output. When available, this script can persist it to ${defaultEnvPath}.`);
}

function parseArgs(argv) {
  const args = {
    project: process.cwd(),
    workflow: "general",
    forceModels: false,
    apiKey: null,
    persistApiKey: false,
    scope: "project",
    json: false,
  };
  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--project") args.project = argv[++i] ?? args.project;
    else if (current === "--workflow") args.workflow = argv[++i] ?? args.workflow;
    else if (current === "--force-models") args.forceModels = true;
    else if (current === "--api-key") args.[REDACTED_SECRET] ?? null;
    else if (current === "--persist-api-key") args.persistApiKey = true;
    else if (current === "--scope") {
      const value = argv[++i] ?? args.scope;
      if (value !== "project" && value !== "home") throw new Error(`Invalid --scope: ${value}`);
      args.scope = value;
    } else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }
  return args;
}

function resolveProvidedApiKey({ apiKey, persistApiKey, env }) {
  if (typeof apiKey === "string" && apiKey.trim()) {
    return { value: apiKey.trim(), source: "arg", requested: true };
  }
  if (persistApiKey) {
    const value = env.IMAGE_GEN_API_KEY?.trim() ?? env.IMAGE_GEN_API_KEY?.trim() ?? null;
    return { value, source: value ? "env" : "missing-env", requested: true };
  }
  return {
    value: env.IMAGE_GEN_API_KEY?.trim() ?? env.IMAGE_GEN_API_KEY?.trim() ?? null,
    source: env.IMAGE_GEN_API_KEY || env.IMAGE_GEN_API_KEY ? "env" : "none",
    requested: false,
  };
}

function serializeEnvValue(value) {
  return JSON.stringify(value);
}

function upsertEnvVar(content, key, value) {
  const lines = content ? content.split(/\r?\n/) : [];
  const pattern = new RegExp(`^\\s*${key}\\s*=`);
  let found = false;
  const nextLines = [];
  for (const line of lines) {
    if (!found && pattern.test(line) && !line.trim().startsWith("#")) {
      nextLines.push(`${key}=${serializeEnvValue(value)}`);
      found = true;
    } else if (line.length > 0) {
      nextLines.push(line);
    }
  }
  if (!found) nextLines.push(`${key}=${serializeEnvValue(value)}`);
  return `${nextLines.join("\n")}\n`;
}

function resolveEnvPath({ project, homeDir, scope }) {
  const targetRoot = scope === "home" ? homeDir : project;
  if (!targetRoot) return null;
  return path.resolve(targetRoot, defaultEnvPath);
}

function writeApiKeyConfig({ project, homeDir, scope, apiKeyInput }) {
  const envPath = resolveEnvPath({ project, homeDir, scope });
  if (!apiKeyInput.requested) {
    return { ok: true, wrote: false, path: envPath, status: "not-requested", source: apiKeyInput.source };
  }
  if (!envPath) {
    return { ok: false, wrote: false, path: null, status: "missing-home-dir", source: apiKeyInput.source };
  }
  if (!apiKeyInput.value) {
    return { ok: false, wrote: false, path: envPath, status: "missing-api-key", source: apiKeyInput.source };
  }

  const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
  const updated = upsertEnvVar(existing, "IMAGE_GEN_API_KEY", apiKeyInput.value);
  const wrote = updated !== existing;
  fs.mkdirSync(path.dirname(envPath), { recursive: true });
  if (wrote) fs.writeFileSync(envPath, updated, "utf8");
  return { ok: true, wrote, path: envPath, status: wrote ? "wrote" : "kept", source: apiKeyInput.source };
}

async function runDoctor({ project, workflow, env }) {
  const { doctorSuite } = await import("./doctor.mjs");
  return doctorSuite({ suiteDir: defaultSkillDir, projectDir: project, workflow, env });
}

async function ensureModelsRegistry({ project, force }) {
  const modelsPath = path.resolve(project, ".image-skills", skillNamespace, "MODELS.json");
  if (fs.existsSync(modelsPath) && !force) {
    return { ok: true, path: modelsPath, wrote: false, reason: "exists" };
  }
  const registry = fs.existsSync(bundledModelsPath)
    ? JSON.parse(fs.readFileSync(bundledModelsPath, "utf8"))
    : await import("./discover-models.mjs").then((m) => m.discoverImageModels());
  fs.mkdirSync(path.dirname(modelsPath), { recursive: true });
  fs.writeFileSync(modelsPath, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
  return {
    ok: true,
    path: modelsPath,
    wrote: true,
    reason: fs.existsSync(bundledModelsPath) ? "bundled-starter" : "generated-from-docs",
  };
}

function ensureDefaultExtend({ project }) {
  const extendPath = path.resolve(project, defaultExtendPath);
  if (fs.existsSync(extendPath)) {
    return {
      ok: true,
      path: extendPath,
      wrote: false,
      defaultModel: null,
    };
  }

  const content = `---
version: 1
default_model: "${defaultStarterModel.id}"
default_quality: 2k
default_aspect_ratio: "1:1"
batch:
  max_workers: 4
---
`;
  fs.mkdirSync(path.dirname(extendPath), { recursive: true });
  fs.writeFileSync(extendPath, content, "utf8");
  return {
    ok: true,
    path: extendPath,
    wrote: true,
    defaultModel: defaultStarterModel,
  };
}

function formatApiKeyLine(apiKey) {
  switch (apiKey.status) {
    case "wrote":
      return `API key config: ${apiKey.path} (wrote)`;
    case "kept":
      return `API key config: ${apiKey.path} (already up to date)`;
    case "missing-api-key":
      return `API key config: no key value was available to write to ${apiKey.path}`;
    case "missing-home-dir":
      return "API key config: HOME is unavailable, so --scope home could not be used";
    default:
      return `API key config: no local write requested (target would be ${apiKey.path ?? defaultEnvPath})`;
  }
}

function formatText(summary) {
  const lines = [];
  lines.push(`Project: ${summary.project}`);
  lines.push(formatApiKeyLine(summary.apiKey));
  lines.push(`Doctor ok: ${summary.doctor.ok ? "yes" : "no"}`);
  if (summary.doctor.errors?.length) {
    lines.push("Doctor errors:");
    for (const e of summary.doctor.errors) lines.push(`- ${e}`);
  }
  if (summary.doctor.warnings?.length) {
    lines.push("Doctor warnings:");
    for (const w of summary.doctor.warnings) lines.push(`- ${w}`);
  }
  lines.push(`Models registry: ${summary.models.path} (${summary.models.wrote ? "wrote" : "kept"})`);
  lines.push(`Default config: ${summary.extend.path} (${summary.extend.wrote ? "wrote" : "kept"})`);
  if (summary.extend.defaultModel) {
    lines.push(
      `Initialized default model: ${summary.extend.defaultModel.label} (${summary.extend.defaultModel.id}). You can switch anytime later.`
    );
  }
  lines.push("Next:");
  lines.push(`- npm run recommend-model -- --workflow ${summary.workflow} ${summary.workflow === "comic" ? "--role comic-page" : ""}`.trim());
  lines.push(`- Current default is ${defaultStarterModel.label} (${defaultStarterModel.id}); change ${defaultExtendPath} or IMAGE_GEN_DEFAULT_MODEL any time if needed`);
  if (!summary.apiKey.ok) {
    lines.push(`- API key write failed: ${summary.apiKey.status}`);
  } else if (!summary.doctor.config.apiKeyInEnv && !summary.doctor.config.apiKeyInProjectEnv && !summary.apiKey.wrote) {
    lines.push(`- Provide IMAGE_GEN_API_KEY and rerun setup with --persist-api-key, or let the agent write ${defaultEnvPath} for you`);
  }
  return lines.join("\n");
}

const isDirectRun = process.argv[1] && path.resolve(process.argv[1]).endsWith("setup.mjs");

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const apiKeyInput = resolveProvidedApiKey({ [REDACTED_SECRET], persistApiKey: args.persistApiKey, env: process.env });
    const [REDACTED_SECRET]
      project: args.project,
      homeDir: process.env.HOME ?? null,
      scope: args.scope,
      apiKeyInput,
    });
    const doctorEnv = apiKeyInput.value
      ? { ...process.env, IMAGE_GEN_API_KEY: apiKeyInput.value, IMAGE_GEN_API_KEY: apiKeyInput.value }
      : process.env;
    const doctor = await runDoctor({ project: args.project, workflow: args.workflow, env: doctorEnv });
    const models = await ensureModelsRegistry({ project: args.project, force: args.forceModels });
    const extend = ensureDefaultExtend({ project: args.project });
    const summary = {
      ok: doctor.ok && models.ok && extend.ok && apiKey.ok,
      project: path.resolve(args.project),
      workflow: args.workflow,
      apiKey,
      doctor,
      models,
      extend,
    };
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else console.log(formatText(summary));
    if (!summary.ok) process.exitCode = 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/smoke-check.mjs
content: import process from "node:process";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";

function parseArgs(argv) {
  const out = { model: null };
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === "--model") out.model = argv[++i] ?? null;
  }
  return out;
}

function runBunScript(relativeScript, args) {
  const result = spawnSync(npxCmd, ["-y", "bun", relativeScript, ...args], {
    cwd: rootDir,
    encoding: "utf8",
  });
  if (result.status !== 0) {
    throw new Error(result.stderr || result.stdout || `Command failed: ${relativeScript}`);
  }
  return result.stdout.trim();
}

const cli = parseArgs(process.argv.slice(2));
const model = cli.model ?? process.env.IMAGE_GEN_DEFAULT_MODEL ?? "smoke-model-placeholder";
const payload = runBunScript("scripts/main.ts", [
  "--prompt",
  "avatar-image-generator smoke preview",
  "--ar",
  "1:1",
  "--model",
  model,
  "--dry-run",
  "--json",
]);
console.log(payload);


path: scripts/style-menu.mjs
content: import process from "node:process";

// Keep this menu aligned with avatar-image-generator/scripts/main.ts STYLE_PRESETS.
const STYLES = [
  { id: "photoreal", en: "Photorealistic / natural photography", zh: "写实 / 真实摄影质感" },
  { id: "cinematic", en: "Cinematic / film still mood", zh: "电影感 / 剧照氛围" },
  { id: "anime", en: "Anime / colorful cartoon", zh: "日系动漫 / 彩色" },
  { id: "manga", en: "Manga / B&W screentone", zh: "漫画 / 黑白网点" },
  { id: "watercolor", en: "Watercolor illustration", zh: "水彩插画" },
  { id: "flat-illustration", en: "Flat illustration / knowledge graphic", zh: "扁平插画 / 知识图" },
  { id: "3d-render", en: "3D render", zh: "3D 渲染" },
  { id: "poster", en: "Poster design", zh: "海报设计" },
  { id: "editorial", en: "Editorial / magazine layout feel", zh: "杂志插画 / 高级排版感" },
  { id: "infographic", en: "Infographic / chart style", zh: "信息图 / 图表风" },
  { id: "chalk", en: "Chalkboard / hand-drawn", zh: "粉笔黑板" },
  { id: "ink-brush", en: "East Asian ink brush", zh: "国风水墨" },
];

function printHelp() {
  console.log(`Usage:
  node scripts/style-menu.mjs [--lang en|zh]

Options:
  --lang <code>  Output language: en (default) or zh
  -h, --help     Show help

This prints the available --style presets and example commands.`);
}

function parseLang(argv) {
  const idx = argv.indexOf("--lang");
  if (idx >= 0 && argv[idx + 1]) return argv[idx + 1];
  return "en";
}

if (process.argv.includes("-h") || process.argv.includes("--help")) {
  printHelp();
  process.exit(0);
}

const lang = parseLang(process.argv.slice(2));
const labelKey = lang === "zh" ? "zh" : "en";

console.log("Available styles (--style):");
for (const s of STYLES) {
  console.log(`- ${s.id.padEnd(16)} ${s[labelKey]}`);
}

console.log("\nExample:");
console.log("  M=<model-key>");
console.log("  npx -y bun scripts/main.ts \\");
console.log("    --prompt \"a cat wearing sunglasses, neon city at night\" \\");
console.log("    --style cinematic --ar 3:4 --image out.png -m \"$M\"");


path: scripts/vendor/shared-image-generation/references/config/first-time-setup.md
content: # First-Time Setup (Before Image Generation)

## 1. First Trigger: Readiness First

Before the first real generation attempt, the agent should run a readiness pass silently:

```bash
npm run ensure-ready -- --project . --workflow <workflow>
```

This should happen before model choice. The purpose is:

- detect missing local runtime dependencies
- auto-bootstrap local script dependencies when possible
- confirm whether `IMAGE_GEN_API_KEY` is already available

If local dependencies are still missing after the check, the agent should ask the user for approval to install them on the user's behalf.

If `IMAGE_GEN_API_KEY` is missing, the agent should tell the user that image generation needs an API key and offer to configure it now. After approval, the agent should persist it to `.image-skills/image-generation/.env` or run `npm run setup -- --project . --workflow <workflow> --persist-api-key` when the key is already available in env. Do not ask the user to debug local setup manually before this readiness pass.

Treat the API key as a secret:

- prefer writing it locally on the user's behalf instead of making the user edit files
- do not ask the user to paste the key into normal chat if a local write path is available
- if the user does share the key, never echo it back or include it in follow-up messages

## 2. API Key

Get a key from the current gateway console at [WeryAI API Keys](https://weryai.com/api/keys), then configure only:

- environment variable **`IMAGE_GEN_API_KEY`** (recommended), or
- project/user `.image-skills/image-generation/.env` with:
  `IMAGE_GEN_API_KEY=<your Bearer token>`

Preferred agent path:

```bash
IMAGE_GEN_API_KEY=<secret> npm run setup -- --project . --workflow <workflow> --persist-api-key
```

## 3. Default Model Initialization

When no default model is configured yet, the **agent** should initialize the workspace with **Nano Banana 2** and clearly tell the user they can switch later at any time:

1. Start from the bundled starter registry (silent, no user involvement). If the agent needs a fresher list, refresh it from docs:

```bash
npm run discover-image-models -- --out .image-skills/image-generation/MODELS.json
```

2. Write the default config immediately:

```bash
mkdir -p .image-skills/image-generation
cat > .image-skills/image-generation/EXTEND.md << 'EOF'
---
version: 1
default_model: "GEMINI_3_1_FLASH_IMAGE"
default_quality: 2k
default_aspect_ratio: "1:1"
batch:
  max_workers: 4
---
EOF
```

3. Tell the user what happened:

> All set — I'll use **Nano Banana 2** as the default model for this workspace.
> If you want, I can switch models anytime later for different quality, style, or text-layout needs.

4. If the user wants a different model right now, run recommendation for the user's workflow:

```bash
npm run recommend-model -- --workflow <workflow> --role <role> --json
```

```bash
npm run recommend-model -- --workflow <workflow> --role <role> --json
```

5. Present alternatives only when needed:

> You're currently set to **Nano Banana 2** by default. If you'd like a better fit for this task, here are other strong options:
> 1. **GPT Image 1.5** — balanced and reliable; good for general images and text-heavy layouts
> 2. **Recraft V4 Pro** — stronger for infographic-style visuals and cleaner layout control
> 3. **WeryAI Image 2.0** — high-quality general image model, better for cover-style visuals
>
> Pick one if you want to switch now — otherwise I can keep Nano Banana 2. You can switch anytime.

6. If the user switches, update `EXTEND.md` and confirm:

> Updated — you're now defaulting to **GPT Image 1.5** instead of **Nano Banana 2**. Let me know anytime if you want to switch again or adjust defaults.

The agent **must not** ask the user to manually edit `EXTEND.md` or figure out the file format. The entire flow should feel like a guided conversation, not a config tutorial.

If a requested model is missing or looks stale, the agent should first refresh the local registry from WeryAI docs before asking the user for any platform-side details.

If the user later asks to switch models, the agent should update `EXTEND.md` in place.

## 4. Verify

```bash
npx -y bun scripts/main.ts --prompt "test" --image ./out/test.png --ar 1:1 --dry-run
```

Use `--dry-run` first to verify the request body is correct without consuming API quota. If the dry-run looks good, run without `--dry-run` to confirm end-to-end.

## 5. If the Request Is Still Vague

If the user's visual direction is underspecified, generate a brief before prompt writing:

```bash
npm run build-visual-brief -- --workflow cover --topic "Habit systems"
```

Use its question menu to guide the conversation. See `SKILL.md` § "If The Request Is Underspecified" when this file is bundled with the skill.


path: scripts/vendor/shared-image-generation/references/config/model-registry-schema.md
content: # `MODELS.json` Schema (`image-generation`)

This optional registry helps the skill recommend a model based on workflow needs instead of treating model choice as an unstructured string.

Recommended file paths, in priority order:

- `<project>/.image-skills/image-generation/MODELS.json`
- `~/.image-skills/image-generation/MODELS.json`
- `<skill>/references/config/starter-models.json` (bundled starter snapshot from current WeryAI docs)

## Example

```json
{
  "version": 1,
  "models": [
    {
      "id": "gateway-model-fast",
      "label": "Fast general model",
      "modalities": ["text-to-image"],
      "best_for": ["general", "cover", "rednote"],
      "best_for_roles": ["rednote-cover"],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": ["editorial", "flat-illustration", "speed"],
      "notes": "Good for quick visual exploration."
    },
    {
      "id": "gateway-model-pro",
      "label": "High quality ref-capable model",
      "modalities": ["text-to-image", "image-to-image"],
      "best_for": ["cover", "comic", "article"],
      "best_for_roles": ["cover-hero", "comic-page", "comic-character-sheet"],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "high",
      "strengths": ["photoreal", "editorial", "comic"],
      "notes": "Best when consistency and image-to-image control matter."
    },
    {
      "id": "gateway-model-diagram",
      "label": "Diagram and infographic model",
      "modalities": ["text-to-image"],
      "best_for": ["infographic", "article"],
      "best_for_roles": ["infographic-dense", "article-framework", "article-flowchart"],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": ["diagram", "infographic", "labels"],
      "notes": "Prefer this for dense, label-heavy visuals."
    }
  ]
}
```

## Fields

| Field | Type | Description |
| --- | --- | --- |
| `version` | int | reserved, currently `1` |
| `models[]` | array | list of locally approved gateway model entries |
| `models[].id` | string | exact gateway `model` key |
| `models[].label` | string | human-readable short label |
| `models[].modalities` | string[] | e.g. `text-to-image`, `image-to-image` |
| `models[].best_for` | string[] | workflow hints such as `cover`, `rednote`, `infographic`, `comic`, `article`, `general` |
| `models[].best_for_roles` | string[] | optional sub-task hints such as `cover-hero`, `cover-typography-space`, `rednote-text-card`, `infographic-dense`, `comic-page`, `comic-character-sheet`, `article-framework`, `article-flowchart` |
| `models[].supports_ref` | bool | whether the model is dependable for reference-image workflows |
| `models[].supports_text_heavy` | bool | whether the model is dependable for label-heavy or diagram-heavy outputs |
| `models[].quality_tier` | string | `fast`, `balanced`, or `high` |
| `models[].strengths` | string[] | optional style or task hints such as `editorial`, `diagram`, `comic`, `photoreal` |
| `models[].notes` | string | optional short operational note |

## Recommendation CLI

After creating the registry, run:

```bash
npm run recommend-model -- --workflow comic --need-ref --quality high --style comic
npm run recommend-model -- --workflow comic --role comic-page
```

Use `--json` when the output should be consumed by another script.

This skill already ships with a bundled starter registry. To refresh it for a project from live docs:

```bash
npm run discover-image-models -- --out .image-skills/image-generation/MODELS.json
```


path: scripts/vendor/shared-image-generation/references/config/preferences-schema.md
content: # `EXTEND.md` Schema (`image-generation`)

Configuration file paths, in priority order:

- `<project>/.image-skills/image-generation/EXTEND.md`
- `~/.image-skills/image-generation/EXTEND.md`

Optional model registry paths:

- `<project>/.image-skills/image-generation/MODELS.json`
- `~/.image-skills/image-generation/MODELS.json`

Field-to-gateway mapping is described in [weryai-platform.md](../weryai-platform.md).

The file is Markdown with YAML front matter at the top.

## Example

```yaml
---
version: 1
default_model: <model key chosen from the gateway docs>
default_quality: 2k
default_aspect_ratio: "16:9"
# default_resolution: 2k
# default_use_web_search: false
# default_caller_id: 12345
batch:
  max_workers: 10
---
```

## Fields

| Field | Type | Description |
| --- | --- | --- |
| `version` | int | reserved, currently `1` |
| `default_model` | string | gateway `model` key; initialization should default to `GEMINI_3_1_FLASH_IMAGE` (Nano Banana 2) unless the user has already chosen another model |
| `default_quality` | `normal` \| `2k` | maps to `resolution`, unless overridden by `--resolution` |
| `default_aspect_ratio` | string | such as `1:1`; overridden by `--ar` |
| `default_resolution` | string | such as `2k`; common values are `1k`, `2k`, and `4k` |
| `default_use_web_search` | bool | default `use_web_search` behavior; can still be forced on by `--use-web-search` |
| `default_caller_id` | int | default `caller_id`; can be overridden by `--caller-id` |
| `batch.max_workers` | int | maximum batch concurrency; can be overridden by `BASE_IMAGE_MAX_WORKERS` |

`MODELS.json` is separate from `EXTEND.md`. Use it when this skill should rank multiple valid model keys by workflow or by narrower roles such as `comic-page` or `article-framework`. See [model-registry-schema.md](model-registry-schema.md).


path: scripts/vendor/shared-image-generation/references/config/starter-models.json
content: {
  "version": 1,
  "generated_at": "2026-03-20T10:35:24.736Z",
  "source": "bundled-weryai-starter",
  "docs": {
    "text_to_image": "https://docs.weryai.com/api-reference/image-generation/submit-text-to-image-task",
    "image_to_image": "https://docs.weryai.com/api-reference/image-generation/submit-image-to-image-task"
  },
  "models": [
    {
      "id": "CHATBOT_GEMINI_3_PRO_IMAGE_PREVIEW",
      "label": "Nano Banana Pro",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "speed"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "DREAMINA",
      "label": "Dreamina 3.1",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "DREAMINA_3",
      "label": "Dreamina 3.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "DREAMINA_4",
      "label": "Dreamina 4.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "FLUX",
      "label": "Flux",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GEMINI_2_5_FLASH",
      "label": "Nano-banana",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "speed"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GEMINI_3_1_FLASH_IMAGE",
      "label": "Nano Banana 2",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "speed"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GPT_IMAGE_1_5",
      "label": "GPT Image 1.5",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "labels"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GPT_IMAGE_1_MINI",
      "label": "Gpt Image Mini",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": true,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "labels"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "GROK_IMAGINE_IMAGE",
      "label": "Grok 2",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "IMAGE4",
      "label": "Imagen4",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "QWEN",
      "label": "Qwen Image",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "labels"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4",
      "label": "Recraft V4",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4_PRO",
      "label": "Recraft V4 Pro",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "high",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4_PRO_TO_VECTOR",
      "label": "Recraft V4 Pro Vector",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "high",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "RECRAFT_V4_TO_VECTOR",
      "label": "Recraft V4 Vector",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": true,
      "quality_tier": "balanced",
      "strengths": [
        "flat-illustration",
        "infographic",
        "diagram"
      ],
      "best_for": [
        "general",
        "cover",
        "infographic",
        "article",
        "rednote"
      ],
      "best_for_roles": [
        "cover-hero",
        "rednote-text-card",
        "infographic-dense",
        "article-framework",
        "article-flowchart"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM",
      "label": "Seedream 4.0",
      "modalities": [
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM_4",
      "label": "Seedream 4.0",
      "modalities": [
        "text-to-image"
      ],
      "supports_ref": false,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM_4_5",
      "label": "Seedream 4.5",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "SEEDREAM_5_0_LITE",
      "label": "Seedream 5.0 lite",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "fast",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WAN_2_5",
      "label": "Wan2.5",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WAN_2_6",
      "label": "Wan2.6",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WERYAI_IMAGE_1_0",
      "label": "WeryAI Image 1.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "balanced",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    },
    {
      "id": "WERYAI_IMAGE_2_0",
      "label": "WeryAI Image 2.0",
      "modalities": [
        "text-to-image",
        "image-to-image"
      ],
      "supports_ref": true,
      "supports_text_heavy": false,
      "quality_tier": "high",
      "strengths": [
        "editorial",
        "photoreal",
        "cover"
      ],
      "best_for": [
        "general",
        "cover"
      ],
      "best_for_roles": [
        "cover-hero",
        "comic-page",
        "comic-character-sheet"
      ],
      "notes": "Bundled starter entry from WeryAI docs snapshot."
    }
  ]
}


path: scripts/vendor/shared-image-generation/references/style-presets.md
content: # Style Presets (`image-generation`)

These presets are **prompt-level style presets**, not native gateway API enums. Each preset appends a style description to the original prompt so the agent and the user can reuse common visual directions more consistently.

Useful when:

- the user only knows a rough direction such as "photoreal", "manga", "poster", or "infographic"
- the same visual language should be reused across a batch
- `--dry-run` is being used to inspect the final assembled request body

Not ideal when:

- the user already provided a very explicit and sufficiently detailed style prompt
- the workflow must rely on model-specific private style tokens

## Available Presets

| preset | User Might Say | Best For | Injected Direction |
| --- | --- | --- | --- |
| `photoreal` | "realistic", "photo-like", "natural" | realistic people, products, environments | photorealistic, natural lighting, realistic texture, high detail |
| `cinematic` | "cinematic", "movie feel", "moody" | cinematic posters, moody scenes | cinematic composition, dramatic lighting, film still, moody atmosphere |
| `anime` | "anime", "colorful cartoon" | anime characters, light-novel covers | anime style, clean linework, vibrant colors, expressive character design |
| `manga` | "manga", "black and white comic" | comic panels, B&W storytelling | manga style, dynamic ink lines, screentone shading, black and white emphasis |
| `watercolor` | "watercolor", "hand-drawn", "soft" | soft illustration, book-style imagery | watercolor illustration, soft brush texture, paper grain, gentle palette |
| `flat-illustration` | "flat", "minimal", "notion-style", "clean" | product illustration, knowledge cards | flat illustration, simplified shapes, clean vector feel, limited palette |
| `3d-render` | "3D", "rendered" | 3D characters, product rendering | 3d render, volumetric lighting, material detail, polished surfaces |
| `poster` | "poster", "bold", "high impact" | promo images, event pages, covers | poster design, bold focal point, strong typography space, high contrast |
| `editorial` | "magazine", "premium", "elegant layout" | magazine-like, brand-oriented visuals | editorial illustration, refined composition, premium layout feel, restrained palette |
| `infographic` | "infographic", "chart", "data visual" | infographics, knowledge cards | infographic style, clear hierarchy, icon-friendly layout, data visualization feel |
| `chalk` | "chalkboard", "hand-written" | chalkboard, hand-drawn tutorials | chalk drawing, textured strokes, blackboard feel, hand-drawn annotations |
| `ink-brush` | "ink painting", "brush", "East Asian" | ink-painting, East Asian aesthetics | ink brush painting, expressive brushwork, elegant negative space, East Asian aesthetic |

When the user describes a style in natural language, match their description to the closest preset. If unsure between two presets, briefly describe both and ask which feels closer. Run `npm run style-menu` from this skill directory to see the full preset list with localized labels.

## Usage

```bash
${BUN_X} {baseDir}/scripts/main.ts --prompt "a cat" --style photoreal --image out.png -m "<model>"
${BUN_X} {baseDir}/scripts/main.ts --prompt "AI trend summary" --style infographic --image out.png -m "<model>"
${BUN_X} {baseDir}/scripts/main.ts --prompt "girl standing on a rainy night street" --style cinematic --dry-run -m "<model>"
```

## Notes

- `--style` only adds style direction. It does not replace `model`.
- The style preset participates in prompt length validation because it is appended to the final request prompt.
- If the user's original prompt is already clear and detailed, respect that first. The preset is only a lightweight enhancement.


path: scripts/vendor/shared-image-generation/references/weryai-platform.md
content: # Gateway Alignment Notes (Current Gateway: WeryAI)

This skill follows a single-gateway architecture, and the current gateway happens to be WeryAI. The HTTP contract below follows the public **Image Generation** documentation. If the gateway changes later, this file must be updated as well.

## Base URL and Authentication

- **Base URL**: `https://api.weryai.com`
- **Header**: `Authorization: Bearer <API_KEY>` using environment variable `IMAGE_GEN_API_KEY`

## Async Endpoints

| Capability | Method | Path |
| --- | --- | --- |
| text-to-image | POST | `/v1/generation/text-to-image` |
| image-to-image | POST | `/v1/generation/image-to-image` |
| task status | GET | `/v1/generation/{taskId}/status` |

Successful business responses return **`success: true`** (or `status: 200`). Failed business responses return `success: false` with a business status code such as `1002`. On success, `data.task_ids` is a string array and polling uses the returned `task_id`.

## `task_status` Values

Follow the lowercase OpenAPI enum:

- `waiting`
- `processing`
- `succeed` -> `data.images` contains image URLs
- `failed` -> `data.msg` explains the failure

Do not rely on uppercase aliases such as `SUCCESS` or `FAILED`. If examples and OpenAPI disagree, trust the OpenAPI definition.

## Result Image Download

After `succeed`, the platform usually returns **download URLs** (often behind a CDN). Compared with APIs that return raw bytes directly, this introduces an extra network hop and can fail because the link is cold, temporarily unavailable, or subject to different authentication behavior.

The CLI handles each result image with:

- a request timeout (default: 120s)
- up to 4 attempts with exponential backoff
- an unauthenticated `GET` first, then a retry with the same Bearer token on `401/403`
- limited retries for `5xx`, `429`, `408`, and even `404` when the asset is not ready yet
- minimum payload validation to avoid saving empty responses or error pages as images

If download still fails, use the returned image URL manually and inspect the request with `task_id` plus [call history](https://weryai.com/api/history).

**Billing behavior**: once the platform-side task is already `succeed`, the CLI will **not** resubmit the generation request just because the file download failed. It only retries the HTTP file download layer, which avoids accidental double billing.

## Text-to-Image Request Body

Important fields:

| Field | Notes |
| --- | --- |
| `model` | required; see the model table for that endpoint |
| `prompt` | required; positive prompt, subject to documented length limits |
| `aspect_ratio` | required; e.g. `16:9`, `1:1` |
| `negative_prompt` | optional |
| `image_number` | optional, default 1 |
| `resolution` | optional; common values include `1k`, `2k`, `4k`, depending on model support |
| `use_web_search` | optional, default `false` |
| `webhook_url` | optional |
| `caller_id` | optional, int64, for business correlation |

## Image-to-Image Request Body

The image-to-image endpoint adds:

| Field | Notes |
| --- | --- |
| `images` | required string array. The docs show public HTTPS URLs, but the CLI can encode local files as `data:image/...;base64,...` and send them through the same field. |

The supported `model` set for image-to-image may differ from text-to-image. Always check the corresponding endpoint documentation.

## Common Business Status Codes

Examples from the documentation:

- `0` -> success
- `1001` -> parameter error
- `1002` -> authentication failure
- `1003` -> resource not found, such as an invalid task id

The script raises an error when the business response is not successful. For troubleshooting, compare the request body and response with [call history](https://weryai.com/api/history).

## Official Documentation Entry Points

- [API introduction](https://docs.weryai.com/en)
- [text-to-image](https://docs.weryai.com/api-reference/image-generation/submit-text-to-image-task)
- [image-to-image](https://docs.weryai.com/api-reference/image-generation/submit-image-to-image-task)
- [query task status](https://docs.weryai.com/api-reference/tasks/query-task-details)
- [documentation index `llms.txt`](https://docs.weryai.com/llms.txt)

The script validates `prompt` and `negative_prompt` lengths before making requests, following the OpenAPI `maxLength` values. The current implementation uses `prompt <= 2000` and `negative_prompt <= 1000`; update `scripts/main.ts` if the upstream contract changes.


path: scripts/vendor/shared-image-generation/scripts/bootstrap.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
const SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);

function printHelp() {
  console.log(`Usage:
  node scripts/bootstrap.mjs [options]

Options:
  --dir <path>     Skill root directory (default: current skill root)
  --dry-run        Print install targets without running npm install
  --json           Print JSON output
  -h, --help       Show help`);
}

function parseArgs(argv) {
  const args = {
    dir: null,
    dryRun: false,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--dir") args.dir = argv[++i] ?? null;
    else if (current === "--dry-run") args.dryRun = true;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }
  return args;
}

function readJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

function countInstallableDependencies(pkg) {
  const sections = ["dependencies", "optionalDependencies", "devDependencies"];
  return sections.reduce((total, field) => total + Object.keys(pkg[field] ?? {}).length, 0);
}

function collectPackageJsonPaths(rootDir) {
  const results = [];
  function visit(currentDir) {
    for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
      if (entry.isDirectory()) {
        if (SKIP_DIRS.has(entry.name)) continue;
        visit(path.join(currentDir, entry.name));
        continue;
      }
      if (entry.isFile() && entry.name === "package.json") {
        results.push(path.join(currentDir, entry.name));
      }
    }
  }
  visit(rootDir);
  return results;
}

export function discoverBootstrapTargets(rootDir) {
  const suiteRoot = path.resolve(rootDir);
  return collectPackageJsonPaths(suiteRoot)
    .map((packageJsonPath) => {
      const pkg = readJson(packageJsonPath);
      const dependencyCount = countInstallableDependencies(pkg);
      return {
        dir: path.dirname(packageJsonPath),
        relativeDir: path.relative(suiteRoot, path.dirname(packageJsonPath)) || ".",
        packageName: pkg.name ?? path.basename(path.dirname(packageJsonPath)),
        dependencyCount,
      };
    })
    .filter((target) => target.dependencyCount > 0)
    .sort((a, b) => a.relativeDir.localeCompare(b.relativeDir));
}

function installTarget(target) {
  const result = spawnSync(npmCmd, ["install"], {
    cwd: target.dir,
    stdio: "inherit",
  });
  return {
    ...target,
    ok: result.status === 0,
    exitCode: result.status ?? 1,
  };
}

export function bootstrapSuite({ dir, dryRun = false }) {
  const suiteRoot = path.resolve(dir);
  const targets = discoverBootstrapTargets(suiteRoot);
  const summary = {
    ok: true,
    dir: suiteRoot,
    dryRun,
    targetCount: targets.length,
    targets,
    installed: [],
  };

  if (dryRun || targets.length === 0) {
    return summary;
  }

  for (const target of targets) {
    const installed = installTarget(target);
    summary.installed.push(installed);
    if (!installed.ok) {
      summary.ok = false;
      break;
    }
  }
  return summary;
}

function formatText(summary) {
  const lines = [];
  lines.push(`Skill root: ${summary.dir}`);
  lines.push(`Dry run: ${summary.dryRun ? "yes" : "no"}`);
  lines.push(`Install targets: ${summary.targetCount}`);
  if (summary.targets.length) {
    lines.push("Targets:");
    for (const target of summary.targets) {
      lines.push(`- ${target.relativeDir} (${target.dependencyCount} dependencies)`);
    }
  }
  if (!summary.dryRun && summary.installed.length) {
    lines.push("Install results:");
    for (const target of summary.installed) {
      lines.push(`- ${target.relativeDir}: ${target.ok ? "ok" : `failed (${target.exitCode})`}`);
    }
  }
  return lines.join("\n");
}

const defaultDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = bootstrapSuite({ dir: args.dir ?? defaultDir, dryRun: args.dryRun });
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else console.log(formatText(summary));
    if (!summary.ok) process.exitCode = 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/vendor/shared-image-generation/scripts/build-visual-brief.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const VALID_WORKFLOWS = new Set(["general", "cover", "rednote", "infographic", "comic", "article"]);

const FIELD_OPTIONS = {
  styleFamily: ["photoreal", "editorial", "flat-illustration", "poster", "manga", "watercolor", "infographic"],
  palette: ["cool", "warm", "neutral", "high-contrast", "pastel", "dark-cinematic"],
  lighting: ["soft", "natural", "dramatic", "studio", "flat", "high-key"],
  composition: ["single-hero", "balanced", "layered-depth", "grid", "bento", "panel-storytelling"],
  shot: ["close-up", "medium", "wide", "top-down", "isometric", "straight-on"],
  textDensity: ["none", "title-only", "light-labels", "text-rich"],
  aspect: ["1:1", "3:4", "4:3", "16:9", "9:16"],
  referenceMode: ["none", "style-reference", "character-reference", "layout-reference"],
};

const WORKFLOW_DEFAULTS = {
  general: {
    styleFamily: "editorial",
    composition: "balanced",
    shot: "medium",
    aspect: "1:1",
    textDensity: "none",
  },
  cover: {
    styleFamily: "editorial",
    composition: "single-hero",
    shot: "medium",
    aspect: "16:9",
    textDensity: "title-only",
  },
  rednote: {
    styleFamily: "flat-illustration",
    composition: "bento",
    shot: "straight-on",
    aspect: "3:4",
    textDensity: "light-labels",
  },
  infographic: {
    styleFamily: "infographic",
    composition: "grid",
    shot: "straight-on",
    aspect: "3:4",
    textDensity: "text-rich",
  },
  comic: {
    styleFamily: "manga",
    composition: "panel-storytelling",
    shot: "medium",
    aspect: "3:4",
    textDensity: "light-labels",
  },
  article: {
    styleFamily: "editorial",
    composition: "balanced",
    shot: "wide",
    aspect: "16:9",
    textDensity: "light-labels",
  },
};

function printHelp() {
  console.log(`Usage:
  node scripts/build-visual-brief.mjs --workflow <name> --topic <text> [options]

Options:
  --workflow <name>       general | cover | rednote | infographic | comic | article
  --topic <text>          Core topic or request
  --subject <text>        Main subject
  --goal <text>           Intended effect or communication goal
  --style-family <name>   Visual family
  --palette <name>        Color temperature / palette direction
  --lighting <name>       Lighting direction
  --composition <name>    Composition pattern
  --shot <name>           Shot language
  --aspect <ratio>        Aspect ratio
  --lang <code>           On-image text language when needed
  --text-density <name>   none | title-only | light-labels | text-rich
  --reference-mode <name> none | style-reference | character-reference | layout-reference
  --output <path>         Write a markdown brief to this path
  --json                  Print JSON output
  -h, --help              Show help`);
}

function parseArgs(argv) {
  const args = {
    workflow: "general",
    topic: null,
    subject: null,
    goal: null,
    styleFamily: null,
    palette: null,
    lighting: null,
    composition: null,
    shot: null,
    aspect: null,
    language: "en",
    textDensity: null,
    referenceMode: null,
    output: null,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--workflow") args.workflow = argv[++i] ?? args.workflow;
    else if (current === "--topic") args.topic = argv[++i] ?? null;
    else if (current === "--subject") args.subject = argv[++i] ?? null;
    else if (current === "--goal") args.goal = argv[++i] ?? null;
    else if (current === "--style-family") args.styleFamily = argv[++i] ?? null;
    else if (current === "--palette") args.palette = argv[++i] ?? null;
    else if (current === "--lighting") args.lighting = argv[++i] ?? null;
    else if (current === "--composition") args.composition = argv[++i] ?? null;
    else if (current === "--shot") args.shot = argv[++i] ?? null;
    else if (current === "--aspect") args.aspect = argv[++i] ?? null;
    else if (current === "--lang") args.language = argv[++i] ?? args.language;
    else if (current === "--text-density") args.textDensity = argv[++i] ?? null;
    else if (current === "--reference-mode") args.referenceMode = argv[++i] ?? null;
    else if (current === "--output") args.output = argv[++i] ?? null;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }

  if (!VALID_WORKFLOWS.has(args.workflow)) throw new Error(`Invalid workflow: ${args.workflow}`);
  if (!args.topic?.trim()) throw new Error("--topic is required");
  return args;
}

const QUESTIONS_I18N = {
  en: {
    subject:       { prompt: "What is the main visual subject?", options: ["person", "product", "scene", "diagram/structure", "abstract concept"], recommended: "scene" },
    goal:          { prompt: "What is the primary purpose of this image?", options: ["attract clicks", "explain a concept", "set the mood", "highlight contrast", "drive action"], recommended: "explain a concept" },
    styleFamily:   { prompt: "More photorealistic or illustration?" },
    palette:       { prompt: "What color temperature / palette direction?" },
    lighting:      { prompt: "What lighting or atmosphere?" },
    composition:   { prompt: "What composition pattern works best?" },
    shot:          { prompt: "What shot language fits best?" },
    aspect:        { prompt: "What aspect ratio?" },
    textDensity:   { prompt: "How much text should appear on the image?" },
    referenceMode: { prompt: "Do you have a reference image?" },
  },
  zh: {
    subject:       { prompt: "主视觉主体是什么？", options: ["人物", "产品", "场景", "图表/结构", "抽象概念"], recommended: "场景" },
    goal:          { prompt: "这张图最重要的任务是什么？", options: ["吸引点击", "解释概念", "建立氛围", "突出对比", "引导行动"], recommended: "解释概念" },
    styleFamily:   { prompt: "更偏写实还是插画？" },
    palette:       { prompt: "色彩温度想要什么方向？" },
    lighting:      { prompt: "光线/气氛更偏哪种？" },
    composition:   { prompt: "构图组织更适合哪种？" },
    shot:          { prompt: "镜头语言更适合哪种？" },
    aspect:        { prompt: "最终比例是什么？" },
    textDensity:   { prompt: "画面里文字密度要多高？" },
    referenceMode: { prompt: "是否有参考图？" },
  },
};

function question(id, prompt, options, recommended) {
  return { id, prompt, options, recommended };
}

function getI18nQuestion(lang, id) {
  const table = QUESTIONS_I18N[lang] ?? QUESTIONS_I18N.en;
  return table[id] ?? QUESTIONS_I18N.en[id];
}

export function buildVisualBrief(input) {
  const lang = (input.language?.trim() || "en").startsWith("zh") ? "zh" : "en";
  const defaults = WORKFLOW_DEFAULTS[input.workflow] ?? WORKFLOW_DEFAULTS.general;
  const brief = {
    workflow: input.workflow,
    topic: input.topic.trim(),
    subject: input.subject?.trim() || null,
    goal: input.goal?.trim() || null,
    styleFamily: input.styleFamily?.trim() || defaults.styleFamily,
    palette: input.palette?.trim() || null,
    lighting: input.lighting?.trim() || null,
    composition: input.composition?.trim() || defaults.composition,
    shot: input.shot?.trim() || defaults.shot,
    aspect: input.aspect?.trim() || defaults.aspect,
    language: input.language?.trim() || "en",
    textDensity: input.textDensity?.trim() || defaults.textDensity,
    referenceMode: input.referenceMode?.trim() || "none",
  };

  const missingFields = [];
  if (!brief.subject) missingFields.push("subject");
  if (!brief.goal) missingFields.push("goal");
  if (!input.palette) missingFields.push("palette");
  if (!input.lighting) missingFields.push("lighting");

  const q = (id, fallbackOptions, fallbackRecommended) => {
    const i18n = getI18nQuestion(lang, id);
    return question(id, i18n.prompt, i18n.options ?? fallbackOptions, i18n.recommended ?? fallbackRecommended);
  };

  const questions = [];
  if (!brief.subject) questions.push(q("subject"));
  if (!brief.goal) questions.push(q("goal"));
  if (!input.styleFamily) questions.push(q("styleFamily", FIELD_OPTIONS.styleFamily, defaults.styleFamily));
  if (!input.palette) questions.push(q("palette", FIELD_OPTIONS.palette, "neutral"));
  if (!input.lighting) questions.push(q("lighting", FIELD_OPTIONS.lighting, "soft"));
  if (!input.composition) questions.push(q("composition", FIELD_OPTIONS.composition, defaults.composition));
  if (!input.shot) questions.push(q("shot", FIELD_OPTIONS.shot, defaults.shot));
  if (!input.aspect) questions.push(q("aspect", FIELD_OPTIONS.aspect, defaults.aspect));
  if (!input.textDensity) questions.push(q("textDensity", FIELD_OPTIONS.textDensity, defaults.textDensity));
  if (!input.referenceMode) questions.push(q("referenceMode", FIELD_OPTIONS.referenceMode, "none"));

  const promptDirectives = [
    `Main topic: ${brief.topic}.`,
    `Primary subject: ${brief.subject || "<need subject>"}.`,
    `Communication goal: ${brief.goal || "<need goal>"}.`,
    `Style family: ${brief.styleFamily}.`,
    `Palette direction: ${brief.palette || "<need palette>"}.`,
    `Lighting: ${brief.lighting || "<need lighting>"}.`,
    `Composition: ${brief.composition}.`,
    `Shot language: ${brief.shot}.`,
    `Aspect ratio: ${brief.aspect}.`,
    `Language: ${brief.language}.`,
    `Text density: ${brief.textDensity}.`,
    `Reference mode: ${brief.referenceMode}.`,
  ];

  return {
    ok: missingFields.length === 0,
    brief,
    missingFields,
    questions,
    promptDirectives,
  };
}

function renderMarkdown(summary) {
  const { brief, missingFields, questions, promptDirectives } = summary;
  const lines = [
    "# Visual Brief",
    "",
    "## Resolved Brief",
    `- Workflow: ${brief.workflow}`,
    `- Topic: ${brief.topic}`,
    `- Subject: ${brief.subject ?? "<need subject>"}`,
    `- Goal: ${brief.goal ?? "<need goal>"}`,
    `- Style family: ${brief.styleFamily}`,
    `- Palette: ${brief.palette ?? "<need palette>"}`,
    `- Lighting: ${brief.lighting ?? "<need lighting>"}`,
    `- Composition: ${brief.composition}`,
    `- Shot: ${brief.shot}`,
    `- Aspect ratio: ${brief.aspect}`,
    `- Language: ${brief.language}`,
    `- Text density: ${brief.textDensity}`,
    `- Reference mode: ${brief.referenceMode}`,
    "",
    "## Missing Fields",
    ...(missingFields.length ? missingFields.map((field) => `- ${field}`) : ["- none"]),
    "",
    "## Questions To Ask",
    ...(questions.length
      ? questions.flatMap((item) => [
          `- ${item.prompt}`,
          `  options: ${item.options.join(" | ")}`,
          `  recommended: ${item.recommended}`,
        ])
      : ["- none"]),
    "",
    "## Prompt Directives",
    ...promptDirectives.map((line) => `- ${line}`),
    "",
  ];
  return `${lines.join("\n")}`;
}

function writeBrief(outputPath, summary) {
  const resolved = path.resolve(outputPath);
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
  fs.writeFileSync(resolved, renderMarkdown(summary), "utf8");
  return resolved;
}

const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = buildVisualBrief(args);
    const outputPath = args.output ? writeBrief(args.output, summary) : null;
    if (args.json) {
      console.log(JSON.stringify({ ...summary, outputPath }, null, 2));
    } else {
      console.log(renderMarkdown(summary));
      if (outputPath) console.log(`Brief written: ${outputPath}`);
    }
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/vendor/shared-image-generation/scripts/discover-models.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

// Prefer raw markdown endpoints (if exposed), but fall back to HTML pages.
const TEXT_TO_IMAGE_DOC_URLS = [
  "https://docs.weryai.com/api-reference/image-generation/submit-text-to-image-task.md",
  "https://docs.weryai.com/api-reference/image-generation/submit-text-to-image-task",
];
const IMAGE_TO_IMAGE_DOC_URLS = [
  "https://docs.weryai.com/api-reference/image-generation/submit-image-to-image-task.md",
  "https://docs.weryai.com/api-reference/image-generation/submit-image-to-image-task",
];

function printHelp() {
  console.log(`Usage:
  node scripts/discover-models.mjs [options]

Options:
  --out <path>   Write discovered MODELS.json starter to this path
  --force        Overwrite existing output file
  --json         Print JSON output
  -h, --help     Show help`);
}

function parseArgs(argv) {
  const args = {
    out: null,
    force: false,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--out") args.out = argv[++i] ?? null;
    else if (current === "--force") args.force = true;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }
  return args;
}

export function parseModelTable(markdown) {
  const lines = markdown.replace(/\r\n/g, "\n").split("\n");

  // Locate a markdown table containing a model list. Docs may tweak header wording,
  // so we infer the "key" column and the "name" column rather than matching a fixed string.
  const normalize = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
  const isTableLine = (line) => /^\s*\|/.test(line);
  const splitRow = (line) => line.split("|").map((part) => part.trim()).filter(Boolean);

  let headerIndex = -1;
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!isTableLine(line)) continue;
    const parts = splitRow(line);
    if (parts.length < 2) continue;
    const joined = normalize(parts.join(" | "));
    if (joined.includes("model") && joined.includes("key")) {
      headerIndex = i;
      break;
    }
  }
  if (headerIndex === -1) return [];

  const headerParts = splitRow(lines[headerIndex] ?? "").map(normalize);
  const keyIdx = headerParts.findIndex((h) => h === "model key" || h.endsWith(" key") || h.includes("key"));
  const nameIdx = headerParts.findIndex((h) => h === "model" || h === "model name" || h.includes("name"));
  const resolvedKeyIdx = keyIdx >= 0 ? keyIdx : 1;
  const resolvedNameIdx = nameIdx >= 0 ? nameIdx : 0;

  const rows = [];
  for (let i = headerIndex + 2; i < lines.length; i++) {
    const line = lines[i].trim();
    if (!line.startsWith("|")) break;
    const parts = line.split("|").map((part) => part.trim()).filter(Boolean);
    if (parts.length < 2) continue;
    const label = parts[resolvedNameIdx] ?? parts[0];
    const id = parts[resolvedKeyIdx] ?? parts[1];
    if (!label || !id) continue;
    rows.push({ label, id });
  }
  return rows;
}

function inferQualityTier(label, id) {
  const value = `${label} ${id}`.toLowerCase();
  if (/(mini|lite|flash)/i.test(value)) return "fast";
  if (/(pro|2\.0|5\.0|v4 pro)/i.test(value)) return "high";
  return "balanced";
}

function inferStrengths(label, id) {
  const value = `${label} ${id}`.toLowerCase();
  const strengths = new Set();
  if (/(vector|recraft)/i.test(value)) {
    strengths.add("flat-illustration");
    strengths.add("infographic");
    strengths.add("diagram");
  }
  if (/(qwen|gpt)/i.test(value)) {
    strengths.add("editorial");
    strengths.add("labels");
  }
  if (/(dreamina|flux|imagen|weryai image|seedream|wan)/i.test(value)) {
    strengths.add("editorial");
    strengths.add("photoreal");
    strengths.add("cover");
  }
  if (/(gemini|banana)/i.test(value)) {
    strengths.add("editorial");
    strengths.add("speed");
  }
  if (strengths.size === 0) strengths.add("editorial");
  return [...strengths];
}

function inferSupportsTextHeavy(label, id) {
  return /(vector|recraft|qwen|gpt|labels)/i.test(`${label} ${id}`);
}

function inferBestFor(strengths, supportsTextHeavy) {
  const bestFor = new Set(["general", "cover"]);
  if (supportsTextHeavy || strengths.includes("infographic") || strengths.includes("diagram")) {
    bestFor.add("infographic");
    bestFor.add("article");
    bestFor.add("rednote");
  }
  return [...bestFor];
}

function inferBestForRoles(bestFor, strengths, supportsRef, supportsTextHeavy) {
  const roles = new Set();
  if (bestFor.includes("cover")) roles.add("cover-hero");
  if (supportsTextHeavy) {
    roles.add("rednote-text-card");
    roles.add("infographic-dense");
    roles.add("article-framework");
    roles.add("article-flowchart");
  }
  if (strengths.includes("infographic")) roles.add("infographic-dense");
  if (supportsRef) {
    roles.add("comic-page");
    roles.add("comic-character-sheet");
  }
  return [...roles];
}

export function buildDiscoveredRegistry({ textToImageModels, imageToImageModels }) {
  const byId = new Map();

  for (const item of textToImageModels) {
    const current = byId.get(item.id) ?? {
      id: item.id,
      label: item.label,
      modalities: [],
    };
    current.modalities = [...new Set([...current.modalities, "text-to-image"])];
    byId.set(item.id, current);
  }

  for (const item of imageToImageModels) {
    const current = byId.get(item.id) ?? {
      id: item.id,
      label: item.label,
      modalities: [],
    };
    current.modalities = [...new Set([...current.modalities, "image-to-image"])];
    byId.set(item.id, current);
  }

  const models = [...byId.values()]
    .map((model) => {
      const strengths = inferStrengths(model.label, model.id);
      const supportsTextHeavy = inferSupportsTextHeavy(model.label, model.id);
      const bestFor = inferBestFor(strengths, supportsTextHeavy);
      return {
        id: model.id,
        label: model.label,
        modalities: model.modalities,
        supports_ref: model.modalities.includes("image-to-image"),
        supports_text_heavy: supportsTextHeavy,
        quality_tier: inferQualityTier(model.label, model.id),
        strengths,
        best_for: bestFor,
        best_for_roles: inferBestForRoles(bestFor, strengths, model.modalities.includes("image-to-image"), supportsTextHeavy),
        notes: "Auto-discovered from current WeryAI image generation documentation.",
      };
    })
    .sort((a, b) => a.id.localeCompare(b.id));

  return {
    version: 1,
    generated_at: new Date().toISOString(),
    source: "weryai-docs-auto-discovery",
    docs: {
      text_to_image: TEXT_TO_IMAGE_DOC_URLS[0],
      image_to_image: IMAGE_TO_IMAGE_DOC_URLS[0],
    },
    models,
  };
}

async function fetchFirstOk(urls, fetchImpl) {
  let last = null;
  for (const url of urls) {
    try {
      const res = await fetchImpl(url);
      if (res.ok) return { res, url };
      last = new Error(`HTTP ${res.status} for ${url}`);
    } catch (e) {
      last = e instanceof Error ? e : new Error(String(e));
    }
  }
  throw last ?? new Error("Failed to fetch docs");
}

export async function discoverImageModels({ fetchImpl = fetch } = {}) {
  const [text, image] = await Promise.all([
    fetchFirstOk(TEXT_TO_IMAGE_DOC_URLS, fetchImpl),
    fetchFirstOk(IMAGE_TO_IMAGE_DOC_URLS, fetchImpl),
  ]);

  const [textMarkdown, imageMarkdown] = await Promise.all([text.res.text(), image.res.text()]);
  const textToImageModels = parseModelTable(textMarkdown);
  const imageToImageModels = parseModelTable(imageMarkdown);
  if (textToImageModels.length === 0 && imageToImageModels.length === 0) {
    throw new Error("No image model table was found in current WeryAI docs");
  }

  return buildDiscoveredRegistry({ textToImageModels, imageToImageModels });
}

function writeRegistry(filePath, registry) {
  const resolved = path.resolve(filePath);
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
  fs.writeFileSync(resolved, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
  return resolved;
}

const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const registry = await discoverImageModels();

    let outputPath = null;
    let wrote = false;
    if (args.out) {
      const resolvedOut = path.resolve(args.out);
      if (fs.existsSync(resolvedOut) && !args.force) {
        outputPath = resolvedOut;
        wrote = false;
      } else {
        outputPath = writeRegistry(resolvedOut, registry);
        wrote = true;
      }
    }

    const summary = {
      ok: true,
      modelCount: registry.models.length,
      source: registry.source,
      docs: registry.docs,
      outputPath,
      wrote,
      registry,
    };
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else {
      console.log(`Discovered ${summary.modelCount} image models from WeryAI docs.`);
      if (args.out && summary.outputPath) {
        if (!summary.wrote) {
          console.log(`Registry already exists (skipped): ${summary.outputPath}`);
          console.log("Tip: pass --force to overwrite.");
        } else {
          console.log(`Wrote starter registry: ${summary.outputPath}`);
        }
      }
    }
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/vendor/shared-image-generation/scripts/doctor.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

import { discoverBootstrapTargets } from "./bootstrap.mjs";

const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
const VALID_WORKFLOWS = new Set(["general", "cover", "rednote", "infographic", "comic", "article", "compress"]);
const skillNamespace = process.env.IMAGE_SKILL_NAMESPACE?.trim() || "image-generation";

function printHelp() {
  console.log(`Usage:
  node scripts/doctor.mjs [options]

Options:
  --dir <path>       Skill root directory (default: current skill root)
  --project <path>   Project directory to inspect for .image-skills config (default: current working directory)
  --workflow <name>  Optional readiness focus: general, cover, rednote, infographic, comic, article, compress
  --json             Print JSON output
  -h, --help         Show help`);
}

function parseArgs(argv) {
  const args = {
    dir: null,
    project: null,
    workflow: null,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--dir") args.dir = argv[++i] ?? null;
    else if (current === "--project") args.project = argv[++i] ?? null;
    else if (current === "--workflow") args.workflow = argv[++i] ?? null;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }
  if (args.workflow && !VALID_WORKFLOWS.has(args.workflow)) {
    throw new Error(`Invalid --workflow: ${args.workflow}`);
  }
  return args;
}

function pathExists(filePath) {
  return !!filePath && fs.existsSync(filePath);
}

function checkCommand(command, args) {
  const result = spawnSync(command, args, { encoding: "utf8" });
  return {
    available: result.status === 0,
    version: result.status === 0 ? (result.stdout || result.stderr).trim().split("\n")[0] : null,
  };
}

function inspectToolchain() {
  const nodeStatus = checkCommand(process.execPath, ["--version"]);
  const npmStatus = checkCommand(npmCmd, ["--version"]);
  const bunStatus = checkCommand("bun", ["--version"]);
  const npxStatus = checkCommand(npxCmd, ["--version"]);
  const sipsStatus = checkCommand("sips", ["--help"]);
  const cwebpStatus = checkCommand("cwebp", ["-version"]);
  const magickStatus = checkCommand("magick", ["-version"]);
  const convertStatus = checkCommand("convert", ["-version"]);
  return [
    { id: "node", label: "Node.js", required: true, ...nodeStatus },
    { id: "npm", label: "npm", required: true, ...npmStatus },
    { id: "bun", label: "Bun", required: false, ...bunStatus },
    { id: "npx", label: "npx", required: false, ...npxStatus },
    { id: "sips", label: "sips", required: false, ...sipsStatus },
    { id: "cwebp", label: "cwebp", required: false, ...cwebpStatus },
    { id: "magick", label: "ImageMagick", required: false, ...magickStatus },
    { id: "convert", label: "convert", required: false, ...convertStatus },
  ];
}

export function inspectConfigState({ projectDir, homeDir, env }) {
  const projectRoot = path.resolve(projectDir);
  const homeRoot = homeDir ? path.resolve(homeDir) : null;
  const projectSkillDir = path.join(projectRoot, ".image-skills", skillNamespace);
  const homeSkillDir = homeRoot ? path.join(homeRoot, ".image-skills", skillNamespace) : null;

  const projectEnvPath = path.join(projectRoot, ".image-skills", skillNamespace, ".env");
  const projectEnvFile = pathExists(projectEnvPath);
  let apiKeyInProjectEnv = false;
  if (projectEnvFile) {
    try {
      const content = fs.readFileSync(projectEnvPath, "utf8");
      apiKeyInProjectEnv =
        /(^|\n)\s*IMAGE_GEN_API_KEY\s*=\s*[^\n#]+/m.test(content) ||
        /(^|\n)\s*IMAGE_GEN_API_KEY\s*=\s*[^\n#]+/m.test(content);
    } catch {
      apiKeyInProjectEnv = false;
    }
  }

  return {
    projectDir: projectRoot,
    apiKeyInEnv: Boolean(env.IMAGE_GEN_API_KEY || env.IMAGE_GEN_API_KEY),
    apiKeyInProjectEnv,
    defaultModelInEnv: Boolean(env.IMAGE_GEN_DEFAULT_MODEL),
    projectEnvFile,
    projectExtend: pathExists(path.join(projectSkillDir, "EXTEND.md")),
    projectModelRegistry: pathExists(path.join(projectSkillDir, "MODELS.json")),
    homeExtend: homeSkillDir ? pathExists(path.join(homeSkillDir, "EXTEND.md")) : false,
    homeModelRegistry: homeSkillDir ? pathExists(path.join(homeSkillDir, "MODELS.json")) : false,
  };
}

function readJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

export function inspectBootstrapDependencyState(suiteDir) {
  return discoverBootstrapTargets(suiteDir).map((target) => {
    const pkg = readJson(path.join(target.dir, "package.json"));
    const dependencyNames = [
      ...Object.keys(pkg.dependencies ?? {}),
      ...Object.keys(pkg.optionalDependencies ?? {}),
      ...Object.keys(pkg.devDependencies ?? {}),
    ];
    const missingPackages = dependencyNames.filter(
      (dependencyName) => !pathExists(path.join(target.dir, "node_modules", dependencyName, "package.json"))
    );
    return {
      ...target,
      dependencyNames,
      installed: missingPackages.length === 0,
      missingPackages,
    };
  });
}

function hasDetectedApiKey(config) {
  return config.apiKeyInEnv || config.apiKeyInProjectEnv;
}

function hasDetectedDefaultModel(config) {
  return config.defaultModelInEnv || config.projectExtend || config.homeExtend;
}

function findBootstrapTarget(bootstrapTargets, relativeDir) {
  return bootstrapTargets.find((target) => target.relativeDir === relativeDir) ?? null;
}

function toolAvailable(tools, id) {
  return Boolean(tools.find((tool) => tool.id === id)?.available);
}

function buildRootCommandHints(projectDir, workflow = null) {
  const commands = [
    `cd "${projectDir}"`,
    "npm run doctor -- --project .",
  ];
  if (workflow) commands.push(`npm run doctor -- --project . --workflow ${workflow}`);
  commands.push("npm run setup -- --project .");
  commands.push(`npm run discover-image-models -- --out .image-skills/${skillNamespace}/MODELS.json`);
  commands.push("npm run recommend-model -- --workflow cover --role cover-hero");
  return commands;
}

function defaultRoleForWorkflow(workflow) {
  switch (workflow) {
    case "cover":
      return "cover-hero";
    case "rednote":
      return "rednote-text-card";
    case "infographic":
      return "infographic-dense";
    case "comic":
      return "comic-page";
    case "article":
      return "article-framework";
    default:
      return null;
  }
}

export function inspectWorkflowReadiness({ workflow, tools, config, bootstrapTargets }) {
  if (!workflow) return null;

  const blockers = [];
  const warnings = [];
  const suggestions = [];
  const checks = [];
  const hasApiKey = hasDetectedApiKey(config);
  const hasDefaultModel = hasDetectedDefaultModel(config);
  const hasModelRegistry = config.projectModelRegistry || config.homeModelRegistry;
  const recommendedRole = defaultRoleForWorkflow(workflow);

  const mark = (label, ok, detail = null) => {
    checks.push({ label, ok, detail });
    return ok;
  };

  if (workflow === "compress") {
    const compressorOk =
      toolAvailable(tools, "sips") ||
      toolAvailable(tools, "cwebp") ||
      toolAvailable(tools, "magick") ||
      toolAvailable(tools, "convert");
    if (!mark("local-compressor-tool", compressorOk, "Need one of sips, cwebp, magick, or convert")) {
      blockers.push("No local image compression tool was detected");
      suggestions.push("Install at least one of sips, cwebp, or ImageMagick before using compress-image");
    }
  } else {
    if (!mark("gateway-api-key", hasApiKey, `Need IMAGE_GEN_API_KEY in env or .image-skills/${skillNamespace}/.env`)) {
      blockers.push("Gateway API key is not configured");
      suggestions.push(`Provide IMAGE_GEN_API_KEY, then rerun \`npm run setup -- --project . --persist-api-key\` or write .image-skills/${skillNamespace}/.env`);
    }
    if (!mark("default-model", hasDefaultModel, "Need IMAGE_GEN_DEFAULT_MODEL or EXTEND.md default_model for smooth first use")) {
      warnings.push("No default model is configured yet");
      suggestions.push("Run npm run setup -- --project . to initialize Nano Banana 2 as the default model, or set IMAGE_GEN_DEFAULT_MODEL / EXTEND.md yourself");
    }
    if (!mark("model-registry", hasModelRegistry, "MODELS.json enables workflow and role-aware model ranking")) {
      warnings.push("No MODELS.json registry was detected for workflow-aware model recommendation");
      suggestions.push("Run npm run setup -- --project . to write the bundled starter registry, or refresh from docs before model recommendation");
    }
  }

  if (workflow === "comic") {
    const comicBootstrap = findBootstrapTarget(bootstrapTargets, "comic/scripts");
    const pdfReady = comicBootstrap ? comicBootstrap.installed : true;
    if (!mark("comic-pdf-merge", pdfReady, "merge-to-pdf.ts needs local pdf-lib install")) {
      warnings.push("Comic PDF merge dependency is not installed yet");
      suggestions.push("Run npm run bootstrap in this skill directory before using comic PDF merge");
    }
  }

  const recommendationHint =
    workflow !== "compress" && recommendedRole
      ? `npm run recommend-model -- --workflow ${workflow} --role ${recommendedRole}`
      : null;
  if (recommendationHint) {
    suggestions.push(`Before generation, rank models with: ${recommendationHint}`);
  }

  return {
    workflow,
    ready: blockers.length === 0,
    recommendationHint,
    blockers,
    warnings,
    suggestions,
    checks,
  };
}

export function doctorSuite({
  suiteDir,
  projectDir = process.cwd(),
  homeDir = process.env.HOME ?? null,
  env = process.env,
  tools = null,
  workflow = null,
}) {
  const resolvedSuiteDir = path.resolve(suiteDir);
  const toolchain = tools ?? inspectToolchain();
  const config = inspectConfigState({ projectDir, homeDir, env });
  const bootstrap = inspectBootstrapDependencyState(resolvedSuiteDir);
  const warnings = [];
  const errors = [];
  const suggestions = [];

  const hasNode = toolchain.find((tool) => tool.id === "node")?.available;
  const hasNpm = toolchain.find((tool) => tool.id === "npm")?.available;
  const hasBun = toolchain.find((tool) => tool.id === "bun")?.available;
  const hasNpx = toolchain.find((tool) => tool.id === "npx")?.available;

  if (!hasNode) errors.push("Node.js is required but was not detected");
  if (!hasNpm) errors.push("npm is required but was not detected");
  if (!hasBun && !hasNpx) errors.push("Need Bun or npx so Bun-based workflow scripts can run");

  const hasApiKey = hasDetectedApiKey(config);
  if (!hasApiKey) {
    warnings.push(`IMAGE_GEN_API_KEY was not detected in the environment or project .image-skills/${skillNamespace}/.env`);
    suggestions.push(`Provide IMAGE_GEN_API_KEY, then rerun \`npm run setup -- --project . --persist-api-key\` or write .image-skills/${skillNamespace}/.env before using gateway generation`);
  }

  const hasDefaultModel = hasDetectedDefaultModel(config);
  if (!hasDefaultModel) {
    warnings.push("No default model was detected in IMAGE_GEN_DEFAULT_MODEL or EXTEND.md");
    suggestions.push("Run npm run setup -- --project . to initialize Nano Banana 2 as the default model, or set IMAGE_GEN_DEFAULT_MODEL / EXTEND.md yourself");
  }

  const pendingBootstrap = bootstrap.filter((target) => !target.installed);
  if (pendingBootstrap.length > 0) {
    warnings.push(`Optional local dependencies are not installed for ${pendingBootstrap.map((target) => target.relativeDir).join(", ")}`);
    suggestions.push("Run npm run bootstrap in this skill directory to install optional local dependencies");
  }

  const workflowReadiness = inspectWorkflowReadiness({
    workflow,
    tools: toolchain,
    config,
    bootstrapTargets: bootstrap,
  });

  return {
    ok: errors.length === 0,
    suiteDir: resolvedSuiteDir,
    projectDir: config.projectDir,
    workflow,
    behavior: {
      readOnly: true,
      writesFiles: false,
      generatedArtifacts: [],
    },
    commandHints: buildRootCommandHints(config.projectDir, workflow),
    tools: toolchain,
    config,
    bootstrap: {
      targets: bootstrap,
      pendingCount: pendingBootstrap.length,
    },
    workflowReadiness,
    warnings,
    errors,
    suggestions,
  };
}

function formatText(summary) {
  const lines = [];
  lines.push(`Skill root: ${summary.suiteDir}`);
  lines.push(`Project dir: ${summary.projectDir}`);
  lines.push(`Status: ${summary.ok ? "ok" : "failed"}`);
  lines.push("Doctor behavior: read-only check; it does not create or modify files.");
  if (summary.workflowReadiness) {
    lines.push(`Workflow focus: ${summary.workflowReadiness.workflow}`);
    lines.push(`Workflow ready: ${summary.workflowReadiness.ready ? "yes" : "no"}`);
  }
  lines.push("Toolchain:");
  for (const tool of summary.tools) {
    lines.push(`- ${tool.label}: ${tool.available ? tool.version ?? "ok" : "missing"}`);
  }
  lines.push("Gateway config:");
  lines.push(`- API key: ${summary.config.apiKeyInEnv || summary.config.projectEnvFile ? "detected" : "missing"}`);
  lines.push(`- Default model: ${summary.config.defaultModelInEnv || summary.config.projectExtend || summary.config.homeExtend ? "detected" : "missing"}`);
  lines.push("Optional dependencies:");
  if (summary.bootstrap.targets.length === 0) {
    lines.push("- none");
  } else {
    for (const target of summary.bootstrap.targets) {
      lines.push(`- ${target.relativeDir}: ${target.installed ? "installed" : `missing ${target.missingPackages.join(", ")}`}`);
    }
  }
  if (summary.errors.length) {
    lines.push("Errors:");
    for (const error of summary.errors) lines.push(`- ${error}`);
  }
  if (summary.warnings.length) {
    lines.push("Warnings:");
    for (const warning of summary.warnings) lines.push(`- ${warning}`);
  }
  if (summary.suggestions.length) {
    lines.push("Suggestions:");
    for (const suggestion of summary.suggestions) lines.push(`- ${suggestion}`);
  }
  if (summary.commandHints?.length) {
    lines.push("Suggested commands:");
    for (const command of summary.commandHints) lines.push(`- ${command}`);
  }
  if (summary.workflowReadiness) {
    if (summary.workflowReadiness.blockers.length) {
      lines.push("Workflow blockers:");
      for (const blocker of summary.workflowReadiness.blockers) lines.push(`- ${blocker}`);
    }
    if (summary.workflowReadiness.warnings.length) {
      lines.push("Workflow warnings:");
      for (const warning of summary.workflowReadiness.warnings) lines.push(`- ${warning}`);
    }
    if (summary.workflowReadiness.suggestions.length) {
      lines.push("Workflow suggestions:");
      for (const suggestion of summary.workflowReadiness.suggestions) lines.push(`- ${suggestion}`);
    }
    if (summary.workflowReadiness.recommendationHint) {
      lines.push(`Workflow model hint: ${summary.workflowReadiness.recommendationHint}`);
    }
  }
  return lines.join("\n");
}

const defaultSuiteDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = doctorSuite({
      suiteDir: args.dir ?? defaultSuiteDir,
      projectDir: args.project ?? process.cwd(),
      workflow: args.workflow,
    });
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else console.log(formatText(summary));
    if (!summary.ok) process.exitCode = 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/vendor/shared-image-generation/scripts/ensure-ready.mjs
content: import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

import { bootstrapSuite } from "./bootstrap.mjs";
import { doctorSuite } from "./doctor.mjs";

const DEFAULT_MODEL = "GEMINI_3_1_FLASH_IMAGE";

function ensureDefaultModelConfig({ suiteDir, projectDir, dryRun }) {
  if (process.env.IMAGE_GEN_DEFAULT_MODEL) return { wrote: false, reason: "env" };
  const namespace = path.basename(path.resolve(suiteDir));
  const extendPath = path.resolve(projectDir, ".image-skills", namespace, "EXTEND.md");
  if (fs.existsSync(extendPath)) return { wrote: false, reason: "exists" };
  if (dryRun) return { wrote: false, reason: "dry-run" };
  const content = `---
version: 1
default_model: "GEMINI_3_1_FLASH_IMAGE"
default_quality: 2k
default_aspect_ratio: "1:1"
batch:
  max_workers: 4
---
`;
  fs.mkdirSync(path.dirname(extendPath), { recursive: true });
  fs.writeFileSync(extendPath, content, "utf8");
  return { wrote: true, reason: "created", path: extendPath };
}

function printHelp() {
  console.log(`Usage:
  node scripts/ensure-ready.mjs [options]

Options:
  --dir <path>       Skill root directory (default: current image-generation root)
  --project <path>   Project directory to inspect for .image-skills config (default: current working directory)
  --workflow <name>  Optional readiness focus: general, cover, rednote, infographic, comic, article, compress
  --dry-run          Report what would be installed without running bootstrap
  --json             Print JSON output
  -h, --help         Show help`);
}

function parseArgs(argv) {
  const args = {
    dir: null,
    project: null,
    workflow: null,
    dryRun: false,
    json: false,
  };

  for (let i = 0; i < argv.length; i++) {
    const current = argv[i];
    if (current === "--dir") args.dir = argv[++i] ?? null;
    else if (current === "--project") args.project = argv[++i] ?? null;
    else if (current === "--workflow") args.workflow = argv[++i] ?? null;
    else if (current === "--dry-run") args.dryRun = true;
    else if (current === "--json") args.json = true;
    else if (current === "--help" || current === "-h") {
      printHelp();
      process.exit(0);
    }
  }

  return args;
}

function formatText(summary) {
  const lines = [];
  lines.push(`Skill root: ${summary.suiteDir}`);
  lines.push(`Project dir: ${summary.projectDir}`);
  lines.push(`Workflow: ${summary.workflow ?? "general"}`);
  if (summary.defaultModelConfig?.wrote) lines.push(`Default model: initialized to GEMINI_3_1_FLASH_IMAGE`);
  lines.push(`Bootstrap needed: ${summary.initial.bootstrap.pendingCount > 0 ? "yes" : "no"}`);
  lines.push(`Bootstrap attempted: ${summary.bootstrap.attempted ? "yes" : "no"}`);
  lines.push(`Bootstrap changed files: ${summary.bootstrap.changed ? "yes" : "no"}`);
  lines.push(`Ready: ${summary.final.ok ? "yes" : "no"}`);

  if (summary.bootstrap.targets.length) {
    lines.push("Bootstrap targets:");
    for (const target of summary.bootstrap.targets) {
      lines.push(`- ${target.relativeDir}`);
    }
  }

  if (summary.final.errors.length) {
    lines.push("Errors:");
    for (const error of summary.final.errors) lines.push(`- ${error}`);
  }
  if (summary.final.warnings.length) {
    lines.push("Warnings:");
    for (const warning of summary.final.warnings) lines.push(`- ${warning}`);
  }
  if (summary.final.suggestions.length) {
    lines.push("Suggestions:");
    for (const suggestion of summary.final.suggestions) lines.push(`- ${suggestion}`);
  }
  return lines.join("\n");
}

export function ensureReady({
  suiteDir,
  projectDir = process.cwd(),
  homeDir = process.env.HOME ?? null,
  env = process.env,
  workflow = null,
  dryRun = false,
  doctorImpl = doctorSuite,
  bootstrapImpl = bootstrapSuite,
}) {
  const defaultModelConfig = ensureDefaultModelConfig({ suiteDir, projectDir, dryRun });

  const initial = doctorImpl({
    suiteDir,
    projectDir,
    homeDir,
    env,
    workflow,
  });
  const pendingTargets = initial.bootstrap.targets.filter((target) => !target.installed);

  let bootstrapSummary = {
    attempted: false,
    changed: false,
    dryRun,
    ok: true,
    targets: pendingTargets,
    result: null,
  };

  if (pendingTargets.length > 0) {
    bootstrapSummary = {
      attempted: true,
      changed: !dryRun,
      dryRun,
      ok: true,
      targets: pendingTargets,
      result: bootstrapImpl({ dir: suiteDir, dryRun }),
    };
    if (bootstrapSummary.result && bootstrapSummary.result.ok === false) {
      bootstrapSummary.ok = false;
    }
  }

  const final = doctorImpl({
    suiteDir,
    projectDir,
    homeDir,
    env,
    workflow,
  });

  return {
    ok: final.ok && bootstrapSummary.ok,
    suiteDir: path.resolve(suiteDir),
    projectDir: path.resolve(projectDir),
    workflow,
    initial,
    bootstrap: bootstrapSummary,
    final,
    defaultModelConfig,
  };
}

const defaultSuiteDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const isDirectRun =
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));

if (isDirectRun) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const summary = ensureReady({
      suiteDir: args.dir ?? defaultSuiteDir,
      projectDir: args.project ?? process.cwd(),
      workflow: args.workflow,
      dryRun: args.dryRun,
    });
    if (args.json) console.log(JSON.stringify(summary, null, 2));
    else console.log(formatText(summary));
    if (!summary.ok) process.exitCode = 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}


path: scripts/vendor/shared-image-generation/scripts/package.json
content: {
  "name": "image-generation-scripts",
  "private": true,
  "type": "module",
  "description": "CLI for async text-to-image and image-to-image (Bun)",
  "scripts": {
    "test": "npx -y bun test"
  }
}


path: scripts/vendor/shared-image-generation/scripts/smoke-check.mjs
content: import process from "node:process";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";

function parseArgs(argv) {
  const out = { model: null };
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === "--model") out.model = argv[++i] ?? null;
  }
  return out;
}

function runBunScript(relativeScript, args) {
  const result = spawnSync(npxCmd, ["-y", "bun", relativeScript, ...args], {
    cwd: rootDir,
    encoding: "utf8",
  });
  if (result.status !== 0) {
    throw new Error(result.stderr || result.stdout || `Command failed: ${relativeScript}`);
  }
  return result.stdout.trim();
}

const cli = parseArgs(process.argv.slice(2));
const model = cli.model ?? process.env.IMAGE_GEN_DEFAULT_MODEL ?? "smoke-model-placeholder";
const payload = runBunScript("scripts/main.ts", [
  "--prompt",
  "image-generation smoke preview",
  "--ar",
  "1:1",
  "--model",
  model,
  "--dry-run",
  "--json",
]);
console.log(payload);


path: scripts/vendor/shared-image-generation/scripts/style-menu.mjs
content: import process from "node:process";

// Keep this menu aligned with image-generation/scripts/main.ts STYLE_PRESETS.
const STYLES = [
  { id: "photoreal", en: "Photorealistic / natural photography", zh: "写实 / 真实摄影质感" },
  { id: "cinematic", en: "Cinematic / film still mood", zh: "电影感 / 剧照氛围" },
  { id: "anime", en: "Anime / colorful cartoon", zh: "日系动漫 / 彩色" },
  { id: "manga", en: "Manga / B&W screentone", zh: "漫画 / 黑白网点" },
  { id: "watercolor", en: "Watercolor illustration", zh: "水彩插画" },
  { id: "flat-illustration", en: "Flat illustration / knowledge graphic", zh: "扁平插画 / 知识图" },
  { id: "3d-render", en: "3D render", zh: "3D 渲染" },
  { id: "poster", en: "Poster design", zh: "海报设计" },
  { id: "editorial", en: "Editorial / magazine layout feel", zh: "杂志插画 / 高级排版感" },
  { id: "infographic", en: "Infographic / chart style", zh: "信息图 / 图表风" },
  { id: "chalk", en: "Chalkboard / hand-drawn", zh: "粉笔黑板" },
  { id: "ink-brush", en: "East Asian ink brush", zh: "国风水墨" },
];

function printHelp() {
  console.log(`Usage:
  node scripts/style-menu.mjs [--lang en|zh]

Options:
  --lang <code>  Output language: en (default) or zh
  -h, --help     Show help

This prints the available --style presets and example commands.`);
}

function parseLang(argv) {
  const idx = argv.indexOf("--lang");
  if (idx >= 0 && argv[idx + 1]) return argv[idx + 1];
  return "en";
}

if (process.argv.includes("-h") || process.argv.includes("--help")) {
  printHelp();
  process.exit(0);
}

const lang = parseLang(process.argv.slice(2));
const labelKey = lang === "zh" ? "zh" : "en";

console.log("Available styles (--style):");
for (const s of STYLES) {
  console.log(`- ${s.id.padEnd(16)} ${s[labelKey]}`);
}

console.log("\nExample:");
console.log("  M=<model-key>");
console.log("  npx -y bun scripts/main.ts \\");
console.log("    --prompt \"a cat wearing sunglasses, neon city at night\" \\");
console.log("    --style cinematic --ar 3:4 --image out.png -m \"$M\"");


path: scripts/vendor/weryai-core/balance.js
content: import { createClient } from './client.js';
import { formatApiError, formatNetworkError, isApiSuccess } from './errors.js';

export async function executeBalance(_input, ctx) {
  const client = createClient(ctx);

  let res;
  try {
    res = await client.get('/v1/generation/balance', { retries: 3 });
  } catch (err) {
    return formatNetworkError(err);
  }

  if (!isApiSuccess(res)) {
    return formatApiError(res);
  }

  const credits = typeof res.data === 'number' ? res.data : (res.data?.balance ?? res.data);
  return {
    ok: true,
    phase: 'completed',
    balance: credits,
    errorCode: null,
    errorMessage: null,
  };
}


path: scripts/vendor/weryai-image/README.md
content: # WeryAI Image Runtime

这里预留图片领域 runtime。

Phase 1/2 先抽取共享基础设施，后续再把图片领域的 submit、validators、models 迁入这里。


path: scripts/vendor/weryai-image/models.js
content: export const DEFAULT_MODEL = 'WERYAI_IMAGE_2_0';

export const MODELS_API_PATH = '/growthai/v1/image/models';

export const FALLBACK_DEFAULTS = {
  aspect_ratio: '9:16',
  image_number: 1,
};


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal:  | SkillSpector: suspicious (CRITICAL)