@crosshands/cli 0.1.4

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.
package/src/index.ts ADDED
@@ -0,0 +1,558 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { chmod, lstat, open } from 'node:fs/promises'
4
+ import { dirname, resolve } from 'node:path'
5
+
6
+ import type { ComputerOperationName } from '@crosshands/contract'
7
+
8
+ export type CliBrokerClient = {
9
+ request(operation: ComputerOperationName, input: unknown): Promise<unknown>
10
+ close(): Promise<void>
11
+ }
12
+
13
+ export type CliIo = {
14
+ stdin(): Promise<string>
15
+ stdout(value: string): void
16
+ stderr(value: string): void
17
+ }
18
+
19
+ type Flags = Map<string, string | true>
20
+
21
+ const BOOLEAN_FLAGS = new Set([
22
+ 'json',
23
+ 'no-screenshot',
24
+ 'restore-window',
25
+ 'text-stdin',
26
+ 'value-stdin'
27
+ ])
28
+
29
+ const COMMANDS: Record<string, ComputerOperationName | 'doctor'> = {
30
+ capabilities: 'capabilities',
31
+ permissions: 'permissions',
32
+ 'list-apps': 'listApps',
33
+ 'list-windows': 'listWindows',
34
+ 'get-app-state': 'getAppState',
35
+ click: 'click',
36
+ 'perform-secondary-action': 'performSecondaryAction',
37
+ scroll: 'scroll',
38
+ drag: 'drag',
39
+ 'type-text': 'typeText',
40
+ 'press-key': 'pressKey',
41
+ hotkey: 'hotkey',
42
+ 'paste-text': 'pasteText',
43
+ 'set-value': 'setValue',
44
+ doctor: 'doctor'
45
+ }
46
+
47
+ const ALLOWED: Record<string, readonly string[]> = {
48
+ capabilities: ['json'],
49
+ permissions: ['json', 'id'],
50
+ 'list-apps': ['json'],
51
+ 'list-windows': ['json', 'app'],
52
+ 'get-app-state': [
53
+ 'json',
54
+ 'app',
55
+ 'window-id',
56
+ 'window-index',
57
+ 'no-screenshot',
58
+ 'restore-window',
59
+ 'screenshot-output'
60
+ ],
61
+ click: [
62
+ 'json',
63
+ 'app',
64
+ 'context',
65
+ 'element-index',
66
+ 'x',
67
+ 'y',
68
+ 'click-count',
69
+ 'mouse-button',
70
+ 'modifiers',
71
+ 'no-screenshot',
72
+ 'restore-window',
73
+ 'screenshot-output'
74
+ ],
75
+ 'perform-secondary-action': [
76
+ 'json',
77
+ 'app',
78
+ 'context',
79
+ 'element-index',
80
+ 'action',
81
+ 'no-screenshot',
82
+ 'restore-window',
83
+ 'screenshot-output'
84
+ ],
85
+ scroll: [
86
+ 'json',
87
+ 'app',
88
+ 'context',
89
+ 'element-index',
90
+ 'x',
91
+ 'y',
92
+ 'direction',
93
+ 'pages',
94
+ 'no-screenshot',
95
+ 'restore-window',
96
+ 'screenshot-output'
97
+ ],
98
+ drag: [
99
+ 'json',
100
+ 'app',
101
+ 'context',
102
+ 'from-element-index',
103
+ 'to-element-index',
104
+ 'from-x',
105
+ 'from-y',
106
+ 'to-x',
107
+ 'to-y',
108
+ 'duration-ms',
109
+ 'no-screenshot',
110
+ 'restore-window',
111
+ 'screenshot-output'
112
+ ],
113
+ 'type-text': [
114
+ 'json',
115
+ 'app',
116
+ 'context',
117
+ 'text',
118
+ 'text-stdin',
119
+ 'no-screenshot',
120
+ 'restore-window',
121
+ 'screenshot-output'
122
+ ],
123
+ 'press-key': [
124
+ 'json',
125
+ 'app',
126
+ 'context',
127
+ 'key',
128
+ 'no-screenshot',
129
+ 'restore-window',
130
+ 'screenshot-output'
131
+ ],
132
+ hotkey: ['json', 'app', 'context', 'key', 'no-screenshot', 'restore-window', 'screenshot-output'],
133
+ 'paste-text': [
134
+ 'json',
135
+ 'app',
136
+ 'context',
137
+ 'text',
138
+ 'no-screenshot',
139
+ 'restore-window',
140
+ 'screenshot-output'
141
+ ],
142
+ 'set-value': [
143
+ 'json',
144
+ 'app',
145
+ 'context',
146
+ 'element-index',
147
+ 'value',
148
+ 'value-stdin',
149
+ 'no-screenshot',
150
+ 'restore-window',
151
+ 'screenshot-output'
152
+ ],
153
+ doctor: ['json']
154
+ }
155
+
156
+ const EXIT_CODES: Record<string, number> = {
157
+ invalid_argument: 2,
158
+ provider_unavailable: 3,
159
+ permission_denied: 4,
160
+ stale_target: 5,
161
+ interaction_context_invalid: 5,
162
+ interaction_context_expired: 5,
163
+ app_blocked: 6,
164
+ unsupported_capability: 7,
165
+ action_not_supported: 7,
166
+ timeout: 8,
167
+ version_incompatible: 9,
168
+ session_unavailable: 10
169
+ }
170
+
171
+ class CliError extends Error {
172
+ constructor(
173
+ readonly code: string,
174
+ message: string,
175
+ readonly remediation = 'correct_request'
176
+ ) {
177
+ super(message)
178
+ }
179
+ }
180
+
181
+ function parseFlags(args: string[], command: string): Flags {
182
+ const flags: Flags = new Map()
183
+ const allowed = new Set(ALLOWED[command] ?? [])
184
+ for (let index = 0; index < args.length; index += 1) {
185
+ const raw = args[index]
186
+ if (raw === undefined || !raw.startsWith('--'))
187
+ throw new CliError('invalid_argument', `Unexpected argument: ${raw ?? ''}`)
188
+ const name = raw.slice(2)
189
+ if (name === 'worktree' || name === 'session') {
190
+ throw new CliError(
191
+ 'invalid_argument',
192
+ `--${name} was an Orca routing flag and is not used by standalone CrossHands; use --context from get-app-state instead`,
193
+ 'remove_orca_routing_flag'
194
+ )
195
+ }
196
+ if (!allowed.has(name)) throw new CliError('invalid_argument', `Unknown flag --${name}`)
197
+ if (flags.has(name)) throw new CliError('invalid_argument', `Duplicate flag --${name}`)
198
+ if (BOOLEAN_FLAGS.has(name)) {
199
+ flags.set(name, true)
200
+ continue
201
+ }
202
+ const value = args[index + 1]
203
+ if (value === undefined || value.startsWith('--'))
204
+ throw new CliError('invalid_argument', `Missing value for --${name}`)
205
+ flags.set(name, value)
206
+ index += 1
207
+ }
208
+ return flags
209
+ }
210
+
211
+ function stringFlag(flags: Flags, name: string, required = false): string | undefined {
212
+ const value = flags.get(name)
213
+ if (typeof value === 'string') return value
214
+ if (required) throw new CliError('invalid_argument', `Missing required --${name}`)
215
+ return undefined
216
+ }
217
+
218
+ function numberFlag(
219
+ flags: Flags,
220
+ name: string,
221
+ options: { integer?: boolean; min?: number } = {}
222
+ ): number | undefined {
223
+ const raw = stringFlag(flags, name)
224
+ if (raw === undefined) return undefined
225
+ const value = Number(raw)
226
+ if (
227
+ !Number.isFinite(value) ||
228
+ (options.integer === true && !Number.isInteger(value)) ||
229
+ (options.min !== undefined && value < options.min)
230
+ ) {
231
+ throw new CliError('invalid_argument', `Invalid --${name}`)
232
+ }
233
+ return value
234
+ }
235
+
236
+ function observationFlags(flags: Flags): Record<string, unknown> {
237
+ return {
238
+ ...(flags.has('no-screenshot') ? { captureScreenshot: false } : {}),
239
+ ...(flags.has('restore-window') ? { restoreWindow: true } : {})
240
+ }
241
+ }
242
+
243
+ function windowSelector(flags: Flags): unknown {
244
+ const id = stringFlag(flags, 'window-id')
245
+ const index = numberFlag(flags, 'window-index', { integer: true, min: 0 })
246
+ if (id !== undefined && index !== undefined)
247
+ throw new CliError('invalid_argument', 'Choose either --window-id or --window-index')
248
+ return id === undefined ? (index === undefined ? undefined : { index }) : { id }
249
+ }
250
+
251
+ function contextToken(flags: Flags): string {
252
+ return stringFlag(flags, 'context', true)!
253
+ }
254
+
255
+ function elementTarget(flags: Flags, name = 'element-index'): unknown {
256
+ const index = numberFlag(flags, name, { integer: true, min: 0 })
257
+ if (index === undefined) throw new CliError('invalid_argument', `Missing required --${name}`)
258
+ return { kind: 'element', elementIndex: index }
259
+ }
260
+
261
+ function chordTokens(raw: string): string[] {
262
+ return raw.split('+')
263
+ }
264
+
265
+ function parseClickModifiers(flags: Flags): string[] | undefined {
266
+ const raw = stringFlag(flags, 'modifiers')
267
+ if (raw === undefined) return undefined
268
+ const modifiers = chordTokens(raw).filter((token) => token.length > 0)
269
+ if (modifiers.length === 0 || modifiers.length > 4)
270
+ throw new CliError('invalid_argument', 'Invalid --modifiers')
271
+ return modifiers
272
+ }
273
+
274
+ function pointOrElement(flags: Flags): unknown {
275
+ const element = numberFlag(flags, 'element-index', { integer: true, min: 0 })
276
+ const x = numberFlag(flags, 'x')
277
+ const y = numberFlag(flags, 'y')
278
+ const hasCoordinates = x !== undefined || y !== undefined
279
+ if (element !== undefined && hasCoordinates)
280
+ throw new CliError('invalid_argument', 'Choose an element index or coordinates, not both')
281
+ if (element !== undefined) return { kind: 'element', elementIndex: element }
282
+ if (x === undefined || y === undefined)
283
+ throw new CliError('invalid_argument', 'Coordinates require both --x and --y')
284
+ return { kind: 'coordinate', x, y }
285
+ }
286
+
287
+ async function protectedText(flags: Flags, name: 'text' | 'value', io: CliIo): Promise<string> {
288
+ const literal = stringFlag(flags, name)
289
+ const stdin = flags.has(`${name}-stdin`)
290
+ if ((literal === undefined) === !stdin)
291
+ throw new CliError('invalid_argument', `Choose exactly one of --${name} or --${name}-stdin`)
292
+ return stdin ? await io.stdin() : literal!
293
+ }
294
+
295
+ async function operationInput(command: string, flags: Flags, io: CliIo): Promise<unknown> {
296
+ const common = () => {
297
+ const app = stringFlag(flags, 'app')
298
+ return {
299
+ contextToken: contextToken(flags),
300
+ ...(app === undefined ? {} : { app }),
301
+ ...observationFlags(flags)
302
+ }
303
+ }
304
+ switch (command) {
305
+ case 'capabilities':
306
+ case 'list-apps':
307
+ return {}
308
+ case 'permissions': {
309
+ const id = stringFlag(flags, 'id')
310
+ if (id !== undefined && id !== 'accessibility' && id !== 'screenshots')
311
+ throw new CliError('invalid_argument', '--id must be accessibility or screenshots')
312
+ return id === undefined ? {} : { id }
313
+ }
314
+ case 'list-windows':
315
+ return { app: stringFlag(flags, 'app', true) }
316
+ case 'get-app-state': {
317
+ const window = windowSelector(flags)
318
+ return {
319
+ app: stringFlag(flags, 'app', true),
320
+ ...(window === undefined ? {} : { window }),
321
+ ...observationFlags(flags)
322
+ }
323
+ }
324
+ case 'click': {
325
+ const clickCount = numberFlag(flags, 'click-count', { integer: true, min: 1 })
326
+ const button = stringFlag(flags, 'mouse-button')
327
+ if (button !== undefined && !['left', 'right', 'middle'].includes(button))
328
+ throw new CliError('invalid_argument', 'Invalid --mouse-button')
329
+ const modifiers = parseClickModifiers(flags)
330
+ return {
331
+ ...common(),
332
+ target: pointOrElement(flags),
333
+ ...(clickCount === undefined ? {} : { clickCount }),
334
+ ...(button === undefined ? {} : { button }),
335
+ ...(modifiers === undefined ? {} : { modifiers })
336
+ }
337
+ }
338
+ case 'perform-secondary-action':
339
+ return {
340
+ ...common(),
341
+ target: elementTarget(flags),
342
+ action: stringFlag(flags, 'action', true)
343
+ }
344
+ case 'scroll': {
345
+ const direction = stringFlag(flags, 'direction', true)!
346
+ if (!['up', 'down', 'left', 'right'].includes(direction))
347
+ throw new CliError('invalid_argument', 'Invalid --direction')
348
+ const pages = numberFlag(flags, 'pages', { integer: true, min: 1 })
349
+ return {
350
+ ...common(),
351
+ target: pointOrElement(flags),
352
+ direction,
353
+ ...(pages === undefined ? {} : { pages })
354
+ }
355
+ }
356
+ case 'drag': {
357
+ const fromElement = numberFlag(flags, 'from-element-index', { integer: true, min: 0 })
358
+ const toElement = numberFlag(flags, 'to-element-index', { integer: true, min: 0 })
359
+ const coordinates = ['from-x', 'from-y', 'to-x', 'to-y'].map((name) =>
360
+ numberFlag(flags, name)
361
+ )
362
+ const endpoint = (
363
+ label: string,
364
+ element: number | undefined,
365
+ x: number | undefined,
366
+ y: number | undefined
367
+ ): unknown => {
368
+ if (element !== undefined && (x !== undefined || y !== undefined))
369
+ throw new CliError('invalid_argument', `Choose one selector for drag ${label}`)
370
+ if (element !== undefined) return { kind: 'element', elementIndex: element }
371
+ if (x !== undefined && y !== undefined) return { kind: 'coordinate', x, y }
372
+ throw new CliError('invalid_argument', `Drag ${label} requires an element or coordinates`)
373
+ }
374
+ const from = endpoint('start', fromElement, coordinates[0], coordinates[1])
375
+ const to = endpoint('end', toElement, coordinates[2], coordinates[3])
376
+ const durationMs = numberFlag(flags, 'duration-ms', { integer: true, min: 50 })
377
+ return {
378
+ ...common(),
379
+ from,
380
+ to,
381
+ ...(durationMs === undefined ? {} : { durationMs })
382
+ }
383
+ }
384
+ case 'type-text':
385
+ case 'paste-text':
386
+ return {
387
+ ...common(),
388
+ target: { kind: 'context-window' },
389
+ text: await protectedText(flags, 'text', io)
390
+ }
391
+ case 'press-key':
392
+ return {
393
+ ...common(),
394
+ target: { kind: 'context-window' },
395
+ key: stringFlag(flags, 'key', true)
396
+ }
397
+ case 'hotkey': {
398
+ const keys = chordTokens(stringFlag(flags, 'key', true)!)
399
+ if (keys.length < 2 || keys.some((key) => key.length === 0))
400
+ throw new CliError('invalid_argument', 'Hotkeys require a modifier and key')
401
+ return { ...common(), target: { kind: 'context-window' }, keys }
402
+ }
403
+ case 'set-value':
404
+ return {
405
+ ...common(),
406
+ target: elementTarget(flags),
407
+ value: await protectedText(flags, 'value', io)
408
+ }
409
+ default:
410
+ throw new CliError('invalid_argument', `Unknown computer command: ${command}`)
411
+ }
412
+ }
413
+
414
+ function readiness(capabilities: unknown): string {
415
+ if (capabilities === null || typeof capabilities !== 'object') return 'unavailable'
416
+ const record = capabilities as Record<string, unknown>
417
+ const permissions = record.permissions
418
+ if (permissions !== null && typeof permissions === 'object') {
419
+ const values = Object.values(permissions)
420
+ if (values.includes('denied') || values.includes('unknown')) return 'operator_action_required'
421
+ }
422
+ const operations = record.operations
423
+ if (operations !== null && typeof operations === 'object') {
424
+ const values = Object.values(operations)
425
+ if (values.includes(false)) return 'capability_reduced'
426
+ }
427
+ return 'ready'
428
+ }
429
+
430
+ async function runDoctor(client: CliBrokerClient): Promise<unknown> {
431
+ const capabilities = await client.request('capabilities', {})
432
+ const permissions = await client.request('permissions', {})
433
+ const capabilityResult =
434
+ capabilities !== null && typeof capabilities === 'object' && 'result' in capabilities
435
+ ? (capabilities as Record<string, unknown>).result
436
+ : capabilities
437
+ return { readiness: readiness(capabilityResult), checks: { capabilities, permissions } }
438
+ }
439
+
440
+ function publicBrokerResult(value: unknown): unknown {
441
+ if (value !== null && typeof value === 'object' && 'requestId' in value && 'result' in value)
442
+ return (value as Record<string, unknown>).result
443
+ return value
444
+ }
445
+
446
+ function serializedError(cause: unknown): Record<string, unknown> {
447
+ if (cause !== null && typeof cause === 'object') {
448
+ const record = cause as Record<string, unknown>
449
+ if (typeof record.toJSON === 'function')
450
+ return (record.toJSON as () => Record<string, unknown>)()
451
+ return {
452
+ code: typeof record.code === 'string' ? record.code : 'provider_unavailable',
453
+ message:
454
+ cause instanceof Error
455
+ ? cause.message
456
+ : typeof record.message === 'string'
457
+ ? record.message
458
+ : 'Request failed',
459
+ retry: typeof record.retry === 'boolean' ? record.retry : false,
460
+ remediation: typeof record.remediation === 'string' ? record.remediation : 'run_doctor'
461
+ }
462
+ }
463
+ return {
464
+ code: 'provider_unavailable',
465
+ message: 'Request failed',
466
+ retry: false,
467
+ remediation: 'run_doctor'
468
+ }
469
+ }
470
+
471
+ function findScreenshot(value: unknown): Record<string, unknown> | undefined {
472
+ if (value === null || typeof value !== 'object') return undefined
473
+ const record = value as Record<string, unknown>
474
+ if (record.screenshot !== null && typeof record.screenshot === 'object') {
475
+ const screenshot = record.screenshot as Record<string, unknown>
476
+ if (typeof screenshot.data === 'string') return screenshot
477
+ }
478
+ for (const nested of Object.values(record)) {
479
+ const found = findScreenshot(nested)
480
+ if (found !== undefined) return found
481
+ }
482
+ return undefined
483
+ }
484
+
485
+ async function exportScreenshot(value: unknown, destination: string): Promise<void> {
486
+ const screenshot = findScreenshot(value)
487
+ if (screenshot === undefined)
488
+ throw new CliError('invalid_argument', 'Result has no screenshot data to export')
489
+ const absolute = resolve(destination)
490
+ const parent = dirname(absolute)
491
+ const parentInfo = await lstat(parent).catch(() => undefined)
492
+ if (parentInfo === undefined || !parentInfo.isDirectory() || parentInfo.isSymbolicLink())
493
+ throw new CliError('invalid_argument', 'Screenshot output parent must be a real directory')
494
+ const existing = await lstat(absolute).catch((cause: NodeJS.ErrnoException) => {
495
+ if (cause.code === 'ENOENT') return undefined
496
+ throw cause
497
+ })
498
+ if (existing !== undefined)
499
+ throw new CliError(
500
+ 'invalid_argument',
501
+ 'Screenshot output already exists; overwrite is forbidden'
502
+ )
503
+ const bytes = Buffer.from(screenshot.data as string, 'base64')
504
+ const handle = await open(
505
+ absolute,
506
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
507
+ 0o600
508
+ )
509
+ try {
510
+ await handle.writeFile(bytes)
511
+ await handle.sync()
512
+ } finally {
513
+ await handle.close()
514
+ }
515
+ await chmod(absolute, 0o600)
516
+ delete screenshot.data
517
+ Object.assign(screenshot, {
518
+ path: absolute,
519
+ bytes: bytes.byteLength,
520
+ sha256: createHash('sha256').update(bytes).digest('hex'),
521
+ dataOmitted: true
522
+ })
523
+ }
524
+
525
+ export async function runCli(argv: string[], io: CliIo, client: CliBrokerClient): Promise<number> {
526
+ let json = false
527
+ try {
528
+ if (argv[0] !== 'computer')
529
+ throw new CliError('invalid_argument', 'Usage: crosshands computer <command> --json')
530
+ const command = argv[1]
531
+ if (command === undefined || COMMANDS[command] === undefined)
532
+ throw new CliError('invalid_argument', `Unknown computer command: ${command ?? ''}`)
533
+ const flags = parseFlags(argv.slice(2), command)
534
+ json = flags.has('json')
535
+ const operation = COMMANDS[command]!
536
+ const brokerResult =
537
+ operation === 'doctor'
538
+ ? await runDoctor(client)
539
+ : publicBrokerResult(
540
+ await client.request(operation, await operationInput(command, flags, io))
541
+ )
542
+ const result = structuredClone(brokerResult)
543
+ const screenshotOutput = stringFlag(flags, 'screenshot-output')
544
+ if (screenshotOutput !== undefined) await exportScreenshot(result, screenshotOutput)
545
+ io.stdout(`${JSON.stringify(result)}\n`)
546
+ return 0
547
+ } catch (cause) {
548
+ const error = serializedError(cause)
549
+ io.stdout(`${JSON.stringify({ error })}\n`)
550
+ if (!json) io.stderr(`${String(error.message)}\n`)
551
+ return EXIT_CODES[String(error.code)] ?? 1
552
+ } finally {
553
+ await client.close().catch(() => undefined)
554
+ }
555
+ }
556
+
557
+ export { createProductionBrokerClient, localClientPaths } from './local-client.js'
558
+ export type { LocalClientPaths, ProductionClientOptions } from './local-client.js'