@narumitw/pi-chrome-devtools 0.1.33 → 0.1.35

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 CHANGED
@@ -20,6 +20,9 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
20
20
  - Uses a local Chrome DevTools Protocol endpoint.
21
21
  - Retries briefly while Chrome is starting and reports actionable endpoint errors.
22
22
  - Shows statusline activity only while Chrome DevTools tools are running.
23
+ - Provides a `/chrome-devtools` menu with quick-start help and tool controls.
24
+ - Provides a Plan-mode-style selector for choosing individual Chrome DevTools tools.
25
+ - Persists the selected Chrome DevTools tools across Pi restarts.
23
26
 
24
27
  ## 📦 Install
25
28
 
@@ -75,7 +78,40 @@ PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extension
75
78
  /chrome-devtools
76
79
  ```
77
80
 
78
- Shows the configured CDP endpoint and a quick-start hint.
81
+ Opens a menu with quick-start help, command usage, tool status, controls for enabling or
82
+ disabling all Chrome DevTools tools, and a selector for choosing individual tools.
83
+
84
+ Direct subcommands are also available:
85
+
86
+ ```text
87
+ /chrome-devtools help
88
+ /chrome-devtools quickstart
89
+ /chrome-devtools status
90
+ /chrome-devtools tools
91
+ /chrome-devtools toggle
92
+ /chrome-devtools enable
93
+ /chrome-devtools disable
94
+ ```
95
+
96
+ - `help` shows command usage.
97
+ - `quickstart` shows the configured CDP endpoint and launch hint.
98
+ - `status` shows runtime tool state, persisted selection, settings file path, and active
99
+ non-Chrome tool count.
100
+ - `tools` opens a Plan-mode-style selector for choosing individual `chrome_devtools_*` tools.
101
+ - `toggle` is an alias for `tools`.
102
+ - `enable` enables all `chrome_devtools_*` tools for future turns.
103
+ - `disable` disables all `chrome_devtools_*` tools for future turns. The slash command remains
104
+ available.
105
+
106
+ The selected tool names are saved to:
107
+
108
+ ```text
109
+ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools-settings.json
110
+ ```
111
+
112
+ When the file is missing or invalid, the extension preserves Pi's current active-tool policy
113
+ instead of enabling tools by itself. A valid saved selection is restored on Pi startup and
114
+ `/reload`.
79
115
 
80
116
  ## 🧠 Use cases
81
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,11 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
1
4
  import {
2
5
  defineTool,
3
6
  type AgentToolResult,
4
7
  type ExtensionAPI,
8
+ type ExtensionCommandContext,
5
9
  type ToolRenderResultOptions,
6
10
  } from "@mariozechner/pi-coding-agent";
7
11
  import { Type } from "typebox";
@@ -13,6 +17,53 @@ const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
13
17
  const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
14
18
  const DEFAULT_ENDPOINT_RETRY_MS = 250;
15
19
  const STATUS_KEY = "chrome-devtools";
20
+ const SETTINGS_FILE = join(
21
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
22
+ "pi-chrome-devtools-settings.json",
23
+ );
24
+ const CHROME_DEVTOOLS_TOOL_NAMES = [
25
+ "chrome_devtools_list_pages",
26
+ "chrome_devtools_select_page",
27
+ "chrome_devtools_navigate",
28
+ "chrome_devtools_evaluate",
29
+ "chrome_devtools_screenshot",
30
+ ] as const;
31
+ const COMMAND_COMPLETIONS = [
32
+ { value: "help", label: "Show command usage" },
33
+ { value: "quickstart", label: "Show endpoint and launch help" },
34
+ { value: "status", label: "Show tool and settings status" },
35
+ { value: "tools", label: "Select Chrome DevTools tools" },
36
+ { value: "toggle", label: "Select Chrome DevTools tools" },
37
+ { value: "enable", label: "Enable all Chrome DevTools tools" },
38
+ { value: "disable", label: "Disable all Chrome DevTools tools" },
39
+ ];
40
+ const MENU_OPTIONS = {
41
+ quickstart: "Quick start / endpoint help",
42
+ help: "Command usage guide",
43
+ status: "Show tool status",
44
+ tools: "Select Chrome DevTools tools",
45
+ enable: "Enable all Chrome DevTools tools",
46
+ disable: "Disable all Chrome DevTools tools",
47
+ } as const;
48
+ const TOOL_SELECTOR_DONE = "Done";
49
+ const TOOL_SELECTOR_ENABLE_ALL = "Enable all Chrome DevTools tools";
50
+ const TOOL_SELECTOR_DISABLE_ALL = "Disable all Chrome DevTools tools";
51
+
52
+ type ChromeDevToolsToolName = (typeof CHROME_DEVTOOLS_TOOL_NAMES)[number];
53
+ type ToolRuntimeStatus = "enabled" | "disabled" | "partial";
54
+ type CommandAction =
55
+ | "menu"
56
+ | "help"
57
+ | "quickstart"
58
+ | "status"
59
+ | "tools"
60
+ | "enable"
61
+ | "disable";
62
+ type CommandContext = ExtensionCommandContext;
63
+ type ToolSelectorAction = "enableAll" | "disableAll" | "done";
64
+ type ToolSelectorRow =
65
+ | { kind: "tool"; toolName: ChromeDevToolsToolName }
66
+ | { kind: "action"; action: ToolSelectorAction; label: string };
16
67
 
17
68
  interface StatusContext {
18
69
  ui: { setStatus: (key: string, value: string | undefined) => void };
@@ -42,6 +93,17 @@ interface ChromeDevToolsState {
42
93
  activePageId?: string;
43
94
  }
44
95
 
96
+ interface ChromeDevToolsSettings {
97
+ tools: ChromeDevToolsToolName[];
98
+ updatedAt: number;
99
+ }
100
+
101
+ interface ToolStatusSummary {
102
+ runtimeStatus: ToolRuntimeStatus;
103
+ activeChromeToolCount: number;
104
+ activeNonChromeToolCount: number;
105
+ }
106
+
45
107
  interface CdpResponse<T = unknown> {
46
108
  id: number;
47
109
  result?: T;
@@ -58,7 +120,7 @@ const state: ChromeDevToolsState = {
58
120
  };
59
121
 
60
122
  const listPagesTool = defineTool({
61
- name: "chrome_devtools_list_pages",
123
+ name: CHROME_DEVTOOLS_TOOL_NAMES[0],
62
124
  label: "Chrome DevTools: List Pages",
63
125
  description: "List Chrome tabs/pages from a running Chrome DevTools Protocol endpoint.",
64
126
  promptSnippet: "List Chrome tabs/pages available over Chrome DevTools Protocol",
@@ -74,7 +136,7 @@ const listPagesTool = defineTool({
74
136
  });
75
137
 
76
138
  const selectPageTool = defineTool({
77
- name: "chrome_devtools_select_page",
139
+ name: CHROME_DEVTOOLS_TOOL_NAMES[1],
78
140
  label: "Chrome DevTools: Select Page",
79
141
  description: "Select the active Chrome page for later chrome_devtools_* tool calls.",
80
142
  promptSnippet: "Select the Chrome tab/page to inspect or control",
@@ -95,7 +157,7 @@ const selectPageTool = defineTool({
95
157
  });
96
158
 
97
159
  const navigateTool = defineTool({
98
- name: "chrome_devtools_navigate",
160
+ name: CHROME_DEVTOOLS_TOOL_NAMES[2],
99
161
  label: "Chrome DevTools: Navigate",
100
162
  description:
101
163
  "Navigate a Chrome page to a URL through Chrome DevTools Protocol, creating a page first if none is available.",
@@ -128,7 +190,7 @@ const navigateTool = defineTool({
128
190
  });
129
191
 
130
192
  const evaluateTool = defineTool({
131
- name: "chrome_devtools_evaluate",
193
+ name: CHROME_DEVTOOLS_TOOL_NAMES[3],
132
194
  label: "Chrome DevTools: Evaluate",
133
195
  description: "Evaluate JavaScript in a Chrome page through Chrome DevTools Protocol.",
134
196
  promptSnippet: "Evaluate JavaScript in the selected Chrome tab",
@@ -161,7 +223,7 @@ const evaluateTool = defineTool({
161
223
  });
162
224
 
163
225
  const screenshotTool = defineTool({
164
- name: "chrome_devtools_screenshot",
226
+ name: CHROME_DEVTOOLS_TOOL_NAMES[4],
165
227
  label: "Chrome DevTools: Screenshot",
166
228
  description: "Capture a PNG screenshot from a Chrome page through Chrome DevTools Protocol.",
167
229
  promptSnippet: "Capture a screenshot from the selected Chrome tab",
@@ -222,14 +284,23 @@ export default function chromeDevtools(pi: ExtensionAPI) {
222
284
  pi.registerTool(screenshotTool);
223
285
 
224
286
  pi.registerCommand("chrome-devtools", {
225
- description: "Show Chrome DevTools endpoint and quick start help",
226
- handler: async (_args, ctx) => {
227
- ctx.ui.notify(`Chrome DevTools endpoint: ${devToolsEndpoint()}\n${launchHint()}`, "info");
287
+ description: "Open Chrome DevTools help and tool controls",
288
+ getArgumentCompletions: (prefix) => commandCompletions(prefix),
289
+ handler: async (args, ctx) => {
290
+ await handleChromeDevtoolsCommand(pi, args, ctx);
228
291
  },
229
292
  });
230
293
 
231
- pi.on("session_start", (_event, ctx) => {
294
+ pi.on("session_start", async (_event, ctx) => {
232
295
  ctx.ui.setStatus(STATUS_KEY, undefined);
296
+ const settings = await loadSettings();
297
+ if (settings.kind === "loaded") {
298
+ applyChromeDevtoolsTools(pi, settings.settings.tools);
299
+ return;
300
+ }
301
+ if (settings.kind === "invalid") {
302
+ ctx.ui.notify(`Chrome DevTools settings ignored: ${settings.reason}`, "warning");
303
+ }
233
304
  });
234
305
 
235
306
  pi.on("session_shutdown", (_event, ctx) => {
@@ -237,6 +308,450 @@ export default function chromeDevtools(pi: ExtensionAPI) {
237
308
  });
238
309
  }
239
310
 
311
+ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx: CommandContext) {
312
+ const command = parseCommand(args);
313
+ switch (command) {
314
+ case "menu":
315
+ await showMenu(pi, ctx);
316
+ return;
317
+ case "help":
318
+ ctx.ui.notify(buildCommandGuide(), "info");
319
+ return;
320
+ case "quickstart":
321
+ ctx.ui.notify(buildQuickstartMessage(), "info");
322
+ return;
323
+ case "status":
324
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
325
+ return;
326
+ case "tools":
327
+ await showToolSelector(pi, ctx);
328
+ return;
329
+ case "enable":
330
+ await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
331
+ return;
332
+ case "disable":
333
+ await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
334
+ return;
335
+ }
336
+
337
+ ctx.ui.notify(`Unknown /chrome-devtools command: ${args.trim()}
338
+
339
+ ${buildCommandGuide()}`, "warning");
340
+ }
341
+
342
+ async function showMenu(pi: ExtensionAPI, ctx: CommandContext) {
343
+ if (!ctx.hasUI) {
344
+ ctx.ui.notify(`${buildCommandGuide()}
345
+
346
+ ${await buildToolStatusMessage(pi)}`, "info");
347
+ return;
348
+ }
349
+
350
+ const choice = await ctx.ui.select("Chrome DevTools", Object.values(MENU_OPTIONS));
351
+ switch (choice) {
352
+ case MENU_OPTIONS.quickstart:
353
+ ctx.ui.notify(buildQuickstartMessage(), "info");
354
+ return;
355
+ case MENU_OPTIONS.help:
356
+ ctx.ui.notify(buildCommandGuide(), "info");
357
+ return;
358
+ case MENU_OPTIONS.status:
359
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
360
+ return;
361
+ case MENU_OPTIONS.tools:
362
+ await showToolSelector(pi, ctx);
363
+ return;
364
+ case MENU_OPTIONS.enable:
365
+ await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
366
+ return;
367
+ case MENU_OPTIONS.disable:
368
+ await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
369
+ return;
370
+ }
371
+ }
372
+
373
+ function parseCommand(args: string): CommandAction | "unknown" {
374
+ const command = args.trim().toLowerCase();
375
+ if (!command) return "menu";
376
+ if (command === "help") return "help";
377
+ if (command === "quickstart") return "quickstart";
378
+ if (command === "status") return "status";
379
+ if (command === "tools" || command === "select" || command === "toggle") return "tools";
380
+ if (command === "enable" || command === "on") return "enable";
381
+ if (command === "disable" || command === "off") return "disable";
382
+ return "unknown";
383
+ }
384
+
385
+ function commandCompletions(prefix: string) {
386
+ const normalized = prefix.trim().toLowerCase();
387
+ if (normalized.includes(" ")) return null;
388
+
389
+ const matches = COMMAND_COMPLETIONS.filter((completion) =>
390
+ completion.value.startsWith(normalized),
391
+ );
392
+ return matches.length > 0 ? matches : null;
393
+ }
394
+
395
+ async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
396
+ if (!ctx.hasUI) {
397
+ ctx.ui.notify(
398
+ `Chrome DevTools tool selection needs an interactive UI.
399
+
400
+ ${await buildToolStatusMessage(pi)}`,
401
+ "info",
402
+ );
403
+ return;
404
+ }
405
+
406
+ let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
407
+ let persistQueue = Promise.resolve();
408
+ const commitSelectedTools = () => {
409
+ const nextSelectedTools = orderedChromeDevtoolsTools(selectedTools);
410
+ applyChromeDevtoolsTools(pi, nextSelectedTools);
411
+ persistQueue = persistQueue.then(() => persistSettings(ctx, nextSelectedTools));
412
+ };
413
+
414
+ const customResult = await ctx.ui.custom<"closed" | undefined>(
415
+ (tui, theme, keybindings, done) => {
416
+ const rows = chromeDevtoolsToolSelectorRows();
417
+ let selectedIndex = 0;
418
+ const moveSelection = (delta: number) => {
419
+ selectedIndex = (selectedIndex + delta + rows.length) % rows.length;
420
+ };
421
+ const activateSelectedRow = () => {
422
+ const row = rows[selectedIndex];
423
+ if (!row) return;
424
+
425
+ if (row.kind === "tool") {
426
+ if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
427
+ else selectedTools.add(row.toolName);
428
+ commitSelectedTools();
429
+ return;
430
+ }
431
+
432
+ if (row.action === "enableAll") {
433
+ selectedTools = new Set(allChromeDevtoolsTools());
434
+ commitSelectedTools();
435
+ return;
436
+ }
437
+ if (row.action === "disableAll") {
438
+ selectedTools = new Set();
439
+ commitSelectedTools();
440
+ return;
441
+ }
442
+
443
+ done("closed");
444
+ };
445
+
446
+ return {
447
+ invalidate() {},
448
+ render() {
449
+ return [
450
+ theme.fg("accent", theme.bold(toolSelectorTitle(selectedTools))),
451
+ "",
452
+ ...rows.map((row, index) => {
453
+ const label = formatToolSelectorRow(row, selectedTools);
454
+ if (index === selectedIndex) {
455
+ return `${theme.fg("accent", "›")} ${theme.fg("accent", label)}`;
456
+ }
457
+ return ` ${label}`;
458
+ }),
459
+ "",
460
+ theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"),
461
+ ];
462
+ },
463
+ handleInput(data: string) {
464
+ if (keybindings.matches(data, "tui.select.up")) {
465
+ moveSelection(-1);
466
+ tui.requestRender();
467
+ return;
468
+ }
469
+ if (keybindings.matches(data, "tui.select.down")) {
470
+ moveSelection(1);
471
+ tui.requestRender();
472
+ return;
473
+ }
474
+ if (keybindings.matches(data, "tui.select.pageUp")) {
475
+ selectedIndex = 0;
476
+ tui.requestRender();
477
+ return;
478
+ }
479
+ if (keybindings.matches(data, "tui.select.pageDown")) {
480
+ selectedIndex = rows.length - 1;
481
+ tui.requestRender();
482
+ return;
483
+ }
484
+ if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
485
+ activateSelectedRow();
486
+ tui.requestRender();
487
+ return;
488
+ }
489
+ if (keybindings.matches(data, "tui.select.cancel")) {
490
+ done("closed");
491
+ }
492
+ },
493
+ };
494
+ },
495
+ );
496
+
497
+ if (customResult !== "closed") {
498
+ await showDialogToolSelector(pi, ctx);
499
+ return;
500
+ }
501
+
502
+ await persistQueue;
503
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
504
+ }
505
+
506
+ async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
507
+ let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
508
+ while (true) {
509
+ const rows = chromeDevtoolsToolSelectorRows();
510
+ const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
511
+ const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
512
+ if (!choice) break;
513
+
514
+ const row = rows[choices.indexOf(choice)];
515
+ if (!row) continue;
516
+ if (row.kind === "action" && row.action === "done") break;
517
+
518
+ if (row.kind === "tool") {
519
+ if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
520
+ else selectedTools.add(row.toolName);
521
+ } else if (row.action === "enableAll") {
522
+ selectedTools = new Set(allChromeDevtoolsTools());
523
+ } else if (row.action === "disableAll") {
524
+ selectedTools = new Set();
525
+ }
526
+
527
+ await setSelectedChromeDevtoolsTools(pi, ctx, orderedChromeDevtoolsTools(selectedTools));
528
+ }
529
+
530
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
531
+ }
532
+
533
+ async function updateChromeDevtoolsTools(
534
+ pi: ExtensionAPI,
535
+ ctx: CommandContext,
536
+ selectedTools: readonly ChromeDevToolsToolName[],
537
+ action: string,
538
+ ) {
539
+ await setSelectedChromeDevtoolsTools(pi, ctx, selectedTools);
540
+ ctx.ui.notify(`Chrome DevTools tools ${action}.
541
+
542
+ ${await buildToolStatusMessage(pi)}`, "info");
543
+ }
544
+
545
+ async function setSelectedChromeDevtoolsTools(
546
+ pi: ExtensionAPI,
547
+ ctx: CommandContext,
548
+ selectedTools: readonly ChromeDevToolsToolName[],
549
+ ) {
550
+ applyChromeDevtoolsTools(pi, selectedTools);
551
+ await persistSettings(ctx, selectedTools);
552
+ }
553
+
554
+ function applyChromeDevtoolsTools(
555
+ pi: ExtensionAPI,
556
+ selectedTools: readonly ChromeDevToolsToolName[],
557
+ ) {
558
+ const activeToolNames = pi.getActiveTools();
559
+ const chromeToolNames = new Set<string>(CHROME_DEVTOOLS_TOOL_NAMES);
560
+ const activeNonChromeToolNames = activeToolNames.filter((name) => !chromeToolNames.has(name));
561
+ pi.setActiveTools(unique([...activeNonChromeToolNames, ...selectedTools]));
562
+ }
563
+
564
+ function getToolStatusSummary(pi: ExtensionAPI): ToolStatusSummary {
565
+ const chromeToolNames = new Set<string>(CHROME_DEVTOOLS_TOOL_NAMES);
566
+ const activeToolNames = new Set(pi.getActiveTools());
567
+ const activeChromeToolCount = CHROME_DEVTOOLS_TOOL_NAMES.filter((name) =>
568
+ activeToolNames.has(name),
569
+ ).length;
570
+ const activeNonChromeToolCount = Array.from(activeToolNames).filter(
571
+ (name) => !chromeToolNames.has(name),
572
+ ).length;
573
+ const runtimeStatus =
574
+ activeChromeToolCount === CHROME_DEVTOOLS_TOOL_NAMES.length
575
+ ? "enabled"
576
+ : activeChromeToolCount === 0
577
+ ? "disabled"
578
+ : "partial";
579
+
580
+ return { runtimeStatus, activeChromeToolCount, activeNonChromeToolCount };
581
+ }
582
+
583
+ async function buildToolStatusMessage(pi: ExtensionAPI) {
584
+ const summary = getToolStatusSummary(pi);
585
+ const persistedSetting = await persistedSettingLabel();
586
+ return [
587
+ `Chrome DevTools tools: ${formatRuntimeStatus(summary)}`,
588
+ `Persisted selection: ${persistedSetting}`,
589
+ `Settings file: ${SETTINGS_FILE}`,
590
+ `Other active tools preserved: ${summary.activeNonChromeToolCount}`,
591
+ `Endpoint: ${devToolsEndpoint()}`,
592
+ ].join("\n");
593
+ }
594
+
595
+ function buildQuickstartMessage() {
596
+ return [
597
+ `Chrome DevTools endpoint: ${devToolsEndpoint()}`,
598
+ launchHint(),
599
+ endpointConfigHint(),
600
+ ].join("\n");
601
+ }
602
+
603
+ function buildCommandGuide() {
604
+ return [
605
+ "Chrome DevTools commands:",
606
+ "/chrome-devtools — open this menu",
607
+ "/chrome-devtools help — show command usage",
608
+ "/chrome-devtools quickstart — show endpoint and launch help",
609
+ "/chrome-devtools status — show tool and settings status",
610
+ "/chrome-devtools tools — select individual Chrome DevTools tools",
611
+ "/chrome-devtools toggle — alias for /chrome-devtools tools",
612
+ "/chrome-devtools enable — enable all Chrome DevTools tools",
613
+ "/chrome-devtools disable — disable all Chrome DevTools tools",
614
+ ].join("\n");
615
+ }
616
+
617
+ function toolSelectorTitle(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
618
+ return `Chrome DevTools tools (${selectedTools.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
619
+ }
620
+
621
+ function chromeDevtoolsToolSelectorRows(): ToolSelectorRow[] {
622
+ return [
623
+ ...CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({ kind: "tool" as const, toolName })),
624
+ { kind: "action", action: "enableAll", label: TOOL_SELECTOR_ENABLE_ALL },
625
+ { kind: "action", action: "disableAll", label: TOOL_SELECTOR_DISABLE_ALL },
626
+ { kind: "action", action: "done", label: TOOL_SELECTOR_DONE },
627
+ ];
628
+ }
629
+
630
+ function formatToolSelectorRow(
631
+ row: ToolSelectorRow,
632
+ selectedTools: ReadonlySet<ChromeDevToolsToolName>,
633
+ ) {
634
+ if (row.kind === "action") return row.label;
635
+ return `${selectedTools.has(row.toolName) ? "[x]" : "[ ]"} ${row.toolName}`;
636
+ }
637
+
638
+ function getActiveChromeDevtoolsTools(pi: ExtensionAPI) {
639
+ const activeToolNames = new Set(pi.getActiveTools());
640
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => activeToolNames.has(toolName));
641
+ }
642
+
643
+ function allChromeDevtoolsTools() {
644
+ return [...CHROME_DEVTOOLS_TOOL_NAMES];
645
+ }
646
+
647
+ function orderedChromeDevtoolsTools(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
648
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
649
+ }
650
+
651
+ function formatRuntimeStatus(summary: ToolStatusSummary) {
652
+ return `${summary.runtimeStatus} (${summary.activeChromeToolCount}/${CHROME_DEVTOOLS_TOOL_NAMES.length} active)`;
653
+ }
654
+
655
+ async function persistedSettingLabel() {
656
+ const settings = await loadSettings();
657
+ if (settings.kind === "loaded") return formatPersistedSelection(settings.settings.tools);
658
+ if (settings.kind === "invalid") {
659
+ return `none; current active-tool policy preserved (invalid settings ignored: ${settings.reason})`;
660
+ }
661
+ return "none; current active-tool policy preserved";
662
+ }
663
+
664
+ function formatPersistedSelection(tools: readonly ChromeDevToolsToolName[]) {
665
+ if (tools.length === CHROME_DEVTOOLS_TOOL_NAMES.length) {
666
+ return `all enabled (${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected)`;
667
+ }
668
+ if (tools.length === 0) return `all disabled (0/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected)`;
669
+ return `${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
670
+ }
671
+
672
+ async function persistSettings(
673
+ ctx: CommandContext,
674
+ selectedTools: readonly ChromeDevToolsToolName[],
675
+ ) {
676
+ try {
677
+ await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
678
+ } catch (error) {
679
+ ctx.ui.notify(`Chrome DevTools settings save failed: ${formatError(error)}`, "warning");
680
+ }
681
+ }
682
+
683
+ async function loadSettings(): Promise<
684
+ | { kind: "missing" }
685
+ | { kind: "invalid"; reason: string }
686
+ | { kind: "loaded"; settings: ChromeDevToolsSettings }
687
+ > {
688
+ let text: string;
689
+ try {
690
+ text = await readFile(SETTINGS_FILE, "utf8");
691
+ } catch (error) {
692
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
693
+ return { kind: "invalid", reason: formatError(error) };
694
+ }
695
+
696
+ try {
697
+ const parsed = JSON.parse(text) as unknown;
698
+ const settings = normalizeChromeDevtoolsSettings(parsed);
699
+ if (settings) return { kind: "loaded", settings };
700
+ return { kind: "invalid", reason: "expected tools to be an array of Chrome DevTools tool names" };
701
+ } catch (error) {
702
+ return { kind: "invalid", reason: formatError(error) };
703
+ }
704
+ }
705
+
706
+ function normalizeChromeDevtoolsSettings(value: unknown): ChromeDevToolsSettings | undefined {
707
+ if (!value || typeof value !== "object") return undefined;
708
+ const settings = value as { tools?: unknown; updatedAt?: unknown };
709
+ if (typeof settings.updatedAt !== "number") return undefined;
710
+
711
+ if (settings.tools === "enabled") {
712
+ return { tools: allChromeDevtoolsTools(), updatedAt: settings.updatedAt };
713
+ }
714
+ if (settings.tools === "disabled") return { tools: [], updatedAt: settings.updatedAt };
715
+
716
+ if (!Array.isArray(settings.tools)) return undefined;
717
+ if (!settings.tools.every(isChromeDevtoolsToolName)) return undefined;
718
+ return { tools: orderedUniqueChromeDevtoolsTools(settings.tools), updatedAt: settings.updatedAt };
719
+ }
720
+
721
+ function isChromeDevtoolsToolName(value: unknown): value is ChromeDevToolsToolName {
722
+ return typeof value === "string" && CHROME_DEVTOOLS_TOOL_NAMES.includes(value as never);
723
+ }
724
+
725
+ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName[]) {
726
+ const selectedTools = new Set(tools);
727
+ return orderedChromeDevtoolsTools(selectedTools);
728
+ }
729
+
730
+ async function saveSettings(settings: ChromeDevToolsSettings) {
731
+ await mkdir(dirname(SETTINGS_FILE), { recursive: true });
732
+ const tempFile = `${SETTINGS_FILE}.${process.pid}.${Date.now()}.tmp`;
733
+ try {
734
+ await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}
735
+ `, "utf8");
736
+ await rename(tempFile, SETTINGS_FILE);
737
+ } catch (error) {
738
+ await rm(tempFile, { force: true }).catch(() => undefined);
739
+ throw error;
740
+ }
741
+ }
742
+
743
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
744
+ return error instanceof Error && "code" in error;
745
+ }
746
+
747
+ function formatError(error: unknown) {
748
+ return error instanceof Error ? error.message : String(error);
749
+ }
750
+
751
+ function unique<T>(values: T[]) {
752
+ return Array.from(new Set(values));
753
+ }
754
+
240
755
  async function listPages(options: { waitMs?: number } = {}) {
241
756
  return withEndpointRetry(async () => {
242
757
  const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");