screenhand 0.1.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.
@@ -0,0 +1,284 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ // ── Schema building blocks ──
5
+ const TargetSchema = z.union([
6
+ z.string().describe("Shorthand: text to find, or 'css=...' / 'text=...' / 'ax_id=...' prefix"),
7
+ z.object({
8
+ text: z.string(),
9
+ exact: z.boolean().optional(),
10
+ }).describe("Find by visible text"),
11
+ z.object({
12
+ role: z.string(),
13
+ name: z.string(),
14
+ exact: z.boolean().optional(),
15
+ }).describe("Find by ARIA/AX role and accessible name"),
16
+ z.object({
17
+ selector: z.string(),
18
+ }).describe("Find by CSS selector (browser) or AX identifier (desktop)"),
19
+ z.object({
20
+ x: z.number(),
21
+ y: z.number(),
22
+ }).describe("Click at screen coordinates"),
23
+ z.object({
24
+ attribute: z.string(),
25
+ value: z.string(),
26
+ }).describe("Find by accessibility attribute"),
27
+ ]);
28
+ const WaitConditionSchema = z.object({
29
+ type: z.enum([
30
+ "selector_visible",
31
+ "selector_hidden",
32
+ "url_matches",
33
+ "text_appears",
34
+ "spinner_disappears",
35
+ "element_exists",
36
+ "element_gone",
37
+ "window_title_matches",
38
+ "app_idle",
39
+ ]),
40
+ selector: z.string().optional(),
41
+ regex: z.string().optional(),
42
+ text: z.string().optional(),
43
+ target: TargetSchema.optional(),
44
+ bundleId: z.string().optional(),
45
+ timeoutMs: z.number().optional(),
46
+ }).describe("Condition to wait for");
47
+ const RegionSchema = z.object({
48
+ x: z.number(),
49
+ y: z.number(),
50
+ width: z.number(),
51
+ height: z.number(),
52
+ });
53
+ // ── Target parser ──
54
+ function parseTarget(input) {
55
+ if (typeof input === "string") {
56
+ if (input.startsWith("css="))
57
+ return { type: "selector", value: input.slice(4) };
58
+ if (input.startsWith("text="))
59
+ return { type: "text", value: input.slice(5), exact: true };
60
+ if (input.startsWith("ax_id="))
61
+ return { type: "ax_attribute", attribute: "identifier", value: input.slice(6) };
62
+ return { type: "text", value: input };
63
+ }
64
+ const obj = input;
65
+ if (typeof obj.selector === "string")
66
+ return { type: "selector", value: obj.selector };
67
+ if (typeof obj.text === "string")
68
+ return { type: "text", value: obj.text, exact: obj.exact === true };
69
+ if (typeof obj.role === "string" && typeof obj.name === "string")
70
+ return { type: "role", role: obj.role, name: obj.name, exact: obj.exact === true };
71
+ if (typeof obj.x === "number" && typeof obj.y === "number")
72
+ return { type: "coordinates", x: obj.x, y: obj.y };
73
+ if (typeof obj.attribute === "string" && typeof obj.value === "string")
74
+ return { type: "ax_attribute", attribute: obj.attribute, value: obj.value };
75
+ throw new Error("Invalid target");
76
+ }
77
+ function parseWaitCondition(input) {
78
+ const obj = input;
79
+ const type = obj.type;
80
+ switch (type) {
81
+ case "selector_visible": return { type: "selector_visible", selector: obj.selector };
82
+ case "selector_hidden": return { type: "selector_hidden", selector: obj.selector };
83
+ case "url_matches": return { type: "url_matches", regex: obj.regex };
84
+ case "text_appears": return { type: "text_appears", text: obj.text };
85
+ case "spinner_disappears": return { type: "spinner_disappears", selector: obj.selector };
86
+ case "element_exists": return { type: "element_exists", target: parseTarget(obj.target) };
87
+ case "element_gone": return { type: "element_gone", target: parseTarget(obj.target) };
88
+ case "window_title_matches": return { type: "window_title_matches", regex: obj.regex };
89
+ case "app_idle": {
90
+ const cond = { type: "app_idle", bundleId: obj.bundleId };
91
+ if (typeof obj.timeoutMs === "number")
92
+ cond.timeoutMs = obj.timeoutMs;
93
+ return cond;
94
+ }
95
+ default: throw new Error(`Unknown wait condition type: ${type}`);
96
+ }
97
+ }
98
+ // ── Helpers ──
99
+ function ok(data) {
100
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
101
+ }
102
+ function err(message) {
103
+ return { content: [{ type: "text", text: message }], isError: true };
104
+ }
105
+ // ── Server builder ──
106
+ export function createMcpStdioServer(runtime) {
107
+ const mcp = new McpServer({ name: "screenhand", version: "0.1.0" }, {
108
+ capabilities: { tools: {} },
109
+ instructions: "ScreenHand gives AI agents eyes and hands on the desktop. Use session_start to begin, then call tools to control apps.",
110
+ });
111
+ // ── session_start ──
112
+ mcp.tool("session_start", "Start a new automation session. Returns a sessionId needed by all other tools. Automatically attaches to the frontmost app.", { profile: z.string().optional().describe("Session profile name (default: 'automation')") }, async ({ profile }) => {
113
+ try {
114
+ const session = await runtime.sessionStart(profile);
115
+ return ok(session);
116
+ }
117
+ catch (e) {
118
+ return err(`Failed to start session: ${e instanceof Error ? e.message : String(e)}`);
119
+ }
120
+ });
121
+ // ── press ──
122
+ mcp.tool("press", "Click/press a UI element. Finds the element by text, role, selector, or coordinates, then clicks it.", {
123
+ sessionId: z.string().describe("Session ID from session_start"),
124
+ target: TargetSchema.describe("What to click — text string, {role, name}, {selector}, or {x, y}"),
125
+ verify: WaitConditionSchema.optional().describe("Optional condition to verify after clicking"),
126
+ }, async ({ sessionId, target, verify }) => {
127
+ const input = { sessionId, target: parseTarget(target) };
128
+ if (verify)
129
+ input.verify = parseWaitCondition(verify);
130
+ const result = await runtime.press(input);
131
+ return result.ok ? ok(result) : err(result.error.message);
132
+ });
133
+ // ── type_into ──
134
+ mcp.tool("type_into", "Type text into a UI element (text field, search box, etc). Locates the field, optionally clears it, then types.", {
135
+ sessionId: z.string(),
136
+ target: TargetSchema.describe("The input field to type into"),
137
+ text: z.string().describe("Text to type"),
138
+ clear: z.boolean().optional().describe("Clear the field first (default: true)"),
139
+ verify: WaitConditionSchema.optional(),
140
+ }, async ({ sessionId, target, text, clear, verify }) => {
141
+ const input = { sessionId, target: parseTarget(target), text };
142
+ if (typeof clear === "boolean")
143
+ input.clear = clear;
144
+ if (verify)
145
+ input.verify = parseWaitCondition(verify);
146
+ const result = await runtime.typeInto(input);
147
+ return result.ok ? ok(result) : err(result.error.message);
148
+ });
149
+ // ── navigate ──
150
+ mcp.tool("navigate", "Navigate a browser to a URL, or open an app via 'app://com.bundle.id'.", {
151
+ sessionId: z.string(),
152
+ url: z.string().describe("URL to navigate to, or 'app://bundleId' to launch an app"),
153
+ timeoutMs: z.number().optional().describe("Navigation timeout in ms (default: 10000)"),
154
+ }, async ({ sessionId, url, timeoutMs }) => {
155
+ const input = { sessionId, url };
156
+ if (typeof timeoutMs === "number")
157
+ input.timeoutMs = timeoutMs;
158
+ const result = await runtime.navigate(input);
159
+ return result.ok ? ok(result) : err(result.error.message);
160
+ });
161
+ // ── wait_for ──
162
+ mcp.tool("wait_for", "Wait for a condition: element appears/disappears, text appears, URL changes, window title matches, etc.", {
163
+ sessionId: z.string(),
164
+ condition: WaitConditionSchema,
165
+ timeoutMs: z.number().optional().describe("Timeout in ms (default: 2000)"),
166
+ }, async ({ sessionId, condition, timeoutMs }) => {
167
+ const input = { sessionId, condition: parseWaitCondition(condition) };
168
+ if (typeof timeoutMs === "number")
169
+ input.timeoutMs = timeoutMs;
170
+ const result = await runtime.waitFor(input);
171
+ return result.ok ? ok(result) : err(result.error.message);
172
+ });
173
+ // ── extract ──
174
+ mcp.tool("extract", "Extract data from a UI element. Returns text content, table data, or structured JSON from the element.", {
175
+ sessionId: z.string(),
176
+ target: TargetSchema,
177
+ format: z.enum(["text", "table", "json"]).describe("Output format"),
178
+ }, async ({ sessionId, target, format }) => {
179
+ const result = await runtime.extract({
180
+ sessionId,
181
+ target: parseTarget(target),
182
+ format: format,
183
+ });
184
+ return result.ok ? ok(result) : err(result.error.message);
185
+ });
186
+ // ── screenshot ──
187
+ mcp.tool("screenshot", "Capture a screenshot of the current app window or a specific screen region. Returns the file path.", {
188
+ sessionId: z.string(),
189
+ region: RegionSchema.optional().describe("Optional screen region to capture"),
190
+ }, async ({ sessionId, region }) => {
191
+ const input = { sessionId };
192
+ if (region)
193
+ input.region = region;
194
+ const result = await runtime.screenshot(input);
195
+ return result.ok ? ok(result) : err(result.error.message);
196
+ });
197
+ // ── app_launch ──
198
+ mcp.tool("app_launch", "Launch a macOS/Windows application by bundle ID (e.g., 'com.apple.Safari', 'com.google.Chrome').", {
199
+ sessionId: z.string(),
200
+ bundleId: z.string().describe("macOS bundle ID or Windows process name"),
201
+ }, async ({ sessionId, bundleId }) => {
202
+ const result = await runtime.appLaunch({ sessionId, bundleId });
203
+ return result.ok ? ok(result) : err(result.error.message);
204
+ });
205
+ // ── app_focus ──
206
+ mcp.tool("app_focus", "Bring a running application to the foreground.", {
207
+ sessionId: z.string(),
208
+ bundleId: z.string(),
209
+ }, async ({ sessionId, bundleId }) => {
210
+ const result = await runtime.appFocus({ sessionId, bundleId });
211
+ return result.ok ? ok(result) : err(result.error.message);
212
+ });
213
+ // ── app_list ──
214
+ mcp.tool("app_list", "List all running applications with their bundle IDs, names, and PIDs.", { sessionId: z.string() }, async ({ sessionId }) => {
215
+ const result = await runtime.appList(sessionId);
216
+ return result.ok ? ok(result) : err(result.error.message);
217
+ });
218
+ // ── window_list ──
219
+ mcp.tool("window_list", "List all visible windows with their titles, positions, and sizes.", { sessionId: z.string() }, async ({ sessionId }) => {
220
+ const result = await runtime.windowList(sessionId);
221
+ return result.ok ? ok(result) : err(result.error.message);
222
+ });
223
+ // ── menu_click ──
224
+ mcp.tool("menu_click", "Click a menu item by path. For example ['File', 'Save As...'] clicks File → Save As.", {
225
+ sessionId: z.string(),
226
+ menuPath: z.array(z.string()).describe("Menu path, e.g. ['File', 'New Window']"),
227
+ }, async ({ sessionId, menuPath }) => {
228
+ const result = await runtime.menuClick({ sessionId, menuPath });
229
+ return result.ok ? ok(result) : err(result.error.message);
230
+ });
231
+ // ── key_combo ──
232
+ mcp.tool("key_combo", "Send a keyboard shortcut. Keys: 'cmd', 'ctrl', 'alt', 'shift', plus any character. E.g. ['cmd', 'c'] for copy.", {
233
+ sessionId: z.string(),
234
+ keys: z.array(z.string()).describe("Key combination, e.g. ['cmd', 's']"),
235
+ }, async ({ sessionId, keys }) => {
236
+ const result = await runtime.keyCombo({ sessionId, keys });
237
+ return result.ok ? ok(result) : err(result.error.message);
238
+ });
239
+ // ── element_tree ──
240
+ mcp.tool("element_tree", "Get the accessibility element tree of the current app. Useful for understanding the UI structure and finding elements to interact with.", {
241
+ sessionId: z.string(),
242
+ maxDepth: z.number().optional().describe("Max tree depth (default: 5)"),
243
+ }, async ({ sessionId, maxDepth }) => {
244
+ const input = { sessionId };
245
+ if (typeof maxDepth === "number")
246
+ input.maxDepth = maxDepth;
247
+ const result = await runtime.elementTree(input);
248
+ return result.ok ? ok(result) : err(result.error.message);
249
+ });
250
+ // ── drag ──
251
+ mcp.tool("drag", "Drag from one UI element to another.", {
252
+ sessionId: z.string(),
253
+ from: TargetSchema.describe("Element to drag from"),
254
+ to: TargetSchema.describe("Element to drag to"),
255
+ }, async ({ sessionId, from, to }) => {
256
+ const result = await runtime.drag({
257
+ sessionId,
258
+ from: parseTarget(from),
259
+ to: parseTarget(to),
260
+ });
261
+ return result.ok ? ok(result) : err(result.error.message);
262
+ });
263
+ // ── scroll ──
264
+ mcp.tool("scroll", "Scroll in a direction, optionally targeting a specific element.", {
265
+ sessionId: z.string(),
266
+ direction: z.enum(["up", "down", "left", "right"]),
267
+ amount: z.number().optional().describe("Scroll amount (default: 3)"),
268
+ target: TargetSchema.optional().describe("Element to scroll within"),
269
+ }, async ({ sessionId, direction, amount, target }) => {
270
+ const input = { sessionId, direction };
271
+ if (typeof amount === "number")
272
+ input.amount = amount;
273
+ if (target)
274
+ input.target = parseTarget(target);
275
+ const result = await runtime.scroll(input);
276
+ return result.ok ? ok(result) : err(result.error.message);
277
+ });
278
+ return mcp;
279
+ }
280
+ export async function startMcpStdioServer(runtime) {
281
+ const mcp = createMcpStdioServer(runtime);
282
+ const transport = new StdioServerTransport();
283
+ await mcp.connect(transport);
284
+ }
@@ -0,0 +1,347 @@
1
+ export class MvpMcpServer {
2
+ runtime;
3
+ constructor(runtime) {
4
+ this.runtime = runtime;
5
+ }
6
+ async invoke(request) {
7
+ switch (request.tool) {
8
+ case "session_start":
9
+ return this.runtime.sessionStart(optionalString(request.args, "profile"));
10
+ case "navigate": {
11
+ const timeoutMs = optionalNumber(request.args, "timeoutMs");
12
+ const input = {
13
+ sessionId: requiredString(request.args, "sessionId"),
14
+ url: requiredString(request.args, "url"),
15
+ };
16
+ if (typeof timeoutMs === "number") {
17
+ input.timeoutMs = timeoutMs;
18
+ }
19
+ return this.runtime.navigate(input);
20
+ }
21
+ case "press": {
22
+ const verify = parseOptionalWaitCondition(request.args.verify);
23
+ const input = {
24
+ sessionId: requiredString(request.args, "sessionId"),
25
+ target: parseTarget(request.args.target),
26
+ };
27
+ if (verify) {
28
+ input.verify = verify;
29
+ }
30
+ return this.runtime.press(input);
31
+ }
32
+ case "type_into": {
33
+ const clear = optionalBoolean(request.args, "clear");
34
+ const verify = parseOptionalWaitCondition(request.args.verify);
35
+ const input = {
36
+ sessionId: requiredString(request.args, "sessionId"),
37
+ target: parseTarget(request.args.target),
38
+ text: requiredString(request.args, "text"),
39
+ };
40
+ if (typeof clear === "boolean") {
41
+ input.clear = clear;
42
+ }
43
+ if (verify) {
44
+ input.verify = verify;
45
+ }
46
+ return this.runtime.typeInto(input);
47
+ }
48
+ case "wait_for": {
49
+ const timeoutMs = optionalNumber(request.args, "timeoutMs");
50
+ const input = {
51
+ sessionId: requiredString(request.args, "sessionId"),
52
+ condition: parseWaitCondition(request.args.condition),
53
+ };
54
+ if (typeof timeoutMs === "number") {
55
+ input.timeoutMs = timeoutMs;
56
+ }
57
+ return this.runtime.waitFor(input);
58
+ }
59
+ case "extract":
60
+ return this.runtime.extract({
61
+ sessionId: requiredString(request.args, "sessionId"),
62
+ target: parseTarget(request.args.target),
63
+ format: parseExtractFormat(request.args.format),
64
+ });
65
+ case "screenshot": {
66
+ const region = parseOptionalRegion(request.args.region);
67
+ const input = {
68
+ sessionId: requiredString(request.args, "sessionId"),
69
+ };
70
+ if (region) {
71
+ input.region = region;
72
+ }
73
+ return this.runtime.screenshot(input);
74
+ }
75
+ // ── Desktop automation tools ──
76
+ case "app_launch":
77
+ return this.runtime.appLaunch({
78
+ sessionId: requiredString(request.args, "sessionId"),
79
+ bundleId: requiredString(request.args, "bundleId"),
80
+ });
81
+ case "app_focus":
82
+ return this.runtime.appFocus({
83
+ sessionId: requiredString(request.args, "sessionId"),
84
+ bundleId: requiredString(request.args, "bundleId"),
85
+ });
86
+ case "app_list":
87
+ return this.runtime.appList(requiredString(request.args, "sessionId"));
88
+ case "window_list":
89
+ return this.runtime.windowList(requiredString(request.args, "sessionId"));
90
+ case "menu_click":
91
+ return this.runtime.menuClick({
92
+ sessionId: requiredString(request.args, "sessionId"),
93
+ menuPath: requiredStringArray(request.args, "menuPath"),
94
+ });
95
+ case "key_combo":
96
+ return this.runtime.keyCombo({
97
+ sessionId: requiredString(request.args, "sessionId"),
98
+ keys: requiredStringArray(request.args, "keys"),
99
+ });
100
+ case "element_tree": {
101
+ const maxDepth = optionalNumber(request.args, "maxDepth");
102
+ const root = request.args.root ? parseTarget(request.args.root) : undefined;
103
+ const etInput = {
104
+ sessionId: requiredString(request.args, "sessionId"),
105
+ };
106
+ if (typeof maxDepth === "number")
107
+ etInput.maxDepth = maxDepth;
108
+ if (root)
109
+ etInput.root = root;
110
+ return this.runtime.elementTree(etInput);
111
+ }
112
+ case "observe_start": {
113
+ const events = request.args.events;
114
+ const osInput = {
115
+ sessionId: requiredString(request.args, "sessionId"),
116
+ };
117
+ if (Array.isArray(events)) {
118
+ osInput.events = events;
119
+ }
120
+ return this.runtime.observeStart(osInput);
121
+ }
122
+ case "observe_stop":
123
+ return this.runtime.observeStop({
124
+ sessionId: requiredString(request.args, "sessionId"),
125
+ });
126
+ case "drag":
127
+ return this.runtime.drag({
128
+ sessionId: requiredString(request.args, "sessionId"),
129
+ from: parseTarget(request.args.from),
130
+ to: parseTarget(request.args.to),
131
+ });
132
+ case "scroll": {
133
+ const scrollTarget = request.args.target ? parseTarget(request.args.target) : undefined;
134
+ const scrollAmount = optionalNumber(request.args, "amount");
135
+ const scrollInput = {
136
+ sessionId: requiredString(request.args, "sessionId"),
137
+ direction: requiredString(request.args, "direction"),
138
+ };
139
+ if (scrollTarget)
140
+ scrollInput.target = scrollTarget;
141
+ if (typeof scrollAmount === "number")
142
+ scrollInput.amount = scrollAmount;
143
+ return this.runtime.scroll(scrollInput);
144
+ }
145
+ default:
146
+ throw new Error(`Unsupported tool: ${String(request.tool)}`);
147
+ }
148
+ }
149
+ }
150
+ function parseTarget(input) {
151
+ if (typeof input === "string") {
152
+ if (input.startsWith("css=")) {
153
+ return { type: "selector", value: input.slice(4) };
154
+ }
155
+ if (input.startsWith("text=")) {
156
+ return { type: "text", value: input.slice(5), exact: true };
157
+ }
158
+ if (input.startsWith("ax_id=")) {
159
+ return { type: "ax_attribute", attribute: "identifier", value: input.slice(6) };
160
+ }
161
+ return { type: "text", value: input };
162
+ }
163
+ if (!isRecord(input)) {
164
+ throw new Error("target must be a string or object");
165
+ }
166
+ if (typeof input.selector === "string") {
167
+ return { type: "selector", value: input.selector };
168
+ }
169
+ if (typeof input.text === "string") {
170
+ return {
171
+ type: "text",
172
+ value: input.text,
173
+ exact: input.exact === true,
174
+ };
175
+ }
176
+ if (typeof input.role === "string" && typeof input.name === "string") {
177
+ return {
178
+ type: "role",
179
+ role: input.role,
180
+ name: input.name,
181
+ exact: input.exact === true,
182
+ };
183
+ }
184
+ if (Array.isArray(input.path)) {
185
+ return { type: "ax_path", path: input.path };
186
+ }
187
+ if (typeof input.attribute === "string" && typeof input.value === "string") {
188
+ return { type: "ax_attribute", attribute: input.attribute, value: input.value };
189
+ }
190
+ if (typeof input.x === "number" && typeof input.y === "number") {
191
+ return { type: "coordinates", x: input.x, y: input.y };
192
+ }
193
+ if (typeof input.base64 === "string") {
194
+ const target = { type: "image", base64: input.base64 };
195
+ if (typeof input.confidence === "number") {
196
+ target.confidence = input.confidence;
197
+ }
198
+ return target;
199
+ }
200
+ throw new Error("target object must contain selector, text, role+name, path, attribute+value, x+y, or base64");
201
+ }
202
+ function parseWaitCondition(input) {
203
+ if (!isRecord(input) || typeof input.type !== "string") {
204
+ throw new Error("condition must be an object with a type");
205
+ }
206
+ switch (input.type) {
207
+ case "selector_visible":
208
+ return {
209
+ type: "selector_visible",
210
+ selector: requiredObjectString(input, "selector"),
211
+ };
212
+ case "selector_hidden":
213
+ return {
214
+ type: "selector_hidden",
215
+ selector: requiredObjectString(input, "selector"),
216
+ };
217
+ case "url_matches":
218
+ return {
219
+ type: "url_matches",
220
+ regex: requiredObjectString(input, "regex"),
221
+ };
222
+ case "text_appears":
223
+ return {
224
+ type: "text_appears",
225
+ text: requiredObjectString(input, "text"),
226
+ };
227
+ case "spinner_disappears":
228
+ return {
229
+ type: "spinner_disappears",
230
+ selector: requiredObjectString(input, "selector"),
231
+ };
232
+ case "element_exists":
233
+ return {
234
+ type: "element_exists",
235
+ target: parseTarget(input.target),
236
+ };
237
+ case "element_gone":
238
+ return {
239
+ type: "element_gone",
240
+ target: parseTarget(input.target),
241
+ };
242
+ case "window_title_matches":
243
+ return {
244
+ type: "window_title_matches",
245
+ regex: requiredObjectString(input, "regex"),
246
+ };
247
+ case "app_idle": {
248
+ const cond = {
249
+ type: "app_idle",
250
+ bundleId: requiredObjectString(input, "bundleId"),
251
+ };
252
+ if (typeof input.timeoutMs === "number") {
253
+ cond.timeoutMs = input.timeoutMs;
254
+ }
255
+ return cond;
256
+ }
257
+ default:
258
+ throw new Error(`Unsupported condition type: ${input.type}`);
259
+ }
260
+ }
261
+ function parseOptionalWaitCondition(input) {
262
+ if (typeof input === "undefined") {
263
+ return undefined;
264
+ }
265
+ return parseWaitCondition(input);
266
+ }
267
+ function parseExtractFormat(input) {
268
+ if (input === "text" || input === "table" || input === "json") {
269
+ return input;
270
+ }
271
+ throw new Error("format must be one of: text, table, json");
272
+ }
273
+ function parseOptionalRegion(input) {
274
+ if (typeof input === "undefined") {
275
+ return undefined;
276
+ }
277
+ if (!isRecord(input)) {
278
+ throw new Error("region must be an object");
279
+ }
280
+ return {
281
+ x: requiredObjectNumber(input, "x"),
282
+ y: requiredObjectNumber(input, "y"),
283
+ width: requiredObjectNumber(input, "width"),
284
+ height: requiredObjectNumber(input, "height"),
285
+ };
286
+ }
287
+ function requiredString(input, key) {
288
+ const value = input[key];
289
+ if (typeof value !== "string") {
290
+ throw new Error(`${key} must be a string`);
291
+ }
292
+ return value;
293
+ }
294
+ function requiredStringArray(input, key) {
295
+ const value = input[key];
296
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
297
+ throw new Error(`${key} must be an array of strings`);
298
+ }
299
+ return value;
300
+ }
301
+ function optionalString(input, key) {
302
+ const value = input[key];
303
+ if (typeof value === "undefined") {
304
+ return undefined;
305
+ }
306
+ if (typeof value !== "string") {
307
+ throw new Error(`${key} must be a string`);
308
+ }
309
+ return value;
310
+ }
311
+ function optionalNumber(input, key) {
312
+ const value = input[key];
313
+ if (typeof value === "undefined") {
314
+ return undefined;
315
+ }
316
+ if (typeof value !== "number") {
317
+ throw new Error(`${key} must be a number`);
318
+ }
319
+ return value;
320
+ }
321
+ function optionalBoolean(input, key) {
322
+ const value = input[key];
323
+ if (typeof value === "undefined") {
324
+ return undefined;
325
+ }
326
+ if (typeof value !== "boolean") {
327
+ throw new Error(`${key} must be a boolean`);
328
+ }
329
+ return value;
330
+ }
331
+ function requiredObjectString(input, key) {
332
+ const value = input[key];
333
+ if (typeof value !== "string") {
334
+ throw new Error(`${key} must be a string`);
335
+ }
336
+ return value;
337
+ }
338
+ function requiredObjectNumber(input, key) {
339
+ const value = input[key];
340
+ if (typeof value !== "number") {
341
+ throw new Error(`${key} must be a number`);
342
+ }
343
+ return value;
344
+ }
345
+ function isRecord(input) {
346
+ return typeof input === "object" && input !== null;
347
+ }