@desklink/axi 0.3.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.
- package/README.md +119 -0
- package/bin/desklink-axi.js +2 -0
- package/dist/atspi.js +267 -0
- package/dist/bridge.js +818 -0
- package/dist/browser-lane.js +586 -0
- package/dist/cli/args.js +99 -0
- package/dist/cli/help.js +28 -0
- package/dist/cli/router.js +74 -0
- package/dist/cli/spec.js +3 -0
- package/dist/commands/home.js +31 -0
- package/dist/hooks.js +18 -0
- package/dist/index.js +71 -0
- package/dist/marks.js +75 -0
- package/dist/output/errors.js +22 -0
- package/dist/output/toon.js +28 -0
- package/dist/skill/content.js +8 -0
- package/dist/token.js +44 -0
- package/package.json +38 -0
- package/skills/desklink-axi/SKILL.md +24 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// Browser lane (build-plan item 2): a thin semantic browser adapter over
|
|
2
|
+
// playwright-core for tab listing, compact accessibility snapshots with
|
|
3
|
+
// element refs, and ref click/fill/upload/press. Anything that needs an OS
|
|
4
|
+
// dialog or a non-browser surface returns a structured not-supported error
|
|
5
|
+
// naming the AXI lane, so the desktop substrate stays in desklink-axi.
|
|
6
|
+
//
|
|
7
|
+
// Consent boundary: nothing is discovered or attached implicitly. The lane
|
|
8
|
+
// only acts after an explicit `browser attach --cdp <loopback endpoint>` (the
|
|
9
|
+
// endpoint you started Chromium with) or `browser launch`, which starts an
|
|
10
|
+
// isolated task-owned Chromium profile with its debugging port bound to
|
|
11
|
+
// loopback only. Non-loopback endpoints are refused.
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { socketPath } from './bridge.js';
|
|
18
|
+
import { AxiError, UsageError } from './output/errors.js';
|
|
19
|
+
import { emitList, print } from './output/toon.js';
|
|
20
|
+
const statePath = () => socketPath.replace(/\.sock$/, '.browser.json');
|
|
21
|
+
/** Parse `host:port` or `http://host:port` and refuse non-loopback hosts. */
|
|
22
|
+
export function parseEndpoint(value) {
|
|
23
|
+
const stripped = value.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
24
|
+
const match = /^(\[[^\]]+\]|[^:]+):(\d{1,5})$/.exec(stripped);
|
|
25
|
+
if (!match)
|
|
26
|
+
throw new UsageError(`invalid CDP endpoint '${value}'`, 'usage: --cdp 127.0.0.1:<port> as started with --remote-debugging-port');
|
|
27
|
+
const host = match[1].replace(/^\[|\]$/g, '');
|
|
28
|
+
const port = Number(match[2]);
|
|
29
|
+
if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
|
|
30
|
+
throw new UsageError(`refusing non-loopback endpoint '${value}'`, 'start Chromium with --remote-debugging-port bound to loopback and pass 127.0.0.1:<port>');
|
|
31
|
+
}
|
|
32
|
+
if (port < 1 || port > 65535)
|
|
33
|
+
throw new UsageError(`invalid port in '${value}'`, 'usage: --cdp 127.0.0.1:<port>');
|
|
34
|
+
return `${host === '::1' ? '[::1]' : host}:${port}`;
|
|
35
|
+
}
|
|
36
|
+
function readState() {
|
|
37
|
+
const path = statePath();
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
throw new AxiError('no-browser: run desklink-axi browser attach --cdp 127.0.0.1:<port> or desklink-axi browser launch first', 'desklink-axi browser attach --help');
|
|
40
|
+
}
|
|
41
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
function writeState(state) {
|
|
44
|
+
const path = statePath();
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); // Browser-only use may run before `start` created the runtime dir.
|
|
46
|
+
writeFileSync(path, JSON.stringify(state, undefined, 1), { mode: 0o600 });
|
|
47
|
+
chmodSync(path, 0o600);
|
|
48
|
+
}
|
|
49
|
+
function forgetState() {
|
|
50
|
+
rmSync(statePath(), { force: true });
|
|
51
|
+
}
|
|
52
|
+
/** Playwright reorders pages by activation between connections, so identity
|
|
53
|
+
* is the CDP target id and positions come from the DevTools target list
|
|
54
|
+
* (most recently activated first — its first entry is the front tab). */
|
|
55
|
+
async function targetIdOf(context, page) {
|
|
56
|
+
const session = await context.newCDPSession(page);
|
|
57
|
+
const { targetInfo } = await session.send('Target.getTargetInfo');
|
|
58
|
+
return targetInfo.targetId;
|
|
59
|
+
}
|
|
60
|
+
async function targetOrder(state) {
|
|
61
|
+
const list = await fetch(`http://${state.endpoint}/json/list`).then(r => r.json()).catch(() => []);
|
|
62
|
+
return list.filter(target => target.type === 'page').map(target => target.id);
|
|
63
|
+
}
|
|
64
|
+
async function orderedPages(state, context) {
|
|
65
|
+
const pages = context.pages();
|
|
66
|
+
const ids = await Promise.all(pages.map(page => targetIdOf(context, page)));
|
|
67
|
+
const byId = new Map(ids.map((id, i) => [id, pages[i]]));
|
|
68
|
+
const order = (await targetOrder(state)).map(id => byId.get(id)).filter((page) => page !== undefined);
|
|
69
|
+
return [...order, ...pages.filter((_, i) => !order.includes(pages[i]))];
|
|
70
|
+
}
|
|
71
|
+
async function connect() {
|
|
72
|
+
const state = readState();
|
|
73
|
+
const { chromium } = await import('playwright-core');
|
|
74
|
+
const browser = await chromium.connectOverCDP(`http://${state.endpoint}`).catch((error) => {
|
|
75
|
+
throw new AxiError(`browser: cannot reach ${state.endpoint}: ${error.message.split('\n')[0]}`, 'check that the browser is still running; re-attach with browser attach --cdp');
|
|
76
|
+
});
|
|
77
|
+
const context = browser.contexts()[0];
|
|
78
|
+
const order = context ? await orderedPages(state, context) : [];
|
|
79
|
+
if (!context || !order.length) {
|
|
80
|
+
await browser.close();
|
|
81
|
+
throw new AxiError('browser: no open pages at the endpoint', 'open a page with browser open <url>');
|
|
82
|
+
}
|
|
83
|
+
context.setDefaultTimeout(10000);
|
|
84
|
+
const byId = await Promise.all(order.map(async (page) => [await targetIdOf(context, page), page]));
|
|
85
|
+
const selected = state.selectedTargetId
|
|
86
|
+
? byId.find(([id]) => id === state.selectedTargetId)?.[1]
|
|
87
|
+
: order[0];
|
|
88
|
+
if (!selected) {
|
|
89
|
+
await browser.close();
|
|
90
|
+
throw new AxiError('stale-tab: the selected tab is gone', 'desklink-axi browser tabs, then browser select <tab>');
|
|
91
|
+
}
|
|
92
|
+
return { browser, context, state, selected, order };
|
|
93
|
+
}
|
|
94
|
+
async function withPage(fn) {
|
|
95
|
+
const lanes = await connect();
|
|
96
|
+
try {
|
|
97
|
+
const selectedId = await targetIdOf(lanes.context, lanes.selected);
|
|
98
|
+
return await fn(lanes.selected, { context: lanes.context, order: lanes.order, state: lanes.state, selectedId });
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await lanes.browser.close(); // For a CDP connection this only disconnects.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function refLocator(page, ref) {
|
|
105
|
+
if (!/^@e\d+$/.test(ref)) {
|
|
106
|
+
throw new UsageError(`invalid ref '${ref}'`, 'refs come from browser snapshot output, e.g. @e12');
|
|
107
|
+
}
|
|
108
|
+
return page.locator(`aria-ref=${ref.slice(1)}`);
|
|
109
|
+
}
|
|
110
|
+
// Snapshot refs are injected into the page per connection, so every ref
|
|
111
|
+
// command re-snapshots first: unchanged pages re-derive identical refs, and
|
|
112
|
+
// a ref that no longer exists is rejected as stale.
|
|
113
|
+
async function resolveRef(page, ref) {
|
|
114
|
+
const locator = refLocator(page, ref);
|
|
115
|
+
const id = ref.slice(1);
|
|
116
|
+
const yaml = await snapshotText(page);
|
|
117
|
+
if (!yaml.includes(`[ref=${id}]`) || (await locator.count()) === 0) {
|
|
118
|
+
throw new AxiError(`stale-ref: ${ref}; the page changed since the snapshot`, 'desklink-axi browser snapshot');
|
|
119
|
+
}
|
|
120
|
+
return locator;
|
|
121
|
+
}
|
|
122
|
+
async function snapshotText(page) {
|
|
123
|
+
return page.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
124
|
+
}
|
|
125
|
+
function truncated(yaml, full) {
|
|
126
|
+
const lines = yaml.split('\n');
|
|
127
|
+
if (full || lines.length <= 60)
|
|
128
|
+
return yaml;
|
|
129
|
+
return [...lines.slice(0, 60), `truncated: ${lines.length - 60} more — use --full`].join('\n');
|
|
130
|
+
}
|
|
131
|
+
const attach = {
|
|
132
|
+
spec: {
|
|
133
|
+
name: 'browser attach',
|
|
134
|
+
summary: 'attach to a Chromium you started with a loopback debugging port (explicit opt-in)',
|
|
135
|
+
flags: [{ name: 'cdp', type: 'string', description: 'loopback CDP endpoint, e.g. 127.0.0.1:9222' }],
|
|
136
|
+
examples: ['desklink-axi browser attach --cdp 127.0.0.1:9222'],
|
|
137
|
+
},
|
|
138
|
+
async run(parsed) {
|
|
139
|
+
const value = parsed.flags.cdp;
|
|
140
|
+
if (typeof value !== 'string' || !value)
|
|
141
|
+
throw new UsageError('browser attach requires --cdp 127.0.0.1:<port>', 'the endpoint is the opt-in: desklink-axi never discovers browsers');
|
|
142
|
+
const endpoint = parseEndpoint(value);
|
|
143
|
+
const previous = existsSync(statePath()) ? readState() : undefined;
|
|
144
|
+
if (previous?.launched && previous.pid)
|
|
145
|
+
await stopLaunched(previous);
|
|
146
|
+
const version = await fetch(`http://${endpoint}/json/version`).then(r => r.json()).catch(() => undefined);
|
|
147
|
+
if (!version)
|
|
148
|
+
throw new AxiError(`browser: no devtools endpoint at ${endpoint}`, 'start chromium with --remote-debugging-port=<port> (loopback) and pass the same port');
|
|
149
|
+
writeState({ endpoint, launched: false });
|
|
150
|
+
const list = await fetch(`http://${endpoint}/json/list`).then(r => r.json()).catch(() => []);
|
|
151
|
+
print(`browser: attached ${endpoint} browser=${version.Browser ?? 'unknown'} pages=${list.filter(t => t.type === 'page').length}`);
|
|
152
|
+
return 0;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
function childrenOf(pid) {
|
|
156
|
+
try {
|
|
157
|
+
return readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim().split(/\s+/).filter(Boolean).map(Number);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Chromium helpers (crashpad handlers double-fork, xdg-settings spawns during
|
|
164
|
+
// shutdown) can reparent out of the recorded child tree, so every launch
|
|
165
|
+
// carries a unique marker in the spawned environment and the sweep below
|
|
166
|
+
// reaps any same-uid process still carrying it.
|
|
167
|
+
function markerStrays(marker) {
|
|
168
|
+
const needle = `DESKLINK_AXI_BROWSER_SESSION=${marker}`;
|
|
169
|
+
let entries;
|
|
170
|
+
try {
|
|
171
|
+
entries = readdirSync('/proc');
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
177
|
+
const found = [];
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
const pid = Number(entry);
|
|
180
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid)
|
|
181
|
+
continue;
|
|
182
|
+
try {
|
|
183
|
+
if (uid !== undefined && statSync(`/proc/${pid}`).uid !== uid)
|
|
184
|
+
continue;
|
|
185
|
+
if (readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').includes(needle))
|
|
186
|
+
found.push(pid);
|
|
187
|
+
}
|
|
188
|
+
catch { /* vanished or unreadable */ }
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
}
|
|
192
|
+
async function stopLaunched(state) {
|
|
193
|
+
const stop = async (pid) => {
|
|
194
|
+
try {
|
|
195
|
+
process.kill(pid, 'SIGTERM');
|
|
196
|
+
}
|
|
197
|
+
catch { /* already gone */ }
|
|
198
|
+
for (let i = 0; i < 20; i++) {
|
|
199
|
+
try {
|
|
200
|
+
process.kill(pid, 0);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await new Promise(r => setTimeout(r, 50));
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
process.kill(pid, 'SIGKILL');
|
|
209
|
+
}
|
|
210
|
+
catch { /* already gone */ }
|
|
211
|
+
};
|
|
212
|
+
if (state.pid) {
|
|
213
|
+
// Record the child tree before any signal; kill only these exact PIDs.
|
|
214
|
+
const kids = [...new Set([...childrenOf(state.pid), ...childrenOf(state.pid).flatMap(childrenOf)])];
|
|
215
|
+
await stop(state.pid);
|
|
216
|
+
for (const kid of kids)
|
|
217
|
+
await stop(kid);
|
|
218
|
+
}
|
|
219
|
+
// Helpers that reparented out of the recorded tree (crashpad handlers,
|
|
220
|
+
// xdg-settings spawned during shutdown) still carry the launch marker:
|
|
221
|
+
// SIGTERM them, then SIGKILL survivors, until none remain (~3 s bound).
|
|
222
|
+
if (state.browserSession) {
|
|
223
|
+
const deadline = Date.now() + 3000;
|
|
224
|
+
let termed = false;
|
|
225
|
+
for (;;) {
|
|
226
|
+
const strays = markerStrays(state.browserSession);
|
|
227
|
+
if (!strays.length)
|
|
228
|
+
break;
|
|
229
|
+
if (Date.now() >= deadline) {
|
|
230
|
+
for (const pid of strays) {
|
|
231
|
+
try {
|
|
232
|
+
process.kill(pid, 'SIGKILL');
|
|
233
|
+
}
|
|
234
|
+
catch { /* gone */ }
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
for (const pid of strays) {
|
|
239
|
+
try {
|
|
240
|
+
process.kill(pid, termed ? 'SIGKILL' : 'SIGTERM');
|
|
241
|
+
}
|
|
242
|
+
catch { /* gone */ }
|
|
243
|
+
}
|
|
244
|
+
termed = true;
|
|
245
|
+
await new Promise(r => setTimeout(r, 100));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Remove the profile only once the browser is truly dead; a dying Chromium
|
|
249
|
+
// still writes into it.
|
|
250
|
+
if (state.profileCreated && state.profile) {
|
|
251
|
+
for (let i = 0; i < 10; i++) {
|
|
252
|
+
rmSync(state.profile, { recursive: true, force: true });
|
|
253
|
+
if (!existsSync(state.profile))
|
|
254
|
+
break;
|
|
255
|
+
await new Promise(r => setTimeout(r, 100));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const launch = {
|
|
260
|
+
spec: {
|
|
261
|
+
name: 'browser launch',
|
|
262
|
+
summary: 'start an isolated task-owned Chromium (loopback debugging port only) and attach',
|
|
263
|
+
flags: [
|
|
264
|
+
{ name: 'profile', type: 'string', description: 'user-data-dir; default is a private temp profile owned by this task' },
|
|
265
|
+
{ name: 'executable', type: 'string', description: 'Chromium path; default: $DESKLINK_AXI_BROWSER, then the playwright-core browser' },
|
|
266
|
+
{ name: 'headless', type: 'boolean', description: 'run without a window' },
|
|
267
|
+
{ name: 'arg', type: 'string', description: 'extra Chromium flag value; repeat once per flag (e.g. --arg --window-size=1280,640)' },
|
|
268
|
+
],
|
|
269
|
+
examples: ['desklink-axi browser launch --profile /tmp/task-profile'],
|
|
270
|
+
},
|
|
271
|
+
async run(parsed) {
|
|
272
|
+
const previous = existsSync(statePath()) ? readState() : undefined;
|
|
273
|
+
if (previous?.launched && previous.pid)
|
|
274
|
+
await stopLaunched(previous);
|
|
275
|
+
const profile = typeof parsed.flags.profile === 'string' && parsed.flags.profile
|
|
276
|
+
? resolve(parsed.flags.profile)
|
|
277
|
+
: mkdtempSync(join(tmpdir(), 'desklink-axi-browser-'));
|
|
278
|
+
mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
279
|
+
let executable = typeof parsed.flags.executable === 'string' ? parsed.flags.executable : process.env.DESKLINK_AXI_BROWSER;
|
|
280
|
+
if (!executable) {
|
|
281
|
+
try {
|
|
282
|
+
executable = (await import('playwright-core')).chromium.executablePath();
|
|
283
|
+
}
|
|
284
|
+
catch { /* fall through to the structured error */ }
|
|
285
|
+
}
|
|
286
|
+
if (!executable || !existsSync(executable)) {
|
|
287
|
+
throw new AxiError('browser: no Chromium executable found', 'pass --executable, set DESKLINK_AXI_BROWSER, or run: npx playwright-core install chromium');
|
|
288
|
+
}
|
|
289
|
+
const args = [
|
|
290
|
+
'--remote-debugging-port=0', '--user-data-dir=' + profile, '--no-first-run', '--no-default-browser-check',
|
|
291
|
+
'--disable-dev-shm-usage', '--password-store=basic',
|
|
292
|
+
// With DISPLAY set, render there — never follow WAYLAND_DISPLAY to a
|
|
293
|
+
// display the session did not ask for.
|
|
294
|
+
...(process.env.DISPLAY ? ['--ozone-platform=x11'] : []),
|
|
295
|
+
...(parsed.flags.headless ? ['--headless=new'] : []),
|
|
296
|
+
...(Array.isArray(parsed.flags.arg) ? parsed.flags.arg.map(String) : typeof parsed.flags.arg === 'string' ? [parsed.flags.arg] : []),
|
|
297
|
+
];
|
|
298
|
+
const browserSession = randomBytes(8).toString('hex');
|
|
299
|
+
const log = openSync(join(profile, 'chromium.log'), 'a');
|
|
300
|
+
const child = spawn(executable, args, { detached: true, stdio: ['ignore', 'ignore', log], env: { ...process.env, DESKLINK_AXI_BROWSER_SESSION: browserSession } });
|
|
301
|
+
closeSync(log);
|
|
302
|
+
const portFile = join(profile, 'DevToolsActivePort');
|
|
303
|
+
for (let i = 0; i < 300 && (!existsSync(portFile) || !readFileSync(portFile, 'utf8').trim()) && child.pid; i++)
|
|
304
|
+
await new Promise(r => setTimeout(r, 100));
|
|
305
|
+
if (!existsSync(portFile) || !readFileSync(portFile, 'utf8').trim()) {
|
|
306
|
+
try {
|
|
307
|
+
child.kill('SIGKILL');
|
|
308
|
+
}
|
|
309
|
+
catch { /* already gone */ }
|
|
310
|
+
for (const pid of markerStrays(browserSession)) {
|
|
311
|
+
try {
|
|
312
|
+
process.kill(pid, 'SIGKILL');
|
|
313
|
+
}
|
|
314
|
+
catch { /* gone */ }
|
|
315
|
+
}
|
|
316
|
+
const tail = existsSync(join(profile, 'chromium.log')) ? readFileSync(join(profile, 'chromium.log'), 'utf8').split('\n').filter(Boolean).at(-1) ?? '' : '';
|
|
317
|
+
throw new AxiError(`browser: Chromium did not open its debugging port${tail ? `: ${tail.slice(0, 200)}` : ''}`, 'check the executable; see chromium.log in the profile; try --headless on displays without a compositor');
|
|
318
|
+
}
|
|
319
|
+
const port = readFileSync(portFile, 'utf8').split('\n')[0].trim();
|
|
320
|
+
writeState({ endpoint: `127.0.0.1:${port}`, launched: true, pid: child.pid, profile, profileCreated: typeof parsed.flags.profile !== 'string', browserSession });
|
|
321
|
+
child.unref();
|
|
322
|
+
print(`browser: launched 127.0.0.1:${port} pid=${child.pid} profile=${profile}`);
|
|
323
|
+
return 0;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
const tabs = {
|
|
327
|
+
spec: {
|
|
328
|
+
name: 'browser tabs',
|
|
329
|
+
summary: 'list tabs; selected is this lane\'s page, front is the tab the browser actually activated',
|
|
330
|
+
flags: [],
|
|
331
|
+
examples: ['desklink-axi browser tabs'],
|
|
332
|
+
},
|
|
333
|
+
async run() {
|
|
334
|
+
await withPage(async (page, { order, state, selectedId }) => {
|
|
335
|
+
const selectedIndex = order.indexOf(page);
|
|
336
|
+
const rows = await Promise.all(order.map(async (candidate, i) => ({
|
|
337
|
+
tab: i,
|
|
338
|
+
title: await candidate.title().catch(() => ''),
|
|
339
|
+
url: candidate.url(),
|
|
340
|
+
selected: i === selectedIndex ? 'yes' : 'no',
|
|
341
|
+
front: i === 0 ? 'yes' : 'no', // DevTools target list: most recently activated first.
|
|
342
|
+
})));
|
|
343
|
+
print(`browser: ${state.endpoint} selected=${selectedIndex} front=0`);
|
|
344
|
+
print(emitList('tabs', rows, ['tab', 'title', 'url', 'selected', 'front']));
|
|
345
|
+
print('help[1]:\n desklink-axi browser select <tab>');
|
|
346
|
+
writeState({ ...state, selectedTargetId: selectedId });
|
|
347
|
+
});
|
|
348
|
+
return 0;
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
const select = {
|
|
352
|
+
spec: {
|
|
353
|
+
name: 'browser select',
|
|
354
|
+
summary: 'select a tab and bring it to the front',
|
|
355
|
+
args: [{ name: 'tab', required: true, description: 'tab index from browser tabs' }],
|
|
356
|
+
flags: [],
|
|
357
|
+
examples: ['desklink-axi browser select 1'],
|
|
358
|
+
},
|
|
359
|
+
async run(parsed) {
|
|
360
|
+
const index = Number(parsed.positionals[0]);
|
|
361
|
+
if (!Number.isInteger(index) || index < 0)
|
|
362
|
+
throw new UsageError('browser select takes a tab index', 'desklink-axi browser tabs');
|
|
363
|
+
await withPage(async (page, { context, order, state }) => {
|
|
364
|
+
const target = order[index];
|
|
365
|
+
if (!target)
|
|
366
|
+
throw new AxiError(`no tab ${index}`, `valid: 0..${order.length - 1}`);
|
|
367
|
+
await target.bringToFront();
|
|
368
|
+
writeState({ ...state, selectedTargetId: await targetIdOf(context, target) });
|
|
369
|
+
print(`browser: selected ${index} url=${target.url()}`);
|
|
370
|
+
});
|
|
371
|
+
return 0;
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
const open = {
|
|
375
|
+
spec: {
|
|
376
|
+
name: 'browser open',
|
|
377
|
+
summary: 'open a URL in a new tab, select it, and bring it to the front',
|
|
378
|
+
args: [{ name: 'url', required: true, description: 'URL to open' }],
|
|
379
|
+
flags: [],
|
|
380
|
+
examples: ['desklink-axi browser open file:///tmp/fixture/index.html'],
|
|
381
|
+
},
|
|
382
|
+
async run(parsed) {
|
|
383
|
+
const url = parsed.positionals[0];
|
|
384
|
+
await withPage(async (page, { context, order, state }) => {
|
|
385
|
+
const created = await context.newPage();
|
|
386
|
+
await created.goto(url, { waitUntil: 'load' }).catch((error) => {
|
|
387
|
+
throw new AxiError(`browser: could not open ${url}: ${error.message.split('\n')[0]}`);
|
|
388
|
+
});
|
|
389
|
+
await created.bringToFront();
|
|
390
|
+
writeState({ ...state, selectedTargetId: await targetIdOf(context, created) });
|
|
391
|
+
print(`browser: opened ${url} tab=${order.length}`);
|
|
392
|
+
});
|
|
393
|
+
return 0;
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
const navigate = {
|
|
397
|
+
spec: {
|
|
398
|
+
name: 'browser navigate',
|
|
399
|
+
summary: 'navigate the selected tab to a URL',
|
|
400
|
+
args: [{ name: 'url', required: true, description: 'URL to load in the selected tab' }],
|
|
401
|
+
flags: [],
|
|
402
|
+
examples: ['desklink-axi browser navigate file:///tmp/fixture/index.html'],
|
|
403
|
+
},
|
|
404
|
+
async run(parsed) {
|
|
405
|
+
await withPage(async (page, { state, selectedId }) => {
|
|
406
|
+
await page.goto(parsed.positionals[0], { waitUntil: 'load' }).catch((error) => {
|
|
407
|
+
throw new AxiError(`browser: could not load ${parsed.positionals[0]}: ${error.message.split('\n')[0]}`);
|
|
408
|
+
});
|
|
409
|
+
writeState({ ...state, selectedTargetId: selectedId });
|
|
410
|
+
print(`browser: navigated ${page.url()}`);
|
|
411
|
+
});
|
|
412
|
+
return 0;
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
const close = {
|
|
416
|
+
spec: {
|
|
417
|
+
name: 'browser close',
|
|
418
|
+
summary: 'close a tab by index',
|
|
419
|
+
args: [{ name: 'tab', required: true, description: 'tab index from browser tabs' }],
|
|
420
|
+
flags: [],
|
|
421
|
+
examples: ['desklink-axi browser close 1'],
|
|
422
|
+
},
|
|
423
|
+
async run(parsed) {
|
|
424
|
+
const index = Number(parsed.positionals[0]);
|
|
425
|
+
if (!Number.isInteger(index) || index < 0)
|
|
426
|
+
throw new UsageError('browser close takes a tab index', 'desklink-axi browser tabs');
|
|
427
|
+
await withPage(async (page, { context, order, state, selectedId }) => {
|
|
428
|
+
const target = order[index];
|
|
429
|
+
if (!target)
|
|
430
|
+
throw new AxiError(`no tab ${index}`, `valid: 0..${order.length - 1}`);
|
|
431
|
+
const closedId = await targetIdOf(context, target);
|
|
432
|
+
await target.close();
|
|
433
|
+
const wasSelected = closedId === selectedId;
|
|
434
|
+
writeState(wasSelected ? { ...state, selectedTargetId: undefined } : state);
|
|
435
|
+
print(`browser: closed ${index}${wasSelected ? '; selection cleared' : ''}`);
|
|
436
|
+
});
|
|
437
|
+
return 0;
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
const snapshot = {
|
|
441
|
+
spec: {
|
|
442
|
+
name: 'browser snapshot',
|
|
443
|
+
summary: 'compact accessibility snapshot of the selected tab with element refs; no screenshots',
|
|
444
|
+
flags: [
|
|
445
|
+
{ name: 'tab', type: 'string', description: 'snapshot this tab index instead of the selected one' },
|
|
446
|
+
{ name: 'full', type: 'boolean', description: 'disable the 60-line bound' },
|
|
447
|
+
],
|
|
448
|
+
examples: ['desklink-axi browser snapshot'],
|
|
449
|
+
},
|
|
450
|
+
async run(parsed) {
|
|
451
|
+
await withPage(async (page, { order, state, selectedId }) => {
|
|
452
|
+
const tabFlag = parsed.flags.tab;
|
|
453
|
+
let target = page;
|
|
454
|
+
if (typeof tabFlag === 'string') {
|
|
455
|
+
const index = Number(tabFlag);
|
|
456
|
+
if (!order[index])
|
|
457
|
+
throw new AxiError(`no tab ${tabFlag}`, `valid: 0..${order.length - 1}`);
|
|
458
|
+
target = order[index];
|
|
459
|
+
}
|
|
460
|
+
const yaml = await snapshotText(target);
|
|
461
|
+
const index = order.indexOf(target);
|
|
462
|
+
print(`browser: ${state.endpoint} page=${index} url=${target.url()}`);
|
|
463
|
+
print(`snapshot:\n${truncated(yaml, parsed.flags.full === true)}`);
|
|
464
|
+
print('help[2]:\n desklink-axi browser click @e<N>\n desklink-axi browser fill @e<N> "text"');
|
|
465
|
+
writeState({ ...state, selectedTargetId: selectedId });
|
|
466
|
+
});
|
|
467
|
+
return 0;
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
async function refAction(parsed, verb, value) {
|
|
471
|
+
const ref = parsed.positionals[0];
|
|
472
|
+
if (!/^@e\d+$/.test(ref)) {
|
|
473
|
+
throw new UsageError(`invalid ref '${ref}'`, 'refs come from browser snapshot output, e.g. @e12');
|
|
474
|
+
}
|
|
475
|
+
await withPage(async (page) => {
|
|
476
|
+
const locator = await resolveRef(page, ref);
|
|
477
|
+
try {
|
|
478
|
+
if (verb === 'click')
|
|
479
|
+
await locator.click();
|
|
480
|
+
else
|
|
481
|
+
await locator.fill(value ?? '');
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
throw new AxiError(`${verb}: ${ref} did not complete: ${error.message.split('\n')[0]}`, 're-snapshot; the element may be covered or the page busy');
|
|
485
|
+
}
|
|
486
|
+
print(`${verb}: applied ${ref}${verb === 'fill' ? ` value=${JSON.stringify(value ?? '')}` : ''}`);
|
|
487
|
+
});
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
const click = {
|
|
491
|
+
spec: {
|
|
492
|
+
name: 'browser click',
|
|
493
|
+
summary: 'click an element by snapshot ref',
|
|
494
|
+
args: [{ name: 'ref', required: true, description: 'element ref from browser snapshot, e.g. @e12' }],
|
|
495
|
+
flags: [],
|
|
496
|
+
examples: ['desklink-axi browser click @e12'],
|
|
497
|
+
},
|
|
498
|
+
async run(parsed) { return refAction(parsed, 'click'); },
|
|
499
|
+
};
|
|
500
|
+
const fill = {
|
|
501
|
+
spec: {
|
|
502
|
+
name: 'browser fill',
|
|
503
|
+
summary: 'fill an input by snapshot ref',
|
|
504
|
+
args: [
|
|
505
|
+
{ name: 'ref', required: true, description: 'element ref from browser snapshot, e.g. @e5' },
|
|
506
|
+
{ name: 'text', required: true, description: 'text to fill' },
|
|
507
|
+
],
|
|
508
|
+
flags: [],
|
|
509
|
+
examples: ['desklink-axi browser fill @e5 "Ada Lovelace"'],
|
|
510
|
+
},
|
|
511
|
+
async run(parsed) { return refAction(parsed, 'fill', parsed.positionals[1]); },
|
|
512
|
+
};
|
|
513
|
+
const press = {
|
|
514
|
+
spec: {
|
|
515
|
+
name: 'browser press',
|
|
516
|
+
summary: 'press a key in the selected tab',
|
|
517
|
+
args: [{ name: 'key', required: true, description: 'key name, e.g. Enter or Control+a' }],
|
|
518
|
+
flags: [],
|
|
519
|
+
examples: ['desklink-axi browser press Enter'],
|
|
520
|
+
},
|
|
521
|
+
async run(parsed) {
|
|
522
|
+
const key = parsed.positionals[0];
|
|
523
|
+
await withPage(async (page) => {
|
|
524
|
+
await page.keyboard.press(key);
|
|
525
|
+
print(`press: applied ${key}`);
|
|
526
|
+
});
|
|
527
|
+
return 0;
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
const upload = {
|
|
531
|
+
spec: {
|
|
532
|
+
name: 'browser upload',
|
|
533
|
+
summary: 'set a file on an <input type=file> by ref; OS dialogs are not-supported (use the AXI lane)',
|
|
534
|
+
args: [
|
|
535
|
+
{ name: 'ref', required: true, description: 'element ref from browser snapshot, e.g. @e9' },
|
|
536
|
+
{ name: 'path', required: true, description: 'file to upload' },
|
|
537
|
+
],
|
|
538
|
+
flags: [],
|
|
539
|
+
examples: ['desklink-axi browser upload @e9 /tmp/report.pdf'],
|
|
540
|
+
},
|
|
541
|
+
async run(parsed) {
|
|
542
|
+
const ref = parsed.positionals[0];
|
|
543
|
+
const path = resolve(parsed.positionals[1]);
|
|
544
|
+
// Consent gate before filesystem access: no-browser fires first.
|
|
545
|
+
await withPage(async (page) => {
|
|
546
|
+
const locator = await resolveRef(page, ref);
|
|
547
|
+
if (!existsSync(path))
|
|
548
|
+
throw new AxiError(`upload: no such file ${path}`);
|
|
549
|
+
const isFileInput = await locator.evaluate(el => el instanceof HTMLInputElement && el.type === 'file').catch(() => false);
|
|
550
|
+
if (!isFileInput) {
|
|
551
|
+
throw new AxiError(`not-supported: ${ref} does not accept files (needs <input type=file>)`, 'OS file dialogs are outside the browser lane; fall back to desklink-axi click/type on the dialog');
|
|
552
|
+
}
|
|
553
|
+
await locator.setInputFiles(path);
|
|
554
|
+
print(`upload: applied ${ref} file=${path}`);
|
|
555
|
+
});
|
|
556
|
+
return 0;
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
const detach = {
|
|
560
|
+
spec: {
|
|
561
|
+
name: 'browser detach',
|
|
562
|
+
summary: 'forget the attached browser; stops it only if this lane launched it',
|
|
563
|
+
flags: [],
|
|
564
|
+
examples: ['desklink-axi browser detach'],
|
|
565
|
+
},
|
|
566
|
+
async run() {
|
|
567
|
+
if (!existsSync(statePath())) {
|
|
568
|
+
print('browser: not attached');
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
const state = readState();
|
|
572
|
+
if (state.launched && state.pid) {
|
|
573
|
+
await stopLaunched(state);
|
|
574
|
+
print(`browser: detached and stopped pid=${state.pid}`);
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
print(`browser: detached ${state.endpoint} (browser left running)`);
|
|
578
|
+
}
|
|
579
|
+
forgetState();
|
|
580
|
+
return 0;
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
export function browserCommands() {
|
|
584
|
+
const modules = { attach, launch, tabs, select, open, navigate, close, snapshot, click, fill, press, upload, detach };
|
|
585
|
+
return Object.fromEntries(Object.entries(modules).map(([name, mod]) => [`browser ${name}`, mod]));
|
|
586
|
+
}
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Strict flag parser (AXI principle 6): unknown flags fail loudly with the
|
|
2
|
+
// valid flag set inline so an agent can self-correct in one turn.
|
|
3
|
+
import { UsageError } from "../output/errors.js";
|
|
4
|
+
export function validFlagsHint(spec) {
|
|
5
|
+
const names = spec.flags.map((f) => `--${f.name}`);
|
|
6
|
+
names.push("--help");
|
|
7
|
+
const scope = spec.name ? ` for '${spec.name}'` : "";
|
|
8
|
+
return `valid flags${scope}: ${names.join(", ")}`;
|
|
9
|
+
}
|
|
10
|
+
function forScope(spec) {
|
|
11
|
+
return spec.name ? ` for '${spec.name}'` : "";
|
|
12
|
+
}
|
|
13
|
+
export function parseArgs(argv, spec) {
|
|
14
|
+
const flags = {};
|
|
15
|
+
for (const f of spec.flags) {
|
|
16
|
+
if (f.default !== undefined)
|
|
17
|
+
flags[f.name] = f.default;
|
|
18
|
+
}
|
|
19
|
+
const positionals = [];
|
|
20
|
+
let help = false;
|
|
21
|
+
let positionalOnly = false;
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const tok = argv[i];
|
|
24
|
+
if (tok === "--" && !positionalOnly) {
|
|
25
|
+
positionalOnly = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (positionalOnly) {
|
|
29
|
+
positionals.push(tok);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (tok === "--help") {
|
|
33
|
+
help = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (tok.startsWith("--")) {
|
|
37
|
+
let name = tok.slice(2);
|
|
38
|
+
let inline;
|
|
39
|
+
const eq = name.indexOf("=");
|
|
40
|
+
if (eq >= 0) {
|
|
41
|
+
inline = name.slice(eq + 1);
|
|
42
|
+
name = name.slice(0, eq);
|
|
43
|
+
}
|
|
44
|
+
const flagSpec = spec.flags.find((f) => f.name === name);
|
|
45
|
+
if (!flagSpec && spec.args?.[positionals.length]?.name === "text") {
|
|
46
|
+
positionals.push(tok);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!flagSpec) {
|
|
50
|
+
throw new UsageError(`unknown flag --${name}${forScope(spec)}`, validFlagsHint(spec));
|
|
51
|
+
}
|
|
52
|
+
if (flagSpec.type === "boolean") {
|
|
53
|
+
if (inline !== undefined) {
|
|
54
|
+
throw new UsageError(`flag --${name} does not take a value`, validFlagsHint(spec));
|
|
55
|
+
}
|
|
56
|
+
flags[name] = true;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
let value = inline;
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
const next = argv[i + 1];
|
|
62
|
+
if (next === undefined || next.startsWith("--")) {
|
|
63
|
+
throw new UsageError(`flag --${name} requires a value`, `usage: --${name} <value>`);
|
|
64
|
+
}
|
|
65
|
+
value = next;
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
if (flagSpec.values && !flagSpec.values.includes(value)) {
|
|
69
|
+
throw new UsageError(`invalid value '${value}' for --${name}`, `valid values: ${flagSpec.values.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
if (flagSpec.multi) {
|
|
72
|
+
const existing = flags[name];
|
|
73
|
+
flags[name] = Array.isArray(existing) ? [...existing, value] : [value];
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
flags[name] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (tok.startsWith("-") && tok.length > 1) {
|
|
81
|
+
if (spec.args?.[positionals.length]?.name === "text") {
|
|
82
|
+
positionals.push(tok);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
throw new UsageError(`unknown flag ${tok}${forScope(spec)}`, `${validFlagsHint(spec)} (short flags are not supported)`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
positionals.push(tok);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!help) {
|
|
92
|
+
const required = (spec.args ?? []).filter((a) => a.required);
|
|
93
|
+
if (positionals.length < required.length) {
|
|
94
|
+
const missing = required[positionals.length];
|
|
95
|
+
throw new UsageError(`missing required argument <${missing.name}>${forScope(spec)}`, spec.examples[0] ?? `run '${spec.name} --help'`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { positionals, flags, help };
|
|
99
|
+
}
|