appshot-cli 0.9.2 → 1.0.1

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 (46) hide show
  1. package/README.md +198 -103
  2. package/dist/cli.js +6 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/build.d.ts.map +1 -1
  5. package/dist/commands/build.js +9 -0
  6. package/dist/commands/build.js.map +1 -1
  7. package/dist/commands/frame.d.ts.map +1 -1
  8. package/dist/commands/frame.js +11 -2
  9. package/dist/commands/frame.js.map +1 -1
  10. package/dist/commands/mcp.d.ts +3 -0
  11. package/dist/commands/mcp.d.ts.map +1 -0
  12. package/dist/commands/mcp.js +19 -0
  13. package/dist/commands/mcp.js.map +1 -0
  14. package/dist/commands/skill.d.ts +3 -0
  15. package/dist/commands/skill.d.ts.map +1 -0
  16. package/dist/commands/skill.js +119 -0
  17. package/dist/commands/skill.js.map +1 -0
  18. package/dist/core/compose.d.ts +1 -0
  19. package/dist/core/compose.d.ts.map +1 -1
  20. package/dist/core/compose.js +14 -2
  21. package/dist/core/compose.js.map +1 -1
  22. package/dist/mcp/cli-options.d.ts +112 -0
  23. package/dist/mcp/cli-options.d.ts.map +1 -0
  24. package/dist/mcp/cli-options.js +158 -0
  25. package/dist/mcp/cli-options.js.map +1 -0
  26. package/dist/mcp/server.d.ts +2 -0
  27. package/dist/mcp/server.d.ts.map +1 -0
  28. package/dist/mcp/server.js +1149 -0
  29. package/dist/mcp/server.js.map +1 -0
  30. package/dist/services/doctor.d.ts.map +1 -1
  31. package/dist/services/doctor.js +2 -1
  32. package/dist/services/doctor.js.map +1 -1
  33. package/dist/utils/filename-caption.d.ts +9 -0
  34. package/dist/utils/filename-caption.d.ts.map +1 -0
  35. package/dist/utils/filename-caption.js +19 -0
  36. package/dist/utils/filename-caption.js.map +1 -0
  37. package/dist/version.d.ts +2 -0
  38. package/dist/version.d.ts.map +1 -0
  39. package/dist/version.js +5 -0
  40. package/dist/version.js.map +1 -0
  41. package/package.json +7 -4
  42. package/skill/SKILL.md +225 -0
  43. package/skill/references/fonts.md +55 -0
  44. package/skill/references/gradients.md +69 -0
  45. package/skill/references/templates.md +170 -0
  46. package/skill/references/troubleshooting.md +228 -0
@@ -0,0 +1,1149 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { spawn } from 'child_process';
5
+ import { fileURLToPath } from 'url';
6
+ import path from 'path';
7
+ import { promises as fs } from 'fs';
8
+ import { APP_VERSION } from '../version.js';
9
+ import { detectLanguagesFromCaptions } from '../utils/language.js';
10
+ import { filenameToCaption } from '../utils/filename-caption.js';
11
+ import { createBuildArgs, createFrameArgs, createExportArgs, createInitArgs, createSpecsArgs, createValidateArgs, createCleanArgs, createLocalizeArgs, createPresetsArgs, createFontsArgs, createTemplateArgs, createQuickstartArgs } from './cli-options.js';
12
+ import { gradientPresets, getGradientPreset, getGradientsByCategory } from '../core/gradient-presets.js';
13
+ // Resolve CLI entry point - handles both dist (cli.js) and dev (cli.ts via tsx) builds
14
+ function resolveCliEntry() {
15
+ const currentFile = fileURLToPath(import.meta.url);
16
+ const dir = path.dirname(currentFile);
17
+ // Check if we're in src/ (dev) or dist/ (prod) - cross-platform
18
+ const pathParts = dir.split(path.sep);
19
+ const isDevMode = pathParts.includes('src');
20
+ if (isDevMode) {
21
+ // Dev mode: use tsx to run TypeScript directly
22
+ const tsEntry = path.resolve(dir, '../cli.ts');
23
+ return tsEntry;
24
+ }
25
+ // Prod mode: use compiled JS
26
+ return path.resolve(dir, '../cli.js');
27
+ }
28
+ const CLI_ENTRY = resolveCliEntry();
29
+ // In dev mode, we need to use tsx to run TypeScript
30
+ function getCliCommand() {
31
+ if (CLI_ENTRY.endsWith('.ts')) {
32
+ // Use tsx for TypeScript files
33
+ return {
34
+ execPath: process.execPath,
35
+ args: [path.resolve(path.dirname(CLI_ENTRY), '../node_modules/.bin/tsx'), CLI_ENTRY]
36
+ };
37
+ }
38
+ return {
39
+ execPath: process.execPath,
40
+ args: [CLI_ENTRY]
41
+ };
42
+ }
43
+ function runAppshotCli(args, options) {
44
+ return new Promise((resolve, reject) => {
45
+ const start = Date.now();
46
+ const command = ['appshot', ...args].join(' ');
47
+ const cli = getCliCommand();
48
+ const child = spawn(cli.execPath, [...cli.args, ...args], {
49
+ cwd: options?.cwd ?? process.cwd(),
50
+ env: {
51
+ ...process.env,
52
+ FORCE_COLOR: '0'
53
+ // Note: APPSHOT_DISABLE_FONT_SCAN is passed through from environment if set
54
+ // This allows accurate font validation while CI can still disable scanning
55
+ }
56
+ });
57
+ let stdout = '';
58
+ let stderr = '';
59
+ child.stdout?.on('data', (chunk) => {
60
+ stdout += chunk.toString();
61
+ });
62
+ child.stderr?.on('data', (chunk) => {
63
+ stderr += chunk.toString();
64
+ });
65
+ child.once('error', (error) => reject(error));
66
+ child.once('close', (code) => {
67
+ resolve({
68
+ stdout: stdout.trim(),
69
+ stderr: stderr.trim(),
70
+ exitCode: code ?? 0,
71
+ durationMs: Date.now() - start,
72
+ command
73
+ });
74
+ });
75
+ });
76
+ }
77
+ function cliResultToToolResponse(action, run) {
78
+ const duration = (run.durationMs / 1000).toFixed(1);
79
+ const status = run.exitCode === 0 ? 'succeeded' : `failed (code ${run.exitCode})`;
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: `${action} ${status} in ${duration}s\nCommand: ${run.command}`
85
+ }
86
+ ],
87
+ structuredContent: {
88
+ stdout: run.stdout,
89
+ stderr: run.stderr,
90
+ exitCode: run.exitCode,
91
+ durationMs: run.durationMs,
92
+ command: run.command
93
+ },
94
+ isError: run.exitCode !== 0
95
+ };
96
+ }
97
+ async function readAppshotConfig(configPath) {
98
+ let target = path.resolve(process.cwd(), configPath ?? '.appshot/config.json');
99
+ // If path is a directory, append .appshot/config.json
100
+ try {
101
+ const stat = await fs.stat(target);
102
+ if (stat.isDirectory()) {
103
+ target = path.join(target, '.appshot', 'config.json');
104
+ }
105
+ }
106
+ catch {
107
+ // Path doesn't exist yet, continue with original target
108
+ }
109
+ const data = await fs.readFile(target, 'utf8');
110
+ const config = JSON.parse(data);
111
+ return { path: target, config };
112
+ }
113
+ export async function startMcpServer() {
114
+ const server = new McpServer({
115
+ name: 'appshot-mcp',
116
+ version: APP_VERSION
117
+ });
118
+ registerProjectInfoTool(server);
119
+ registerDoctorTool(server);
120
+ registerBuildTool(server);
121
+ registerFrameTool(server);
122
+ registerExportTool(server);
123
+ registerInitTool(server);
124
+ registerSpecsTool(server);
125
+ registerValidateTool(server);
126
+ registerCleanTool(server);
127
+ registerLocalizeTool(server);
128
+ registerPresetsTool(server);
129
+ registerLanguagesTool(server);
130
+ registerConfigTool(server);
131
+ registerCaptionsTool(server);
132
+ registerGradientsTool(server);
133
+ registerBackgroundsTool(server);
134
+ registerFontsTool(server);
135
+ registerTemplateTool(server);
136
+ registerQuickstartTool(server);
137
+ const transport = new StdioServerTransport();
138
+ const cleanup = async () => {
139
+ await transport.close();
140
+ process.exit(0);
141
+ };
142
+ process.once('SIGINT', cleanup);
143
+ process.once('SIGTERM', cleanup);
144
+ await server.connect(transport);
145
+ }
146
+ function registerProjectInfoTool(server) {
147
+ const inputSchema = z.object({
148
+ configPath: z.string().optional().describe('Path to appshot.json config file')
149
+ });
150
+ server.registerTool('appshot_projectInfo', {
151
+ title: 'Read project configuration',
152
+ description: 'Loads appshot.json and returns device + language metadata',
153
+ inputSchema,
154
+ outputSchema: z.object({
155
+ path: z.string(),
156
+ deviceCount: z.number(),
157
+ languages: z.array(z.string()).optional(),
158
+ summary: z.record(z.string(), z.any()),
159
+ config: z.record(z.string(), z.any())
160
+ })
161
+ }, async (args) => {
162
+ const { path: resolvedPath, config } = await readAppshotConfig(args.configPath);
163
+ const devices = Object.keys(config.devices ?? {});
164
+ const languages = config.languages ?? (config.defaultLanguage ? [config.defaultLanguage] : undefined);
165
+ return {
166
+ content: [
167
+ {
168
+ type: 'text',
169
+ text: `Loaded ${path.basename(resolvedPath)} with ${devices.length} device(s)`
170
+ }
171
+ ],
172
+ structuredContent: {
173
+ path: resolvedPath,
174
+ deviceCount: devices.length,
175
+ languages,
176
+ summary: {
177
+ output: config.output ?? 'final',
178
+ templates: Object.keys(config.templates ?? {})
179
+ },
180
+ config
181
+ }
182
+ };
183
+ });
184
+ }
185
+ function registerDoctorTool(server) {
186
+ server.registerTool('appshot_doctor', {
187
+ title: 'Run doctor checks',
188
+ description: 'Runs appshot doctor to validate the current project'
189
+ }, async () => {
190
+ const result = await runAppshotCli(['doctor']);
191
+ return cliResultToToolResponse('Doctor', result);
192
+ });
193
+ }
194
+ function registerBuildTool(server) {
195
+ const inputSchema = z.object({
196
+ devices: z.array(z.string()).optional().describe('Device types to build (e.g., ["iphone", "ipad"])'),
197
+ presets: z.array(z.string()).optional().describe('App Store presets (e.g., ["iphone-6-9", "ipad-13"])'),
198
+ languages: z.array(z.string()).optional().describe('Languages to build (e.g., ["en", "es", "fr"])'),
199
+ configPath: z.string().optional().describe('Path to appshot.json config file'),
200
+ dryRun: z.boolean().optional().describe('Show what would be built without actually building'),
201
+ preview: z.boolean().optional().describe('Generate preview images'),
202
+ noFrame: z.boolean().optional().describe('Skip device frame overlay'),
203
+ noGradient: z.boolean().optional().describe('Skip gradient background'),
204
+ noCaption: z.boolean().optional().describe('Skip caption text'),
205
+ autoCaption: z.boolean().optional().describe('Auto-generate captions from filenames'),
206
+ backgroundImage: z.string().optional().describe('Path to background image'),
207
+ backgroundFit: z.enum(['cover', 'contain', 'fill', 'scale-down']).optional().describe('Background image fit mode'),
208
+ autoBackground: z.boolean().optional().describe('Auto-detect background images'),
209
+ noBackground: z.boolean().optional().describe('Skip background entirely'),
210
+ outputDir: z.string().optional().describe('Output directory for built screenshots'),
211
+ verbose: z.boolean().optional().describe('Show detailed output'),
212
+ concurrency: z.number().int().positive().optional().describe('Number of parallel builds')
213
+ });
214
+ server.registerTool('appshot_build', {
215
+ title: 'Run appshot build',
216
+ description: 'Generates screenshots for the configured devices and languages',
217
+ inputSchema
218
+ }, async (args) => {
219
+ const typedArgs = args;
220
+ let cwd;
221
+ if (typedArgs.configPath) {
222
+ // Set cwd to project directory for relative path resolution
223
+ const isDir = !typedArgs.configPath.endsWith('.json');
224
+ if (isDir) {
225
+ // Directory path: set cwd and convert to full config path for CLI
226
+ cwd = typedArgs.configPath;
227
+ typedArgs.configPath = path.join(typedArgs.configPath, '.appshot', 'config.json');
228
+ }
229
+ else {
230
+ // Full config.json path: derive cwd from it
231
+ cwd = path.dirname(path.dirname(typedArgs.configPath));
232
+ }
233
+ }
234
+ const buildArgs = createBuildArgs(typedArgs);
235
+ const result = await runAppshotCli(buildArgs, { cwd });
236
+ return cliResultToToolResponse('Build', result);
237
+ });
238
+ }
239
+ function registerFrameTool(server) {
240
+ const inputSchema = z.object({
241
+ input: z.string().describe('Path to screenshot file or directory'),
242
+ outputDir: z.string().optional().describe('Output directory for framed screenshots'),
243
+ device: z.string().optional().describe('Device type override (e.g., "iphone", "ipad")'),
244
+ recursive: z.boolean().optional().describe('Process directories recursively'),
245
+ format: z.enum(['png', 'jpeg']).optional().describe('Output image format'),
246
+ suffix: z.string().optional().describe('Suffix to add to output filenames'),
247
+ overwrite: z.boolean().optional().describe('Overwrite existing output files'),
248
+ dryRun: z.boolean().optional().describe('Show what would be processed without doing it'),
249
+ verbose: z.boolean().optional().describe('Show detailed output'),
250
+ frameTone: z.enum(['original', 'neutral']).optional().describe('Frame color tone')
251
+ });
252
+ server.registerTool('appshot_frame', {
253
+ title: 'Apply device frames',
254
+ description: 'Wraps the frame CLI for MCP clients',
255
+ inputSchema
256
+ }, async (args) => {
257
+ const frameArgs = createFrameArgs(args);
258
+ const result = await runAppshotCli(frameArgs);
259
+ return cliResultToToolResponse('Frame', result);
260
+ });
261
+ }
262
+ function registerExportTool(server) {
263
+ const inputSchema = z.object({
264
+ format: z.string().optional().describe('Export format (e.g., "fastlane")'),
265
+ sourceDir: z.string().optional().describe('Source directory with built screenshots'),
266
+ outputDir: z.string().optional().describe('Output directory for exported files'),
267
+ languages: z.array(z.string()).optional().describe('Languages to export'),
268
+ devices: z.array(z.string()).optional().describe('Devices to export'),
269
+ copy: z.boolean().optional().describe('Copy files instead of moving'),
270
+ flatten: z.boolean().optional().describe('Flatten directory structure'),
271
+ prefixDevice: z.boolean().optional().describe('Prefix filenames with device name'),
272
+ order: z.boolean().optional().describe('Apply screenshot ordering'),
273
+ clean: z.boolean().optional().describe('Clean output directory first'),
274
+ generateConfig: z.boolean().optional().describe('Generate Fastlane config'),
275
+ dryRun: z.boolean().optional().describe('Show what would be exported'),
276
+ verbose: z.boolean().optional().describe('Show detailed output'),
277
+ json: z.boolean().optional().describe('Output as JSON'),
278
+ configPath: z.string().optional().describe('Path to appshot.json config file')
279
+ });
280
+ server.registerTool('appshot_export', {
281
+ title: 'Export screenshots',
282
+ description: 'Runs appshot export fastlane with optional filters',
283
+ inputSchema
284
+ }, async (args) => {
285
+ const exportArgs = createExportArgs(args);
286
+ const result = await runAppshotCli(exportArgs);
287
+ return cliResultToToolResponse('Export', result);
288
+ });
289
+ }
290
+ function registerInitTool(server) {
291
+ const inputSchema = z.object({
292
+ force: z.boolean().optional().describe('Overwrite existing configuration files'),
293
+ projectDir: z.string().optional().describe('Directory to initialize the project in')
294
+ });
295
+ server.registerTool('appshot_init', {
296
+ title: 'Initialize project',
297
+ description: 'Scaffold a new appshot project with default configuration',
298
+ inputSchema
299
+ }, async (args) => {
300
+ const typedArgs = args;
301
+ const cwd = typedArgs.projectDir;
302
+ delete typedArgs.projectDir;
303
+ const initArgs = createInitArgs(typedArgs);
304
+ const result = await runAppshotCli(initArgs, { cwd });
305
+ return cliResultToToolResponse('Init', result);
306
+ });
307
+ }
308
+ function registerSpecsTool(server) {
309
+ const inputSchema = z.object({
310
+ device: z.string().optional().describe('Filter by device type: iphone, ipad, mac, watch, appletv, visionpro'),
311
+ required: z.boolean().optional().describe('Show only required App Store presets')
312
+ });
313
+ server.registerTool('appshot_specs', {
314
+ title: 'App Store specifications',
315
+ description: 'Get Apple App Store screenshot requirements and specifications (returns JSON)',
316
+ inputSchema
317
+ }, async (args) => {
318
+ const specsArgs = createSpecsArgs(args);
319
+ const result = await runAppshotCli(specsArgs);
320
+ return cliResultToToolResponse('Specs', result);
321
+ });
322
+ }
323
+ function registerValidateTool(server) {
324
+ const inputSchema = z.object({
325
+ strict: z.boolean().optional().describe('Validate against required presets only'),
326
+ fix: z.boolean().optional().describe('Suggest fixes for invalid screenshots')
327
+ });
328
+ server.registerTool('appshot_validate', {
329
+ title: 'Validate screenshots',
330
+ description: 'Validate screenshots against App Store requirements (returns JSON)',
331
+ inputSchema
332
+ }, async (args) => {
333
+ const validateArgs = createValidateArgs(args);
334
+ const result = await runAppshotCli(validateArgs);
335
+ return cliResultToToolResponse('Validate', result);
336
+ });
337
+ }
338
+ function registerCleanTool(server) {
339
+ const inputSchema = z.object({
340
+ outputDir: z.string().optional().describe('Directory to clean (default: "final")'),
341
+ all: z.boolean().optional().describe('Clean all generated files including processed cache'),
342
+ history: z.boolean().optional().describe('Clear caption autocomplete history'),
343
+ keepHistory: z.boolean().optional().describe('Keep history when using --all'),
344
+ configPath: z.string().optional().describe('Path to appshot config file or project directory')
345
+ });
346
+ server.registerTool('appshot_clean', {
347
+ title: 'Clean generated files',
348
+ description: 'Remove generated screenshots and optionally clear caches (auto-confirms)',
349
+ inputSchema
350
+ }, async (args) => {
351
+ const typedArgs = args;
352
+ let cwd;
353
+ if (typedArgs.configPath) {
354
+ const isDir = !typedArgs.configPath.endsWith('.json');
355
+ cwd = isDir ? typedArgs.configPath : path.dirname(path.dirname(typedArgs.configPath));
356
+ delete typedArgs.configPath;
357
+ }
358
+ const cleanArgs = createCleanArgs(typedArgs);
359
+ const result = await runAppshotCli(cleanArgs, { cwd });
360
+ return cliResultToToolResponse('Clean', result);
361
+ });
362
+ }
363
+ function registerLocalizeTool(server) {
364
+ const inputSchema = z.object({
365
+ languages: z.array(z.string()).describe('Language codes to translate to (e.g., ["es", "fr", "de"])'),
366
+ device: z.string().optional().describe('Specific device to localize, or "all" for all devices'),
367
+ model: z.string().optional().describe('OpenAI model to use (default: gpt-4o-mini)'),
368
+ sourceLanguage: z.string().optional().describe('Source language code (default: en)'),
369
+ overwrite: z.boolean().optional().describe('Overwrite existing translations')
370
+ });
371
+ server.registerTool('appshot_localize', {
372
+ title: 'Batch translate captions',
373
+ description: 'Translate captions to multiple languages using AI. Requires OPENAI_API_KEY environment variable.',
374
+ inputSchema
375
+ }, async (args) => {
376
+ const localizeArgs = createLocalizeArgs(args);
377
+ const result = await runAppshotCli(localizeArgs);
378
+ return cliResultToToolResponse('Localize', result);
379
+ });
380
+ }
381
+ function registerPresetsTool(server) {
382
+ const inputSchema = z.object({
383
+ list: z.boolean().optional().describe('List all available presets'),
384
+ required: z.boolean().optional().describe('List only required App Store presets'),
385
+ category: z.string().optional().describe('Filter by category: iphone, ipad, mac, appletv, visionpro, watch'),
386
+ generate: z.array(z.string()).optional().describe('Generate config for specific preset IDs'),
387
+ outputFile: z.string().optional().describe('Output file for generated config')
388
+ });
389
+ server.registerTool('appshot_presets', {
390
+ title: 'List presets',
391
+ description: 'List available App Store presets and generate configuration (returns JSON)',
392
+ inputSchema
393
+ }, async (args) => {
394
+ const presetsArgs = createPresetsArgs(args);
395
+ const result = await runAppshotCli(presetsArgs);
396
+ return cliResultToToolResponse('Presets', result);
397
+ });
398
+ }
399
+ function registerLanguagesTool(server) {
400
+ const inputSchema = z.object({
401
+ device: z.string().optional().describe('Specific device to check (iphone/ipad/mac/watch), or omit for all devices'),
402
+ configPath: z.string().optional().describe('Path to appshot config file or project directory')
403
+ });
404
+ server.registerTool('appshot_languages', {
405
+ title: 'Discover available languages',
406
+ description: 'Scans caption files to discover which languages have translations available',
407
+ inputSchema
408
+ }, async (args) => {
409
+ let projectDir = process.cwd();
410
+ if (args.configPath) {
411
+ projectDir = args.configPath.endsWith('.json')
412
+ ? path.dirname(path.dirname(args.configPath))
413
+ : args.configPath;
414
+ }
415
+ const captionsDir = path.join(projectDir, '.appshot', 'captions');
416
+ const byDevice = {};
417
+ const allLanguages = new Set();
418
+ let captionCount = 0;
419
+ const devices = args.device ? [args.device] : ['iphone', 'ipad', 'mac', 'watch'];
420
+ for (const device of devices) {
421
+ const captionPath = path.join(captionsDir, `${device}.json`);
422
+ try {
423
+ const data = await fs.readFile(captionPath, 'utf8');
424
+ const captions = JSON.parse(data);
425
+ const langs = detectLanguagesFromCaptions(captions);
426
+ if (langs.length > 0) {
427
+ byDevice[device] = langs;
428
+ langs.forEach(l => allLanguages.add(l));
429
+ }
430
+ captionCount += Object.keys(captions).length;
431
+ }
432
+ catch {
433
+ // File doesn't exist or is invalid - skip
434
+ }
435
+ }
436
+ const languages = Array.from(allLanguages).sort();
437
+ return {
438
+ content: [{
439
+ type: 'text',
440
+ text: `Found ${languages.length} language(s) across ${Object.keys(byDevice).length} device(s)`
441
+ }],
442
+ structuredContent: {
443
+ languages,
444
+ byDevice,
445
+ captionCount
446
+ }
447
+ };
448
+ });
449
+ }
450
+ function registerConfigTool(server) {
451
+ const inputSchema = z.object({
452
+ configPath: z.string().optional().describe('Path to appshot config file or project directory'),
453
+ device: z.string().describe('Device to configure (iphone/ipad/mac/watch)'),
454
+ frameScale: z.number().optional().describe('Scale of device frame (0.1-1.5)'),
455
+ framePosition: z.number().optional().describe('Vertical position of device (0-100, or negative for offset)'),
456
+ captionPosition: z.enum(['above', 'below', 'overlay']).optional().describe('Caption position relative to device'),
457
+ captionSize: z.number().optional().describe('Font size for captions'),
458
+ marginTop: z.number().optional().describe('Top margin for caption box'),
459
+ marginBottom: z.number().optional().describe('Bottom margin for caption box')
460
+ });
461
+ server.registerTool('appshot_config', {
462
+ title: 'Update device configuration',
463
+ description: 'Modifies device-specific settings in the appshot config file',
464
+ inputSchema
465
+ }, async (args) => {
466
+ let configFile;
467
+ if (args.configPath) {
468
+ configFile = args.configPath.endsWith('.json')
469
+ ? args.configPath
470
+ : path.join(args.configPath, '.appshot', 'config.json');
471
+ }
472
+ else {
473
+ configFile = path.join(process.cwd(), '.appshot', 'config.json');
474
+ }
475
+ let config;
476
+ try {
477
+ const data = await fs.readFile(configFile, 'utf8');
478
+ config = JSON.parse(data);
479
+ }
480
+ catch (err) {
481
+ return {
482
+ content: [{
483
+ type: 'text',
484
+ text: `Error reading config: ${err instanceof Error ? err.message : String(err)}`
485
+ }],
486
+ isError: true
487
+ };
488
+ }
489
+ const devices = config.devices;
490
+ if (!devices || !devices[args.device]) {
491
+ return {
492
+ content: [{
493
+ type: 'text',
494
+ text: `Device "${args.device}" not found in config. Available: ${devices ? Object.keys(devices).join(', ') : 'none'}`
495
+ }],
496
+ isError: true
497
+ };
498
+ }
499
+ const deviceConfig = devices[args.device];
500
+ const changes = [];
501
+ if (args.frameScale !== undefined) {
502
+ deviceConfig.frameScale = args.frameScale;
503
+ changes.push(`frameScale: ${args.frameScale}`);
504
+ }
505
+ if (args.framePosition !== undefined) {
506
+ deviceConfig.framePosition = args.framePosition;
507
+ changes.push(`framePosition: ${args.framePosition}`);
508
+ }
509
+ if (args.captionPosition !== undefined) {
510
+ deviceConfig.captionPosition = args.captionPosition;
511
+ changes.push(`captionPosition: ${args.captionPosition}`);
512
+ }
513
+ if (args.captionSize !== undefined) {
514
+ deviceConfig.captionSize = args.captionSize;
515
+ changes.push(`captionSize: ${args.captionSize}`);
516
+ }
517
+ if (args.marginTop !== undefined || args.marginBottom !== undefined) {
518
+ const captionBox = deviceConfig.captionBox ?? {};
519
+ if (args.marginTop !== undefined) {
520
+ captionBox.marginTop = args.marginTop;
521
+ changes.push(`captionBox.marginTop: ${args.marginTop}`);
522
+ }
523
+ if (args.marginBottom !== undefined) {
524
+ captionBox.marginBottom = args.marginBottom;
525
+ changes.push(`captionBox.marginBottom: ${args.marginBottom}`);
526
+ }
527
+ deviceConfig.captionBox = captionBox;
528
+ }
529
+ if (changes.length === 0) {
530
+ return {
531
+ content: [{
532
+ type: 'text',
533
+ text: 'No changes specified'
534
+ }]
535
+ };
536
+ }
537
+ try {
538
+ await fs.writeFile(configFile, JSON.stringify(config, null, 2) + '\n', 'utf8');
539
+ }
540
+ catch (err) {
541
+ return {
542
+ content: [{
543
+ type: 'text',
544
+ text: `Error writing config: ${err instanceof Error ? err.message : String(err)}`
545
+ }],
546
+ isError: true
547
+ };
548
+ }
549
+ return {
550
+ content: [{
551
+ type: 'text',
552
+ text: `Updated ${args.device} config:\n${changes.map(c => ` • ${c}`).join('\n')}`
553
+ }],
554
+ structuredContent: {
555
+ device: args.device,
556
+ changes,
557
+ configFile
558
+ }
559
+ };
560
+ });
561
+ }
562
+ function registerCaptionsTool(server) {
563
+ const inputSchema = z.object({
564
+ configPath: z.string().optional().describe('Path to appshot config file or project directory'),
565
+ device: z.string().describe('Device to manage captions for (iphone/ipad/mac/watch)'),
566
+ action: z.enum(['list', 'get', 'set', 'bulk-set', 'auto']).describe('Action to perform'),
567
+ filename: z.string().optional().describe('Screenshot filename (required for get/set)'),
568
+ language: z.string().optional().describe('Language code (default: en)'),
569
+ caption: z.string().optional().describe('Caption text (required for set action)'),
570
+ captions: z.string().optional().describe('JSON object of filename:caption pairs for bulk-set (e.g., {"file1.png": "Caption 1", "file2.png": "Caption 2"})'),
571
+ overwrite: z.boolean().optional().describe('Overwrite existing captions (for auto action, default: false)')
572
+ });
573
+ server.registerTool('appshot_captions', {
574
+ title: 'Manage captions',
575
+ description: 'Read and write caption text for screenshots',
576
+ inputSchema
577
+ }, async (args) => {
578
+ let projectDir = process.cwd();
579
+ if (args.configPath) {
580
+ projectDir = args.configPath.endsWith('.json')
581
+ ? path.dirname(path.dirname(args.configPath))
582
+ : args.configPath;
583
+ }
584
+ const captionFile = path.join(projectDir, '.appshot', 'captions', `${args.device}.json`);
585
+ let captions = {};
586
+ try {
587
+ const data = await fs.readFile(captionFile, 'utf8');
588
+ captions = JSON.parse(data);
589
+ }
590
+ catch {
591
+ // Write actions (set, bulk-set, auto) can proceed with empty captions
592
+ // Read actions (list, get) should report no file found
593
+ const writeActions = ['set', 'bulk-set', 'auto'];
594
+ if (!writeActions.includes(args.action)) {
595
+ return {
596
+ content: [{
597
+ type: 'text',
598
+ text: `No captions file found for ${args.device}`
599
+ }],
600
+ structuredContent: { device: args.device, captions: {} }
601
+ };
602
+ }
603
+ }
604
+ if (args.action === 'list') {
605
+ const captionCount = Object.keys(captions).length;
606
+ return {
607
+ content: [{
608
+ type: 'text',
609
+ text: `Found ${captionCount} caption(s) for ${args.device}`
610
+ }],
611
+ structuredContent: {
612
+ device: args.device,
613
+ captions
614
+ }
615
+ };
616
+ }
617
+ if (args.action === 'get') {
618
+ if (!args.filename) {
619
+ return {
620
+ content: [{
621
+ type: 'text',
622
+ text: 'filename is required for get action'
623
+ }],
624
+ isError: true
625
+ };
626
+ }
627
+ const captionData = captions[args.filename];
628
+ return {
629
+ content: [{
630
+ type: 'text',
631
+ text: captionData ? `Caption for ${args.filename}` : `No caption found for ${args.filename}`
632
+ }],
633
+ structuredContent: {
634
+ filename: args.filename,
635
+ captions: typeof captionData === 'string' ? { en: captionData } : (captionData ?? {})
636
+ }
637
+ };
638
+ }
639
+ if (args.action === 'set') {
640
+ if (!args.filename) {
641
+ return {
642
+ content: [{
643
+ type: 'text',
644
+ text: 'filename is required for set action'
645
+ }],
646
+ isError: true
647
+ };
648
+ }
649
+ if (args.caption === undefined) {
650
+ return {
651
+ content: [{
652
+ type: 'text',
653
+ text: 'caption is required for set action'
654
+ }],
655
+ isError: true
656
+ };
657
+ }
658
+ const lang = args.language ?? 'en';
659
+ const existing = captions[args.filename];
660
+ if (typeof existing === 'string') {
661
+ captions[args.filename] = { en: existing, [lang]: args.caption };
662
+ }
663
+ else if (existing) {
664
+ existing[lang] = args.caption;
665
+ }
666
+ else {
667
+ captions[args.filename] = { [lang]: args.caption };
668
+ }
669
+ const captionsDir = path.dirname(captionFile);
670
+ await fs.mkdir(captionsDir, { recursive: true });
671
+ await fs.writeFile(captionFile, JSON.stringify(captions, null, 2) + '\n', 'utf8');
672
+ return {
673
+ content: [{
674
+ type: 'text',
675
+ text: `Updated caption for ${args.filename} (${lang})`
676
+ }],
677
+ structuredContent: {
678
+ filename: args.filename,
679
+ language: lang,
680
+ caption: args.caption,
681
+ updated: true
682
+ }
683
+ };
684
+ }
685
+ if (args.action === 'bulk-set') {
686
+ if (!args.captions) {
687
+ return {
688
+ content: [{
689
+ type: 'text',
690
+ text: 'captions JSON is required for bulk-set action'
691
+ }],
692
+ isError: true
693
+ };
694
+ }
695
+ let captionsToSet;
696
+ try {
697
+ captionsToSet = JSON.parse(args.captions);
698
+ }
699
+ catch {
700
+ return {
701
+ content: [{
702
+ type: 'text',
703
+ text: 'Invalid JSON in captions parameter'
704
+ }],
705
+ isError: true
706
+ };
707
+ }
708
+ const lang = args.language ?? 'en';
709
+ let count = 0;
710
+ for (const [filename, captionText] of Object.entries(captionsToSet)) {
711
+ const existing = captions[filename];
712
+ if (typeof existing === 'string') {
713
+ captions[filename] = { en: existing, [lang]: captionText };
714
+ }
715
+ else if (existing) {
716
+ existing[lang] = captionText;
717
+ }
718
+ else {
719
+ captions[filename] = { [lang]: captionText };
720
+ }
721
+ count++;
722
+ }
723
+ const captionsDir = path.dirname(captionFile);
724
+ await fs.mkdir(captionsDir, { recursive: true });
725
+ await fs.writeFile(captionFile, JSON.stringify(captions, null, 2) + '\n', 'utf8');
726
+ return {
727
+ content: [{
728
+ type: 'text',
729
+ text: `Set ${count} caption(s) for ${args.device} (${lang})`
730
+ }],
731
+ structuredContent: {
732
+ device: args.device,
733
+ language: lang,
734
+ count,
735
+ captions: captionsToSet,
736
+ updated: true
737
+ }
738
+ };
739
+ }
740
+ if (args.action === 'auto') {
741
+ // Use provided config path or default to .appshot/config.json
742
+ const configFile = args.configPath?.endsWith('.json')
743
+ ? args.configPath
744
+ : path.join(projectDir, '.appshot', 'config.json');
745
+ let config;
746
+ try {
747
+ const data = await fs.readFile(configFile, 'utf8');
748
+ config = JSON.parse(data);
749
+ }
750
+ catch {
751
+ return {
752
+ content: [{
753
+ type: 'text',
754
+ text: 'Could not read config file. Run appshot init first.'
755
+ }],
756
+ isError: true
757
+ };
758
+ }
759
+ const devices = config.devices;
760
+ const deviceConfig = devices?.[args.device];
761
+ const inputDir = deviceConfig?.input ?? `./screenshots/${args.device}`;
762
+ const screenshotsDir = path.resolve(projectDir, inputDir);
763
+ let files;
764
+ try {
765
+ const entries = await fs.readdir(screenshotsDir);
766
+ files = entries.filter(f => /\.(png|jpg|jpeg)$/i.test(f));
767
+ }
768
+ catch {
769
+ return {
770
+ content: [{
771
+ type: 'text',
772
+ text: `Could not read screenshots directory: ${screenshotsDir}`
773
+ }],
774
+ isError: true
775
+ };
776
+ }
777
+ if (files.length === 0) {
778
+ return {
779
+ content: [{
780
+ type: 'text',
781
+ text: `No screenshots found in ${screenshotsDir}`
782
+ }],
783
+ structuredContent: { device: args.device, count: 0, captions: {} }
784
+ };
785
+ }
786
+ const lang = args.language ?? 'en';
787
+ const overwrite = args.overwrite ?? false;
788
+ let count = 0;
789
+ const generated = {};
790
+ for (const filename of files) {
791
+ const existing = captions[filename];
792
+ const hasCaption = existing && (typeof existing === 'string' ||
793
+ (typeof existing === 'object' && existing[lang]));
794
+ if (hasCaption && !overwrite) {
795
+ continue;
796
+ }
797
+ const captionText = filenameToCaption(filename);
798
+ if (typeof existing === 'string') {
799
+ captions[filename] = { en: existing, [lang]: captionText };
800
+ }
801
+ else if (existing) {
802
+ existing[lang] = captionText;
803
+ }
804
+ else {
805
+ captions[filename] = { [lang]: captionText };
806
+ }
807
+ generated[filename] = captionText;
808
+ count++;
809
+ }
810
+ if (count > 0) {
811
+ const captionsDir = path.dirname(captionFile);
812
+ await fs.mkdir(captionsDir, { recursive: true });
813
+ await fs.writeFile(captionFile, JSON.stringify(captions, null, 2) + '\n', 'utf8');
814
+ }
815
+ return {
816
+ content: [{
817
+ type: 'text',
818
+ text: count > 0
819
+ ? `Generated ${count} caption(s) from filenames for ${args.device}`
820
+ : `No new captions generated (${files.length} files already have captions)`
821
+ }],
822
+ structuredContent: {
823
+ device: args.device,
824
+ language: lang,
825
+ count,
826
+ totalFiles: files.length,
827
+ captions: generated,
828
+ updated: count > 0
829
+ }
830
+ };
831
+ }
832
+ return {
833
+ content: [{
834
+ type: 'text',
835
+ text: `Unknown action: ${args.action}`
836
+ }],
837
+ isError: true
838
+ };
839
+ });
840
+ }
841
+ function registerGradientsTool(server) {
842
+ const inputSchema = z.object({
843
+ configPath: z.string().optional().describe('Path to appshot config file or project directory'),
844
+ action: z.enum(['list', 'apply']).describe('Action to perform'),
845
+ category: z.enum(['warm', 'cool', 'vibrant', 'subtle', 'monochrome', 'brand']).optional().describe('Filter by category (for list action)'),
846
+ preset: z.string().optional().describe('Preset ID to apply (required for apply action)')
847
+ });
848
+ server.registerTool('appshot_gradients', {
849
+ title: 'Manage gradients',
850
+ description: 'List available gradient presets and apply them to config',
851
+ inputSchema
852
+ }, async (args) => {
853
+ if (args.action === 'list') {
854
+ const presets = args.category
855
+ ? getGradientsByCategory(args.category)
856
+ : gradientPresets;
857
+ return {
858
+ content: [{
859
+ type: 'text',
860
+ text: `Found ${presets.length} gradient preset(s)${args.category ? ` in category "${args.category}"` : ''}`
861
+ }],
862
+ structuredContent: {
863
+ presets: presets.map(p => ({
864
+ id: p.id,
865
+ name: p.name,
866
+ colors: p.colors,
867
+ direction: p.direction,
868
+ category: p.category
869
+ }))
870
+ }
871
+ };
872
+ }
873
+ if (args.action === 'apply') {
874
+ if (!args.preset) {
875
+ return {
876
+ content: [{
877
+ type: 'text',
878
+ text: 'preset is required for apply action'
879
+ }],
880
+ isError: true
881
+ };
882
+ }
883
+ const preset = getGradientPreset(args.preset);
884
+ if (!preset) {
885
+ return {
886
+ content: [{
887
+ type: 'text',
888
+ text: `Gradient preset "${args.preset}" not found. Use action: list to see available presets.`
889
+ }],
890
+ isError: true
891
+ };
892
+ }
893
+ let configFile;
894
+ if (args.configPath) {
895
+ configFile = args.configPath.endsWith('.json')
896
+ ? args.configPath
897
+ : path.join(args.configPath, '.appshot', 'config.json');
898
+ }
899
+ else {
900
+ configFile = path.join(process.cwd(), '.appshot', 'config.json');
901
+ }
902
+ let config;
903
+ try {
904
+ const data = await fs.readFile(configFile, 'utf8');
905
+ config = JSON.parse(data);
906
+ }
907
+ catch (err) {
908
+ return {
909
+ content: [{
910
+ type: 'text',
911
+ text: `Error reading config: ${err instanceof Error ? err.message : String(err)}`
912
+ }],
913
+ isError: true
914
+ };
915
+ }
916
+ config.gradient = {
917
+ colors: preset.colors,
918
+ direction: preset.direction
919
+ };
920
+ await fs.writeFile(configFile, JSON.stringify(config, null, 2) + '\n', 'utf8');
921
+ return {
922
+ content: [{
923
+ type: 'text',
924
+ text: `Applied gradient preset "${preset.name}"`
925
+ }],
926
+ structuredContent: {
927
+ preset: preset.id,
928
+ colors: preset.colors,
929
+ direction: preset.direction,
930
+ applied: true
931
+ }
932
+ };
933
+ }
934
+ return {
935
+ content: [{
936
+ type: 'text',
937
+ text: `Unknown action: ${args.action}`
938
+ }],
939
+ isError: true
940
+ };
941
+ });
942
+ }
943
+ function registerBackgroundsTool(server) {
944
+ const inputSchema = z.object({
945
+ configPath: z.string().optional().describe('Path to appshot config file or project directory'),
946
+ action: z.enum(['list', 'set', 'clear']).describe('Action to perform'),
947
+ device: z.string().optional().describe('Device to configure (omit for global background)'),
948
+ image: z.string().optional().describe('Path to background image (for set action)'),
949
+ fit: z.enum(['cover', 'contain', 'fill', 'scale-down']).optional().describe('Background fit mode (for set action)')
950
+ });
951
+ server.registerTool('appshot_backgrounds', {
952
+ title: 'Manage backgrounds',
953
+ description: 'Configure background images for screenshots',
954
+ inputSchema
955
+ }, async (args) => {
956
+ let configFile;
957
+ if (args.configPath) {
958
+ configFile = args.configPath.endsWith('.json')
959
+ ? args.configPath
960
+ : path.join(args.configPath, '.appshot', 'config.json');
961
+ }
962
+ else {
963
+ configFile = path.join(process.cwd(), '.appshot', 'config.json');
964
+ }
965
+ let config;
966
+ try {
967
+ const data = await fs.readFile(configFile, 'utf8');
968
+ config = JSON.parse(data);
969
+ }
970
+ catch (err) {
971
+ return {
972
+ content: [{
973
+ type: 'text',
974
+ text: `Error reading config: ${err instanceof Error ? err.message : String(err)}`
975
+ }],
976
+ isError: true
977
+ };
978
+ }
979
+ if (args.action === 'list') {
980
+ const globalBg = config.background;
981
+ const devices = config.devices;
982
+ const deviceBackgrounds = {};
983
+ if (devices) {
984
+ for (const [device, deviceConfig] of Object.entries(devices)) {
985
+ if (deviceConfig.background) {
986
+ deviceBackgrounds[device] = deviceConfig.background;
987
+ }
988
+ }
989
+ }
990
+ return {
991
+ content: [{
992
+ type: 'text',
993
+ text: 'Background configuration loaded'
994
+ }],
995
+ structuredContent: {
996
+ global: globalBg ?? {},
997
+ devices: deviceBackgrounds
998
+ }
999
+ };
1000
+ }
1001
+ if (args.action === 'set') {
1002
+ if (!args.image) {
1003
+ return {
1004
+ content: [{
1005
+ type: 'text',
1006
+ text: 'image is required for set action'
1007
+ }],
1008
+ isError: true
1009
+ };
1010
+ }
1011
+ if (args.device) {
1012
+ const devices = config.devices ?? {};
1013
+ if (!devices[args.device]) {
1014
+ return {
1015
+ content: [{
1016
+ type: 'text',
1017
+ text: `Device "${args.device}" not found in config`
1018
+ }],
1019
+ isError: true
1020
+ };
1021
+ }
1022
+ devices[args.device].background = {
1023
+ image: args.image,
1024
+ ...(args.fit && { fit: args.fit })
1025
+ };
1026
+ }
1027
+ else {
1028
+ config.background = {
1029
+ mode: 'image',
1030
+ image: args.image,
1031
+ ...(args.fit && { fit: args.fit })
1032
+ };
1033
+ }
1034
+ await fs.writeFile(configFile, JSON.stringify(config, null, 2) + '\n', 'utf8');
1035
+ return {
1036
+ content: [{
1037
+ type: 'text',
1038
+ text: `Set background${args.device ? ` for ${args.device}` : ' (global)'}: ${args.image}`
1039
+ }],
1040
+ structuredContent: {
1041
+ device: args.device ?? 'global',
1042
+ image: args.image,
1043
+ fit: args.fit,
1044
+ updated: true
1045
+ }
1046
+ };
1047
+ }
1048
+ if (args.action === 'clear') {
1049
+ if (args.device) {
1050
+ const devices = config.devices ?? {};
1051
+ if (devices[args.device]) {
1052
+ delete devices[args.device].background;
1053
+ }
1054
+ }
1055
+ else {
1056
+ delete config.background;
1057
+ }
1058
+ await fs.writeFile(configFile, JSON.stringify(config, null, 2) + '\n', 'utf8');
1059
+ return {
1060
+ content: [{
1061
+ type: 'text',
1062
+ text: `Cleared background${args.device ? ` for ${args.device}` : ' (global)'}`
1063
+ }],
1064
+ structuredContent: {
1065
+ device: args.device ?? 'global',
1066
+ cleared: true
1067
+ }
1068
+ };
1069
+ }
1070
+ return {
1071
+ content: [{
1072
+ type: 'text',
1073
+ text: `Unknown action: ${args.action}`
1074
+ }],
1075
+ isError: true
1076
+ };
1077
+ });
1078
+ }
1079
+ function registerFontsTool(server) {
1080
+ const inputSchema = z.object({
1081
+ action: z.enum(['list', 'validate', 'embedded']).describe('Action to perform'),
1082
+ font: z.string().optional().describe('Font name to validate (required for validate action)')
1083
+ });
1084
+ server.registerTool('appshot_fonts', {
1085
+ title: 'Manage fonts',
1086
+ description: 'List available fonts and check font availability',
1087
+ inputSchema
1088
+ }, async (args) => {
1089
+ if (args.action === 'validate' && !args.font) {
1090
+ return {
1091
+ content: [{
1092
+ type: 'text',
1093
+ text: 'font is required for validate action'
1094
+ }],
1095
+ isError: true
1096
+ };
1097
+ }
1098
+ const fontsArgs = createFontsArgs(args);
1099
+ const result = await runAppshotCli(fontsArgs);
1100
+ return cliResultToToolResponse('Fonts', result);
1101
+ });
1102
+ }
1103
+ function registerTemplateTool(server) {
1104
+ const inputSchema = z.object({
1105
+ template: z.string().optional().describe('Template ID to apply (modern, minimal, bold, elegant, showcase, playful, corporate)'),
1106
+ list: z.boolean().optional().describe('List all available templates'),
1107
+ preview: z.string().optional().describe('Preview template configuration by ID'),
1108
+ caption: z.string().optional().describe('Add a single caption to all screenshots'),
1109
+ captions: z.string().optional().describe('Add multiple captions as JSON'),
1110
+ device: z.string().optional().describe('Apply template to specific device only'),
1111
+ noBackup: z.boolean().optional().describe('Skip creating backup of current config'),
1112
+ dryRun: z.boolean().optional().describe('Preview changes without applying'),
1113
+ projectDir: z.string().optional().describe('Project directory to apply template to')
1114
+ });
1115
+ server.registerTool('appshot_template', {
1116
+ title: 'Apply template',
1117
+ description: 'Apply professional screenshot templates for quick App Store setup',
1118
+ inputSchema
1119
+ }, async (args) => {
1120
+ const typedArgs = args;
1121
+ const cwd = typedArgs.projectDir;
1122
+ delete typedArgs.projectDir;
1123
+ const templateArgs = createTemplateArgs(typedArgs);
1124
+ const result = await runAppshotCli(templateArgs, { cwd });
1125
+ return cliResultToToolResponse('Template', result);
1126
+ });
1127
+ }
1128
+ function registerQuickstartTool(server) {
1129
+ const inputSchema = z.object({
1130
+ template: z.string().optional().describe('Template to use (default: modern)'),
1131
+ caption: z.string().optional().describe('Main caption for screenshots'),
1132
+ noInteractive: z.boolean().optional().describe('Skip interactive prompts'),
1133
+ force: z.boolean().optional().describe('Overwrite existing configuration'),
1134
+ projectDir: z.string().optional().describe('Directory to initialize the project in')
1135
+ });
1136
+ server.registerTool('appshot_quickstart', {
1137
+ title: 'Quickstart',
1138
+ description: 'Get started with App Store screenshots in seconds - initializes project, applies template, sets up captions',
1139
+ inputSchema
1140
+ }, async (args) => {
1141
+ const typedArgs = args;
1142
+ const cwd = typedArgs.projectDir;
1143
+ delete typedArgs.projectDir;
1144
+ const quickstartArgs = createQuickstartArgs(typedArgs);
1145
+ const result = await runAppshotCli(quickstartArgs, { cwd });
1146
+ return cliResultToToolResponse('Quickstart', result);
1147
+ });
1148
+ }
1149
+ //# sourceMappingURL=server.js.map