@mobileaidev/ai-app-bridge 0.2.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,542 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+
6
+ const packageInfo = require('../package.json');
7
+ const bridgeDir = __dirname;
8
+ const cliScript = path.join(bridgeDir, 'ai-app-bridge.js');
9
+ const nodeBinary = process.env.AI_APP_BRIDGE_NODE || process.execPath;
10
+
11
+ let buffer = Buffer.alloc(0);
12
+
13
+ process.stdin.on('data', (chunk) => {
14
+ buffer = Buffer.concat([buffer, chunk]);
15
+ drainMessages();
16
+ });
17
+
18
+ process.stdin.on('error', () => {});
19
+
20
+ function drainMessages() {
21
+ while (true) {
22
+ const delimiter = findHeaderDelimiter(buffer);
23
+ const headerEnd = delimiter.index;
24
+ if (headerEnd < 0) {
25
+ return;
26
+ }
27
+ const header = buffer.subarray(0, headerEnd).toString('utf8');
28
+ const match = /^Content-Length:\s*(\d+)$/im.exec(header);
29
+ if (!match) {
30
+ buffer = buffer.subarray(headerEnd + delimiter.length);
31
+ continue;
32
+ }
33
+ const contentLength = Number(match[1]);
34
+ const messageStart = headerEnd + delimiter.length;
35
+ const messageEnd = messageStart + contentLength;
36
+ if (buffer.length < messageEnd) {
37
+ return;
38
+ }
39
+ const body = buffer.subarray(messageStart, messageEnd).toString('utf8');
40
+ buffer = buffer.subarray(messageEnd);
41
+ handleMessage(body).catch((error) => {
42
+ writeLog(`unhandled message error: ${error.stack || error}`);
43
+ });
44
+ }
45
+ }
46
+
47
+ function findHeaderDelimiter(source) {
48
+ const crlfIndex = source.indexOf('\r\n\r\n');
49
+ const lfIndex = source.indexOf('\n\n');
50
+ if (crlfIndex < 0) {
51
+ return { index: lfIndex, length: 2 };
52
+ }
53
+ if (lfIndex < 0 || crlfIndex < lfIndex) {
54
+ return { index: crlfIndex, length: 4 };
55
+ }
56
+ return { index: lfIndex, length: 2 };
57
+ }
58
+
59
+ async function handleMessage(body) {
60
+ let message;
61
+ try {
62
+ message = JSON.parse(body);
63
+ } catch (error) {
64
+ sendError(null, -32700, `Parse error: ${error.message}`);
65
+ return;
66
+ }
67
+
68
+ if (!Object.prototype.hasOwnProperty.call(message, 'id')) {
69
+ return;
70
+ }
71
+
72
+ try {
73
+ if (message.method === 'initialize') {
74
+ sendResult(message.id, {
75
+ protocolVersion: message.params?.protocolVersion || '2024-11-05',
76
+ capabilities: {
77
+ tools: {},
78
+ },
79
+ serverInfo: {
80
+ name: 'ai-app-bridge',
81
+ version: packageInfo.version,
82
+ },
83
+ });
84
+ return;
85
+ }
86
+
87
+ if (message.method === 'tools/list') {
88
+ sendResult(message.id, { tools: toolDefinitions() });
89
+ return;
90
+ }
91
+
92
+ if (message.method === 'tools/call') {
93
+ const name = message.params?.name;
94
+ const args = message.params?.arguments || {};
95
+ const result = await callTool(name, args);
96
+ sendResult(message.id, result);
97
+ return;
98
+ }
99
+
100
+ sendError(message.id, -32601, `Method not found: ${message.method}`);
101
+ } catch (error) {
102
+ sendError(message.id, -32000, error.message || String(error));
103
+ }
104
+ }
105
+
106
+ function toolDefinitions() {
107
+ return [
108
+ bridgeTool('status', 'Read compact bridge status, app info, capture counts, and Flutter layout summary. If default port 18080 times out, agents should retry with the target Android packageName so the CLI can discover the app bridge port.', {
109
+ full: { type: 'boolean', description: 'Return the full raw status payload, including large Flutter widget dumps.' },
110
+ }),
111
+ bridgeTool('tree', 'Read the Android View tree from the in-app bridge.'),
112
+ bridgeTool('flutter_tree', 'Read the latest Flutter widget/layout snapshot.'),
113
+ bridgeTool('flutter_nodes', 'Read Flutter operable nodes from the Flutter action bridge.'),
114
+ bridgeTool('tap_flutter_text', 'Tap a Flutter node by visible text through the Flutter-aware bridge path.', {
115
+ targetText: { type: 'string', description: 'Flutter node text to tap.' },
116
+ }, ['targetText']),
117
+ bridgeTool('input_flutter_text', 'Set Flutter TextField text through the Flutter action bridge. Use this for Flutter Chinese/Unicode input; do not use raw adb shell input text.', {
118
+ text: { type: 'string', description: 'Text to set.' },
119
+ tapX: { type: 'number', description: 'Optional physical X coordinate for the Flutter input target.' },
120
+ tapY: { type: 'number', description: 'Optional physical Y coordinate for the Flutter input target.' },
121
+ hideKeyboard: { type: 'boolean', description: 'Hide the soft keyboard after setting text.' },
122
+ }, ['text']),
123
+ bridgeTool('h5_dom', 'Read native Android WebView DOM from the current Activity.'),
124
+ bridgeTool('h5_eval', 'Execute debug JavaScript in the current native Android WebView.', {
125
+ script: { type: 'string' },
126
+ }, ['script']),
127
+ bridgeTool('h5_click', 'Click a native Android WebView DOM element by CSS selector or text.', h5TargetSchema(), []),
128
+ bridgeTool('h5_input', 'Set text in a native Android WebView input by CSS selector or text.', {
129
+ ...h5TargetSchema(),
130
+ value: { type: 'string', description: 'Text value to set.' },
131
+ }, ['value']),
132
+ bridgeTool('h5_wait', 'Wait for a native Android WebView DOM element or body text.', {
133
+ ...h5TargetSchema(),
134
+ timeoutSec: { type: 'number', description: 'Maximum wait time. Defaults to 10 seconds.' },
135
+ intervalMs: { type: 'number', description: 'Polling interval. Defaults to 500 ms.' },
136
+ }),
137
+ bridgeTool('h5_scroll', 'Scroll a native Android WebView or scroll a DOM element into view.', {
138
+ ...h5TargetSchema(),
139
+ deltaX: { type: 'number', description: 'Window scroll delta X when no selector/text is supplied.' },
140
+ deltaY: { type: 'number', description: 'Window scroll delta Y when no selector/text is supplied.' },
141
+ }),
142
+ bridgeTool('flutter_h5_dom', 'Read DOM through a Flutter-registered H5 adapter.'),
143
+ bridgeTool('flutter_h5_eval', 'Execute JavaScript through a Flutter-registered H5 adapter.', {
144
+ script: { type: 'string' },
145
+ }, ['script']),
146
+ bridgeTool('flutter_h5_click', 'Click a Flutter H5 DOM element by CSS selector or text.', h5TargetSchema(), []),
147
+ bridgeTool('flutter_h5_input', 'Set text in a Flutter H5 input by CSS selector or text.', {
148
+ ...h5TargetSchema(),
149
+ value: { type: 'string', description: 'Text value to set.' },
150
+ }, ['value']),
151
+ bridgeTool('flutter_h5_wait', 'Wait for a Flutter H5 DOM element or body text.', {
152
+ ...h5TargetSchema(),
153
+ timeoutSec: { type: 'number', description: 'Maximum wait time. Defaults to 10 seconds.' },
154
+ intervalMs: { type: 'number', description: 'Polling interval. Defaults to 500 ms.' },
155
+ }),
156
+ bridgeTool('flutter_h5_scroll', 'Scroll a Flutter H5 document or DOM element into view.', {
157
+ ...h5TargetSchema(),
158
+ deltaX: { type: 'number', description: 'Window scroll delta X when no selector/text is supplied.' },
159
+ deltaY: { type: 'number', description: 'Window scroll delta Y when no selector/text is supplied.' },
160
+ }),
161
+ bridgeTool('logs', 'Read generic in-app log records.'),
162
+ bridgeTool('logcat', 'Read Android logcat through ADB with optional pid/tag/level/grep filters.', {
163
+ pid: { type: 'string', description: 'Use "current" for the current app pid, or pass a numeric pid.' },
164
+ appPid: { type: 'boolean', description: 'Filter by the current package pid.' },
165
+ tag: { type: 'string', description: 'Comma-separated exact logcat tags.' },
166
+ level: { type: 'string', description: 'Minimum Android log level: V,D,I,W,E,F.' },
167
+ grep: { type: 'string', description: 'Substring filter applied after pid/tag/level.' },
168
+ lines: { type: 'number', description: 'Input logcat tail line count before filtering.' },
169
+ since: { type: 'string', description: 'Passed to adb logcat -T.' },
170
+ follow: { type: 'boolean', description: 'Follow live logs for durationSec seconds.' },
171
+ durationSec: { type: 'number', description: 'Bounded live follow duration. Max 60 seconds.' },
172
+ clear: { type: 'boolean', description: 'Clear logcat before reading/following.' },
173
+ }),
174
+ bridgeTool('network', 'Read generic in-app network records.', {
175
+ compact: { type: 'boolean', description: 'Return one-line-sized network record summaries without bodies.' },
176
+ urlFilter: { type: 'string', description: 'Only retain records whose URL contains this string.' },
177
+ method: { type: 'string', description: 'Only retain records with this HTTP method.' },
178
+ statusCode: { type: 'number', description: 'Only retain records with this HTTP status.' },
179
+ noBodies: { type: 'boolean', description: 'Omit requestBody and responseBody fields from full output.' },
180
+ bodyMaxBytes: { type: 'number', description: 'Maximum request/response body bytes retained per record.' },
181
+ }),
182
+ bridgeTool('webview_pages', 'List attachable Android WebView DevTools/CDP pages for the target package.', {
183
+ webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
184
+ socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
185
+ targetId: { type: 'string', description: 'Optional CDP target/page id.' },
186
+ pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
187
+ keepForward: { type: 'boolean', description: 'Leave the adb forward active after listing pages.' },
188
+ }),
189
+ bridgeTool('webview_network', 'Capture WebView fetch/XHR/resource Network events through Chrome DevTools Protocol.', {
190
+ webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
191
+ socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
192
+ targetId: { type: 'string', description: 'Optional CDP target/page id.' },
193
+ pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
194
+ urlFilter: { type: 'string', description: 'Only retain Network requests whose URL contains this string.' },
195
+ durationMs: { type: 'number', description: 'Capture duration after attach. Defaults to 3000 ms.' },
196
+ script: { type: 'string', description: 'JavaScript expression to evaluate after Network/Runtime are enabled.' },
197
+ includeResponseBody: { type: 'boolean', description: 'Fetch response bodies with Network.getResponseBody when available.' },
198
+ bodyMaxBytes: { type: 'number', description: 'Maximum response/request body bytes retained per event.' },
199
+ maxEvents: { type: 'number', description: 'Maximum raw CDP events retained. Defaults to 200.' },
200
+ }),
201
+ bridgeTool('webview_console', 'Capture WebView console and log events through Chrome DevTools Protocol.', {
202
+ webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
203
+ socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
204
+ targetId: { type: 'string', description: 'Optional CDP target/page id.' },
205
+ pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
206
+ durationMs: { type: 'number', description: 'Capture duration after attach. Defaults to 3000 ms.' },
207
+ script: { type: 'string', description: 'JavaScript expression to evaluate after Runtime is enabled.' },
208
+ maxEvents: { type: 'number', description: 'Maximum raw CDP events retained. Defaults to 200.' },
209
+ }),
210
+ bridgeTool('state', 'Read generic in-app state records.'),
211
+ bridgeTool('events', 'Read generic in-app event records.'),
212
+ bridgeTool('uia_tree', 'Read UIAutomator XML for the current device window.'),
213
+ bridgeTool('screenshot', 'Capture an ADB screenshot.'),
214
+ bridgeTool('install_apk', 'Install an APK through ADB while assisting device-side package-installer confirmation screens with UIAutomator.', {
215
+ apkPath: { type: 'string', description: 'Absolute or workspace-relative APK path.' },
216
+ allowDowngrade: { type: 'boolean', description: 'Pass -d to adb install.' },
217
+ streaming: { type: 'boolean', description: 'Use streaming install instead of the default --no-streaming mode.' },
218
+ installTimeoutMs: { type: 'number', description: 'Maximum time for adb install. Defaults to 180000 ms.' },
219
+ installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
220
+ intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
221
+ }, ['apkPath']),
222
+ bridgeTool('launch_native_test', 'Launch the debug native Android bridge test Activity.'),
223
+ bridgeTool('launch_flutter', 'Launch the Flutter Activity, optionally with an initial route.'),
224
+ bridgeTool('tap', 'Tap device coordinates through ADB.', {
225
+ tapX: { type: 'number' },
226
+ tapY: { type: 'number' },
227
+ }, ['tapX', 'tapY']),
228
+ bridgeTool('tap_text', 'Tap the center of an Android View node by exact text or contentDescription.', {
229
+ targetText: { type: 'string' },
230
+ noAutoHideKeyboard: { type: 'boolean', description: 'Disable the default keyboard-risk guard before tapping lower-screen app nodes.' },
231
+ }, ['targetText']),
232
+ bridgeTool('wait_text', 'Wait until text appears in status, Android tree, or UIAutomator tree.', {
233
+ targetText: { type: 'string' },
234
+ timeoutSec: { type: 'number' },
235
+ }, ['targetText']),
236
+ bridgeTool('input_text', 'Set native Android text through the in-app bridge. Use this for Chinese/Unicode; do not use raw adb shell input text for non-ASCII text.', {
237
+ text: { type: 'string', description: 'Text to set in the focused or coordinate-matched native EditText.' },
238
+ tapX: { type: 'number', description: 'Optional X coordinate used to choose a native EditText target.' },
239
+ tapY: { type: 'number', description: 'Optional Y coordinate used to choose a native EditText target.' },
240
+ hideKeyboard: { type: 'boolean', description: 'Hide the soft keyboard after setting text.' },
241
+ }, ['text']),
242
+ bridgeTool('keyboard_state', 'Read Android soft-keyboard visibility from dumpsys input_method.'),
243
+ bridgeTool('hide_keyboard', 'Hide the Android soft keyboard when it is visible.', {
244
+ force: { type: 'boolean', description: 'Send keyboard-dismiss keys even when the visibility probe says the keyboard is hidden.' },
245
+ intervalMs: { type: 'number', description: 'Delay between dismiss attempts. Defaults to 500 ms.' },
246
+ }),
247
+ bridgeTool('swipe', 'Swipe device coordinates through ADB.', {
248
+ startX: { type: 'number' },
249
+ startY: { type: 'number' },
250
+ endX: { type: 'number' },
251
+ endY: { type: 'number' },
252
+ durationMs: { type: 'number' },
253
+ }, ['startX', 'startY', 'endX', 'endY']),
254
+ bridgeTool('keyevent', 'Send an Android keyevent through ADB.', {
255
+ keyCode: { type: 'number' },
256
+ }, ['keyCode']),
257
+ bridgeTool('permission_state', 'Read Android runtime permission state from dumpsys package.', {
258
+ permission: { type: 'string' },
259
+ }, ['permission']),
260
+ bridgeTool('permission_grant', 'Grant an Android runtime permission with adb pm grant, then read state.', {
261
+ permission: { type: 'string' },
262
+ }, ['permission']),
263
+ bridgeTool('permission_revoke', 'Revoke an Android runtime permission with adb pm revoke, then read state.', {
264
+ permission: { type: 'string' },
265
+ }, ['permission']),
266
+ bridgeTool('appops_set', 'Set an Android app-op mode with adb appops set.', {
267
+ op: { type: 'string' },
268
+ mode: { type: 'string' },
269
+ }, ['op', 'mode']),
270
+ bridgeTool('tap_uia_text', 'Tap a UIAutomator node by text without relying on the in-app tree.', {
271
+ targetText: { type: 'string' },
272
+ exact: { type: 'boolean' },
273
+ }, ['targetText']),
274
+ bridgeTool('permission_dialog', 'Tap a visible Android permission dialog allow button through UIAutomator.', {
275
+ targetText: { type: 'string', description: 'Optional custom allow-button text.' },
276
+ buttonText: { type: 'string', description: 'Optional comma-separated allow-button texts.' },
277
+ resourceId: { type: 'string', description: 'Optional permission button resource id.' },
278
+ attempts: { type: 'number' },
279
+ intervalMs: { type: 'number' },
280
+ exact: { type: 'boolean' },
281
+ }),
282
+ {
283
+ name: 'run_smoke',
284
+ description: 'Run the full Android + Flutter bridge smoke test.',
285
+ inputSchema: baseSchema(),
286
+ },
287
+ ];
288
+ }
289
+
290
+ function bridgeTool(name, description, properties = {}, required = []) {
291
+ return {
292
+ name,
293
+ description,
294
+ inputSchema: baseSchema(properties, required),
295
+ };
296
+ }
297
+
298
+ function baseSchema(extraProperties = {}, extraRequired = []) {
299
+ return {
300
+ type: 'object',
301
+ properties: {
302
+ serial: { type: 'string', description: 'ADB serial. Optional when one device is connected.' },
303
+ adb: { type: 'string', description: 'ADB executable path or command.' },
304
+ port: { type: 'number', description: 'Raw bridge port override. Defaults to 18080; agents should prefer packageName when targeting a known app.' },
305
+ packageName: { type: 'string', description: 'Target Android package name. Use this when default 18080 is unreachable or multiple bridge-enabled apps are installed; the CLI discovers the app bridge port from package-private state.' },
306
+ initialRoute: { type: 'string', description: 'Flutter initial route for launch_flutter.' },
307
+ outFile: { type: 'string', description: 'Screenshot output path for screenshot.' },
308
+ artifactDir: { type: 'string', description: 'Directory for generated default artifacts such as screenshots.' },
309
+ sinceId: { type: 'number', description: 'Capture query lower bound by record id.' },
310
+ sinceMs: { type: 'number', description: 'Capture query lower bound by timestamp milliseconds.' },
311
+ limit: { type: 'number', description: 'Maximum capture records to return.' },
312
+ ...extraProperties,
313
+ },
314
+ required: extraRequired,
315
+ additionalProperties: false,
316
+ };
317
+ }
318
+
319
+ function h5TargetSchema() {
320
+ return {
321
+ selector: { type: 'string', description: 'CSS selector for the target DOM element.' },
322
+ targetText: { type: 'string', description: 'Text, value, aria-label, placeholder, id, name, or role to match.' },
323
+ exact: { type: 'boolean', description: 'Require exact text match instead of substring match.' },
324
+ };
325
+ }
326
+
327
+ async function callTool(name, args) {
328
+ if (name === 'run_smoke') {
329
+ return runSmoke(args);
330
+ }
331
+ const commandMap = {
332
+ flutter_tree: 'flutter-tree',
333
+ h5_dom: 'h5-dom',
334
+ h5_eval: 'h5-eval',
335
+ h5_click: 'h5-click',
336
+ h5_input: 'h5-input',
337
+ h5_wait: 'h5-wait',
338
+ h5_scroll: 'h5-scroll',
339
+ flutter_h5_dom: 'flutter-h5-dom',
340
+ flutter_h5_eval: 'flutter-h5-eval',
341
+ flutter_h5_click: 'flutter-h5-click',
342
+ flutter_h5_input: 'flutter-h5-input',
343
+ flutter_h5_wait: 'flutter-h5-wait',
344
+ flutter_h5_scroll: 'flutter-h5-scroll',
345
+ flutter_nodes: 'flutter-nodes',
346
+ tap_flutter_text: 'tap-flutter-text',
347
+ input_flutter_text: 'input-flutter-text',
348
+ uia_tree: 'uia-tree',
349
+ install_apk: 'install-apk',
350
+ launch_native_test: 'launch-native-test',
351
+ launch_flutter: 'launch-flutter',
352
+ tap_text: 'tap-text',
353
+ wait_text: 'wait-text',
354
+ input_text: 'input-text',
355
+ keyboard_state: 'keyboard-state',
356
+ hide_keyboard: 'hide-keyboard',
357
+ webview_pages: 'webview-pages',
358
+ webview_network: 'webview-network',
359
+ webview_console: 'webview-console',
360
+ permission_state: 'permission-state',
361
+ permission_grant: 'permission-grant',
362
+ permission_revoke: 'permission-revoke',
363
+ appops_set: 'appops-set',
364
+ tap_uia_text: 'tap-uia-text',
365
+ permission_dialog: 'permission-dialog',
366
+ };
367
+ const command = commandMap[name] || name;
368
+ return runBridge(command, args);
369
+ }
370
+
371
+ async function runBridge(command, args) {
372
+ const cliArgs = [cliScript, command];
373
+ addCommonArgs(cliArgs, args);
374
+ addArg(cliArgs, 'initial-route', args.initialRoute);
375
+ addArg(cliArgs, 'out-file', args.outFile);
376
+ addArg(cliArgs, 'artifact-dir', args.artifactDir || defaultArtifactDirFor(command, args));
377
+ addArg(cliArgs, 'apk-path', args.apkPath);
378
+ addArg(cliArgs, 'tap-x', args.tapX);
379
+ addArg(cliArgs, 'tap-y', args.tapY);
380
+ addArg(cliArgs, 'target-text', args.targetText);
381
+ addArg(cliArgs, 'no-auto-hide-keyboard', args.noAutoHideKeyboard);
382
+ addArg(cliArgs, 'timeout-sec', args.timeoutSec);
383
+ addArg(cliArgs, 'text', args.text);
384
+ addArg(cliArgs, 'hide-keyboard', args.hideKeyboard);
385
+ addArg(cliArgs, 'force', args.force);
386
+ addArg(cliArgs, 'start-x', args.startX);
387
+ addArg(cliArgs, 'start-y', args.startY);
388
+ addArg(cliArgs, 'end-x', args.endX);
389
+ addArg(cliArgs, 'end-y', args.endY);
390
+ addArg(cliArgs, 'duration-ms', args.durationMs);
391
+ addArg(cliArgs, 'allow-downgrade', args.allowDowngrade);
392
+ addArg(cliArgs, 'streaming', args.streaming);
393
+ addArg(cliArgs, 'install-timeout-ms', args.installTimeoutMs);
394
+ addArg(cliArgs, 'installer-timeout-ms', args.installerTimeoutMs);
395
+ addArg(cliArgs, 'webview-port', args.webviewPort);
396
+ addArg(cliArgs, 'socket-name', args.socketName);
397
+ addArg(cliArgs, 'target-id', args.targetId);
398
+ addArg(cliArgs, 'page-url-filter', args.pageUrlFilter);
399
+ addArg(cliArgs, 'url-filter', args.urlFilter);
400
+ addArg(cliArgs, 'method', args.method);
401
+ addArg(cliArgs, 'status-code', args.statusCode);
402
+ addArg(cliArgs, 'compact', args.compact);
403
+ addArg(cliArgs, 'full', args.full);
404
+ addArg(cliArgs, 'no-bodies', args.noBodies);
405
+ addArg(cliArgs, 'duration-ms', args.durationMs);
406
+ addArg(cliArgs, 'include-response-body', args.includeResponseBody);
407
+ addArg(cliArgs, 'body-max-bytes', args.bodyMaxBytes);
408
+ addArg(cliArgs, 'max-events', args.maxEvents);
409
+ addArg(cliArgs, 'keep-forward', args.keepForward);
410
+ addArg(cliArgs, 'key-code', args.keyCode);
411
+ addArg(cliArgs, 'permission', args.permission);
412
+ addArg(cliArgs, 'op', args.op);
413
+ addArg(cliArgs, 'mode', args.mode);
414
+ addArg(cliArgs, 'script', args.script);
415
+ addArg(cliArgs, 'selector', args.selector);
416
+ addArg(cliArgs, 'target-text', args.targetText);
417
+ addArg(cliArgs, 'value', args.value);
418
+ addArg(cliArgs, 'exact', args.exact);
419
+ addArg(cliArgs, 'button-text', args.buttonText);
420
+ addArg(cliArgs, 'resource-id', args.resourceId);
421
+ addArg(cliArgs, 'attempts', args.attempts);
422
+ addArg(cliArgs, 'interval-ms', args.intervalMs);
423
+ addArg(cliArgs, 'delta-x', args.deltaX);
424
+ addArg(cliArgs, 'delta-y', args.deltaY);
425
+ addArg(cliArgs, 'since-id', args.sinceId);
426
+ addArg(cliArgs, 'since-ms', args.sinceMs);
427
+ addArg(cliArgs, 'limit', args.limit);
428
+ addArg(cliArgs, 'pid', args.pid);
429
+ addArg(cliArgs, 'app-pid', args.appPid);
430
+ addArg(cliArgs, 'tag', args.tag);
431
+ addArg(cliArgs, 'level', args.level);
432
+ addArg(cliArgs, 'grep', args.grep);
433
+ addArg(cliArgs, 'lines', args.lines);
434
+ addArg(cliArgs, 'since', args.since);
435
+ addArg(cliArgs, 'follow', args.follow);
436
+ addArg(cliArgs, 'duration-sec', args.durationSec);
437
+ addArg(cliArgs, 'clear', args.clear);
438
+ return runProcess(cliArgs);
439
+ }
440
+
441
+ async function runSmoke(args) {
442
+ const cliArgs = [cliScript, 'smoke'];
443
+ addCommonArgs(cliArgs, args);
444
+ addArg(cliArgs, 'out-file', args.outFile);
445
+ addArg(cliArgs, 'artifact-dir', args.artifactDir || defaultArtifactDirFor('smoke', args));
446
+ return runProcess(cliArgs);
447
+ }
448
+
449
+ function defaultArtifactDirFor(command, args) {
450
+ if (args.outFile) return '';
451
+ if (command !== 'screenshot' && command !== 'smoke') return '';
452
+ return path.join(process.cwd(), 'build', 'ai_app_bridge_artifacts');
453
+ }
454
+
455
+ function addCommonArgs(cliArgs, args) {
456
+ addArg(cliArgs, 'adb', args.adb);
457
+ addArg(cliArgs, 'serial', args.serial);
458
+ addArg(cliArgs, 'port', args.port);
459
+ addArg(cliArgs, 'package-name', args.packageName);
460
+ }
461
+
462
+ function addArg(cliArgs, name, value) {
463
+ if (value === undefined || value === null || value === '' || value === false) {
464
+ return;
465
+ }
466
+ cliArgs.push(`--${name}`, String(value));
467
+ }
468
+
469
+ function runProcess(cliArgs) {
470
+ return new Promise((resolve) => {
471
+ const child = spawn(nodeBinary, cliArgs, {
472
+ cwd: bridgeDir,
473
+ windowsHide: true,
474
+ });
475
+ let stdout = '';
476
+ let stderr = '';
477
+ child.stdout.on('data', (chunk) => {
478
+ stdout += chunk.toString();
479
+ });
480
+ child.stderr.on('data', (chunk) => {
481
+ stderr += chunk.toString();
482
+ });
483
+ child.on('error', (error) => {
484
+ resolve(toolText(`failed to start Node bridge CLI: ${error.message}`, true));
485
+ });
486
+ child.on('close', (code) => {
487
+ const text = [
488
+ stdout.trim(),
489
+ stderr.trim() ? `stderr:\n${stderr.trim()}` : '',
490
+ code === 0 ? '' : `exitCode: ${code}`,
491
+ retryWithPackageNameHint(cliArgs, stdout, stderr, code),
492
+ ].filter(Boolean).join('\n\n');
493
+ resolve(toolText(text || 'ok', code !== 0));
494
+ });
495
+ });
496
+ }
497
+
498
+ function retryWithPackageNameHint(cliArgs, stdout, stderr, code) {
499
+ if (code === 0 || cliArgs.includes('--package-name') || cliArgs.includes('--port')) {
500
+ return '';
501
+ }
502
+ const output = `${stdout}\n${stderr}`;
503
+ if (!/HTTP timeout: http:\/\/127\.0\.0\.1:18080\//.test(output)) {
504
+ return '';
505
+ }
506
+ return 'agentHint: Default bridge port 18080 accepted the connection but did not answer. If you know the target app, retry this tool with packageName so the CLI can discover that app bridge port.';
507
+ }
508
+
509
+ function toolText(text, isError = false) {
510
+ return {
511
+ content: [
512
+ {
513
+ type: 'text',
514
+ text,
515
+ },
516
+ ],
517
+ isError,
518
+ };
519
+ }
520
+
521
+ function sendResult(id, result) {
522
+ send({ jsonrpc: '2.0', id, result });
523
+ }
524
+
525
+ function sendError(id, code, message) {
526
+ send({
527
+ jsonrpc: '2.0',
528
+ id,
529
+ error: { code, message },
530
+ });
531
+ }
532
+
533
+ function send(message) {
534
+ const body = Buffer.from(JSON.stringify(message), 'utf8');
535
+ process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
536
+ process.stdout.write(body);
537
+ }
538
+
539
+ function writeLog(text) {
540
+ process.stderr.write(`${text}\n`);
541
+ }
542
+
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@mobileaidev/ai-app-bridge",
3
+ "version": "0.2.0",
4
+ "description": "Desktop CLI and MCP server for AI App Bridge.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/mobileAiDev/ai-app-bridge.git",
8
+ "directory": "desktop/ai-app-bridge-cli"
9
+ },
10
+ "homepage": "https://github.com/mobileAiDev/ai-app-bridge#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/mobileAiDev/ai-app-bridge/issues"
13
+ },
14
+ "bin": {
15
+ "ai-app-bridge": "bin/ai-app-bridge.js",
16
+ "ai-app-bridge-mcp": "bin/mcp-server.js"
17
+ },
18
+ "files": [
19
+ "bin/ai-app-bridge.js",
20
+ "bin/mcp-server.js",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "check": "node -c bin/ai-app-bridge.js && node -c bin/mcp-server.js && node --test",
25
+ "test": "node --test"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "dependencies": {
29
+ "ws": "^8.18.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }