browser-pilot 0.0.4 → 0.0.6

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/dist/cli.mjs CHANGED
@@ -2,14 +2,14 @@
2
2
  import "./chunk-ZIQA4JOT.mjs";
3
3
  import {
4
4
  connect
5
- } from "./chunk-CWSTSVWO.mjs";
5
+ } from "./chunk-PCNEJAJ7.mjs";
6
6
  import "./chunk-BCOZUKWS.mjs";
7
7
  import {
8
8
  getBrowserWebSocketUrl
9
9
  } from "./chunk-R3PS4PCM.mjs";
10
10
  import {
11
11
  addBatchToPage
12
- } from "./chunk-YEHK2XY3.mjs";
12
+ } from "./chunk-6RB3GKQP.mjs";
13
13
 
14
14
  // src/cli/commands/actions.ts
15
15
  var ACTIONS_HELP = `
@@ -272,6 +272,60 @@ async function getDefaultSession() {
272
272
  return sessions[0] ?? null;
273
273
  }
274
274
 
275
+ // src/cli/commands/clean.ts
276
+ function parseCleanArgs(args) {
277
+ const options = {};
278
+ for (let i = 0; i < args.length; i++) {
279
+ const arg = args[i];
280
+ if (arg === "--max-age") {
281
+ const value = args[++i];
282
+ options.maxAge = parseInt(value ?? "24", 10);
283
+ } else if (arg === "--dry-run") {
284
+ options.dryRun = true;
285
+ } else if (arg === "--all") {
286
+ options.all = true;
287
+ }
288
+ }
289
+ return options;
290
+ }
291
+ async function cleanCommand(args, globalOptions) {
292
+ const options = parseCleanArgs(args);
293
+ const maxAgeMs = (options.maxAge ?? 24) * 60 * 60 * 1e3;
294
+ const now = Date.now();
295
+ const sessions = await listSessions();
296
+ const stale = sessions.filter((s) => {
297
+ if (options.all) return true;
298
+ const age = now - new Date(s.lastActivity).getTime();
299
+ return age > maxAgeMs;
300
+ });
301
+ if (stale.length === 0) {
302
+ output({ message: "No stale sessions found", cleaned: 0 }, globalOptions.output);
303
+ return;
304
+ }
305
+ if (options.dryRun) {
306
+ output(
307
+ {
308
+ message: `Would clean ${stale.length} session(s)`,
309
+ sessions: stale.map((s) => s.id),
310
+ dryRun: true
311
+ },
312
+ globalOptions.output
313
+ );
314
+ return;
315
+ }
316
+ for (const session of stale) {
317
+ await deleteSession(session.id);
318
+ }
319
+ output(
320
+ {
321
+ message: `Cleaned ${stale.length} session(s)`,
322
+ cleaned: stale.length,
323
+ sessions: stale.map((s) => s.id)
324
+ },
325
+ globalOptions.output
326
+ );
327
+ }
328
+
275
329
  // src/cli/commands/close.ts
276
330
  async function closeCommand(args, globalOptions) {
277
331
  let session;
@@ -390,6 +444,17 @@ async function connectCommand(args, globalOptions) {
390
444
  }
391
445
 
392
446
  // src/cli/commands/exec.ts
447
+ async function validateSession(session) {
448
+ try {
449
+ const wsUrl = new URL(session.wsUrl);
450
+ const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:";
451
+ const httpUrl = `${protocol}//${wsUrl.host}/json/version`;
452
+ const response = await fetch(httpUrl, { signal: AbortSignal.timeout(3e3) });
453
+ return response.ok;
454
+ } catch {
455
+ return false;
456
+ }
457
+ }
393
458
  function parseExecArgs(args) {
394
459
  const options = {};
395
460
  let actionsJson;
@@ -434,6 +499,14 @@ Run 'bp actions' for complete action reference.`
434
499
  throw new Error('No session found. Run "bp connect" first.');
435
500
  }
436
501
  }
502
+ const isValid = await validateSession(session);
503
+ if (!isValid) {
504
+ await deleteSession(session.id);
505
+ throw new Error(
506
+ `Session "${session.id}" is no longer valid (browser may have closed).
507
+ Session file has been cleaned up. Run "bp connect" to create a new session.`
508
+ );
509
+ }
437
510
  const browser = await connect({
438
511
  provider: session.provider,
439
512
  wsUrl: session.wsUrl,
@@ -588,12 +661,760 @@ COMMON ACTIONS
588
661
  snapshot {"action":"snapshot"}
589
662
  screenshot {"action":"screenshot"}
590
663
 
664
+ RECORDING (FOR HUMANS)
665
+ Want to create automations by demonstrating instead of coding?
666
+ Use 'bp record' to capture your browser interactions as replayable JSON:
667
+
668
+ bp record # Record from local Chrome
669
+ bp exec --file login.json # Replay the recording
670
+
671
+ Great for creating initial automation scripts that AI agents can refine.
672
+
591
673
  Run 'bp actions' for the complete action reference.
592
674
  `;
593
675
  async function quickstartCommand() {
594
676
  console.log(QUICKSTART);
595
677
  }
596
678
 
679
+ // src/recording/aggregator.ts
680
+ var INPUT_DEBOUNCE_MS = 300;
681
+ var NAVIGATION_DEBOUNCE_MS = 500;
682
+ function selectBestSelectors(candidates) {
683
+ const qualityOrder = {
684
+ "stable-attr": 0,
685
+ id: 1,
686
+ "css-path": 2
687
+ };
688
+ const sorted = [...candidates].sort((a, b) => {
689
+ const aOrder = qualityOrder[a.quality] ?? 3;
690
+ const bOrder = qualityOrder[b.quality] ?? 3;
691
+ return aOrder - bOrder;
692
+ });
693
+ const seen = /* @__PURE__ */ new Set();
694
+ const result = [];
695
+ for (const candidate of sorted) {
696
+ if (!seen.has(candidate.selector)) {
697
+ seen.add(candidate.selector);
698
+ result.push(candidate.selector);
699
+ }
700
+ }
701
+ return result;
702
+ }
703
+ function debounceInputEvents(events) {
704
+ const result = [];
705
+ for (let i = 0; i < events.length; i++) {
706
+ const event = events[i];
707
+ if (event.kind !== "input") {
708
+ result.push(event);
709
+ continue;
710
+ }
711
+ const primarySelector = event.selectors[0]?.selector;
712
+ if (!primarySelector) {
713
+ result.push(event);
714
+ continue;
715
+ }
716
+ let finalEvent = event;
717
+ let j = i + 1;
718
+ while (j < events.length) {
719
+ const nextEvent = events[j];
720
+ if (nextEvent.timestamp - finalEvent.timestamp > INPUT_DEBOUNCE_MS) {
721
+ break;
722
+ }
723
+ if (nextEvent.kind !== "input") {
724
+ break;
725
+ }
726
+ const nextPrimarySelector = nextEvent.selectors[0]?.selector;
727
+ if (nextPrimarySelector !== primarySelector) {
728
+ break;
729
+ }
730
+ finalEvent = nextEvent;
731
+ j++;
732
+ }
733
+ i = j - 1;
734
+ result.push(finalEvent);
735
+ }
736
+ return result;
737
+ }
738
+ function debounceNavigationEvents(events) {
739
+ const result = [];
740
+ for (let i = 0; i < events.length; i++) {
741
+ const event = events[i];
742
+ if (event.kind !== "navigation") {
743
+ result.push(event);
744
+ continue;
745
+ }
746
+ let finalEvent = event;
747
+ let j = i + 1;
748
+ while (j < events.length) {
749
+ const nextEvent = events[j];
750
+ if (nextEvent.timestamp - finalEvent.timestamp > NAVIGATION_DEBOUNCE_MS) {
751
+ break;
752
+ }
753
+ if (nextEvent.kind !== "navigation") {
754
+ break;
755
+ }
756
+ finalEvent = nextEvent;
757
+ j++;
758
+ }
759
+ i = j - 1;
760
+ result.push(finalEvent);
761
+ }
762
+ return result;
763
+ }
764
+ function insertNavigationSteps(events, startUrl) {
765
+ const result = [];
766
+ let lastUrl = startUrl || null;
767
+ for (const event of events) {
768
+ if (lastUrl !== null && event.url !== lastUrl) {
769
+ result.push({
770
+ kind: "navigation",
771
+ timestamp: event.timestamp,
772
+ url: event.url,
773
+ selectors: []
774
+ });
775
+ }
776
+ result.push(event);
777
+ lastUrl = event.url;
778
+ }
779
+ return result;
780
+ }
781
+ function eventToStep(event) {
782
+ const selectors = selectBestSelectors(event.selectors);
783
+ switch (event.kind) {
784
+ case "click":
785
+ case "dblclick":
786
+ if (selectors.length === 0) return null;
787
+ return {
788
+ action: "click",
789
+ selector: selectors.length === 1 ? selectors[0] : selectors
790
+ };
791
+ case "input":
792
+ if (selectors.length === 0) return null;
793
+ return {
794
+ action: "fill",
795
+ selector: selectors.length === 1 ? selectors[0] : selectors,
796
+ value: event.value ?? ""
797
+ };
798
+ case "change": {
799
+ if (selectors.length === 0) return null;
800
+ const element = event.element;
801
+ const tag = element?.tag;
802
+ const type = element?.type?.toLowerCase();
803
+ if (tag === "select") {
804
+ return {
805
+ action: "select",
806
+ selector: selectors.length === 1 ? selectors[0] : selectors,
807
+ value: event.value ?? ""
808
+ };
809
+ }
810
+ if (type === "checkbox" || type === "radio") {
811
+ return {
812
+ action: event.checked ? "check" : "uncheck",
813
+ selector: selectors.length === 1 ? selectors[0] : selectors
814
+ };
815
+ }
816
+ return {
817
+ action: "fill",
818
+ selector: selectors.length === 1 ? selectors[0] : selectors,
819
+ value: event.value ?? ""
820
+ };
821
+ }
822
+ case "keydown":
823
+ if (event.key === "Enter") {
824
+ if (selectors.length === 0) return null;
825
+ return {
826
+ action: "submit",
827
+ selector: selectors.length === 1 ? selectors[0] : selectors,
828
+ method: "enter"
829
+ };
830
+ }
831
+ return null;
832
+ case "submit":
833
+ if (selectors.length === 0) return null;
834
+ return {
835
+ action: "submit",
836
+ selector: selectors.length === 1 ? selectors[0] : selectors
837
+ };
838
+ case "navigation":
839
+ return {
840
+ action: "goto",
841
+ url: event.url
842
+ };
843
+ default:
844
+ return null;
845
+ }
846
+ }
847
+ function deduplicateSteps(steps) {
848
+ const result = [];
849
+ for (let i = 0; i < steps.length; i++) {
850
+ const step = steps[i];
851
+ const prevStep = result[result.length - 1];
852
+ if (step.action === "submit" && prevStep?.action === "submit" && JSON.stringify(step.selector) === JSON.stringify(prevStep.selector)) {
853
+ continue;
854
+ }
855
+ result.push(step);
856
+ }
857
+ return result;
858
+ }
859
+ function aggregateEvents(events, startUrl) {
860
+ if (events.length === 0) return [];
861
+ let processed = insertNavigationSteps(events, startUrl);
862
+ processed = debounceNavigationEvents(processed);
863
+ processed = debounceInputEvents(processed);
864
+ const steps = [];
865
+ for (const event of processed) {
866
+ const step = eventToStep(event);
867
+ if (step) {
868
+ steps.push(step);
869
+ }
870
+ }
871
+ return deduplicateSteps(steps);
872
+ }
873
+
874
+ // src/recording/script.ts
875
+ var RECORDER_BINDING_NAME = "__recorder";
876
+ var RECORDER_SCRIPT = `(function() {
877
+ // Guard against multiple installations
878
+ if (window.__recorderInstalled) return;
879
+ window.__recorderInstalled = true;
880
+
881
+ const BINDING_NAME = '__recorder';
882
+
883
+ // Safe JSON stringify
884
+ function safeJson(obj) {
885
+ try {
886
+ return JSON.stringify(obj);
887
+ } catch (e) {
888
+ return JSON.stringify({ error: 'unserializable' });
889
+ }
890
+ }
891
+
892
+ // Send event to CDP client via binding
893
+ function sendEvent(payload) {
894
+ try {
895
+ if (typeof window[BINDING_NAME] === 'function') {
896
+ window[BINDING_NAME](safeJson(payload));
897
+ }
898
+ } catch (e) {
899
+ // Binding not ready, ignore
900
+ }
901
+ }
902
+
903
+ // CSS escape for identifiers
904
+ function cssEscape(str) {
905
+ return String(str).replace(/([\\[\\]#.:>+~=|^$*!"'(){}])/g, '\\\\$1');
906
+ }
907
+
908
+ // Check if selector is unique in document
909
+ function isUnique(selector, root) {
910
+ try {
911
+ return (root || document).querySelectorAll(selector).length === 1;
912
+ } catch (e) {
913
+ return false;
914
+ }
915
+ }
916
+
917
+ // Get stable attribute selector (data-testid, aria-label, name, etc.)
918
+ function getStableAttrSelector(el) {
919
+ if (!el || el.nodeType !== 1) return null;
920
+ const attrs = ['data-testid', 'data-test', 'data-qa', 'aria-label', 'name'];
921
+ for (const attr of attrs) {
922
+ const val = el.getAttribute(attr);
923
+ if (val && val.length <= 200) {
924
+ const escaped = val.replace(/"/g, '\\\\"');
925
+ return '[' + attr + '="' + escaped + '"]';
926
+ }
927
+ }
928
+ return null;
929
+ }
930
+
931
+ // Get ID selector
932
+ function getIdSelector(el) {
933
+ if (!el || !el.id || el.id.length > 100) return null;
934
+ // Skip dynamic-looking IDs
935
+ if (/^[0-9]|^:/.test(el.id)) return null;
936
+ return '#' + cssEscape(el.id);
937
+ }
938
+
939
+ // Build CSS path for element
940
+ function buildCssPath(el) {
941
+ if (!el || el.nodeType !== 1) return null;
942
+ const parts = [];
943
+ let cur = el;
944
+
945
+ for (let depth = 0; cur && cur !== document.body && depth < 8; depth++) {
946
+ let part = cur.tagName.toLowerCase();
947
+
948
+ // If ID exists and looks stable, use it and stop
949
+ if (cur.id && !/^[0-9]|^:/.test(cur.id) && cur.id.length <= 50) {
950
+ part = '#' + cssEscape(cur.id);
951
+ parts.unshift(part);
952
+ break;
953
+ }
954
+
955
+ // Add stable classes (skip dynamic ones)
956
+ const classes = Array.from(cur.classList || [])
957
+ .filter(c => c.length < 40 && !/^css-|^_|^[0-9]/.test(c))
958
+ .slice(0, 2);
959
+ if (classes.length) {
960
+ part += '.' + classes.map(cssEscape).join('.');
961
+ }
962
+
963
+ // Add position if siblings have same tag
964
+ const parent = cur.parentElement;
965
+ if (parent) {
966
+ const sameTag = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
967
+ if (sameTag.length > 1) {
968
+ const idx = sameTag.indexOf(cur) + 1;
969
+ part += ':nth-of-type(' + idx + ')';
970
+ }
971
+ }
972
+
973
+ parts.unshift(part);
974
+ cur = cur.parentElement;
975
+ }
976
+
977
+ return parts.join(' > ');
978
+ }
979
+
980
+ // Generate selector candidates ordered by quality
981
+ function getSelectorCandidates(el) {
982
+ const candidates = [];
983
+
984
+ // 1. Stable attributes (highest quality)
985
+ const stableAttr = getStableAttrSelector(el);
986
+ if (stableAttr) {
987
+ candidates.push({ selector: stableAttr, quality: 'stable-attr' });
988
+ }
989
+
990
+ // 2. ID selector
991
+ const idSel = getIdSelector(el);
992
+ if (idSel) {
993
+ candidates.push({ selector: idSel, quality: 'id' });
994
+ }
995
+
996
+ // 3. CSS path (fallback)
997
+ const cssPath = buildCssPath(el);
998
+ if (cssPath) {
999
+ candidates.push({ selector: cssPath, quality: 'css-path' });
1000
+ }
1001
+
1002
+ return candidates;
1003
+ }
1004
+
1005
+ // Get element summary for debugging
1006
+ function getElementSummary(el) {
1007
+ if (!el || el.nodeType !== 1) return null;
1008
+ const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
1009
+ return {
1010
+ tag: el.tagName.toLowerCase(),
1011
+ id: el.id || null,
1012
+ name: el.getAttribute('name') || null,
1013
+ type: el.getAttribute('type') || null,
1014
+ role: el.getAttribute('role') || null,
1015
+ ariaLabel: el.getAttribute('aria-label') || null,
1016
+ testid: el.getAttribute('data-testid') || null,
1017
+ text: text || null
1018
+ };
1019
+ }
1020
+
1021
+ // Get event target, handling shadow DOM via composedPath
1022
+ function getEventTarget(ev) {
1023
+ const path = ev.composedPath ? ev.composedPath() : null;
1024
+ if (path && path.length > 0) {
1025
+ for (const node of path) {
1026
+ if (node && node.nodeType === 1) return node;
1027
+ }
1028
+ }
1029
+ return ev.target && ev.target.nodeType === 1 ? ev.target : null;
1030
+ }
1031
+
1032
+ // Find clickable ancestor (button, a, [role=button])
1033
+ function findClickableAncestor(el) {
1034
+ if (!el) return el;
1035
+ const clickable = el.closest('button, a, [role="button"], [role="link"]');
1036
+ return clickable || el;
1037
+ }
1038
+
1039
+ // Check if element is a password input
1040
+ function isPasswordInput(el) {
1041
+ if (!el) return false;
1042
+ const tag = el.tagName.toLowerCase();
1043
+ if (tag !== 'input') return false;
1044
+ const type = (el.getAttribute('type') || '').toLowerCase();
1045
+ return type === 'password';
1046
+ }
1047
+
1048
+ // Get input value, redacting passwords
1049
+ function getInputValue(el) {
1050
+ if (isPasswordInput(el)) return '[REDACTED]';
1051
+ if (el.value !== undefined) return el.value;
1052
+ if (el.isContentEditable) return el.textContent || '';
1053
+ return '';
1054
+ }
1055
+
1056
+ // Current timestamp
1057
+ function now() { return Date.now(); }
1058
+
1059
+ // Click handler
1060
+ window.addEventListener('click', function(ev) {
1061
+ const rawTarget = getEventTarget(ev);
1062
+ if (!rawTarget) return;
1063
+
1064
+ // Bubble up to clickable ancestor for better selectors
1065
+ const el = findClickableAncestor(rawTarget);
1066
+
1067
+ sendEvent({
1068
+ kind: 'click',
1069
+ timestamp: now(),
1070
+ url: location.href,
1071
+ element: getElementSummary(el),
1072
+ selectors: getSelectorCandidates(el),
1073
+ client: { x: ev.clientX, y: ev.clientY }
1074
+ });
1075
+ }, true);
1076
+
1077
+ // Double click handler
1078
+ window.addEventListener('dblclick', function(ev) {
1079
+ const rawTarget = getEventTarget(ev);
1080
+ if (!rawTarget) return;
1081
+
1082
+ const el = findClickableAncestor(rawTarget);
1083
+
1084
+ sendEvent({
1085
+ kind: 'dblclick',
1086
+ timestamp: now(),
1087
+ url: location.href,
1088
+ element: getElementSummary(el),
1089
+ selectors: getSelectorCandidates(el),
1090
+ client: { x: ev.clientX, y: ev.clientY }
1091
+ });
1092
+ }, true);
1093
+
1094
+ // Input handler (for text inputs, textareas, contenteditable)
1095
+ window.addEventListener('input', function(ev) {
1096
+ const el = getEventTarget(ev);
1097
+ if (!el) return;
1098
+
1099
+ const tag = el.tagName.toLowerCase();
1100
+ const isTexty = tag === 'input' || tag === 'textarea' || el.isContentEditable;
1101
+ if (!isTexty) return;
1102
+
1103
+ sendEvent({
1104
+ kind: 'input',
1105
+ timestamp: now(),
1106
+ url: location.href,
1107
+ element: getElementSummary(el),
1108
+ selectors: getSelectorCandidates(el),
1109
+ value: getInputValue(el)
1110
+ });
1111
+ }, true);
1112
+
1113
+ // Change handler (for select, checkbox, radio)
1114
+ window.addEventListener('change', function(ev) {
1115
+ const el = getEventTarget(ev);
1116
+ if (!el) return;
1117
+
1118
+ const tag = el.tagName.toLowerCase();
1119
+ const type = (el.getAttribute('type') || '').toLowerCase();
1120
+ const isCheckable = type === 'checkbox' || type === 'radio';
1121
+
1122
+ sendEvent({
1123
+ kind: 'change',
1124
+ timestamp: now(),
1125
+ url: location.href,
1126
+ element: getElementSummary(el),
1127
+ selectors: getSelectorCandidates(el),
1128
+ value: isCheckable ? undefined : getInputValue(el),
1129
+ checked: isCheckable ? el.checked : undefined
1130
+ });
1131
+ }, true);
1132
+
1133
+ // Keydown handler (capture Enter for form submission)
1134
+ window.addEventListener('keydown', function(ev) {
1135
+ if (ev.key !== 'Enter') return;
1136
+
1137
+ const el = getEventTarget(ev);
1138
+
1139
+ sendEvent({
1140
+ kind: 'keydown',
1141
+ timestamp: now(),
1142
+ url: location.href,
1143
+ key: ev.key,
1144
+ element: el ? getElementSummary(el) : null,
1145
+ selectors: el ? getSelectorCandidates(el) : []
1146
+ });
1147
+ }, true);
1148
+
1149
+ // Submit handler
1150
+ window.addEventListener('submit', function(ev) {
1151
+ const el = getEventTarget(ev);
1152
+
1153
+ sendEvent({
1154
+ kind: 'submit',
1155
+ timestamp: now(),
1156
+ url: location.href,
1157
+ element: el ? getElementSummary(el) : null,
1158
+ selectors: el ? getSelectorCandidates(el) : []
1159
+ });
1160
+ }, true);
1161
+ })();`;
1162
+
1163
+ // src/recording/recorder.ts
1164
+ var Recorder = class {
1165
+ cdp;
1166
+ events = [];
1167
+ recording = false;
1168
+ startTime = 0;
1169
+ startUrl = "";
1170
+ bindingHandler = null;
1171
+ constructor(cdp) {
1172
+ this.cdp = cdp;
1173
+ }
1174
+ /**
1175
+ * Check if recording is currently active.
1176
+ */
1177
+ get isRecording() {
1178
+ return this.recording;
1179
+ }
1180
+ /**
1181
+ * Start recording browser interactions.
1182
+ *
1183
+ * Sets up CDP bindings and injects the recorder script into
1184
+ * the current page and all future navigations.
1185
+ */
1186
+ async start() {
1187
+ if (this.recording) {
1188
+ throw new Error("Recording already in progress");
1189
+ }
1190
+ this.events = [];
1191
+ this.startTime = Date.now();
1192
+ this.recording = true;
1193
+ await this.cdp.send("Runtime.enable");
1194
+ await this.cdp.send("Page.enable");
1195
+ try {
1196
+ const result = await this.cdp.send("Runtime.evaluate", {
1197
+ expression: "location.href",
1198
+ returnByValue: true
1199
+ });
1200
+ this.startUrl = result.result.value;
1201
+ } catch {
1202
+ this.startUrl = "";
1203
+ }
1204
+ await this.cdp.send("Runtime.addBinding", { name: RECORDER_BINDING_NAME });
1205
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
1206
+ source: RECORDER_SCRIPT
1207
+ });
1208
+ await this.cdp.send("Runtime.evaluate", {
1209
+ expression: RECORDER_SCRIPT,
1210
+ awaitPromise: false
1211
+ });
1212
+ this.bindingHandler = (params) => {
1213
+ if (params["name"] === RECORDER_BINDING_NAME) {
1214
+ this.handleBindingCall(params["payload"]);
1215
+ }
1216
+ };
1217
+ this.cdp.on("Runtime.bindingCalled", this.bindingHandler);
1218
+ }
1219
+ /**
1220
+ * Stop recording and return aggregated output.
1221
+ *
1222
+ * Returns a RecordingOutput with steps compatible with page.batch().
1223
+ */
1224
+ async stop() {
1225
+ if (!this.recording) {
1226
+ throw new Error("No recording in progress");
1227
+ }
1228
+ this.recording = false;
1229
+ const duration = Date.now() - this.startTime;
1230
+ if (this.bindingHandler) {
1231
+ this.cdp.off("Runtime.bindingCalled", this.bindingHandler);
1232
+ this.bindingHandler = null;
1233
+ }
1234
+ const steps = aggregateEvents(this.events, this.startUrl);
1235
+ return {
1236
+ recordedAt: new Date(this.startTime).toISOString(),
1237
+ startUrl: this.startUrl,
1238
+ duration,
1239
+ steps
1240
+ };
1241
+ }
1242
+ /**
1243
+ * Get raw recorded events (for debugging).
1244
+ */
1245
+ getEvents() {
1246
+ return [...this.events];
1247
+ }
1248
+ /**
1249
+ * Handle incoming binding call from the browser.
1250
+ */
1251
+ handleBindingCall(payload) {
1252
+ if (!this.recording) return;
1253
+ try {
1254
+ const event = JSON.parse(payload);
1255
+ this.events.push(event);
1256
+ } catch {
1257
+ }
1258
+ }
1259
+ };
1260
+
1261
+ // src/cli/commands/record.ts
1262
+ var RECORD_HELP = `
1263
+ bp record - Record browser actions to JSON
1264
+
1265
+ Usage:
1266
+ bp record [options]
1267
+
1268
+ Options:
1269
+ -s, --session [id] Session to use:
1270
+ - omit -s: auto-connect to local browser
1271
+ - -s alone: use most recent session
1272
+ - -s <id>: use specific session
1273
+ -f, --file <path> Output file (default: recording.json)
1274
+ --timeout <ms> Auto-stop after timeout (optional)
1275
+ -h, --help Show this help
1276
+
1277
+ Examples:
1278
+ bp record # Auto-connect to local Chrome
1279
+ bp record -s # Use most recent session
1280
+ bp record -s mysession # Use specific session
1281
+ bp record -f login.json # Save to specific file
1282
+ bp record --timeout 60000 # Auto-stop after 60s
1283
+
1284
+ Recording captures: clicks, inputs, form submissions, navigation.
1285
+ Password fields are automatically redacted as [REDACTED].
1286
+
1287
+ Press Ctrl+C to stop recording and save.
1288
+ `;
1289
+ function parseRecordArgs(args) {
1290
+ const options = {};
1291
+ for (let i = 0; i < args.length; i++) {
1292
+ const arg = args[i];
1293
+ if (arg === "-f" || arg === "--file") {
1294
+ options.file = args[++i];
1295
+ } else if (arg === "--timeout") {
1296
+ options.timeout = Number.parseInt(args[++i] ?? "", 10);
1297
+ } else if (arg === "-h" || arg === "--help") {
1298
+ options.help = true;
1299
+ } else if (arg === "-s" || arg === "--session") {
1300
+ const nextArg = args[i + 1];
1301
+ if (!nextArg || nextArg.startsWith("-")) {
1302
+ options.useLatestSession = true;
1303
+ }
1304
+ }
1305
+ }
1306
+ return options;
1307
+ }
1308
+ async function resolveConnection(sessionId, useLatestSession, trace) {
1309
+ if (sessionId) {
1310
+ const session2 = await loadSession(sessionId);
1311
+ const browser2 = await connect({
1312
+ provider: session2.provider,
1313
+ wsUrl: session2.wsUrl,
1314
+ debug: trace
1315
+ });
1316
+ return { browser: browser2, session: session2, isNewSession: false };
1317
+ }
1318
+ if (useLatestSession) {
1319
+ const session2 = await getDefaultSession();
1320
+ if (!session2) {
1321
+ throw new Error(
1322
+ 'No sessions found. Run "bp connect" first or use "bp record" to auto-connect.'
1323
+ );
1324
+ }
1325
+ const browser2 = await connect({
1326
+ provider: session2.provider,
1327
+ wsUrl: session2.wsUrl,
1328
+ debug: trace
1329
+ });
1330
+ return { browser: browser2, session: session2, isNewSession: false };
1331
+ }
1332
+ let wsUrl;
1333
+ try {
1334
+ wsUrl = await getBrowserWebSocketUrl("localhost:9222");
1335
+ } catch {
1336
+ throw new Error(
1337
+ "Could not auto-discover browser.\nEither:\n 1. Start Chrome with: --remote-debugging-port=9222\n 2. Use an existing session: bp record -s <session-id>\n 3. Use latest session: bp record -s"
1338
+ );
1339
+ }
1340
+ const browser = await connect({
1341
+ provider: "generic",
1342
+ wsUrl,
1343
+ debug: trace
1344
+ });
1345
+ const page = await browser.page();
1346
+ const currentUrl = await page.url();
1347
+ const newSessionId = generateSessionId();
1348
+ const session = {
1349
+ id: newSessionId,
1350
+ provider: "generic",
1351
+ wsUrl: browser.wsUrl,
1352
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1353
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
1354
+ currentUrl
1355
+ };
1356
+ await saveSession(session);
1357
+ return { browser, session, isNewSession: true };
1358
+ }
1359
+ async function recordCommand(args, globalOptions) {
1360
+ const options = parseRecordArgs(args);
1361
+ if (options.help || globalOptions.help) {
1362
+ console.log(RECORD_HELP);
1363
+ return;
1364
+ }
1365
+ const outputFile = options.file ?? "recording.json";
1366
+ const { browser, session, isNewSession } = await resolveConnection(
1367
+ globalOptions.session,
1368
+ options.useLatestSession ?? false,
1369
+ globalOptions.trace ?? false
1370
+ );
1371
+ if (isNewSession) {
1372
+ console.log(`Created new session: ${session.id}`);
1373
+ }
1374
+ const page = await browser.page();
1375
+ const cdp = page.cdpClient;
1376
+ const recorder = new Recorder(cdp);
1377
+ let stopping = false;
1378
+ async function stopAndSave() {
1379
+ if (stopping) return;
1380
+ stopping = true;
1381
+ try {
1382
+ const recording = await recorder.stop();
1383
+ const fs = await import("fs/promises");
1384
+ await fs.writeFile(outputFile, JSON.stringify(recording, null, 2));
1385
+ const currentUrl = await page.url();
1386
+ await updateSession(session.id, { currentUrl });
1387
+ await browser.disconnect();
1388
+ console.log(`
1389
+ Saved ${recording.steps.length} steps to ${outputFile}`);
1390
+ if (globalOptions.output === "json") {
1391
+ output(
1392
+ {
1393
+ success: true,
1394
+ file: outputFile,
1395
+ steps: recording.steps.length,
1396
+ duration: recording.duration
1397
+ },
1398
+ "json"
1399
+ );
1400
+ }
1401
+ process.exit(0);
1402
+ } catch (error) {
1403
+ console.error("Error saving recording:", error);
1404
+ process.exit(1);
1405
+ }
1406
+ }
1407
+ process.on("SIGINT", stopAndSave);
1408
+ process.on("SIGTERM", stopAndSave);
1409
+ if (options.timeout && options.timeout > 0) {
1410
+ setTimeout(stopAndSave, options.timeout);
1411
+ }
1412
+ await recorder.start();
1413
+ console.log(`Recording... Press Ctrl+C to stop and save to ${outputFile}`);
1414
+ console.log(`Session: ${session.id}`);
1415
+ console.log(`URL: ${await page.url()}`);
1416
+ }
1417
+
597
1418
  // src/cli/commands/screenshot.ts
598
1419
  function parseScreenshotArgs(args) {
599
1420
  const options = {};
@@ -767,11 +1588,13 @@ Commands:
767
1588
  quickstart Getting started guide (start here!)
768
1589
  connect Create browser session
769
1590
  exec Execute actions
1591
+ record Record browser actions to JSON
770
1592
  snapshot Get page with element refs
771
1593
  text Extract text content
772
1594
  screenshot Take screenshot
773
1595
  close Close session
774
1596
  list List sessions
1597
+ clean Clean up old sessions
775
1598
  actions Complete action reference
776
1599
 
777
1600
  Options:
@@ -786,6 +1609,8 @@ Examples:
786
1609
  bp exec '{"action":"goto","url":"https://example.com"}'
787
1610
  bp snapshot --format text
788
1611
  bp exec '{"action":"click","selector":"ref:e3"}'
1612
+ bp record # Record from local browser
1613
+ bp record -s -f login.json # Record from latest session
789
1614
 
790
1615
  Run 'bp quickstart' for CLI workflow guide.
791
1616
  Run 'bp actions' for complete action reference.
@@ -818,7 +1643,10 @@ function output(data, format = "pretty") {
818
1643
  if (typeof data === "string") {
819
1644
  console.log(data);
820
1645
  } else if (typeof data === "object" && data !== null) {
821
- prettyPrint(data);
1646
+ const { truncated } = prettyPrint(data);
1647
+ if (truncated) {
1648
+ console.log("\n(Output truncated. Use -o json for full data)");
1649
+ }
822
1650
  } else {
823
1651
  console.log(data);
824
1652
  }
@@ -826,16 +1654,20 @@ function output(data, format = "pretty") {
826
1654
  }
827
1655
  function prettyPrint(obj, indent = 0) {
828
1656
  const prefix = " ".repeat(indent);
1657
+ let truncated = false;
829
1658
  for (const [key, value] of Object.entries(obj)) {
830
1659
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
831
1660
  console.log(`${prefix}${key}:`);
832
- prettyPrint(value, indent + 1);
1661
+ const result = prettyPrint(value, indent + 1);
1662
+ if (result.truncated) truncated = true;
833
1663
  } else if (Array.isArray(value)) {
834
1664
  console.log(`${prefix}${key}: [${value.length} items]`);
1665
+ truncated = true;
835
1666
  } else {
836
1667
  console.log(`${prefix}${key}: ${value}`);
837
1668
  }
838
1669
  }
1670
+ return { truncated };
839
1671
  }
840
1672
  async function main() {
841
1673
  const args = process.argv.slice(2);
@@ -875,9 +1707,15 @@ async function main() {
875
1707
  case "list":
876
1708
  await listCommand(remaining, options);
877
1709
  break;
1710
+ case "clean":
1711
+ await cleanCommand(remaining, options);
1712
+ break;
878
1713
  case "actions":
879
1714
  await actionsCommand();
880
1715
  break;
1716
+ case "record":
1717
+ await recordCommand(remaining, options);
1718
+ break;
881
1719
  case "help":
882
1720
  case "--help":
883
1721
  case "-h":