💬 Commit message: Update 2026-02-07 14:20:25, 8 files, 1060 lines

📁 Files changed: 8
📝 Lines changed: 1060

  • package-lock.json
  • package.json
  • browser.ts
  • cli.ts
  • image.ts
  • index.ts
  • mcp.ts
  • types.ts
This commit is contained in:
Adam Ladachowski
2026-02-07 14:20:25 +01:00
parent 4f0bec5f81
commit 67b6dc3a79
8 changed files with 1056 additions and 4 deletions
+67
View File
@@ -1,5 +1,6 @@
import { resolve } from 'node:path';
import { type Browser, type BrowserContext, type Page, webkit } from 'playwright';
import * as image from './image.js';
import type { BrowserCommand, BrowserOptions, CommandResponse, ElementInfo } from './types.js';
export class ClaudeBrowser {
@@ -190,6 +191,72 @@ export class ClaudeBrowser {
const result = await this.eval(cmd.script);
return { ok: true, result };
}
case 'favicon': {
const result = await image.createFavicon(cmd.input, cmd.outputDir);
return { ok: true, files: result.files, outputDir: result.outputDir };
}
case 'convert': {
const result = await image.convert(cmd.input, cmd.output, cmd.format);
return {
ok: true,
path: result.path,
width: result.width,
height: result.height,
format: result.format,
size: result.size,
};
}
case 'resize': {
const result = await image.resize(cmd.input, cmd.output, cmd.width, cmd.height, cmd.fit);
return {
ok: true,
path: result.path,
width: result.width,
height: result.height,
format: result.format,
size: result.size,
};
}
case 'crop': {
const result = await image.crop(
cmd.input,
cmd.output,
cmd.left,
cmd.top,
cmd.width,
cmd.height
);
return {
ok: true,
path: result.path,
width: result.width,
height: result.height,
format: result.format,
size: result.size,
};
}
case 'compress': {
const result = await image.compress(cmd.input, cmd.output, cmd.quality);
return {
ok: true,
path: result.path,
width: result.width,
height: result.height,
format: result.format,
size: result.size,
};
}
case 'thumbnail': {
const result = await image.thumbnail(cmd.input, cmd.output, cmd.size);
return {
ok: true,
path: result.path,
width: result.width,
height: result.height,
format: result.format,
size: result.size,
};
}
default: {
const _exhaustive: never = cmd;
return { ok: false, error: `Unknown command: ${(_exhaustive as { cmd: string }).cmd}` };
+59
View File
@@ -2,6 +2,7 @@
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
import { ClaudeBrowser } from './browser.js';
import * as image from './image.js';
import { startServer } from './server.js';
import type { ElementInfo } from './types.js';
@@ -21,6 +22,11 @@ const { values, positionals } = parseArgs({
type: { type: 'string', short: 't', multiple: true },
help: { type: 'boolean', default: false },
version: { type: 'boolean', short: 'v', default: false },
// Image processing options
favicon: { type: 'string' },
convert: { type: 'string' },
resize: { type: 'string' },
compress: { type: 'string' },
},
});
@@ -42,6 +48,12 @@ Options:
-v, --version Show version
--help Show this help
Image Processing:
--favicon <dir> Generate favicon set to directory (from screenshot or input)
--convert <format> Convert screenshot to format (png, jpeg, webp, avif)
--resize <WxH> Resize screenshot (e.g., 800x600 or 800 for width only)
--compress <quality> Compress with quality 1-100
Examples:
claude-browse https://example.com
claude-browse -o page.png -w 1920 -h 1080 https://example.com
@@ -52,6 +64,12 @@ Examples:
claude-browse -t "input[name=q]=hello" -c "button[type=submit]" https://google.com
claude-browse -c ".cookie-accept" -c "a.nav-link" -q "h1" https://example.com
Image processing examples:
claude-browse https://example.com --favicon ./favicons/
claude-browse https://example.com -o page.webp --convert webp
claude-browse https://example.com --resize 800x600
claude-browse https://example.com --compress 60
Server mode (default):
claude-browse # Start server on port 13373
claude-browse --headed # Start with visible browser
@@ -65,6 +83,12 @@ Server mode (default):
curl -X POST http://localhost:13373 -d '{"cmd":"url"}'
curl -X POST http://localhost:13373 -d '{"cmd":"html"}'
curl -X POST http://localhost:13373 -d '{"cmd":"close"}'
# Image processing via server:
curl localhost:13373 -d '{"cmd":"favicon","input":"screenshot.png","outputDir":"./favicons"}'
curl localhost:13373 -d '{"cmd":"convert","input":"img.png","output":"img.webp","format":"webp"}'
curl localhost:13373 -d '{"cmd":"resize","input":"img.png","output":"small.png","width":400}'
curl localhost:13373 -d '{"cmd":"compress","input":"img.png","output":"compressed.png","quality":60}'
`;
function getViewportConfig() {
@@ -157,10 +181,45 @@ async function runInteractiveMode(browser: ClaudeBrowser): Promise<void> {
await new Promise(() => {});
}
async function processImageOptions(screenshotPath: string): Promise<void> {
// Process image options on the screenshot
if (values.favicon) {
console.log(`Generating favicon set to: ${values.favicon}`);
const result = await image.createFavicon(screenshotPath, values.favicon as string);
console.log(`Created ${result.files.length} favicon files`);
}
if (values.convert) {
const format = values.convert as 'png' | 'jpeg' | 'webp' | 'avif';
const outputPath = screenshotPath.replace(/\.[^.]+$/, `.${format}`);
console.log(`Converting to ${format}: ${outputPath}`);
await image.convert(screenshotPath, outputPath, format);
}
if (values.resize) {
const resizeValue = values.resize as string;
const [widthStr, heightStr] = resizeValue.split('x');
const width = Number.parseInt(widthStr);
const height = heightStr ? Number.parseInt(heightStr) : undefined;
console.log(`Resizing to ${width}${height ? `x${height}` : ''}`);
await image.resize(screenshotPath, screenshotPath, width, height);
}
if (values.compress) {
const quality = Number.parseInt(values.compress as string);
console.log(`Compressing with quality ${quality}`);
await image.compress(screenshotPath, screenshotPath, quality);
}
}
async function runScreenshotMode(browser: ClaudeBrowser): Promise<void> {
const outputPath = resolve(values.output as string);
console.log(`Saving screenshot to: ${outputPath}`);
await browser.screenshot(outputPath, values.fullpage);
// Process any image options
await processImageOptions(outputPath);
await browser.close();
console.log('Done!');
}
+196
View File
@@ -0,0 +1,196 @@
import { mkdir } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import sharp from 'sharp';
export type FitType = 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
export type FormatType = 'png' | 'jpeg' | 'webp' | 'avif';
export type ThumbnailSize = 'small' | 'medium' | 'large';
const THUMBNAIL_SIZES: Record<ThumbnailSize, number> = {
small: 150,
medium: 300,
large: 600,
};
const FAVICON_SIZES = [
{ name: 'favicon-16x16.png', size: 16 },
{ name: 'favicon-32x32.png', size: 32 },
{ name: 'favicon-48x48.png', size: 48 },
{ name: 'apple-touch-icon.png', size: 180 },
{ name: 'android-chrome-192x192.png', size: 192 },
{ name: 'android-chrome-512x512.png', size: 512 },
];
export interface FaviconResult {
files: string[];
outputDir: string;
}
export interface ImageResult {
path: string;
width?: number;
height?: number;
format?: string;
size?: number;
}
async function ensureDir(filePath: string): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
}
export async function createFavicon(input: string, outputDir: string): Promise<FaviconResult> {
const resolvedDir = resolve(outputDir);
await mkdir(resolvedDir, { recursive: true });
const files: string[] = [];
const image = sharp(input);
for (const { name, size } of FAVICON_SIZES) {
const outputPath = join(resolvedDir, name);
await image.clone().resize(size, size, { fit: 'cover' }).png().toFile(outputPath);
files.push(outputPath);
}
// Create favicon.ico with multiple sizes (16, 32, 48)
const icoPath = join(resolvedDir, 'favicon.ico');
const sizes = [16, 32, 48];
const buffers = await Promise.all(
sizes.map((size) => image.clone().resize(size, size, { fit: 'cover' }).png().toBuffer())
);
// ICO format: simple approach - use largest PNG as ICO
// For true multi-size ICO, we'd need a dedicated library
// Sharp doesn't support ICO output, so we'll use the 32x32 PNG
await image.clone().resize(32, 32, { fit: 'cover' }).png().toFile(icoPath);
files.push(icoPath);
return { files, outputDir: resolvedDir };
}
export async function convert(
input: string,
output: string,
format: FormatType
): Promise<ImageResult> {
const resolvedOutput = resolve(output);
await ensureDir(resolvedOutput);
const image = sharp(input);
let result: sharp.Sharp;
switch (format) {
case 'png':
result = image.png();
break;
case 'jpeg':
result = image.jpeg();
break;
case 'webp':
result = image.webp();
break;
case 'avif':
result = image.avif();
break;
}
const info = await result.toFile(resolvedOutput);
return {
path: resolvedOutput,
width: info.width,
height: info.height,
format: info.format,
size: info.size,
};
}
export async function resize(
input: string,
output: string,
width: number,
height?: number,
fit: FitType = 'cover'
): Promise<ImageResult> {
const resolvedOutput = resolve(output);
await ensureDir(resolvedOutput);
const info = await sharp(input).resize(width, height, { fit }).toFile(resolvedOutput);
return {
path: resolvedOutput,
width: info.width,
height: info.height,
format: info.format,
size: info.size,
};
}
export async function crop(
input: string,
output: string,
left: number,
top: number,
width: number,
height: number
): Promise<ImageResult> {
const resolvedOutput = resolve(output);
await ensureDir(resolvedOutput);
const info = await sharp(input).extract({ left, top, width, height }).toFile(resolvedOutput);
return {
path: resolvedOutput,
width: info.width,
height: info.height,
format: info.format,
size: info.size,
};
}
export async function compress(input: string, output: string, quality = 80): Promise<ImageResult> {
const resolvedOutput = resolve(output);
await ensureDir(resolvedOutput);
const image = sharp(input);
const metadata = await image.metadata();
const format = metadata.format;
let result: sharp.Sharp;
switch (format) {
case 'png':
result = image.png({ quality });
break;
case 'jpeg':
case 'jpg':
result = image.jpeg({ quality });
break;
case 'webp':
result = image.webp({ quality });
break;
case 'avif':
result = image.avif({ quality });
break;
default:
// Default to PNG for unknown formats
result = image.png({ quality });
}
const info = await result.toFile(resolvedOutput);
return {
path: resolvedOutput,
width: info.width,
height: info.height,
format: info.format,
size: info.size,
};
}
export async function thumbnail(
input: string,
output: string,
size: ThumbnailSize = 'medium'
): Promise<ImageResult> {
const dimension = THUMBNAIL_SIZES[size];
return resize(input, output, dimension, dimension, 'cover');
}
+19
View File
@@ -1,5 +1,18 @@
export { ClaudeBrowser } from './browser.js';
export { BrowserServer, startServer } from './server.js';
export {
createFavicon,
convert,
resize,
crop,
compress,
thumbnail,
type FaviconResult,
type ImageResult,
type FitType,
type FormatType,
type ThumbnailSize,
} from './image.js';
export type {
BrowserOptions,
BrowserCommand,
@@ -21,5 +34,11 @@ export type {
NewPageCommand,
CloseCommand,
EvalCommand,
FaviconCommand,
ConvertCommand,
ResizeCommand,
CropCommand,
CompressCommand,
ThumbnailCommand,
} from './types.js';
export type { ServerOptions } from './server.js';
+103
View File
@@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { ClaudeBrowser } from './browser.js';
import * as image from './image.js';
import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js';
const browser = new ClaudeBrowser({ headless: true, width: 1280, height: 800 });
@@ -187,6 +188,108 @@ server.tool(
})
);
// Image processing
server.tool(
'favicon',
'Generate a complete favicon set from an image (16x16, 32x32, 48x48, apple-touch-icon 180x180, android-chrome 192x192 and 512x512)',
{
input: z.string().describe('Path to source image'),
outputDir: z.string().describe('Directory to output favicon files'),
},
withLogging('favicon', async ({ input, outputDir }) => {
const result = await image.createFavicon(input, outputDir);
return textResult(
JSON.stringify({ ok: true, files: result.files, outputDir: result.outputDir })
);
})
);
server.tool(
'convert',
'Convert an image to a different format (png, jpeg, webp, avif)',
{
input: z.string().describe('Path to source image'),
output: z.string().describe('Path for output image'),
format: z.enum(['png', 'jpeg', 'webp', 'avif']).describe('Target format'),
},
withLogging('convert', async ({ input, output, format }) => {
const result = await image.convert(input, output, format);
return textResult(JSON.stringify({ ok: true, ...result }));
})
);
server.tool(
'resize',
'Resize an image to specified dimensions',
{
input: z.string().describe('Path to source image'),
output: z.string().describe('Path for output image'),
width: z.number().describe('Target width in pixels'),
height: z
.number()
.optional()
.describe('Target height in pixels (optional, maintains aspect ratio if omitted)'),
fit: z
.enum(['cover', 'contain', 'fill', 'inside', 'outside'])
.optional()
.default('cover')
.describe('How to fit the image'),
},
withLogging('resize', async ({ input, output, width, height, fit }) => {
const result = await image.resize(input, output, width, height, fit);
return textResult(JSON.stringify({ ok: true, ...result }));
})
);
server.tool(
'crop',
'Crop a region from an image',
{
input: z.string().describe('Path to source image'),
output: z.string().describe('Path for output image'),
left: z.number().describe('Left edge position in pixels'),
top: z.number().describe('Top edge position in pixels'),
width: z.number().describe('Width of crop region in pixels'),
height: z.number().describe('Height of crop region in pixels'),
},
withLogging('crop', async ({ input, output, left, top, width, height }) => {
const result = await image.crop(input, output, left, top, width, height);
return textResult(JSON.stringify({ ok: true, ...result }));
})
);
server.tool(
'compress',
'Compress an image to reduce file size',
{
input: z.string().describe('Path to source image'),
output: z.string().describe('Path for output image'),
quality: z.number().min(1).max(100).optional().default(80).describe('Quality level 1-100'),
},
withLogging('compress', async ({ input, output, quality }) => {
const result = await image.compress(input, output, quality);
return textResult(JSON.stringify({ ok: true, ...result }));
})
);
server.tool(
'thumbnail',
'Create a thumbnail from an image',
{
input: z.string().describe('Path to source image'),
output: z.string().describe('Path for output image'),
size: z
.enum(['small', 'medium', 'large'])
.optional()
.default('medium')
.describe('Thumbnail size preset (small=150px, medium=300px, large=600px)'),
},
withLogging('thumbnail', async ({ input, output, size }) => {
const result = await image.thumbnail(input, output, size);
return textResult(JSON.stringify({ ok: true, ...result }));
})
);
// Start server
async function main(): Promise<void> {
const transport = new StdioServerTransport();
+61 -1
View File
@@ -77,6 +77,53 @@ export interface EvalCommand {
script: string;
}
// Image processing commands
export interface FaviconCommand {
cmd: 'favicon';
input: string;
outputDir: string;
}
export interface ConvertCommand {
cmd: 'convert';
input: string;
output: string;
format: 'png' | 'jpeg' | 'webp' | 'avif';
}
export interface ResizeCommand {
cmd: 'resize';
input: string;
output: string;
width: number;
height?: number;
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
}
export interface CropCommand {
cmd: 'crop';
input: string;
output: string;
left: number;
top: number;
width: number;
height: number;
}
export interface CompressCommand {
cmd: 'compress';
input: string;
output: string;
quality?: number;
}
export interface ThumbnailCommand {
cmd: 'thumbnail';
input: string;
output: string;
size?: 'small' | 'medium' | 'large';
}
export type BrowserCommand =
| GotoCommand
| ClickCommand
@@ -91,7 +138,13 @@ export type BrowserCommand =
| WaitCommand
| NewPageCommand
| CloseCommand
| EvalCommand;
| EvalCommand
| FaviconCommand
| ConvertCommand
| ResizeCommand
| CropCommand
| CompressCommand
| ThumbnailCommand;
// Response types
export interface SuccessResponse {
@@ -103,6 +156,13 @@ export interface SuccessResponse {
count?: number;
elements?: ElementInfo[];
result?: unknown;
// Image processing fields
files?: string[];
outputDir?: string;
width?: number;
height?: number;
format?: string;
size?: number;
}
export interface ErrorResponse {