@open-assets/open-assets 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { resolve, join } from "path";
4
- import { existsSync, readFileSync } from "fs";
4
+ import { readFileSync } from "fs";
5
5
 
6
6
  /**
7
7
  * Resolve an option value with env var fallback.
@@ -11,13 +11,6 @@ function env(name, fallback) {
11
11
  return process.env[name] ?? fallback;
12
12
  }
13
13
 
14
- /**
15
- * Get all export sizes from a collection, flattened.
16
- */
17
- function flatSizes(col) {
18
- return (col.exportSizes || []).flatMap((group) => group.sizes || []);
19
- }
20
-
21
14
  const program = new Command();
22
15
 
23
16
  const pkg = JSON.parse(
@@ -34,22 +27,24 @@ program
34
27
  program
35
28
  .command("dev")
36
29
  .description("Start the dev server with live preview and export UI")
37
- .argument("[dir]", "Project directory containing manifest.json", ".")
30
+ .argument("[dir]", "Project directory containing assets.json", ".")
38
31
  .option("-p, --port <port>", "Port to listen on (env: OPEN_ASSETS_PORT)", env("OPEN_ASSETS_PORT", "3200"))
39
32
  .option("-H, --host <host>", "Host to bind to (env: OPEN_ASSETS_HOST)", env("OPEN_ASSETS_HOST", "localhost"))
40
33
  .option("--no-open", "Don't auto-open the browser (env: OPEN_ASSETS_NO_OPEN)")
41
34
  .option("-q, --quiet", "Suppress server logs (env: OPEN_ASSETS_QUIET)", env("OPEN_ASSETS_QUIET") === "true")
42
35
  .option("--ci", "CI mode: no browser, quiet logging", env("CI") === "true")
43
- .option("--manifest <path>", "Path to manifest file (env: OPEN_ASSETS_MANIFEST)", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
36
+ .option("--config <path>", "Path to config file (env: OPEN_ASSETS_CONFIG)", env("OPEN_ASSETS_CONFIG", "assets.json"))
44
37
  .option("--static-dir <dirs...>", "Additional static directories to serve")
45
38
  .option("--render-timeout <ms>", "Puppeteer render timeout in ms (env: OPEN_ASSETS_RENDER_TIMEOUT)", env("OPEN_ASSETS_RENDER_TIMEOUT", "30000"))
46
39
  .action(async (dir, opts) => {
40
+ const { loadConfig } = await import("../lib/manifest.mjs");
47
41
  const projectDir = resolve(dir);
48
- const manifestPath = resolve(projectDir, opts.manifest);
49
42
 
50
- if (!existsSync(manifestPath)) {
43
+ try {
44
+ loadConfig(projectDir, opts.config);
45
+ } catch {
51
46
  console.error(
52
- `Error: ${opts.manifest} not found in ${projectDir}\n` +
47
+ `Error: ${opts.config} not found in ${projectDir}\n` +
53
48
  `Run "open-assets init" to create one.`
54
49
  );
55
50
  process.exit(1);
@@ -72,7 +67,7 @@ program
72
67
  port,
73
68
  host: opts.host,
74
69
  quiet: opts.quiet,
75
- manifestPath,
70
+ manifestPath: resolve(projectDir, opts.config),
76
71
  staticDirs: opts.staticDir,
77
72
  renderTimeout: parseInt(opts.renderTimeout, 10),
78
73
  });
@@ -88,219 +83,66 @@ program
88
83
  program
89
84
  .command("render")
90
85
  .description("Render assets headlessly via CLI")
91
- .argument("[dir]", "Project directory containing manifest.json", ".")
86
+ .argument("[dir]", "Project directory containing assets.json", ".")
92
87
  .option("--collection <id>", "Render only the collection with this ID")
93
88
  .option("--tag <tag>", "Render only collections with this tag")
94
89
  .option("--template <name>", "Render only the template with this name")
95
- .option("--size <name>", "Use a named export size from manifest")
96
- .option("--platform <name>", "Render only sizes for this platform")
90
+ .option("--size <name>", "Use a named export size from config")
97
91
  .option("--width <px>", "Output width in pixels (custom size)")
98
92
  .option("--height <px>", "Output height in pixels (custom size)")
99
- .option("--all", "Export at every size defined in the manifest")
93
+ .option("--all", "Export at every size defined in the config")
100
94
  .option("--force", "Re-render all assets even if unchanged")
101
95
  .option("-o, --output <dir>", "Output directory (env: OPEN_ASSETS_OUTPUT)", env("OPEN_ASSETS_OUTPUT", "./exports"))
102
- .option("--manifest <path>", "Path to manifest file (env: OPEN_ASSETS_MANIFEST)", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
96
+ .option("--config <path>", "Path to config file (env: OPEN_ASSETS_CONFIG)", env("OPEN_ASSETS_CONFIG", "assets.json"))
103
97
  .option("--parallel <count>", "Number of parallel renders (env: OPEN_ASSETS_PARALLEL)", env("OPEN_ASSETS_PARALLEL", "1"))
104
98
  .option("--render-timeout <ms>", "Puppeteer render timeout in ms", env("OPEN_ASSETS_RENDER_TIMEOUT", "30000"))
105
99
  .option("--json", "Output results as JSON (for CI/scripting)")
106
100
  .option("-q, --quiet", "Suppress progress logs", env("OPEN_ASSETS_QUIET") === "true")
107
101
  .action(async (dir, opts) => {
108
- const projectDir = resolve(dir);
109
- const manifestPath = resolve(projectDir, opts.manifest);
102
+ const { loadConfig } = await import("../lib/manifest.mjs");
103
+ const { renderAssets } = await import("../lib/render.mjs");
104
+ const renderer = await import("../lib/renderer.mjs");
105
+ const lockfile = await import("../lib/lockfile.mjs");
110
106
 
111
- if (!existsSync(manifestPath)) {
112
- console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
107
+ const projectDir = resolve(dir);
108
+ let manifest;
109
+ try {
110
+ manifest = loadConfig(projectDir, opts.config);
111
+ } catch (err) {
112
+ console.error(`Error: ${err.message}`);
113
113
  process.exit(1);
114
114
  }
115
115
 
116
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
117
-
118
- const { renderScreenshot, runXcodeOutput, closeBrowser } = await import(
119
- "../lib/renderer.mjs"
120
- );
121
- const { readLockfile, writeLockfile, computeChecksum, isUpToDate, recordExport } = await import(
122
- "../lib/lockfile.mjs"
123
- );
124
- const { mkdirSync, writeFileSync, copyFileSync } = await import("fs");
125
-
126
- const outputDir = resolve(opts.output);
127
- mkdirSync(outputDir, { recursive: true });
128
-
129
- let collections = manifest.collections;
130
- if (opts.collection) {
131
- collections = collections.filter((c) => c.id === opts.collection);
132
- }
133
- if (opts.tag) {
134
- collections = collections.filter((c) => (c.tags || []).includes(opts.tag));
135
- }
136
-
137
- if (collections.length === 0) {
138
- const filter = opts.collection ? `collection "${opts.collection}"` : `tag "${opts.tag}"`;
139
- console.error(`Error: no collections matched ${filter} in manifest`);
116
+ const log = opts.quiet ? undefined : (msg) => console.log(msg);
117
+ const deps = {
118
+ renderScreenshot: renderer.renderScreenshot,
119
+ runXcodeOutput: renderer.runXcodeOutput,
120
+ closeBrowser: renderer.closeBrowser,
121
+ readLockfile: lockfile.readLockfile,
122
+ writeLockfile: lockfile.writeLockfile,
123
+ computeChecksum: lockfile.computeChecksum,
124
+ isUpToDate: lockfile.isUpToDate,
125
+ recordExport: lockfile.recordExport,
126
+ };
127
+
128
+ const result = await renderAssets(projectDir, manifest, opts, deps, log);
129
+
130
+ if (result.error) {
131
+ console.error(`Error: ${result.error}`);
140
132
  process.exit(1);
141
133
  }
142
134
 
143
- const lockData = readLockfile(projectDir);
144
- const results = [];
145
- let skipped = 0;
146
- const startTime = Date.now();
147
-
148
- for (const col of collections) {
149
- const sourceW = col.sourceSize.width;
150
- const sourceH = col.sourceSize.height;
151
-
152
- // Filter templates
153
- const templates = opts.template
154
- ? col.templates.filter((t) => t.name === opts.template || t.label === opts.template)
155
- : col.templates;
156
-
157
- // Determine which sizes to render
158
- let sizesToRender = [];
159
-
160
- if (opts.width && opts.height) {
161
- // Custom size from CLI
162
- sizesToRender = [{
163
- name: `${opts.width}x${opts.height}`,
164
- label: `${opts.width}x${opts.height}`,
165
- width: parseInt(opts.width),
166
- height: parseInt(opts.height),
167
- }];
168
- } else if (opts.size) {
169
- // Specific named size
170
- const found = flatSizes(col).find((s) => s.name === opts.size || s.label === opts.size);
171
- if (found) {
172
- sizesToRender = [found];
173
- } else {
174
- if (!opts.quiet) console.log(` Warning: size "${opts.size}" not found in collection "${col.id}", skipping`);
175
- continue;
176
- }
177
- } else if (opts.all) {
178
- // All sizes
179
- sizesToRender = flatSizes(col);
180
- // Filter by platform if specified
181
- if (opts.platform) {
182
- sizesToRender = (col.exportSizes || [])
183
- .filter((g) => g.platform && g.platform.toLowerCase().includes(opts.platform.toLowerCase()))
184
- .flatMap((g) => g.sizes || []);
185
- }
186
- } else {
187
- // Default: render at source size
188
- sizesToRender = [{
189
- name: `${sourceW}x${sourceH}`,
190
- label: `${sourceW}x${sourceH}`,
191
- width: sourceW,
192
- height: sourceH,
193
- }];
194
- }
195
-
196
- if (sizesToRender.length === 0) {
197
- if (!opts.quiet) console.log(` No export sizes found for collection "${col.id}"`);
198
- continue;
199
- }
200
-
201
- // Determine if we need subdirectories (multiple sizes = subdirs)
202
- const useSubDirs = sizesToRender.length > 1 || opts.all;
203
-
204
- for (const size of sizesToRender) {
205
- const sizeDir = useSubDirs
206
- ? join(outputDir, col.id, size.name)
207
- : join(outputDir, col.id);
208
- mkdirSync(sizeDir, { recursive: true });
209
-
210
- if (!opts.quiet && sizesToRender.length > 1) {
211
- const platformInfo = (col.exportSizes || [])
212
- .find((g) => (g.sizes || []).some((s) => s.name === size.name));
213
- const platform = platformInfo?.platform ? `[${platformInfo.platform}] ` : '';
214
- console.log(`\n ${platform}${size.label} (${size.width}x${size.height})`);
215
- }
216
-
217
- for (const template of templates) {
218
- const outPath = join(sizeDir, `${template.name}.png`);
219
- const assetKey = `${col.id}/${template.name}`;
220
- const variantKey = size.name;
221
- const srcPath = resolve(projectDir, template.src);
222
- const checksum = computeChecksum(srcPath);
223
-
224
- // Skip if unchanged
225
- if (!opts.force && checksum && isUpToDate(lockData, assetKey, variantKey, checksum, outPath)) {
226
- if (!opts.quiet) console.log(` Skipping ${template.name} at ${size.width}x${size.height} (unchanged)`);
227
- skipped++;
228
- continue;
229
- }
230
-
231
- if (!opts.quiet) console.log(` Rendering ${template.name} at ${size.width}x${size.height}...`);
232
-
233
- // Detect SVG source → use copy-source approach for SVG outputs
234
- const isSvg = template.src.endsWith(".svg");
235
-
236
- if (isSvg && size.width === sourceW && size.height === sourceH) {
237
- // For SVGs at source size, just copy the source
238
- copyFileSync(srcPath, outPath.replace(".png", ".svg"));
239
- }
240
-
241
- const buffer = await renderScreenshot(
242
- projectDir,
243
- template.src,
244
- size.width,
245
- size.height,
246
- sourceW,
247
- sourceH
248
- );
249
- writeFileSync(outPath, buffer);
250
- results.push({
251
- collection: col.id,
252
- template: template.name,
253
- path: outPath,
254
- width: size.width,
255
- height: size.height,
256
- size: buffer.length,
257
- });
258
- if (checksum) recordExport(lockData, assetKey, variantKey, checksum, outPath);
259
- if (!opts.quiet) console.log(` → ${outPath}`);
260
- }
261
- }
262
-
263
- // Handle outputs (xcode, copy-source, etc.)
264
- if (col.outputs && opts.all) {
265
- for (const output of col.outputs) {
266
- if (output.type === "xcode") {
267
- if (!opts.quiet) console.log(`\n Exporting to Xcode: ${output.path}`);
268
- const path = await runXcodeOutput(projectDir, col, output);
269
- results.push({ collection: col.id, type: "xcode", path });
270
- } else if (output.type === "copy-source") {
271
- // Copy source files (e.g., SVG originals)
272
- const format = output.format || "svg";
273
- const copyDir = join(outputDir, col.id, format);
274
- mkdirSync(copyDir, { recursive: true });
275
- for (const template of templates) {
276
- if (template.src.endsWith(`.${format}`)) {
277
- const srcPath = resolve(projectDir, template.src);
278
- const destPath = join(copyDir, `${template.name}.${format}`);
279
- if (existsSync(srcPath)) {
280
- copyFileSync(srcPath, destPath);
281
- results.push({ collection: col.id, template: template.name, type: `copy-${format}`, path: destPath });
282
- if (!opts.quiet) console.log(` → ${destPath}`);
283
- }
284
- }
285
- }
286
- }
287
- }
288
- }
289
- }
290
-
291
- // Persist lockfile
292
- writeLockfile(projectDir, lockData);
293
-
294
- await closeBrowser();
295
-
296
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
297
-
298
135
  if (opts.json) {
299
- console.log(JSON.stringify({ ok: true, assets: results, skipped, elapsed: `${elapsed}s` }, null, 2));
136
+ console.log(JSON.stringify({
137
+ ok: true,
138
+ assets: result.results,
139
+ skipped: result.skipped,
140
+ elapsed: result.elapsed,
141
+ }, null, 2));
300
142
  } else if (!opts.quiet) {
301
- const parts = [`${results.length} asset(s) rendered`];
302
- if (skipped > 0) parts.push(`${skipped} skipped (unchanged)`);
303
- console.log(`\nDone. ${parts.join(", ")} in ${elapsed}s.`);
143
+ const parts = [`${result.results.length} asset(s) rendered`];
144
+ if (result.skipped > 0) parts.push(`${result.skipped} skipped (unchanged)`);
145
+ console.log(`\nDone. ${parts.join(", ")} in ${result.elapsed}.`);
304
146
  }
305
147
  });
306
148
 
@@ -308,149 +150,64 @@ program
308
150
 
309
151
  program
310
152
  .command("list")
311
- .description("List all collections and templates defined in the manifest")
312
- .argument("[dir]", "Project directory containing manifest.json", ".")
313
- .option("--manifest <path>", "Path to manifest file", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
153
+ .description("List all collections and templates defined in the config")
154
+ .argument("[dir]", "Project directory containing assets.json", ".")
155
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
314
156
  .option("--tag <tag>", "List only collections with this tag")
315
157
  .option("--json", "Output as JSON")
316
158
  .action(async (dir, opts) => {
317
- const projectDir = resolve(dir);
318
- const manifestPath = resolve(projectDir, opts.manifest);
159
+ const { loadConfig } = await import("../lib/manifest.mjs");
160
+ const { listCollections, formatCollectionList } = await import("../lib/list.mjs");
319
161
 
320
- if (!existsSync(manifestPath)) {
321
- console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
162
+ const projectDir = resolve(dir);
163
+ let manifest;
164
+ try {
165
+ manifest = loadConfig(projectDir, opts.config);
166
+ } catch (err) {
167
+ console.error(`Error: ${err.message}`);
322
168
  process.exit(1);
323
169
  }
324
170
 
325
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
326
-
327
171
  if (opts.json) {
328
172
  console.log(JSON.stringify(manifest, null, 2));
329
173
  return;
330
174
  }
331
175
 
332
- // Show available tags if defined
333
- if (manifest.tags && manifest.tags.length > 0 && !opts.tag) {
334
- console.log(`\n Tags: ${manifest.tags.map((t) => t.id).join(", ")}`);
335
- }
336
-
337
- let cols = manifest.collections;
338
- if (opts.tag) {
339
- cols = cols.filter((c) => (c.tags || []).includes(opts.tag));
340
- if (cols.length === 0) {
341
- console.error(`\n No collections found with tag "${opts.tag}"\n`);
342
- process.exit(1);
343
- }
176
+ const result = listCollections(manifest, { tag: opts.tag });
177
+ if (result.error) {
178
+ console.error(`\n ${result.error}\n`);
179
+ process.exit(1);
344
180
  }
345
181
 
346
- console.log(`\n ${manifest.name}\n`);
347
- for (const col of cols) {
348
- const templateCount = col.templates.length;
349
- const sizeCount = flatSizes(col).length;
350
- const tagStr = (col.tags && col.tags.length > 0) ? ` [${col.tags.join(", ")}]` : "";
351
- console.log(` ${col.label} (${col.id})${tagStr} — ${templateCount} template(s), ${sizeCount} size(s)`);
352
-
353
- for (const template of col.templates) {
354
- console.log(` • ${template.label || template.name} → ${template.src}`);
355
- }
356
-
357
- for (const group of col.exportSizes || []) {
358
- for (const size of group.sizes || []) {
359
- const platform = group.platform ? `${group.platform}: ` : '';
360
- console.log(` ↳ ${platform}${size.label} (${size.width}×${size.height})`);
361
- }
362
- }
363
-
364
- if (col.outputs) {
365
- for (const output of col.outputs) {
366
- if (output.type === "xcode") console.log(` ↳ Xcode: ${output.path}`);
367
- else if (output.type === "copy-source") console.log(` ↳ Copy source: ${output.format}`);
368
- }
369
- }
370
- }
371
- console.log();
182
+ console.log(`\n ${formatCollectionList(manifest, result.collections).split("\n").join("\n ")}\n`);
372
183
  });
373
184
 
374
185
  // ── validate ─────────────────────────────────────────────────────────────────
375
186
 
376
187
  program
377
188
  .command("validate")
378
- .description("Validate the manifest.json and check that all referenced files exist")
379
- .argument("[dir]", "Project directory containing manifest.json", ".")
380
- .option("--manifest <path>", "Path to manifest file", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
189
+ .description("Validate the assets.json and check that all referenced files exist")
190
+ .argument("[dir]", "Project directory containing assets.json", ".")
191
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
381
192
  .action(async (dir, opts) => {
382
- const projectDir = resolve(dir);
383
- const manifestPath = resolve(projectDir, opts.manifest);
384
-
385
- if (!existsSync(manifestPath)) {
386
- console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
387
- process.exit(1);
388
- }
193
+ const { loadConfig } = await import("../lib/manifest.mjs");
194
+ const { validateConfig } = await import("../lib/validate.mjs");
389
195
 
196
+ const projectDir = resolve(dir);
390
197
  let manifest;
391
198
  try {
392
- manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
199
+ manifest = loadConfig(projectDir, opts.config);
393
200
  } catch (err) {
394
- console.error(`Error: Invalid JSON in ${opts.manifest}: ${err.message}`);
201
+ console.error(`Error: ${err.message}`);
395
202
  process.exit(1);
396
203
  }
397
204
 
398
- let errors = 0;
399
- const ok = (msg) => console.log(` ✓ ${msg}`);
400
- const fail = (msg) => { console.log(` ✗ ${msg}`); errors++; };
401
-
402
- console.log(`\n Validating ${opts.manifest}...\n`);
205
+ console.log(`\n Validating ${opts.config}...\n`);
403
206
 
404
- // Check top-level fields
405
- if (manifest.name) ok(`name: "${manifest.name}"`);
406
- else fail("Missing top-level 'name' field");
207
+ const { checks, errors } = validateConfig(manifest, projectDir);
407
208
 
408
- if (Array.isArray(manifest.collections) && manifest.collections.length > 0) {
409
- ok(`${manifest.collections.length} collection(s) defined`);
410
- } else {
411
- fail("Missing or empty 'collections' array");
412
- process.exit(1);
413
- }
414
-
415
- // Check each collection
416
- const ids = new Set();
417
- for (const col of manifest.collections) {
418
- if (!col.id) { fail(`Collection missing 'id' field`); continue; }
419
- if (ids.has(col.id)) { fail(`Duplicate collection id: "${col.id}"`); }
420
- ids.add(col.id);
421
-
422
- if (!col.sourceSize || !col.sourceSize.width || !col.sourceSize.height) {
423
- fail(`Collection "${col.id}" missing or invalid 'sourceSize'`);
424
- } else {
425
- ok(`Collection "${col.id}" sourceSize: ${col.sourceSize.width}×${col.sourceSize.height}`);
426
- }
427
-
428
- if (!col.templates || col.templates.length === 0) {
429
- fail(`Collection "${col.id}" has no templates`);
430
- } else {
431
- for (const template of col.templates) {
432
- if (!template.src) {
433
- fail(`Collection "${col.id}" has a template without 'src'`);
434
- continue;
435
- }
436
- const filePath = resolve(projectDir, template.src);
437
- if (existsSync(filePath)) ok(`${template.src} exists`);
438
- else fail(`${template.src} not found`);
439
- }
440
- }
441
-
442
- const sizes = flatSizes(col);
443
- if (sizes.length > 0) {
444
- ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
445
- }
446
-
447
- // Validate outputs
448
- if (col.outputs) {
449
- for (const output of col.outputs) {
450
- if (!output.type) fail(`Collection "${col.id}" has an output without 'type'`);
451
- else ok(`Output: ${output.type}`);
452
- }
453
- }
209
+ for (const check of checks) {
210
+ console.log(` ${check.ok ? "\u2713" : "\u2717"} ${check.message}`);
454
211
  }
455
212
 
456
213
  console.log();
@@ -466,11 +223,67 @@ program
466
223
 
467
224
  program
468
225
  .command("init")
469
- .description("Scaffold a new manifest.json and example assets")
226
+ .description("Scaffold a new assets.json and example assets")
470
227
  .argument("[dir]", "Target directory", ".")
471
228
  .action(async (dir) => {
472
229
  const { scaffoldProject } = await import("../lib/init.mjs");
473
230
  await scaffoldProject(resolve(dir));
474
231
  });
475
232
 
233
+ // ── add ─────────────────────────────────────────────────────────────────────
234
+
235
+ const add = program
236
+ .command("add")
237
+ .description("Add collections, templates, or export sizes to the config");
238
+
239
+ add
240
+ .command("collection")
241
+ .description("Add a new asset collection (choose from presets or custom)")
242
+ .argument("[dir]", "Project directory containing assets.json", ".")
243
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
244
+ .action(async (dir, opts) => {
245
+ const { addCollection } = await import("../lib/add.mjs");
246
+ await addCollection(resolve(dir), opts.config);
247
+ });
248
+
249
+ add
250
+ .command("template")
251
+ .description("Add a new template to an existing collection (HTML or SVG)")
252
+ .argument("[dir]", "Project directory containing assets.json", ".")
253
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
254
+ .action(async (dir, opts) => {
255
+ const { addTemplate } = await import("../lib/add.mjs");
256
+ await addTemplate(resolve(dir), opts.config);
257
+ });
258
+
259
+ add
260
+ .command("size")
261
+ .description("Add an export size to a collection (choose from presets or custom)")
262
+ .argument("[dir]", "Project directory containing assets.json", ".")
263
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
264
+ .action(async (dir, opts) => {
265
+ const { addSize } = await import("../lib/add.mjs");
266
+ await addSize(resolve(dir), opts.config);
267
+ });
268
+
269
+ // ── skills ──────────────────────────────────────────────────────────────────
270
+
271
+ program
272
+ .command("skills")
273
+ .description("Install Claude Code skills for open-assets into the current project")
274
+ .argument("[dir]", "Target project directory", ".")
275
+ .action(async (dir) => {
276
+ const { cpSync, mkdirSync } = await import("fs");
277
+ const targetDir = resolve(dir);
278
+ const skillsSrc = new URL("../skills", import.meta.url).pathname;
279
+ const skillsDest = join(targetDir, ".claude", "skills");
280
+
281
+ mkdirSync(skillsDest, { recursive: true });
282
+ cpSync(skillsSrc, skillsDest, { recursive: true });
283
+
284
+ console.log(`\n Installed open-assets skills to ${join(targetDir, ".claude", "skills", "open-assets")}\n`);
285
+ console.log(` Claude Code will now use the open-assets skill when you use /open-assets or`);
286
+ console.log(` ask it to create marketing assets.\n`);
287
+ });
288
+
476
289
  program.parse();