Files
baoyu-skills/skills/baoyu-imagine/scripts/build-batch.ts
T
Jim Liu 宝玉 2c800c670a [codex] Refactor skills into focused references (#135)
* docs: add runtime-neutral User Input Tools convention across skills

Introduce docs/user-input-tools.md as the author-side canonical source
and inline the tool-selection rule into every SKILL.md that prompts
the user. Also add Skill Self-Containment and User Input Tools sections
to CLAUDE.md and the copy-verbatim template to docs/creating-skills.md,
so skills stay portable across Claude Code, Codex, Hermes, and other
runtimes.

* feat: runtime-neutral image generation convention across skills

- Introduce inline `## Image Generation Tools` rule in every rendering SKILL.md so skills delegate backend choice instead of hard-coding one; author-side canonical copy lives in docs/image-generation-tools.md.
- Add `## Reference Images` support (`--ref`, frontmatter `references:` with direct/style/palette usage) to all seven image-rendering skills.
- Move build-batch.ts (with ref propagation into batch JSON) from baoyu-article-illustrator to baoyu-imagine so non-backend skills don't own backend-specific scripts; update baoyu-image-gen stub in sync and relax the CLAUDE.md deprecation note accordingly.

* refactor: slim heavy SKILL.md files and move detail to references/

Trim the four largest active skills and move presets, option tables, and
confirmation scripts into per-skill references/ so SKILL.md stays focused
on the decision flow.

- baoyu-slide-deck: 761→258, + styles-gallery.md, confirmation.md
- baoyu-image-cards: 657→280, + gallery.md, confirmation.md
- baoyu-post-to-wechat: 518→267, + multi-account.md, api-setup.md
- baoyu-imagine: 500→230, + providers/, usage-examples.md

Also un-deprecate baoyu-image-gen (drop stub warning) so it stays functional
alongside baoyu-imagine, and update CLAUDE.md to reflect that both
superseded skills are kept in sync rather than stubbed.

* refactor: slim four medium SKILL.md files into references/

Continue the P2 pattern on the next tier of skills — move option catalogs,
per-provider/adapter detail, and repeated EXTEND.md path boilerplate into
their own references so SKILL.md stays focused on the decision flow.

- baoyu-comic: 380→297 (art/tone/preset tables → auto-selection.md;
  Step 7 expanded detail → workflow.md)
- baoyu-infographic: 312→207 (layouts/styles/combinations/keywords →
  gallery.md; ASCII box tables → markdown tables)
- baoyu-format-markdown: 376→296 (title + summary generation →
  title-summary.md; ASCII box tables → markdown tables)
- baoyu-url-to-markdown: 334→169 (quality gate + recovery →
  quality-gate.md; adapters + media download → adapters.md)

* chore: sync deprecated skills with their replacements

Per project policy, baoyu-xhs-images and baoyu-image-gen are kept
functional alongside the active skills they were superseded by. Sync
their SKILL.md bodies and references/ to the slimmed baoyu-image-cards
and baoyu-imagine versions respectively, so cross-cutting fixes stay
consistent. Only the frontmatter (name, description, version, homepage)
differs — content is identical.

- baoyu-xhs-images: 657→281 (synced with baoyu-image-cards + new
  confirmation.md, gallery.md)
- baoyu-image-gen: 408→231 (synced with baoyu-imagine + new
  providers/, usage-examples.md)

* refactor: collapse EXTEND.md boilerplate into priority tables

Replace the dual bash/powershell existence-check blocks and ASCII box
art with a single markdown priority table across nine SKILL.md files.
The runtime-neutral phrasing removes shell-specific snippets without
losing the priority semantics.

* fix: address refactor-skills branch review findings

- image-gen: restore EXTEND.md paths to baoyu-image-gen (were pointing at
  baoyu-imagine) and mark descriptions of both deprecated skills as
  [Deprecated].
- xhs-images: sync neon/warm palettes with image-cards to add the
  "do not render color names/hex as visible text" safety sentence.
- infographic: restore Layout Gallery (21), Style Gallery (21),
  Recommended Combinations, and Keyword Shortcuts inline (previous
  refactor split them out but SKILL.md still depended on them), and add
  the missing references/config/first-time-setup.md + preferences-schema.md.
- image-cards / xhs-images / slide-deck / format-markdown: restore the
  sections that got over-slimmed into references/ (galleries, presets,
  dimensions, auto-selection, style x layout matrix, title/summary flow)
  and drop the now-empty shell files.
- docs/image-generation-tools.md: note that backend skills themselves
  (baoyu-imagine, baoyu-image-gen, baoyu-danger-gemini-web) are exempt
  from the ## Image Generation Tools section requirement.

* feat(image-gen): sync Z.AI GLM-Image provider from baoyu-imagine

Add Z.AI as a full provider in the deprecated baoyu-image-gen skill so
it stays in sync with baoyu-imagine's provider list.

- new scripts/providers/zai.ts + zai.test.ts (verbatim port; test
  factory trimmed to match image-gen's CliArgs shape).
- types.ts: "zai" added to Provider union and default_model.
- main.ts: rate-limit defaults, provider help text, env var help,
  --provider validation, loadProviderModule, detectProvider
  auto-detect chain, getModelForProvider, YAML parser allow-lists.
- references/config: Q2e Z.AI model question + zai slot in the
  preferences schema and batch.provider_limits.

Scope is intentionally limited to the Z.AI chain; unrelated drift
between image-gen and imagine (OpenAI image-API dialect,
aspectRatioSource, imageSizeSource) is left alone.

* docs: align inline-convention wording and note backend-skill exemption

- docs/user-input-tools.md: fix stale "links here" wording so it
  matches the inline convention already enforced everywhere else.
- CLAUDE.md §Image Generation Tools: inline the backend-skill
  exemption so readers don't need to cross-reference
  docs/image-generation-tools.md.
2026-04-19 00:48:44 -05:00

239 lines
7.3 KiB
TypeScript

import path from "node:path";
import process from "node:process";
import { readdir, readFile, writeFile } from "node:fs/promises";
type CliArgs = {
outlinePath: string | null;
promptsDir: string | null;
outputPath: string | null;
imagesDir: string | null;
refsDir: string;
provider: string;
model: string | null;
aspectRatio: string;
quality: string;
jobs: number | null;
help: boolean;
};
type OutlineEntry = {
index: number;
filename: string;
};
type PromptReference = {
filename: string;
usage: "direct" | "style" | "palette";
};
function printUsage(): void {
console.log(`Usage:
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
Options:
--outline <path> Path to outline.md
--prompts <path> Path to prompts directory
--output <path> Path to output batch.json
--images-dir <path> Directory for generated images
--refs-dir <path> Directory holding reference images, relative to batch file (default: references)
--provider <name> Provider for baoyu-imagine batch tasks (default: replicate)
--model <id> Explicit model for baoyu-imagine batch tasks (default: resolved by baoyu-imagine config/env)
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
--quality <level> Quality for all tasks (default: 2k)
--jobs <count> Recommended worker count metadata (optional)
-h, --help Show help`);
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
outlinePath: null,
promptsDir: null,
outputPath: null,
imagesDir: null,
refsDir: "references",
provider: "replicate",
model: null,
aspectRatio: "16:9",
quality: "2k",
jobs: null,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const current = argv[i]!;
if (current === "--outline") args.outlinePath = argv[++i] ?? null;
else if (current === "--prompts") args.promptsDir = argv[++i] ?? null;
else if (current === "--output") args.outputPath = argv[++i] ?? null;
else if (current === "--images-dir") args.imagesDir = argv[++i] ?? null;
else if (current === "--refs-dir") args.refsDir = argv[++i] ?? args.refsDir;
else if (current === "--provider") args.provider = argv[++i] ?? args.provider;
else if (current === "--model") args.model = argv[++i] ?? args.model;
else if (current === "--ar") args.aspectRatio = argv[++i] ?? args.aspectRatio;
else if (current === "--quality") args.quality = argv[++i] ?? args.quality;
else if (current === "--jobs") {
const value = argv[++i];
args.jobs = value ? parseInt(value, 10) : null;
} else if (current === "--help" || current === "-h") {
args.help = true;
}
}
return args;
}
function parsePromptReferences(content: string): PromptReference[] {
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
if (!fmMatch) return [];
const lines = fmMatch[1]!.split(/\r?\n/);
const refs: PromptReference[] = [];
let current: Partial<PromptReference> | null = null;
let inReferences = false;
let listIndent = 0;
const flush = () => {
if (current?.filename) {
refs.push({
filename: current.filename,
usage: (current.usage ?? "direct") as PromptReference["usage"],
});
}
current = null;
};
const unquote = (raw: string): string => raw.trim().replace(/^["']|["']$/g, "");
for (const line of lines) {
if (!line.trim() || line.trim().startsWith("#")) continue;
const keyMatch = line.match(/^(\S[^:]*):\s*(.*)$/);
if (keyMatch) {
flush();
if (keyMatch[1] === "references") {
inReferences = true;
listIndent = 0;
continue;
}
inReferences = false;
continue;
}
if (!inReferences) continue;
const itemMatch = line.match(/^(\s*)-\s*(.*)$/);
if (itemMatch) {
flush();
listIndent = itemMatch[1]!.length;
current = {};
const rest = itemMatch[2]!.trim();
if (rest) {
const kv = rest.match(/^(\w+)\s*:\s*(.*)$/);
if (kv && (kv[1] === "filename" || kv[1] === "usage")) {
(current as Record<string, string>)[kv[1]] = unquote(kv[2]!);
}
}
continue;
}
const kvMatch = line.match(/^(\s+)(\w+)\s*:\s*(.*)$/);
if (kvMatch && kvMatch[1]!.length > listIndent && current) {
if (kvMatch[2] === "filename" || kvMatch[2] === "usage") {
(current as Record<string, string>)[kvMatch[2]!] = unquote(kvMatch[3]!);
}
}
}
flush();
return refs;
}
function parseOutline(content: string): OutlineEntry[] {
const entries: OutlineEntry[] = [];
const blocks = content.split(/^## Illustration\s+/m).slice(1);
for (const block of blocks) {
const indexMatch = block.match(/^(\d+)/);
const filenameMatch = block.match(/\*\*Filename\*\*:\s*(.+)/);
if (indexMatch && filenameMatch) {
entries.push({
index: parseInt(indexMatch[1]!, 10),
filename: filenameMatch[1]!.trim(),
});
}
}
return entries;
}
async function findPromptFile(promptsDir: string, entry: OutlineEntry): Promise<string | null> {
const files = await readdir(promptsDir);
const prefix = String(entry.index).padStart(2, "0");
const match = files.find((f) => f.startsWith(prefix) && f.endsWith(".md"));
return match ? path.join(promptsDir, match) : null;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
if (!args.outlinePath) {
console.error("Error: --outline is required");
process.exit(1);
}
if (!args.promptsDir) {
console.error("Error: --prompts is required");
process.exit(1);
}
if (!args.outputPath) {
console.error("Error: --output is required");
process.exit(1);
}
const outlineContent = await readFile(args.outlinePath, "utf8");
const entries = parseOutline(outlineContent);
if (entries.length === 0) {
console.error("No illustration entries found in outline.");
process.exit(1);
}
const tasks = [];
for (const entry of entries) {
const promptFile = await findPromptFile(args.promptsDir, entry);
if (!promptFile) {
console.error(`Warning: No prompt file found for illustration ${entry.index}, skipping.`);
continue;
}
const imageDir = args.imagesDir ?? path.dirname(args.outputPath);
const promptContent = await readFile(promptFile, "utf8");
const refs = parsePromptReferences(promptContent)
.filter((r) => r.usage === "direct")
.map((r) => path.posix.join(args.refsDir, r.filename));
const task: Record<string, unknown> = {
id: `illustration-${String(entry.index).padStart(2, "0")}`,
promptFiles: [promptFile],
image: path.join(imageDir, entry.filename),
provider: args.provider,
ar: args.aspectRatio,
quality: args.quality,
};
if (args.model) task.model = args.model;
if (refs.length > 0) task.ref = refs;
tasks.push(task);
}
const output: Record<string, unknown> = { tasks };
if (args.jobs) output.jobs = args.jobs;
await writeFile(args.outputPath, JSON.stringify(output, null, 2) + "\n");
console.log(`Batch file written: ${args.outputPath} (${tasks.length} tasks)`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});