slides-grab 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README-ko.md +8 -6
  2. package/README.md +8 -6
  3. package/bin/ppt-agent.js +119 -6
  4. package/package.json +6 -2
  5. package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
  6. package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
  7. package/scripts/build-viewer.js +67 -5
  8. package/scripts/design-gate.js +241 -0
  9. package/scripts/html2png.js +246 -0
  10. package/scripts/install-runtime.js +216 -0
  11. package/skills/slides-grab/SKILL.md +19 -14
  12. package/skills/slides-grab/references/presentation-workflow-reference.md +8 -6
  13. package/skills/slides-grab-card-news/SKILL.md +1 -1
  14. package/skills/slides-grab-design/SKILL.md +19 -11
  15. package/skills/slides-grab-design/references/design-gate.md +349 -0
  16. package/skills/slides-grab-design/references/design-rules.md +12 -3
  17. package/skills/slides-grab-design/references/design-system-full.md +4 -4
  18. package/skills/slides-grab-design/references/detailed-design-rules.md +9 -0
  19. package/skills/slides-grab-export/SKILL.md +10 -7
  20. package/skills/slides-grab-export/references/export-rules.md +3 -0
  21. package/skills/slides-grab-export/references/pptx-skill-reference.md +7 -42
  22. package/skills/slides-grab-plan/SKILL.md +6 -3
  23. package/skills/slides-grab-plan/references/plan-workflow-reference.md +14 -14
  24. package/src/design-diversity-data.js +6932 -0
  25. package/src/design-gate-report.js +244 -0
  26. package/src/design-gate-state.js +294 -0
  27. package/src/design-styles.js +82 -2
  28. package/src/editor/codex-edit.js +26 -1
  29. package/src/editor/editor.html +1 -1
  30. package/src/editor/js/model-registry.js +1 -1
  31. package/src/validation/core.js +76 -0
  32. package/templates/design-styles/README.md +2 -1
  33. package/templates/design-styles/preview.html +1088 -6
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdir, readFile } from 'node:fs/promises';
6
+ import { dirname, join, relative, resolve } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ import { ensureSlidesPassValidation } from './validate-slides.js';
10
+ import { assertProceedReportsComplete } from '../src/design-gate-report.js';
11
+ import {
12
+ buildDesignGatePaths,
13
+ buildDesignGateReport,
14
+ collectFileFingerprints,
15
+ collectSlideFingerprints,
16
+ createDesignGateState,
17
+ findGateSlideFiles,
18
+ normalizeGateVerdict,
19
+ writeDesignGateState,
20
+ } from '../src/design-gate-state.js';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const packageRoot = resolve(__dirname, '..');
24
+ const DEFAULT_SLIDES_DIR = 'slides';
25
+ const DEFAULT_SLIDE_MODE = 'presentation';
26
+ const DEFAULT_RESOLUTION = '2160p';
27
+
28
+ function printUsage() {
29
+ process.stdout.write(
30
+ [
31
+ 'Usage: slides-grab design-gate [options]',
32
+ '',
33
+ 'Options:',
34
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
35
+ ` --slide-mode <mode> Slide mode: presentation|card-news (default: ${DEFAULT_SLIDE_MODE})`,
36
+ ` --resolution <preset> PNG evidence resolution preset (default: ${DEFAULT_RESOLUTION})`,
37
+ ' --verdict <verdict> Gate verdict: proceed|revise|rethink',
38
+ ' --pass-a-report <path> Pass A review report file',
39
+ ' --pass-b-report <path> Pass B review report file',
40
+ ' --output-dir <path> PNG evidence directory (default: <slides-dir>/.slides-grab/gate-preview)',
41
+ ' -h, --help Show this help message',
42
+ ].join('\n'),
43
+ );
44
+ process.stdout.write('\n');
45
+ }
46
+
47
+ function parseArgs(args) {
48
+ const options = {
49
+ slidesDir: DEFAULT_SLIDES_DIR,
50
+ slideMode: DEFAULT_SLIDE_MODE,
51
+ resolution: DEFAULT_RESOLUTION,
52
+ verdict: '',
53
+ passAReport: '',
54
+ passBReport: '',
55
+ outputDir: '',
56
+ help: false,
57
+ };
58
+
59
+ for (let index = 0; index < args.length; index += 1) {
60
+ const arg = args[index];
61
+ if (arg === '-h' || arg === '--help') {
62
+ options.help = true;
63
+ continue;
64
+ }
65
+
66
+ if (ARGUMENT_READERS.has(arg)) {
67
+ options[ARGUMENT_READERS.get(arg)] = readOptionValue(args, index, arg);
68
+ index += 1;
69
+ continue;
70
+ }
71
+
72
+ const equalSignIndex = arg.indexOf('=');
73
+ if (equalSignIndex > -1) {
74
+ const name = arg.slice(0, equalSignIndex);
75
+ const value = arg.slice(equalSignIndex + 1);
76
+ if (ARGUMENT_READERS.has(name)) {
77
+ options[ARGUMENT_READERS.get(name)] = value;
78
+ continue;
79
+ }
80
+ }
81
+
82
+ throw new Error(`Unknown option: ${arg}`);
83
+ }
84
+
85
+ if (options.help) return options;
86
+
87
+ options.slidesDir = requireNonEmpty(options.slidesDir, '--slides-dir');
88
+ options.slideMode = requireNonEmpty(options.slideMode, '--slide-mode');
89
+ options.resolution = requireNonEmpty(options.resolution, '--resolution');
90
+ options.verdict = normalizeGateVerdict(requireNonEmpty(options.verdict, '--verdict'));
91
+ options.passAReport = requireNonEmpty(options.passAReport, '--pass-a-report');
92
+ options.passBReport = requireNonEmpty(options.passBReport, '--pass-b-report');
93
+ if (options.outputDir) options.outputDir = options.outputDir.trim();
94
+ return options;
95
+ }
96
+
97
+ const ARGUMENT_READERS = new Map([
98
+ ['--slides-dir', 'slidesDir'],
99
+ ['--slide-mode', 'slideMode'],
100
+ ['--resolution', 'resolution'],
101
+ ['--verdict', 'verdict'],
102
+ ['--pass-a-report', 'passAReport'],
103
+ ['--pass-b-report', 'passBReport'],
104
+ ['--output-dir', 'outputDir'],
105
+ ]);
106
+
107
+ function readOptionValue(args, index, optionName) {
108
+ const value = args[index + 1];
109
+ if (!value || value.startsWith('-')) {
110
+ throw new Error(`Missing value for ${optionName}.`);
111
+ }
112
+ return value;
113
+ }
114
+
115
+ function requireNonEmpty(value, optionName) {
116
+ const trimmed = String(value || '').trim();
117
+ if (!trimmed) throw new Error(`${optionName} must be a non-empty string.`);
118
+ return trimmed;
119
+ }
120
+
121
+ async function renderGateEvidence(options, outputDir) {
122
+ await mkdir(outputDir, { recursive: true });
123
+ const scriptPath = resolve(packageRoot, 'scripts/html2png.js');
124
+ const args = [
125
+ scriptPath,
126
+ '--slides-dir',
127
+ options.slidesDir,
128
+ '--output-dir',
129
+ outputDir,
130
+ '--slide-mode',
131
+ options.slideMode,
132
+ '--resolution',
133
+ options.resolution,
134
+ ];
135
+
136
+ await new Promise((resolvePromise, rejectPromise) => {
137
+ const child = spawn(process.execPath, args, {
138
+ cwd: process.cwd(),
139
+ stdio: 'inherit',
140
+ env: { ...process.env, PPT_AGENT_PACKAGE_ROOT: packageRoot },
141
+ });
142
+ child.on('error', rejectPromise);
143
+ child.on('close', (code, signal) => {
144
+ if (signal) {
145
+ rejectPromise(new Error(`PNG evidence render terminated by signal ${signal}.`));
146
+ return;
147
+ }
148
+ if (code !== 0) {
149
+ rejectPromise(new Error(`PNG evidence render failed with exit code ${code}.`));
150
+ return;
151
+ }
152
+ resolvePromise();
153
+ });
154
+ });
155
+ }
156
+
157
+ async function readEvidenceReport(filePath, label) {
158
+ const resolvedPath = resolve(process.cwd(), filePath);
159
+ if (!existsSync(resolvedPath)) {
160
+ throw new Error(`${label} report not found: ${resolvedPath}`);
161
+ }
162
+ const report = (await readFile(resolvedPath, 'utf-8')).trim();
163
+ if (!report) {
164
+ throw new Error(`${label} report is empty: ${resolvedPath}`);
165
+ }
166
+ return { resolvedPath, report };
167
+ }
168
+
169
+ export async function main(argv = process.argv.slice(2)) {
170
+ const options = parseArgs(argv);
171
+ if (options.help) {
172
+ printUsage();
173
+ return;
174
+ }
175
+
176
+ const paths = buildDesignGatePaths(options.slidesDir);
177
+ const previewDir = options.outputDir ? resolve(process.cwd(), options.outputDir) : paths.previewDir;
178
+ await ensureSlidesPassValidation(paths.slidesDir, {
179
+ exportLabel: 'design gate',
180
+ slideMode: options.slideMode,
181
+ shouldBlockIssue: () => true,
182
+ });
183
+ await renderGateEvidence(options, previewDir);
184
+
185
+ const passA = await readEvidenceReport(options.passAReport, 'Pass A');
186
+ const passB = await readEvidenceReport(options.passBReport, 'Pass B');
187
+ let gateValidation = { status: options.verdict === 'proceed' ? 'passed' : 'not-run' };
188
+ const slideFiles = await findGateSlideFiles(paths.slidesDir);
189
+ const slideFingerprints = await collectSlideFingerprints(paths.slidesDir);
190
+ const evidenceFiles = slideFiles.map((fileName) => fileName.replace(/\.html$/i, '.png'));
191
+ const previewRelativeDir = relative(paths.slidesDir, previewDir);
192
+ const previewFingerprintFiles = evidenceFiles.map((fileName) => join(previewRelativeDir, fileName));
193
+ const passReportFingerprintFiles = [
194
+ relative(paths.slidesDir, passA.resolvedPath),
195
+ relative(paths.slidesDir, passB.resolvedPath),
196
+ ];
197
+
198
+ if (options.verdict === 'proceed') {
199
+ gateValidation = assertProceedReportsComplete({
200
+ passAReport: passA.report,
201
+ passBReport: passB.report,
202
+ evidenceFiles,
203
+ slideFingerprints,
204
+ });
205
+ }
206
+ const state = await createDesignGateState({
207
+ slidesDir: paths.slidesDir,
208
+ slideMode: options.slideMode,
209
+ resolution: options.resolution,
210
+ verdict: options.verdict,
211
+ previewDir,
212
+ reportPath: paths.reportPath,
213
+ passA: { reportPath: passA.resolvedPath, summary: firstLine(passA.report), ...gateValidation.passA },
214
+ passB: { reportPath: passB.resolvedPath, summary: firstLine(passB.report), ...gateValidation.passB },
215
+ gateValidation,
216
+ passReportFingerprints: await collectFileFingerprints(paths.slidesDir, passReportFingerprintFiles),
217
+ previewFingerprints: await collectFileFingerprints(paths.slidesDir, previewFingerprintFiles),
218
+ });
219
+ const report = buildDesignGateReport(state, passA.report, passB.report);
220
+ await writeDesignGateState(paths.slidesDir, state, report);
221
+
222
+ process.stdout.write(`Design gate recorded: ${state.verdict}\n`);
223
+ process.stdout.write(`Evidence PNGs: ${previewDir}\n`);
224
+ process.stdout.write(`Gate report: ${paths.reportPath}\n`);
225
+ process.stdout.write(`Gate state: ${paths.statePath}\n`);
226
+
227
+ if (state.verdict !== 'proceed') {
228
+ process.exitCode = 1;
229
+ }
230
+ }
231
+
232
+ function firstLine(value) {
233
+ return value.split(/\r?\n/).find((line) => line.trim())?.trim() || '';
234
+ }
235
+
236
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
237
+ main().catch((error) => {
238
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
239
+ process.exit(1);
240
+ });
241
+ }
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readdir } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { chromium } from 'playwright';
8
+
9
+ import { ensureSlidesPassValidation } from './validate-slides.js';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const {
13
+ getResolutionChoices,
14
+ getResolutionSize,
15
+ normalizeResolutionPreset,
16
+ } = require('../src/export-resolution.cjs');
17
+ const {
18
+ DEFAULT_SLIDE_MODE,
19
+ getSlideModeChoices,
20
+ getSlideModeConfig,
21
+ normalizeSlideMode,
22
+ } = require('../src/slide-mode.cjs');
23
+
24
+ const DEFAULT_SLIDES_DIR = 'slides';
25
+ const DEFAULT_RESOLUTION = '2160p';
26
+ const DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR = 2;
27
+ const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
28
+ const RENDER_SETTLE_MS = 120;
29
+
30
+ function printUsage() {
31
+ process.stdout.write(
32
+ [
33
+ 'Usage: node scripts/html2png.js [options]',
34
+ '',
35
+ 'Options:',
36
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
37
+ ' --output-dir <path> Output directory for PNG files (default: <slides-dir>/out-png)',
38
+ ` --slide-mode <mode> Slide mode: ${getSlideModeChoices().join('|')} (default: ${DEFAULT_SLIDE_MODE})`,
39
+ ` --resolution <preset> Raster size preset: ${getResolutionChoices().join('|')}|4k (default: ${DEFAULT_RESOLUTION})`,
40
+ ' -h, --help Show this help message',
41
+ '',
42
+ 'Examples:',
43
+ ' node scripts/html2png.js --slides-dir slides',
44
+ ' node scripts/html2png.js --slides-dir cards --slide-mode card-news',
45
+ ' node scripts/html2png.js --slides-dir slides --resolution 1440p',
46
+ ].join('\n'),
47
+ );
48
+ process.stdout.write('\n');
49
+ }
50
+
51
+ function readOptionValue(args, index, optionName) {
52
+ const next = args[index + 1];
53
+ if (!next || next.startsWith('-')) {
54
+ throw new Error(`Missing value for ${optionName}.`);
55
+ }
56
+ return next;
57
+ }
58
+
59
+ function toSlideOrder(fileName) {
60
+ const match = fileName.match(/\d+/);
61
+ return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
62
+ }
63
+
64
+ function sortSlideFiles(a, b) {
65
+ const orderA = toSlideOrder(a);
66
+ const orderB = toSlideOrder(b);
67
+ if (orderA !== orderB) return orderA - orderB;
68
+ return a.localeCompare(b);
69
+ }
70
+
71
+ function parseCliArgs(args) {
72
+ const options = {
73
+ slidesDir: DEFAULT_SLIDES_DIR,
74
+ outputDir: '',
75
+ slideMode: DEFAULT_SLIDE_MODE,
76
+ resolution: DEFAULT_RESOLUTION,
77
+ help: false,
78
+ };
79
+
80
+ for (let i = 0; i < args.length; i += 1) {
81
+ const arg = args[i];
82
+
83
+ if (arg === '-h' || arg === '--help') {
84
+ options.help = true;
85
+ continue;
86
+ }
87
+
88
+ if (arg === '--slides-dir') {
89
+ options.slidesDir = readOptionValue(args, i, '--slides-dir');
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (arg.startsWith('--slides-dir=')) {
94
+ options.slidesDir = arg.slice('--slides-dir='.length);
95
+ continue;
96
+ }
97
+
98
+ if (arg === '--output-dir') {
99
+ options.outputDir = readOptionValue(args, i, '--output-dir');
100
+ i += 1;
101
+ continue;
102
+ }
103
+ if (arg.startsWith('--output-dir=')) {
104
+ options.outputDir = arg.slice('--output-dir='.length);
105
+ continue;
106
+ }
107
+
108
+ if (arg === '--slide-mode') {
109
+ options.slideMode = normalizeSlideMode(
110
+ readOptionValue(args, i, '--slide-mode'),
111
+ { optionName: '--slide-mode' },
112
+ );
113
+ i += 1;
114
+ continue;
115
+ }
116
+ if (arg.startsWith('--slide-mode=')) {
117
+ options.slideMode = normalizeSlideMode(
118
+ arg.slice('--slide-mode='.length),
119
+ { optionName: '--slide-mode' },
120
+ );
121
+ continue;
122
+ }
123
+
124
+ if (arg === '--resolution') {
125
+ options.resolution = normalizeResolutionPreset(
126
+ readOptionValue(args, i, '--resolution'),
127
+ { allowEmpty: false },
128
+ );
129
+ i += 1;
130
+ continue;
131
+ }
132
+ if (arg.startsWith('--resolution=')) {
133
+ options.resolution = normalizeResolutionPreset(
134
+ arg.slice('--resolution='.length),
135
+ { allowEmpty: false },
136
+ );
137
+ continue;
138
+ }
139
+
140
+ throw new Error(`Unknown option: ${arg}`);
141
+ }
142
+
143
+ return options;
144
+ }
145
+
146
+ function computeDeviceScaleFactor(resolution, slideMode) {
147
+ const { framePx } = getSlideModeConfig(slideMode);
148
+ const target = getResolutionSize(resolution, slideMode);
149
+ if (!target) return DEFAULT_CAPTURE_DEVICE_SCALE_FACTOR;
150
+ return target.height / framePx.height;
151
+ }
152
+
153
+ async function discoverSlideFiles(slidesDir) {
154
+ const entries = await readdir(slidesDir);
155
+ return entries
156
+ .filter((name) => SLIDE_FILE_PATTERN.test(name))
157
+ .sort(sortSlideFiles);
158
+ }
159
+
160
+ async function renderSlideToPng(page, slidesDir, slideFile, outputPath) {
161
+ const url = pathToFileURL(join(slidesDir, slideFile)).href;
162
+ await page.goto(url, { waitUntil: 'load' });
163
+ await page.evaluate(async () => {
164
+ if (document.fonts?.ready) {
165
+ await document.fonts.ready;
166
+ }
167
+ });
168
+ await page.waitForTimeout(RENDER_SETTLE_MS);
169
+ await page.screenshot({ path: outputPath, fullPage: false });
170
+ }
171
+
172
+ async function main() {
173
+ let options;
174
+ try {
175
+ options = parseCliArgs(process.argv.slice(2));
176
+ } catch (error) {
177
+ process.stderr.write(`${error.message}\n`);
178
+ printUsage();
179
+ process.exit(2);
180
+ }
181
+
182
+ if (options.help) {
183
+ printUsage();
184
+ return;
185
+ }
186
+
187
+ const slidesDir = resolve(process.cwd(), options.slidesDir);
188
+ const outputDir = resolve(
189
+ process.cwd(),
190
+ options.outputDir || join(options.slidesDir, 'out-png'),
191
+ );
192
+
193
+ await ensureSlidesPassValidation(slidesDir, {
194
+ exportLabel: 'PNG export',
195
+ slideMode: options.slideMode,
196
+ });
197
+
198
+ const slideFiles = await discoverSlideFiles(slidesDir);
199
+ if (slideFiles.length === 0) {
200
+ process.stderr.write(`No slide-*.html files found in ${slidesDir}\n`);
201
+ process.exit(1);
202
+ }
203
+
204
+ await mkdir(outputDir, { recursive: true });
205
+
206
+ const { framePx } = getSlideModeConfig(options.slideMode);
207
+ const deviceScaleFactor = computeDeviceScaleFactor(
208
+ options.resolution,
209
+ options.slideMode,
210
+ );
211
+ const outputSize = getResolutionSize(options.resolution, options.slideMode) || {
212
+ width: framePx.width * deviceScaleFactor,
213
+ height: framePx.height * deviceScaleFactor,
214
+ };
215
+
216
+ process.stdout.write(
217
+ `Rendering ${slideFiles.length} slide(s) at ${outputSize.width}x${outputSize.height} (${options.slideMode})\n`,
218
+ );
219
+
220
+ const browser = await chromium.launch({ headless: true });
221
+ try {
222
+ const context = await browser.newContext({
223
+ viewport: { width: framePx.width, height: framePx.height },
224
+ deviceScaleFactor,
225
+ });
226
+ const page = await context.newPage();
227
+
228
+ for (const slideFile of slideFiles) {
229
+ const outputName = slideFile.replace(/\.html$/i, '.png');
230
+ const outputPath = join(outputDir, outputName);
231
+ await renderSlideToPng(page, slidesDir, slideFile, outputPath);
232
+ process.stdout.write(` ${outputName}\n`);
233
+ }
234
+
235
+ await context.close();
236
+ } finally {
237
+ await browser.close();
238
+ }
239
+
240
+ process.stdout.write(`PNG export complete: ${outputDir}\n`);
241
+ }
242
+
243
+ main().catch((error) => {
244
+ process.stderr.write(`${error?.stack || error?.message || error}\n`);
245
+ process.exit(1);
246
+ });
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cp, mkdir, readdir, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const packageRoot = resolve(__dirname, '..');
10
+ const VALID_TARGETS = new Set(['all', 'codex', 'claude-code']);
11
+ const VALID_SCOPES = new Set(['project', 'user']);
12
+ const SKILL_NAMES = [
13
+ 'slides-grab',
14
+ 'slides-grab-plan',
15
+ 'slides-grab-design',
16
+ 'slides-grab-export',
17
+ 'slides-grab-card-news',
18
+ ];
19
+
20
+ function printUsage() {
21
+ process.stdout.write(
22
+ [
23
+ 'Usage: slides-grab install-skills [options]',
24
+ '',
25
+ 'Options:',
26
+ ' --target <target> Runtime target: all|codex|claude-code (default: all)',
27
+ ' --runtime <target> Alias for --target',
28
+ ' --scope <scope> Install scope: project|user (default: project)',
29
+ ' --project-dir <path> Project directory for project scope (default: cwd)',
30
+ ' --target-root <path> Root directory for user-style installs',
31
+ ' --dry-run Print planned writes without copying',
32
+ ' --json Print JSON result',
33
+ ' -h, --help Show this help message',
34
+ ].join('\n'),
35
+ );
36
+ process.stdout.write('\n');
37
+ }
38
+
39
+ function parseArgs(args) {
40
+ const options = {
41
+ target: 'all',
42
+ scope: 'project',
43
+ projectDir: process.cwd(),
44
+ targetRoot: '',
45
+ dryRun: false,
46
+ json: false,
47
+ help: false,
48
+ };
49
+
50
+ for (let index = 0; index < args.length; index += 1) {
51
+ const arg = args[index];
52
+ if (arg === '-h' || arg === '--help') {
53
+ options.help = true;
54
+ continue;
55
+ }
56
+ if (arg === '--dry-run') {
57
+ options.dryRun = true;
58
+ continue;
59
+ }
60
+ if (arg === '--json') {
61
+ options.json = true;
62
+ continue;
63
+ }
64
+ if (OPTION_FIELDS.has(arg)) {
65
+ options[OPTION_FIELDS.get(arg)] = readOptionValue(args, index, arg);
66
+ index += 1;
67
+ continue;
68
+ }
69
+ const equalSignIndex = arg.indexOf('=');
70
+ if (equalSignIndex > -1) {
71
+ const name = arg.slice(0, equalSignIndex);
72
+ if (OPTION_FIELDS.has(name)) {
73
+ options[OPTION_FIELDS.get(name)] = arg.slice(equalSignIndex + 1);
74
+ continue;
75
+ }
76
+ }
77
+ throw new Error(`Unknown option: ${arg}`);
78
+ }
79
+
80
+ if (options.help) return options;
81
+
82
+ options.target = normalizeTarget(options.target);
83
+ options.scope = normalizeScope(options.scope);
84
+ options.projectDir = resolve(process.cwd(), options.projectDir);
85
+ options.targetRoot = options.targetRoot ? resolve(process.cwd(), options.targetRoot) : '';
86
+ return options;
87
+ }
88
+
89
+ const OPTION_FIELDS = new Map([
90
+ ['--target', 'target'],
91
+ ['--runtime', 'target'],
92
+ ['--scope', 'scope'],
93
+ ['--project-dir', 'projectDir'],
94
+ ['--target-root', 'targetRoot'],
95
+ ]);
96
+
97
+ function readOptionValue(args, index, optionName) {
98
+ const value = args[index + 1];
99
+ if (!value || value.startsWith('-')) {
100
+ throw new Error(`Missing value for ${optionName}.`);
101
+ }
102
+ return value;
103
+ }
104
+
105
+ function normalizeTarget(value) {
106
+ const target = String(value || '').trim().toLowerCase();
107
+ if (!VALID_TARGETS.has(target)) {
108
+ throw new Error(`Unknown runtime target "${value}". Expected: all, codex, or claude-code.`);
109
+ }
110
+ return target;
111
+ }
112
+
113
+ function normalizeScope(value) {
114
+ const scope = String(value || '').trim().toLowerCase();
115
+ if (!VALID_SCOPES.has(scope)) {
116
+ throw new Error(`Unknown install scope "${value}". Expected: project or user.`);
117
+ }
118
+ return scope;
119
+ }
120
+
121
+ function selectedTargets(target) {
122
+ return target === 'all' ? ['codex', 'claude-code'] : [target];
123
+ }
124
+
125
+ function runtimeRoots(options, target) {
126
+ if (options.scope === 'project') {
127
+ return {
128
+ codex: {
129
+ skillsDir: join(options.projectDir, '.agents', 'skills'),
130
+ agentsDir: join(options.projectDir, '.codex', 'agents'),
131
+ },
132
+ 'claude-code': {
133
+ skillsDir: join(options.projectDir, '.claude', 'skills'),
134
+ agentsDir: join(options.projectDir, '.claude', 'agents'),
135
+ },
136
+ }[target];
137
+ }
138
+
139
+ const root = options.targetRoot || homedir();
140
+ return {
141
+ codex: {
142
+ skillsDir: join(root, '.agents', 'skills'),
143
+ agentsDir: join(root, '.codex', 'agents'),
144
+ },
145
+ 'claude-code': {
146
+ skillsDir: join(root, '.claude', 'skills'),
147
+ agentsDir: join(root, '.claude', 'agents'),
148
+ },
149
+ }[target];
150
+ }
151
+
152
+ async function copyDirectory(source, destination, dryRun) {
153
+ if (dryRun) return;
154
+ await rm(destination, { recursive: true, force: true });
155
+ await mkdir(dirname(destination), { recursive: true });
156
+ await cp(source, destination, { recursive: true });
157
+ }
158
+
159
+ async function installTarget(options, target) {
160
+ const roots = runtimeRoots(options, target);
161
+ const writes = [];
162
+ if (!options.dryRun) {
163
+ await mkdir(roots.skillsDir, { recursive: true });
164
+ await mkdir(roots.agentsDir, { recursive: true });
165
+ }
166
+
167
+ for (const skillName of SKILL_NAMES) {
168
+ const source = join(packageRoot, 'skills', skillName);
169
+ const destination = join(roots.skillsDir, skillName);
170
+ await copyDirectory(source, destination, options.dryRun);
171
+ writes.push(destination);
172
+ }
173
+
174
+ const adapterSourceDir = join(packageRoot, 'runtimes', target, 'agents');
175
+ const adapterFiles = await readdir(adapterSourceDir);
176
+ for (const adapterFile of adapterFiles) {
177
+ const source = join(adapterSourceDir, adapterFile);
178
+ const destination = join(roots.agentsDir, adapterFile);
179
+ await copyDirectory(source, destination, options.dryRun);
180
+ writes.push(destination);
181
+ }
182
+
183
+ return { target, skillsDir: roots.skillsDir, agentsDir: roots.agentsDir, writes };
184
+ }
185
+
186
+ export async function main(argv = process.argv.slice(2)) {
187
+ const options = parseArgs(argv);
188
+ if (options.help) {
189
+ printUsage();
190
+ return;
191
+ }
192
+
193
+ const installs = [];
194
+ for (const target of selectedTargets(options.target)) {
195
+ installs.push(await installTarget(options, target));
196
+ }
197
+
198
+ if (options.json) {
199
+ process.stdout.write(`${JSON.stringify({ installs, dryRun: options.dryRun }, null, 2)}\n`);
200
+ return;
201
+ }
202
+
203
+ for (const install of installs) {
204
+ const label = install.target === 'claude-code' ? 'Claude Code' : 'Codex';
205
+ process.stdout.write(`Installed slides-grab skills for ${label}\n`);
206
+ process.stdout.write(` Skills: ${install.skillsDir}\n`);
207
+ process.stdout.write(` Agents: ${install.agentsDir}\n`);
208
+ }
209
+ }
210
+
211
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
212
+ main().catch((error) => {
213
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
214
+ process.exit(1);
215
+ });
216
+ }