playwriter 0.0.89 → 0.0.103

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.
Files changed (137) hide show
  1. package/dist/aria-snapshot.d.ts +7 -3
  2. package/dist/aria-snapshot.d.ts.map +1 -1
  3. package/dist/aria-snapshot.js +76 -18
  4. package/dist/aria-snapshot.js.map +1 -1
  5. package/dist/aria-snapshot.test.js +94 -0
  6. package/dist/aria-snapshot.test.js.map +1 -1
  7. package/dist/bippy.js +1 -1
  8. package/dist/browser-config.d.ts +12 -1
  9. package/dist/browser-config.d.ts.map +1 -1
  10. package/dist/browser-config.js +101 -38
  11. package/dist/browser-config.js.map +1 -1
  12. package/dist/browser-launch.d.ts +17 -0
  13. package/dist/browser-launch.d.ts.map +1 -0
  14. package/dist/browser-launch.js +44 -0
  15. package/dist/browser-launch.js.map +1 -0
  16. package/dist/cdp-relay.d.ts.map +1 -1
  17. package/dist/cdp-relay.js +34 -10
  18. package/dist/cdp-relay.js.map +1 -1
  19. package/dist/channel-owner-inspect.test.d.ts +2 -0
  20. package/dist/channel-owner-inspect.test.d.ts.map +1 -0
  21. package/dist/channel-owner-inspect.test.js +75 -0
  22. package/dist/channel-owner-inspect.test.js.map +1 -0
  23. package/dist/chrome-discovery.d.ts +65 -0
  24. package/dist/chrome-discovery.d.ts.map +1 -0
  25. package/dist/chrome-discovery.js +165 -0
  26. package/dist/chrome-discovery.js.map +1 -0
  27. package/dist/chrome-discovery.test.d.ts +2 -0
  28. package/dist/chrome-discovery.test.d.ts.map +1 -0
  29. package/dist/chrome-discovery.test.js +41 -0
  30. package/dist/chrome-discovery.test.js.map +1 -0
  31. package/dist/cli.js +357 -53
  32. package/dist/cli.js.map +1 -1
  33. package/dist/executor.d.ts +44 -11
  34. package/dist/executor.d.ts.map +1 -1
  35. package/dist/executor.js +121 -55
  36. package/dist/executor.js.map +1 -1
  37. package/dist/executor.unit.test.js +29 -1
  38. package/dist/executor.unit.test.js.map +1 -1
  39. package/dist/extension/background.js +1704 -0
  40. package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
  41. package/dist/extension/icons/icon-black-128.png +0 -0
  42. package/dist/extension/icons/icon-black-16.png +0 -0
  43. package/dist/extension/icons/icon-black-32.png +0 -0
  44. package/dist/extension/icons/icon-black-48.png +0 -0
  45. package/dist/extension/icons/icon-gray-128.png +0 -0
  46. package/dist/extension/icons/icon-gray-16.png +0 -0
  47. package/dist/extension/icons/icon-gray-32.png +0 -0
  48. package/dist/extension/icons/icon-gray-48.png +0 -0
  49. package/dist/extension/icons/icon-green-128.png +0 -0
  50. package/dist/extension/icons/icon-green-16.png +0 -0
  51. package/dist/extension/icons/icon-green-32.png +0 -0
  52. package/dist/extension/icons/icon-green-48.png +0 -0
  53. package/dist/extension/icons/playwriter-icon-black.png +0 -0
  54. package/dist/extension/icons/playwriter-icon-gray-disabled.png +0 -0
  55. package/dist/extension/icons/playwriter-icon-gray-disabled.svg +1 -0
  56. package/dist/extension/manifest.json +40 -0
  57. package/dist/extension/offscreen.js +214 -0
  58. package/dist/extension/src/offscreen.html +9 -0
  59. package/dist/extension/src/prism-bash.min.js +1 -0
  60. package/dist/extension/src/prism.min.js +1 -0
  61. package/dist/extension/src/welcome.html +399 -0
  62. package/dist/kitty-graphics.d.ts +22 -0
  63. package/dist/kitty-graphics.d.ts.map +1 -0
  64. package/dist/kitty-graphics.js +71 -0
  65. package/dist/kitty-graphics.js.map +1 -0
  66. package/dist/kitty-graphics.test.d.ts +2 -0
  67. package/dist/kitty-graphics.test.d.ts.map +1 -0
  68. package/dist/kitty-graphics.test.js +125 -0
  69. package/dist/kitty-graphics.test.js.map +1 -0
  70. package/dist/mcp.d.ts.map +1 -1
  71. package/dist/mcp.js +76 -12
  72. package/dist/mcp.js.map +1 -1
  73. package/dist/package-paths.d.ts +3 -0
  74. package/dist/package-paths.d.ts.map +1 -0
  75. package/dist/package-paths.js +29 -0
  76. package/dist/package-paths.js.map +1 -0
  77. package/dist/prompt.md +4 -2
  78. package/dist/readability.js +1 -1
  79. package/dist/relay-core.test.js +14 -4
  80. package/dist/relay-core.test.js.map +1 -1
  81. package/dist/relay-navigation.test.d.ts.map +1 -1
  82. package/dist/relay-navigation.test.js +95 -1
  83. package/dist/relay-navigation.test.js.map +1 -1
  84. package/dist/scoped-fs.d.ts +5 -4
  85. package/dist/scoped-fs.d.ts.map +1 -1
  86. package/dist/scoped-fs.js +11 -9
  87. package/dist/scoped-fs.js.map +1 -1
  88. package/dist/scoped-fs.test.d.ts +5 -0
  89. package/dist/scoped-fs.test.d.ts.map +1 -0
  90. package/dist/scoped-fs.test.js +70 -0
  91. package/dist/scoped-fs.test.js.map +1 -0
  92. package/dist/screen-recording.d.ts +3 -6
  93. package/dist/screen-recording.d.ts.map +1 -1
  94. package/dist/screen-recording.js +7 -21
  95. package/dist/screen-recording.js.map +1 -1
  96. package/dist/selector-generator.js +1 -1
  97. package/dist/snapshot-tools.test.js +3 -4
  98. package/dist/snapshot-tools.test.js.map +1 -1
  99. package/dist/test-utils.d.ts +3 -1
  100. package/dist/test-utils.d.ts.map +1 -1
  101. package/dist/test-utils.js +24 -12
  102. package/dist/test-utils.js.map +1 -1
  103. package/package.json +6 -5
  104. package/src/aria-snapshot.test.ts +110 -0
  105. package/src/aria-snapshot.ts +86 -19
  106. package/src/aria-snapshots/contenteditable-interactive.txt +7 -0
  107. package/src/aria-snapshots/contenteditable-raw.txt +14 -0
  108. package/src/aria-snapshots/github-interactive.txt +0 -2
  109. package/src/aria-snapshots/github-raw.txt +0 -4
  110. package/src/aria-snapshots/hackernews-interactive.txt +240 -237
  111. package/src/aria-snapshots/hackernews-raw.txt +262 -258
  112. package/src/aria-snapshots/prosemirror-interactive.txt +63 -0
  113. package/src/aria-snapshots/prosemirror-raw.txt +141 -0
  114. package/src/assets/aria-labels-hacker-news.png +0 -0
  115. package/src/browser-config.ts +143 -37
  116. package/src/browser-launch.ts +75 -0
  117. package/src/cdp-relay.ts +46 -11
  118. package/src/channel-owner-inspect.test.ts +94 -0
  119. package/src/chrome-discovery.test.ts +50 -0
  120. package/src/chrome-discovery.ts +211 -0
  121. package/src/cli.ts +432 -59
  122. package/src/executor.ts +171 -72
  123. package/src/executor.unit.test.ts +32 -1
  124. package/src/kitty-graphics.test.ts +139 -0
  125. package/src/kitty-graphics.ts +79 -0
  126. package/src/mcp.ts +82 -12
  127. package/src/package-paths.ts +36 -0
  128. package/src/relay-core.test.ts +14 -4
  129. package/src/relay-navigation.test.ts +106 -1
  130. package/src/scoped-fs.test.ts +77 -0
  131. package/src/scoped-fs.ts +12 -9
  132. package/src/screen-recording.ts +7 -22
  133. package/src/skill.md +32 -2
  134. package/src/snapshot-tools.test.ts +3 -4
  135. package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
  136. package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
  137. package/src/test-utils.ts +25 -11
package/src/cli.ts CHANGED
@@ -4,14 +4,23 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import util from 'node:util'
6
6
  import { fileURLToPath } from 'node:url'
7
- import { cac } from '@xmorse/cac'
7
+ import { goke } from 'goke'
8
+ import { z } from 'zod'
8
9
  import pc from 'picocolors'
10
+ import {
11
+ getBrowserLaunchArgs,
12
+ getDefaultBrowserUserDataDir,
13
+ startBrowserProcess,
14
+ } from './browser-launch.js'
15
+ import { resolveBrowserExecutablePath, shouldUseHeadlessByDefault } from './browser-config.js'
16
+ import { getBundledExtensionPath } from './package-paths.js'
9
17
 
10
18
  // Prevent Buffers from dumping hex bytes in util.inspect output.
11
19
  Buffer.prototype[util.inspect.custom] = function () {
12
20
  return `<Buffer ${this.length} bytes>`
13
21
  }
14
22
  import { killPortProcess } from './kill-port.js'
23
+ import { canEmitKittyGraphics, emitKittyImage } from './kitty-graphics.js'
15
24
  import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js'
16
25
  import {
17
26
  ensureRelayServer,
@@ -21,12 +30,73 @@ import {
21
30
  getExtensionStatus,
22
31
  type ExtensionStatus,
23
32
  } from './relay-client.js'
33
+ import { discoverChromeInstances, resolveDirectInput, type DiscoveredInstance } from './chrome-discovery.js'
24
34
 
25
35
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
26
36
 
27
37
  const cliRelayEnv = { PLAYWRITER_AUTO_ENABLE: '1' }
28
38
 
29
- const cli = cac('playwriter')
39
+ const cli = goke('playwriter')
40
+
41
+ cli
42
+ .command('browser start [binaryPath]', 'Start Chromium or Chrome for Testing with the bundled Playwriter extension')
43
+ .hidden()
44
+ .option('--user-data-dir <dir>', 'Persistent browser profile directory used for the managed browser')
45
+ .option('--headless', 'Run the browser in headless mode')
46
+ .option('--headed', 'Force headed mode even on Linux without DISPLAY/WAYLAND_DISPLAY')
47
+ .option('--disable-sandbox', 'Disable the browser sandbox, useful on some VPS setups')
48
+ .action(async (binaryPath, options) => {
49
+ if (options.headless && options.headed) {
50
+ console.error('Error: --headless and --headed cannot be used together.')
51
+ process.exit(1)
52
+ }
53
+
54
+ try {
55
+ await ensureRelayServer({ logger: console, env: cliRelayEnv })
56
+
57
+ const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath })
58
+ const extensionPath = getBundledExtensionPath()
59
+ const userDataDir = path.resolve(options.userDataDir || getDefaultBrowserUserDataDir())
60
+ const headless = options.headed ? false : options.headless ? true : shouldUseHeadlessByDefault()
61
+ const args = getBrowserLaunchArgs({
62
+ extensionPath,
63
+ userDataDir,
64
+ headless,
65
+ noSandbox: options.disableSandbox,
66
+ })
67
+
68
+ const { pid } = startBrowserProcess({
69
+ browserPath,
70
+ args,
71
+ userDataDir,
72
+ })
73
+
74
+ const connectedExtensions = await waitForConnectedExtensions({
75
+ timeoutMs: 15000,
76
+ pollIntervalMs: 250,
77
+ logger: console,
78
+ })
79
+
80
+ console.log(`Browser started (pid ${pid}).`)
81
+ console.log(` Binary: ${browserPath}`)
82
+ console.log(` Extension: ${extensionPath}`)
83
+ console.log(` Profile: ${userDataDir}`)
84
+ console.log(` Mode: ${headless ? 'headless' : 'headed'}`)
85
+ console.log(' Permissions: recording/tabCapture flags enabled')
86
+
87
+ if (connectedExtensions.length > 0) {
88
+ console.log('Playwriter extension connected to the relay server.')
89
+ return
90
+ }
91
+
92
+ console.log('Browser started, but the extension has not connected yet.')
93
+ console.log(`Check logs at: ${LOG_FILE_PATH}`)
94
+ } catch (error: any) {
95
+ console.error(`Error: ${error.message}`)
96
+ process.exit(1)
97
+ }
98
+ },
99
+ )
30
100
 
31
101
  cli
32
102
  .command('', 'Start the MCP server or controls the browser with -e')
@@ -34,8 +104,8 @@ cli
34
104
  .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
35
105
  .option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
36
106
  .option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
37
- .option('--timeout <ms>', 'Execution timeout in milliseconds', { default: 10000 })
38
- .action(async (options: { host?: string; token?: string; eval?: string; timeout?: number; session?: string }) => {
107
+ .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
108
+ .action(async (options) => {
39
109
  // If -e flag is provided, execute code via relay server
40
110
  if (options.eval) {
41
111
  await executeCode({
@@ -49,6 +119,7 @@ cli
49
119
  }
50
120
 
51
121
  // Otherwise start the MCP server
122
+ // For direct CDP in MCP mode, use PLAYWRITER_DIRECT env var
52
123
  const { startMcp } = await import('./mcp.js')
53
124
  await startMcp({
54
125
  host: options.host,
@@ -171,6 +242,7 @@ async function executeCode(options: {
171
242
  const result = (await response.json()) as {
172
243
  text: string
173
244
  images: Array<{ data: string; mimeType: string }>
245
+ screenshots: Array<{ path: string; base64: string; snapshot: string; labelCount: number }>
174
246
  isError: boolean
175
247
  }
176
248
 
@@ -183,9 +255,36 @@ async function executeCode(options: {
183
255
  }
184
256
  }
185
257
 
186
- // Note: images are base64 encoded, we could save them to files if needed
187
- if (result.images && result.images.length > 0) {
188
- console.log(`\n${result.images.length} screenshot(s) captured`)
258
+ // Emit images via Kitty Graphics Protocol when AGENT_GRAPHICS=kitty.
259
+ // Agents with kitty-graphics-agent intercept these escape sequences and pass
260
+ // the PNG images to the LLM as media parts — no extra tool call needed.
261
+ const kittyEnabled = canEmitKittyGraphics()
262
+
263
+ // Track emitted base64 to avoid duplicates (screenshots appear in both
264
+ // result.screenshots and result.images from the same screenshotCollector)
265
+ const emittedImages = new Set<string>()
266
+
267
+ if (result.screenshots && result.screenshots.length > 0) {
268
+ for (const s of result.screenshots) {
269
+ if (kittyEnabled && s.base64) {
270
+ emitKittyImage({ base64: s.base64 })
271
+ emittedImages.add(s.base64)
272
+ }
273
+ console.log(`\nScreenshot saved to: ${s.path}`)
274
+ console.log(`Labels shown: ${s.labelCount}\n`)
275
+ console.log(`Accessibility snapshot:\n${s.snapshot}`)
276
+ }
277
+ }
278
+
279
+ // Emit resized images from resizeImageForAgent() calls that aren't
280
+ // already emitted as part of labeled screenshots
281
+ if (kittyEnabled && result.images && result.images.length > 0) {
282
+ for (const img of result.images) {
283
+ if (img.data && !emittedImages.has(img.data)) {
284
+ emitKittyImage({ base64: img.data })
285
+ emittedImages.add(img.data)
286
+ }
287
+ }
189
288
  }
190
289
 
191
290
  if (result.isError) {
@@ -204,13 +303,106 @@ async function executeCode(options: {
204
303
  }
205
304
 
206
305
  // Session management commands
306
+ // Unified browser option type used in the multi-browser selection table
307
+ interface BrowserOption {
308
+ key: string
309
+ type: 'extension' | 'direct'
310
+ browser: string
311
+ profile: string
312
+ /** For extension entries */
313
+ extensionId?: string | null
314
+ /** For direct CDP entries */
315
+ wsUrl?: string
316
+ /** Raw profile data from discovery (for passing to relay) */
317
+ profiles?: Array<{ name: string; email: string }>
318
+ }
319
+
207
320
  cli
208
321
  .command('session new', 'Create a new session and print the session ID')
209
322
  .option('--host <host>', 'Remote relay server host')
210
- .option('--browser <stableKey>', 'Stable browser key when multiple browsers are connected')
211
- .action(async (options: { host?: string; browser?: string }) => {
323
+ .option('--browser <key>', 'Browser key when multiple browsers are available')
324
+ .option('--direct [endpoint]', 'Use direct CDP connection without the extension. Enable debugging first at chrome://inspect/#remote-debugging or launch Chrome with --remote-debugging-port=9222. Auto-discovers instances or accepts an explicit ws:// endpoint')
325
+ .action(async (options) => {
212
326
  const isLocal = !options.host && !process.env.PLAYWRITER_HOST
327
+ const directEndpoint = typeof options.direct === 'string' ? options.direct : null
328
+
329
+ // If --direct with explicit endpoint, resolve it (handles host:port → ws://) then skip discovery
330
+ if (directEndpoint) {
331
+ let cdpEndpoint: string
332
+ try {
333
+ cdpEndpoint = await resolveDirectInput(directEndpoint)
334
+ } catch (error: any) {
335
+ console.error(`Error: ${error.message}`)
336
+ process.exit(1)
337
+ }
338
+ await ensureRelayForSessionCreation(isLocal)
339
+ const serverUrl = await getServerUrl(options.host)
340
+ const result = await createDirectSession({ serverUrl, cdpEndpoint })
341
+ console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
342
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
343
+ return
344
+ }
213
345
 
346
+ // If --direct with no endpoint, discover Chrome instances
347
+ if (options.direct === true) {
348
+ if (!isLocal) {
349
+ console.error('Error: --direct auto-discovery only works locally.')
350
+ console.error('For remote relay, pass an explicit endpoint reachable from the relay host:')
351
+ console.error(' playwriter session new --host <host> --direct ws://relay-host:9222/devtools/browser/...')
352
+ process.exit(1)
353
+ }
354
+ await ensureRelayForSessionCreation(isLocal)
355
+ console.log(pc.dim('Discovering Chrome instances with debugging enabled...'))
356
+ const instances = await discoverChromeInstances()
357
+
358
+ if (instances.length === 0) {
359
+ console.error('No Chrome instances with debugging enabled found.')
360
+ console.error('')
361
+ console.error('Enable debugging in one of these ways:')
362
+ console.error(' 1. Open chrome://inspect/#remote-debugging in Chrome')
363
+ console.error(' 2. Launch Chrome with: chrome --remote-debugging-port=9222')
364
+ process.exit(1)
365
+ }
366
+
367
+ if (instances.length === 1 && !options.browser) {
368
+ const instance = instances[0]
369
+ const serverUrl = await getServerUrl(options.host)
370
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles })
371
+ const profileLabel = formatInstanceProfiles(instance)
372
+ console.log(
373
+ `Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: playwriter -s ${result.id} -e "..."`,
374
+ )
375
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
376
+ return
377
+ }
378
+
379
+ // Multiple instances or --browser specified
380
+ const directOptions = instances.map((instance) => {
381
+ return instanceToBrowserOption(instance)
382
+ })
383
+
384
+ if (options.browser) {
385
+ const selected = directOptions.find((opt) => {
386
+ return opt.key === options.browser
387
+ })
388
+ if (!selected) {
389
+ console.error(`Browser not found: ${options.browser}`)
390
+ console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '))
391
+ process.exit(1)
392
+ }
393
+ const serverUrl = await getServerUrl(options.host)
394
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
395
+ console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
396
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
397
+ return
398
+ }
399
+
400
+ printBrowserTable(directOptions)
401
+ console.log('\nRun again with --browser <key>.')
402
+ process.exit(1)
403
+ }
404
+
405
+ // Default mode: extension-based (existing behavior)
214
406
  let extensions: ExtensionStatus[] = []
215
407
 
216
408
  if (isLocal) {
@@ -235,6 +427,7 @@ cli
235
427
 
236
428
  if (extensions.length === 0) {
237
429
  console.error('No connected browsers detected. Click the Playwriter extension icon.')
430
+ console.error(pc.dim('Tip: Use --direct to connect via Chrome DevTools Protocol instead.'))
238
431
  process.exit(1)
239
432
  }
240
433
 
@@ -247,64 +440,184 @@ cli
247
440
  }
248
441
  }
249
442
 
250
- let selectedExtension: ExtensionStatus | null = null
251
-
252
- if (extensions.length === 1) {
253
- selectedExtension = extensions[0]
254
- } else if (!options.browser) {
255
- console.log('Multiple browsers detected:\n')
256
- console.log('KEY BROWSER PROFILE')
257
- console.log('----------------------- ------- -------')
258
- for (const extension of extensions) {
259
- const label = extension.profile?.email || '(not signed in)'
260
- const stableKey = extension.stableKey || '-'
261
- console.log(`${stableKey.padEnd(23)} ${(extension.browser || 'Chrome').padEnd(7)} ${label}`)
262
- }
263
- console.log('\nRun again with --browser <stableKey>.')
264
- process.exit(1)
265
- } else {
266
- const browserArg = options.browser
267
- selectedExtension = extensions.find((extension) => extension.stableKey === browserArg) || null
268
- if (!selectedExtension) {
269
- console.error(`Browser not found: ${browserArg}`)
443
+ // Single extension: auto-select (unchanged behavior)
444
+ if (extensions.length === 1 && !options.browser) {
445
+ const selectedExtension = extensions[0]
446
+ try {
447
+ const serverUrl = await getServerUrl(options.host)
448
+ const extensionId =
449
+ selectedExtension.extensionId === 'default'
450
+ ? null
451
+ : selectedExtension.stableKey || selectedExtension.extensionId
452
+ const cwd = process.cwd()
453
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
454
+ method: 'POST',
455
+ headers: { 'Content-Type': 'application/json' },
456
+ body: JSON.stringify({ extensionId, cwd }),
457
+ })
458
+ if (!response.ok) {
459
+ const text = await response.text()
460
+ console.error(`Error: ${response.status} ${text}`)
461
+ process.exit(1)
462
+ }
463
+ const result = (await response.json()) as { id: string; extensionId: string | null }
464
+ console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
465
+ } catch (error: any) {
466
+ console.error(`Error: ${error.message}`)
270
467
  process.exit(1)
271
468
  }
469
+ return
272
470
  }
273
471
 
274
- if (!selectedExtension) {
275
- console.error('Unable to determine browser identity.')
276
- process.exit(1)
277
- }
278
-
279
- try {
280
- const serverUrl = await getServerUrl(options.host)
281
- const extensionId =
282
- selectedExtension.extensionId === 'default'
283
- ? null
284
- : selectedExtension.stableKey || selectedExtension.extensionId
285
- const cwd = process.cwd()
286
- const response = await fetch(`${serverUrl}/cli/session/new`, {
287
- method: 'POST',
288
- headers: { 'Content-Type': 'application/json' },
289
- body: JSON.stringify({ extensionId, cwd }),
472
+ // Multiple extensions: also discover direct CDP instances and show unified table.
473
+ // Only discover locally — remote relay can't reach local Chrome debug ports.
474
+ const directInstances = isLocal ? await (async () => {
475
+ console.log(pc.dim('Discovering additional Chrome instances...'))
476
+ return await discoverChromeInstances()
477
+ })() : []
478
+
479
+ const allOptions: BrowserOption[] = [
480
+ ...extensions.map((ext) => {
481
+ return {
482
+ key: ext.stableKey || ext.extensionId,
483
+ type: 'extension' as const,
484
+ browser: ext.browser || 'Chrome',
485
+ profile: ext.profile?.email || '(not signed in)',
486
+ extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
487
+ }
488
+ }),
489
+ ...directInstances.map((instance) => {
490
+ return instanceToBrowserOption(instance)
491
+ }),
492
+ ]
493
+
494
+ if (options.browser) {
495
+ const selected = allOptions.find((opt) => {
496
+ return opt.key === options.browser
290
497
  })
291
- if (!response.ok) {
292
- const text = await response.text()
293
- console.error(`Error: ${response.status} ${text}`)
498
+ if (!selected) {
499
+ console.error(`Browser not found: ${options.browser}`)
500
+ console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '))
294
501
  process.exit(1)
295
502
  }
296
- const result = (await response.json()) as { id: string; extensionId: string | null }
297
- console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
298
- } catch (error: any) {
299
- console.error(`Error: ${error.message}`)
300
- process.exit(1)
503
+
504
+ try {
505
+ const serverUrl = await getServerUrl(options.host)
506
+ if (selected.type === 'direct') {
507
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
508
+ console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
509
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
510
+ } else {
511
+ const cwd = process.cwd()
512
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
513
+ method: 'POST',
514
+ headers: { 'Content-Type': 'application/json' },
515
+ body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
516
+ })
517
+ if (!response.ok) {
518
+ const text = await response.text()
519
+ console.error(`Error: ${response.status} ${text}`)
520
+ process.exit(1)
521
+ }
522
+ const result = (await response.json()) as { id: string }
523
+ console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
524
+ }
525
+ } catch (error: any) {
526
+ console.error(`Error: ${error.message}`)
527
+ process.exit(1)
528
+ }
529
+ return
301
530
  }
531
+
532
+ // Show unified table
533
+ console.log('\nMultiple browsers detected:\n')
534
+ printBrowserTable(allOptions)
535
+ console.log('\nRun again with --browser <key>.')
536
+ process.exit(1)
537
+ })
538
+
539
+ async function ensureRelayForSessionCreation(isLocal: boolean): Promise<void> {
540
+ if (isLocal) {
541
+ await ensureRelayServer({ logger: console, env: cliRelayEnv })
542
+ }
543
+ }
544
+
545
+ async function createDirectSession({
546
+ serverUrl,
547
+ cdpEndpoint,
548
+ browser,
549
+ profiles,
550
+ }: {
551
+ serverUrl: string
552
+ cdpEndpoint: string
553
+ browser?: string
554
+ profiles?: Array<{ name: string; email: string }>
555
+ }): Promise<{ id: string }> {
556
+ const cwd = process.cwd()
557
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
558
+ method: 'POST',
559
+ headers: { 'Content-Type': 'application/json' },
560
+ body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
561
+ })
562
+ if (!response.ok) {
563
+ const text = await response.text()
564
+ throw new Error(`${response.status} ${text}`)
565
+ }
566
+ return (await response.json()) as { id: string }
567
+ }
568
+
569
+ function instanceToBrowserOption(instance: DiscoveredInstance): BrowserOption {
570
+ return {
571
+ key: `direct:${instance.port}`,
572
+ type: 'direct',
573
+ browser: instance.browser,
574
+ profile: formatInstanceProfiles(instance),
575
+ wsUrl: instance.wsUrl,
576
+ profiles: instance.profiles,
577
+ }
578
+ }
579
+
580
+ function formatInstanceProfiles(instance: DiscoveredInstance): string {
581
+ if (instance.profiles.length === 0) {
582
+ return '(unknown)'
583
+ }
584
+ return instance.profiles
585
+ .map((p) => {
586
+ return p.email ? `${p.name} (${p.email})` : p.name
587
+ })
588
+ .join(', ')
589
+ }
590
+
591
+ function printBrowserTable(options: BrowserOption[]): void {
592
+ const typeLabels = options.map((opt) => {
593
+ return opt.type === 'direct' ? '--direct' : opt.type
302
594
  })
595
+ const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length))
596
+ const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length))
597
+ const browserWidth = Math.max(7, ...options.map((opt) => opt.browser.length))
598
+
599
+ console.log(
600
+ 'KEY'.padEnd(keyWidth) + ' ' + 'TYPE'.padEnd(typeWidth) + ' ' + 'BROWSER'.padEnd(browserWidth) + ' ' + 'PROFILE',
601
+ )
602
+ console.log('-'.repeat(keyWidth + typeWidth + browserWidth + 20))
603
+ for (let i = 0; i < options.length; i++) {
604
+ const opt = options[i]
605
+ console.log(
606
+ opt.key.padEnd(keyWidth) +
607
+ ' ' +
608
+ typeLabels[i].padEnd(typeWidth) +
609
+ ' ' +
610
+ opt.browser.padEnd(browserWidth) +
611
+ ' ' +
612
+ opt.profile,
613
+ )
614
+ }
615
+ }
303
616
 
304
617
  cli
305
618
  .command('session list', 'List all active sessions')
306
619
  .option('--host <host>', 'Remote relay server host')
307
- .action(async (options: { host?: string }) => {
620
+ .action(async (options) => {
308
621
  if (!options.host && !process.env.PLAYWRITER_HOST) {
309
622
  await ensureRelayServer({ logger: console, env: cliRelayEnv })
310
623
  }
@@ -316,6 +629,7 @@ cli
316
629
  browser: string | null
317
630
  profile: { email: string; id: string } | null
318
631
  extensionId: string | null
632
+ cwd: string | null
319
633
  }> = []
320
634
 
321
635
  try {
@@ -333,6 +647,7 @@ cli
333
647
  browser: string | null
334
648
  profile: { email: string; id: string } | null
335
649
  extensionId: string | null
650
+ cwd: string | null
336
651
  }>
337
652
  }
338
653
  sessions = result.sessions
@@ -350,6 +665,7 @@ cli
350
665
  const browserWidth = Math.max(7, ...sessions.map((session) => (session.browser || 'Chrome').length))
351
666
  const profileWidth = Math.max(7, ...sessions.map((session) => (session.profile?.email || '').length || 1))
352
667
  const extensionWidth = Math.max(2, ...sessions.map((session) => (session.extensionId || '').length || 1))
668
+ const cwdWidth = Math.max(3, ...sessions.map((session) => (session.cwd || '').length || 1))
353
669
  const stateWidth = Math.max(10, ...sessions.map((session) => session.stateKeys.join(', ').length || 1))
354
670
 
355
671
  console.log(
@@ -361,13 +677,16 @@ cli
361
677
  ' ' +
362
678
  'EXT'.padEnd(extensionWidth) +
363
679
  ' ' +
680
+ 'CWD'.padEnd(cwdWidth) +
681
+ ' ' +
364
682
  'STATE KEYS',
365
683
  )
366
- console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8))
684
+ console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + cwdWidth + stateWidth + 10))
367
685
 
368
686
  for (const session of sessions) {
369
687
  const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-'
370
688
  const profileLabel = session.profile?.email || '-'
689
+ const cwdLabel = session.cwd || '-'
371
690
  console.log(
372
691
  String(session.id).padEnd(idWidth) +
373
692
  ' ' +
@@ -377,6 +696,8 @@ cli
377
696
  ' ' +
378
697
  (session.extensionId || '-').padEnd(extensionWidth) +
379
698
  ' ' +
699
+ cwdLabel.padEnd(cwdWidth) +
700
+ ' ' +
380
701
  stateStr,
381
702
  )
382
703
  }
@@ -385,7 +706,7 @@ cli
385
706
  cli
386
707
  .command('session delete <sessionId>', 'Delete a session and clear its state')
387
708
  .option('--host <host>', 'Remote relay server host')
388
- .action(async (sessionId: string, options: { host?: string }) => {
709
+ .action(async (sessionId, options) => {
389
710
  const serverUrl = await getServerUrl(options.host)
390
711
 
391
712
  if (!options.host && !process.env.PLAYWRITER_HOST) {
@@ -415,7 +736,7 @@ cli
415
736
  cli
416
737
  .command('session reset <sessionId>', 'Reset the browser connection for a session')
417
738
  .option('--host <host>', 'Remote relay server host')
418
- .action(async (sessionId: string, options: { host?: string }) => {
739
+ .action(async (sessionId, options) => {
419
740
  const cwd = process.cwd()
420
741
  const serverUrl = await getServerUrl(options.host)
421
742
 
@@ -451,10 +772,10 @@ cli
451
772
  'serve',
452
773
  `Start the relay server on this machine (must be the same host where Chrome is running). Remote clients (Docker, other machines) connect via PLAYWRITER_HOST. Use --host localhost for Docker (no token needed) — containers reach it via host.docker.internal. Use --host 0.0.0.0 for LAN/internet access (requires --token).`,
453
774
  )
454
- .option('--host <host>', 'Host to bind to (use "localhost" for Docker, "0.0.0.0" for remote access)', { default: '0.0.0.0' })
775
+ .option('--host [host]', z.string().default('0.0.0.0').describe('Host to bind to (use "localhost" for Docker, "0.0.0.0" for remote access)'))
455
776
  .option('--token <token>', 'Authentication token, required when --host is 0.0.0.0 (or use PLAYWRITER_TOKEN env var)')
456
777
  .option('--replace', 'Kill existing server if running')
457
- .action(async (options: { host: string; token?: string; replace?: boolean }) => {
778
+ .action(async (options) => {
458
779
  const token = options.token || process.env.PLAYWRITER_TOKEN
459
780
  const isPublicHost = options.host === '0.0.0.0' || options.host === '::'
460
781
  if (isPublicHost && !token) {
@@ -543,6 +864,58 @@ cli
543
864
  })
544
865
  })
545
866
 
867
+ cli
868
+ .command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
869
+ .option('--host <host>', z.string().describe('Remote relay server host'))
870
+ .action(async (options) => {
871
+ const isLocal = !options.host && !process.env.PLAYWRITER_HOST
872
+
873
+ // Start relay if local so the extension can connect, then fetch in parallel
874
+ if (isLocal) {
875
+ await ensureRelayServer({ logger: console, env: cliRelayEnv })
876
+ }
877
+
878
+ const [extensions, directInstances] = await Promise.all([
879
+ isLocal
880
+ ? waitForConnectedExtensions({ timeoutMs: 2000, pollIntervalMs: 200, logger: console })
881
+ : fetchExtensionsStatus(options.host),
882
+ isLocal ? discoverChromeInstances() : Promise.resolve([] as DiscoveredInstance[]),
883
+ ])
884
+
885
+ const allOptions: BrowserOption[] = [
886
+ ...extensions.map((ext) => {
887
+ return {
888
+ key: ext.stableKey || ext.extensionId,
889
+ type: 'extension' as const,
890
+ browser: ext.browser || 'Chrome',
891
+ profile: ext.profile?.email || '(not signed in)',
892
+ extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
893
+ }
894
+ }),
895
+ ...directInstances.map(instanceToBrowserOption),
896
+ ]
897
+
898
+ if (allOptions.length === 0) {
899
+ console.log('No browsers detected.\n')
900
+ console.log(' Extension: click the Playwriter icon on a tab to connect')
901
+ console.log(' Direct: open chrome://inspect/#remote-debugging in Chrome')
902
+ return
903
+ }
904
+
905
+ printBrowserTable(allOptions)
906
+ console.log('')
907
+
908
+ const hasDirectInstances = allOptions.some((opt) => {
909
+ return opt.type === 'direct'
910
+ })
911
+ if (hasDirectInstances) {
912
+ console.log(pc.dim('Connect with: playwriter session new --direct'))
913
+ console.log(pc.dim('Chrome may ask to approve the debugging connection.'))
914
+ } else {
915
+ console.log(pc.dim('Use with: playwriter session new [--browser <key>]'))
916
+ }
917
+ })
918
+
546
919
  cli.command('logfile', 'Print the path to the relay server log file').action(() => {
547
920
  console.log(`relay: ${LOG_FILE_PATH}`)
548
921
  console.log(`cdp: ${LOG_CDP_FILE_PATH}`)