browser-pilot 0.0.5 → 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.cjs CHANGED
@@ -1464,6 +1464,13 @@ var Page = class {
1464
1464
  this.cdp = cdp;
1465
1465
  this.batchExecutor = new BatchExecutor(this);
1466
1466
  }
1467
+ /**
1468
+ * Get the underlying CDP client for advanced operations.
1469
+ * Use with caution - prefer high-level Page methods when possible.
1470
+ */
1471
+ get cdpClient() {
1472
+ return this.cdp;
1473
+ }
1467
1474
  /**
1468
1475
  * Initialize the page (enable required CDP domains)
1469
1476
  */
@@ -3460,12 +3467,760 @@ COMMON ACTIONS
3460
3467
  snapshot {"action":"snapshot"}
3461
3468
  screenshot {"action":"screenshot"}
3462
3469
 
3470
+ RECORDING (FOR HUMANS)
3471
+ Want to create automations by demonstrating instead of coding?
3472
+ Use 'bp record' to capture your browser interactions as replayable JSON:
3473
+
3474
+ bp record # Record from local Chrome
3475
+ bp exec --file login.json # Replay the recording
3476
+
3477
+ Great for creating initial automation scripts that AI agents can refine.
3478
+
3463
3479
  Run 'bp actions' for the complete action reference.
3464
3480
  `;
3465
3481
  async function quickstartCommand() {
3466
3482
  console.log(QUICKSTART);
3467
3483
  }
3468
3484
 
3485
+ // src/recording/aggregator.ts
3486
+ var INPUT_DEBOUNCE_MS = 300;
3487
+ var NAVIGATION_DEBOUNCE_MS = 500;
3488
+ function selectBestSelectors(candidates) {
3489
+ const qualityOrder = {
3490
+ "stable-attr": 0,
3491
+ id: 1,
3492
+ "css-path": 2
3493
+ };
3494
+ const sorted = [...candidates].sort((a, b) => {
3495
+ const aOrder = qualityOrder[a.quality] ?? 3;
3496
+ const bOrder = qualityOrder[b.quality] ?? 3;
3497
+ return aOrder - bOrder;
3498
+ });
3499
+ const seen = /* @__PURE__ */ new Set();
3500
+ const result = [];
3501
+ for (const candidate of sorted) {
3502
+ if (!seen.has(candidate.selector)) {
3503
+ seen.add(candidate.selector);
3504
+ result.push(candidate.selector);
3505
+ }
3506
+ }
3507
+ return result;
3508
+ }
3509
+ function debounceInputEvents(events) {
3510
+ const result = [];
3511
+ for (let i = 0; i < events.length; i++) {
3512
+ const event = events[i];
3513
+ if (event.kind !== "input") {
3514
+ result.push(event);
3515
+ continue;
3516
+ }
3517
+ const primarySelector = event.selectors[0]?.selector;
3518
+ if (!primarySelector) {
3519
+ result.push(event);
3520
+ continue;
3521
+ }
3522
+ let finalEvent = event;
3523
+ let j = i + 1;
3524
+ while (j < events.length) {
3525
+ const nextEvent = events[j];
3526
+ if (nextEvent.timestamp - finalEvent.timestamp > INPUT_DEBOUNCE_MS) {
3527
+ break;
3528
+ }
3529
+ if (nextEvent.kind !== "input") {
3530
+ break;
3531
+ }
3532
+ const nextPrimarySelector = nextEvent.selectors[0]?.selector;
3533
+ if (nextPrimarySelector !== primarySelector) {
3534
+ break;
3535
+ }
3536
+ finalEvent = nextEvent;
3537
+ j++;
3538
+ }
3539
+ i = j - 1;
3540
+ result.push(finalEvent);
3541
+ }
3542
+ return result;
3543
+ }
3544
+ function debounceNavigationEvents(events) {
3545
+ const result = [];
3546
+ for (let i = 0; i < events.length; i++) {
3547
+ const event = events[i];
3548
+ if (event.kind !== "navigation") {
3549
+ result.push(event);
3550
+ continue;
3551
+ }
3552
+ let finalEvent = event;
3553
+ let j = i + 1;
3554
+ while (j < events.length) {
3555
+ const nextEvent = events[j];
3556
+ if (nextEvent.timestamp - finalEvent.timestamp > NAVIGATION_DEBOUNCE_MS) {
3557
+ break;
3558
+ }
3559
+ if (nextEvent.kind !== "navigation") {
3560
+ break;
3561
+ }
3562
+ finalEvent = nextEvent;
3563
+ j++;
3564
+ }
3565
+ i = j - 1;
3566
+ result.push(finalEvent);
3567
+ }
3568
+ return result;
3569
+ }
3570
+ function insertNavigationSteps(events, startUrl) {
3571
+ const result = [];
3572
+ let lastUrl = startUrl || null;
3573
+ for (const event of events) {
3574
+ if (lastUrl !== null && event.url !== lastUrl) {
3575
+ result.push({
3576
+ kind: "navigation",
3577
+ timestamp: event.timestamp,
3578
+ url: event.url,
3579
+ selectors: []
3580
+ });
3581
+ }
3582
+ result.push(event);
3583
+ lastUrl = event.url;
3584
+ }
3585
+ return result;
3586
+ }
3587
+ function eventToStep(event) {
3588
+ const selectors = selectBestSelectors(event.selectors);
3589
+ switch (event.kind) {
3590
+ case "click":
3591
+ case "dblclick":
3592
+ if (selectors.length === 0) return null;
3593
+ return {
3594
+ action: "click",
3595
+ selector: selectors.length === 1 ? selectors[0] : selectors
3596
+ };
3597
+ case "input":
3598
+ if (selectors.length === 0) return null;
3599
+ return {
3600
+ action: "fill",
3601
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3602
+ value: event.value ?? ""
3603
+ };
3604
+ case "change": {
3605
+ if (selectors.length === 0) return null;
3606
+ const element = event.element;
3607
+ const tag = element?.tag;
3608
+ const type = element?.type?.toLowerCase();
3609
+ if (tag === "select") {
3610
+ return {
3611
+ action: "select",
3612
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3613
+ value: event.value ?? ""
3614
+ };
3615
+ }
3616
+ if (type === "checkbox" || type === "radio") {
3617
+ return {
3618
+ action: event.checked ? "check" : "uncheck",
3619
+ selector: selectors.length === 1 ? selectors[0] : selectors
3620
+ };
3621
+ }
3622
+ return {
3623
+ action: "fill",
3624
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3625
+ value: event.value ?? ""
3626
+ };
3627
+ }
3628
+ case "keydown":
3629
+ if (event.key === "Enter") {
3630
+ if (selectors.length === 0) return null;
3631
+ return {
3632
+ action: "submit",
3633
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3634
+ method: "enter"
3635
+ };
3636
+ }
3637
+ return null;
3638
+ case "submit":
3639
+ if (selectors.length === 0) return null;
3640
+ return {
3641
+ action: "submit",
3642
+ selector: selectors.length === 1 ? selectors[0] : selectors
3643
+ };
3644
+ case "navigation":
3645
+ return {
3646
+ action: "goto",
3647
+ url: event.url
3648
+ };
3649
+ default:
3650
+ return null;
3651
+ }
3652
+ }
3653
+ function deduplicateSteps(steps) {
3654
+ const result = [];
3655
+ for (let i = 0; i < steps.length; i++) {
3656
+ const step = steps[i];
3657
+ const prevStep = result[result.length - 1];
3658
+ if (step.action === "submit" && prevStep?.action === "submit" && JSON.stringify(step.selector) === JSON.stringify(prevStep.selector)) {
3659
+ continue;
3660
+ }
3661
+ result.push(step);
3662
+ }
3663
+ return result;
3664
+ }
3665
+ function aggregateEvents(events, startUrl) {
3666
+ if (events.length === 0) return [];
3667
+ let processed = insertNavigationSteps(events, startUrl);
3668
+ processed = debounceNavigationEvents(processed);
3669
+ processed = debounceInputEvents(processed);
3670
+ const steps = [];
3671
+ for (const event of processed) {
3672
+ const step = eventToStep(event);
3673
+ if (step) {
3674
+ steps.push(step);
3675
+ }
3676
+ }
3677
+ return deduplicateSteps(steps);
3678
+ }
3679
+
3680
+ // src/recording/script.ts
3681
+ var RECORDER_BINDING_NAME = "__recorder";
3682
+ var RECORDER_SCRIPT = `(function() {
3683
+ // Guard against multiple installations
3684
+ if (window.__recorderInstalled) return;
3685
+ window.__recorderInstalled = true;
3686
+
3687
+ const BINDING_NAME = '__recorder';
3688
+
3689
+ // Safe JSON stringify
3690
+ function safeJson(obj) {
3691
+ try {
3692
+ return JSON.stringify(obj);
3693
+ } catch (e) {
3694
+ return JSON.stringify({ error: 'unserializable' });
3695
+ }
3696
+ }
3697
+
3698
+ // Send event to CDP client via binding
3699
+ function sendEvent(payload) {
3700
+ try {
3701
+ if (typeof window[BINDING_NAME] === 'function') {
3702
+ window[BINDING_NAME](safeJson(payload));
3703
+ }
3704
+ } catch (e) {
3705
+ // Binding not ready, ignore
3706
+ }
3707
+ }
3708
+
3709
+ // CSS escape for identifiers
3710
+ function cssEscape(str) {
3711
+ return String(str).replace(/([\\[\\]#.:>+~=|^$*!"'(){}])/g, '\\\\$1');
3712
+ }
3713
+
3714
+ // Check if selector is unique in document
3715
+ function isUnique(selector, root) {
3716
+ try {
3717
+ return (root || document).querySelectorAll(selector).length === 1;
3718
+ } catch (e) {
3719
+ return false;
3720
+ }
3721
+ }
3722
+
3723
+ // Get stable attribute selector (data-testid, aria-label, name, etc.)
3724
+ function getStableAttrSelector(el) {
3725
+ if (!el || el.nodeType !== 1) return null;
3726
+ const attrs = ['data-testid', 'data-test', 'data-qa', 'aria-label', 'name'];
3727
+ for (const attr of attrs) {
3728
+ const val = el.getAttribute(attr);
3729
+ if (val && val.length <= 200) {
3730
+ const escaped = val.replace(/"/g, '\\\\"');
3731
+ return '[' + attr + '="' + escaped + '"]';
3732
+ }
3733
+ }
3734
+ return null;
3735
+ }
3736
+
3737
+ // Get ID selector
3738
+ function getIdSelector(el) {
3739
+ if (!el || !el.id || el.id.length > 100) return null;
3740
+ // Skip dynamic-looking IDs
3741
+ if (/^[0-9]|^:/.test(el.id)) return null;
3742
+ return '#' + cssEscape(el.id);
3743
+ }
3744
+
3745
+ // Build CSS path for element
3746
+ function buildCssPath(el) {
3747
+ if (!el || el.nodeType !== 1) return null;
3748
+ const parts = [];
3749
+ let cur = el;
3750
+
3751
+ for (let depth = 0; cur && cur !== document.body && depth < 8; depth++) {
3752
+ let part = cur.tagName.toLowerCase();
3753
+
3754
+ // If ID exists and looks stable, use it and stop
3755
+ if (cur.id && !/^[0-9]|^:/.test(cur.id) && cur.id.length <= 50) {
3756
+ part = '#' + cssEscape(cur.id);
3757
+ parts.unshift(part);
3758
+ break;
3759
+ }
3760
+
3761
+ // Add stable classes (skip dynamic ones)
3762
+ const classes = Array.from(cur.classList || [])
3763
+ .filter(c => c.length < 40 && !/^css-|^_|^[0-9]/.test(c))
3764
+ .slice(0, 2);
3765
+ if (classes.length) {
3766
+ part += '.' + classes.map(cssEscape).join('.');
3767
+ }
3768
+
3769
+ // Add position if siblings have same tag
3770
+ const parent = cur.parentElement;
3771
+ if (parent) {
3772
+ const sameTag = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
3773
+ if (sameTag.length > 1) {
3774
+ const idx = sameTag.indexOf(cur) + 1;
3775
+ part += ':nth-of-type(' + idx + ')';
3776
+ }
3777
+ }
3778
+
3779
+ parts.unshift(part);
3780
+ cur = cur.parentElement;
3781
+ }
3782
+
3783
+ return parts.join(' > ');
3784
+ }
3785
+
3786
+ // Generate selector candidates ordered by quality
3787
+ function getSelectorCandidates(el) {
3788
+ const candidates = [];
3789
+
3790
+ // 1. Stable attributes (highest quality)
3791
+ const stableAttr = getStableAttrSelector(el);
3792
+ if (stableAttr) {
3793
+ candidates.push({ selector: stableAttr, quality: 'stable-attr' });
3794
+ }
3795
+
3796
+ // 2. ID selector
3797
+ const idSel = getIdSelector(el);
3798
+ if (idSel) {
3799
+ candidates.push({ selector: idSel, quality: 'id' });
3800
+ }
3801
+
3802
+ // 3. CSS path (fallback)
3803
+ const cssPath = buildCssPath(el);
3804
+ if (cssPath) {
3805
+ candidates.push({ selector: cssPath, quality: 'css-path' });
3806
+ }
3807
+
3808
+ return candidates;
3809
+ }
3810
+
3811
+ // Get element summary for debugging
3812
+ function getElementSummary(el) {
3813
+ if (!el || el.nodeType !== 1) return null;
3814
+ const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
3815
+ return {
3816
+ tag: el.tagName.toLowerCase(),
3817
+ id: el.id || null,
3818
+ name: el.getAttribute('name') || null,
3819
+ type: el.getAttribute('type') || null,
3820
+ role: el.getAttribute('role') || null,
3821
+ ariaLabel: el.getAttribute('aria-label') || null,
3822
+ testid: el.getAttribute('data-testid') || null,
3823
+ text: text || null
3824
+ };
3825
+ }
3826
+
3827
+ // Get event target, handling shadow DOM via composedPath
3828
+ function getEventTarget(ev) {
3829
+ const path = ev.composedPath ? ev.composedPath() : null;
3830
+ if (path && path.length > 0) {
3831
+ for (const node of path) {
3832
+ if (node && node.nodeType === 1) return node;
3833
+ }
3834
+ }
3835
+ return ev.target && ev.target.nodeType === 1 ? ev.target : null;
3836
+ }
3837
+
3838
+ // Find clickable ancestor (button, a, [role=button])
3839
+ function findClickableAncestor(el) {
3840
+ if (!el) return el;
3841
+ const clickable = el.closest('button, a, [role="button"], [role="link"]');
3842
+ return clickable || el;
3843
+ }
3844
+
3845
+ // Check if element is a password input
3846
+ function isPasswordInput(el) {
3847
+ if (!el) return false;
3848
+ const tag = el.tagName.toLowerCase();
3849
+ if (tag !== 'input') return false;
3850
+ const type = (el.getAttribute('type') || '').toLowerCase();
3851
+ return type === 'password';
3852
+ }
3853
+
3854
+ // Get input value, redacting passwords
3855
+ function getInputValue(el) {
3856
+ if (isPasswordInput(el)) return '[REDACTED]';
3857
+ if (el.value !== undefined) return el.value;
3858
+ if (el.isContentEditable) return el.textContent || '';
3859
+ return '';
3860
+ }
3861
+
3862
+ // Current timestamp
3863
+ function now() { return Date.now(); }
3864
+
3865
+ // Click handler
3866
+ window.addEventListener('click', function(ev) {
3867
+ const rawTarget = getEventTarget(ev);
3868
+ if (!rawTarget) return;
3869
+
3870
+ // Bubble up to clickable ancestor for better selectors
3871
+ const el = findClickableAncestor(rawTarget);
3872
+
3873
+ sendEvent({
3874
+ kind: 'click',
3875
+ timestamp: now(),
3876
+ url: location.href,
3877
+ element: getElementSummary(el),
3878
+ selectors: getSelectorCandidates(el),
3879
+ client: { x: ev.clientX, y: ev.clientY }
3880
+ });
3881
+ }, true);
3882
+
3883
+ // Double click handler
3884
+ window.addEventListener('dblclick', function(ev) {
3885
+ const rawTarget = getEventTarget(ev);
3886
+ if (!rawTarget) return;
3887
+
3888
+ const el = findClickableAncestor(rawTarget);
3889
+
3890
+ sendEvent({
3891
+ kind: 'dblclick',
3892
+ timestamp: now(),
3893
+ url: location.href,
3894
+ element: getElementSummary(el),
3895
+ selectors: getSelectorCandidates(el),
3896
+ client: { x: ev.clientX, y: ev.clientY }
3897
+ });
3898
+ }, true);
3899
+
3900
+ // Input handler (for text inputs, textareas, contenteditable)
3901
+ window.addEventListener('input', function(ev) {
3902
+ const el = getEventTarget(ev);
3903
+ if (!el) return;
3904
+
3905
+ const tag = el.tagName.toLowerCase();
3906
+ const isTexty = tag === 'input' || tag === 'textarea' || el.isContentEditable;
3907
+ if (!isTexty) return;
3908
+
3909
+ sendEvent({
3910
+ kind: 'input',
3911
+ timestamp: now(),
3912
+ url: location.href,
3913
+ element: getElementSummary(el),
3914
+ selectors: getSelectorCandidates(el),
3915
+ value: getInputValue(el)
3916
+ });
3917
+ }, true);
3918
+
3919
+ // Change handler (for select, checkbox, radio)
3920
+ window.addEventListener('change', function(ev) {
3921
+ const el = getEventTarget(ev);
3922
+ if (!el) return;
3923
+
3924
+ const tag = el.tagName.toLowerCase();
3925
+ const type = (el.getAttribute('type') || '').toLowerCase();
3926
+ const isCheckable = type === 'checkbox' || type === 'radio';
3927
+
3928
+ sendEvent({
3929
+ kind: 'change',
3930
+ timestamp: now(),
3931
+ url: location.href,
3932
+ element: getElementSummary(el),
3933
+ selectors: getSelectorCandidates(el),
3934
+ value: isCheckable ? undefined : getInputValue(el),
3935
+ checked: isCheckable ? el.checked : undefined
3936
+ });
3937
+ }, true);
3938
+
3939
+ // Keydown handler (capture Enter for form submission)
3940
+ window.addEventListener('keydown', function(ev) {
3941
+ if (ev.key !== 'Enter') return;
3942
+
3943
+ const el = getEventTarget(ev);
3944
+
3945
+ sendEvent({
3946
+ kind: 'keydown',
3947
+ timestamp: now(),
3948
+ url: location.href,
3949
+ key: ev.key,
3950
+ element: el ? getElementSummary(el) : null,
3951
+ selectors: el ? getSelectorCandidates(el) : []
3952
+ });
3953
+ }, true);
3954
+
3955
+ // Submit handler
3956
+ window.addEventListener('submit', function(ev) {
3957
+ const el = getEventTarget(ev);
3958
+
3959
+ sendEvent({
3960
+ kind: 'submit',
3961
+ timestamp: now(),
3962
+ url: location.href,
3963
+ element: el ? getElementSummary(el) : null,
3964
+ selectors: el ? getSelectorCandidates(el) : []
3965
+ });
3966
+ }, true);
3967
+ })();`;
3968
+
3969
+ // src/recording/recorder.ts
3970
+ var Recorder = class {
3971
+ cdp;
3972
+ events = [];
3973
+ recording = false;
3974
+ startTime = 0;
3975
+ startUrl = "";
3976
+ bindingHandler = null;
3977
+ constructor(cdp) {
3978
+ this.cdp = cdp;
3979
+ }
3980
+ /**
3981
+ * Check if recording is currently active.
3982
+ */
3983
+ get isRecording() {
3984
+ return this.recording;
3985
+ }
3986
+ /**
3987
+ * Start recording browser interactions.
3988
+ *
3989
+ * Sets up CDP bindings and injects the recorder script into
3990
+ * the current page and all future navigations.
3991
+ */
3992
+ async start() {
3993
+ if (this.recording) {
3994
+ throw new Error("Recording already in progress");
3995
+ }
3996
+ this.events = [];
3997
+ this.startTime = Date.now();
3998
+ this.recording = true;
3999
+ await this.cdp.send("Runtime.enable");
4000
+ await this.cdp.send("Page.enable");
4001
+ try {
4002
+ const result = await this.cdp.send("Runtime.evaluate", {
4003
+ expression: "location.href",
4004
+ returnByValue: true
4005
+ });
4006
+ this.startUrl = result.result.value;
4007
+ } catch {
4008
+ this.startUrl = "";
4009
+ }
4010
+ await this.cdp.send("Runtime.addBinding", { name: RECORDER_BINDING_NAME });
4011
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
4012
+ source: RECORDER_SCRIPT
4013
+ });
4014
+ await this.cdp.send("Runtime.evaluate", {
4015
+ expression: RECORDER_SCRIPT,
4016
+ awaitPromise: false
4017
+ });
4018
+ this.bindingHandler = (params) => {
4019
+ if (params["name"] === RECORDER_BINDING_NAME) {
4020
+ this.handleBindingCall(params["payload"]);
4021
+ }
4022
+ };
4023
+ this.cdp.on("Runtime.bindingCalled", this.bindingHandler);
4024
+ }
4025
+ /**
4026
+ * Stop recording and return aggregated output.
4027
+ *
4028
+ * Returns a RecordingOutput with steps compatible with page.batch().
4029
+ */
4030
+ async stop() {
4031
+ if (!this.recording) {
4032
+ throw new Error("No recording in progress");
4033
+ }
4034
+ this.recording = false;
4035
+ const duration = Date.now() - this.startTime;
4036
+ if (this.bindingHandler) {
4037
+ this.cdp.off("Runtime.bindingCalled", this.bindingHandler);
4038
+ this.bindingHandler = null;
4039
+ }
4040
+ const steps = aggregateEvents(this.events, this.startUrl);
4041
+ return {
4042
+ recordedAt: new Date(this.startTime).toISOString(),
4043
+ startUrl: this.startUrl,
4044
+ duration,
4045
+ steps
4046
+ };
4047
+ }
4048
+ /**
4049
+ * Get raw recorded events (for debugging).
4050
+ */
4051
+ getEvents() {
4052
+ return [...this.events];
4053
+ }
4054
+ /**
4055
+ * Handle incoming binding call from the browser.
4056
+ */
4057
+ handleBindingCall(payload) {
4058
+ if (!this.recording) return;
4059
+ try {
4060
+ const event = JSON.parse(payload);
4061
+ this.events.push(event);
4062
+ } catch {
4063
+ }
4064
+ }
4065
+ };
4066
+
4067
+ // src/cli/commands/record.ts
4068
+ var RECORD_HELP = `
4069
+ bp record - Record browser actions to JSON
4070
+
4071
+ Usage:
4072
+ bp record [options]
4073
+
4074
+ Options:
4075
+ -s, --session [id] Session to use:
4076
+ - omit -s: auto-connect to local browser
4077
+ - -s alone: use most recent session
4078
+ - -s <id>: use specific session
4079
+ -f, --file <path> Output file (default: recording.json)
4080
+ --timeout <ms> Auto-stop after timeout (optional)
4081
+ -h, --help Show this help
4082
+
4083
+ Examples:
4084
+ bp record # Auto-connect to local Chrome
4085
+ bp record -s # Use most recent session
4086
+ bp record -s mysession # Use specific session
4087
+ bp record -f login.json # Save to specific file
4088
+ bp record --timeout 60000 # Auto-stop after 60s
4089
+
4090
+ Recording captures: clicks, inputs, form submissions, navigation.
4091
+ Password fields are automatically redacted as [REDACTED].
4092
+
4093
+ Press Ctrl+C to stop recording and save.
4094
+ `;
4095
+ function parseRecordArgs(args) {
4096
+ const options = {};
4097
+ for (let i = 0; i < args.length; i++) {
4098
+ const arg = args[i];
4099
+ if (arg === "-f" || arg === "--file") {
4100
+ options.file = args[++i];
4101
+ } else if (arg === "--timeout") {
4102
+ options.timeout = Number.parseInt(args[++i] ?? "", 10);
4103
+ } else if (arg === "-h" || arg === "--help") {
4104
+ options.help = true;
4105
+ } else if (arg === "-s" || arg === "--session") {
4106
+ const nextArg = args[i + 1];
4107
+ if (!nextArg || nextArg.startsWith("-")) {
4108
+ options.useLatestSession = true;
4109
+ }
4110
+ }
4111
+ }
4112
+ return options;
4113
+ }
4114
+ async function resolveConnection(sessionId, useLatestSession, trace) {
4115
+ if (sessionId) {
4116
+ const session2 = await loadSession(sessionId);
4117
+ const browser2 = await connect({
4118
+ provider: session2.provider,
4119
+ wsUrl: session2.wsUrl,
4120
+ debug: trace
4121
+ });
4122
+ return { browser: browser2, session: session2, isNewSession: false };
4123
+ }
4124
+ if (useLatestSession) {
4125
+ const session2 = await getDefaultSession();
4126
+ if (!session2) {
4127
+ throw new Error(
4128
+ 'No sessions found. Run "bp connect" first or use "bp record" to auto-connect.'
4129
+ );
4130
+ }
4131
+ const browser2 = await connect({
4132
+ provider: session2.provider,
4133
+ wsUrl: session2.wsUrl,
4134
+ debug: trace
4135
+ });
4136
+ return { browser: browser2, session: session2, isNewSession: false };
4137
+ }
4138
+ let wsUrl;
4139
+ try {
4140
+ wsUrl = await getBrowserWebSocketUrl("localhost:9222");
4141
+ } catch {
4142
+ throw new Error(
4143
+ "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"
4144
+ );
4145
+ }
4146
+ const browser = await connect({
4147
+ provider: "generic",
4148
+ wsUrl,
4149
+ debug: trace
4150
+ });
4151
+ const page = await browser.page();
4152
+ const currentUrl = await page.url();
4153
+ const newSessionId = generateSessionId();
4154
+ const session = {
4155
+ id: newSessionId,
4156
+ provider: "generic",
4157
+ wsUrl: browser.wsUrl,
4158
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4159
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
4160
+ currentUrl
4161
+ };
4162
+ await saveSession(session);
4163
+ return { browser, session, isNewSession: true };
4164
+ }
4165
+ async function recordCommand(args, globalOptions) {
4166
+ const options = parseRecordArgs(args);
4167
+ if (options.help || globalOptions.help) {
4168
+ console.log(RECORD_HELP);
4169
+ return;
4170
+ }
4171
+ const outputFile = options.file ?? "recording.json";
4172
+ const { browser, session, isNewSession } = await resolveConnection(
4173
+ globalOptions.session,
4174
+ options.useLatestSession ?? false,
4175
+ globalOptions.trace ?? false
4176
+ );
4177
+ if (isNewSession) {
4178
+ console.log(`Created new session: ${session.id}`);
4179
+ }
4180
+ const page = await browser.page();
4181
+ const cdp = page.cdpClient;
4182
+ const recorder = new Recorder(cdp);
4183
+ let stopping = false;
4184
+ async function stopAndSave() {
4185
+ if (stopping) return;
4186
+ stopping = true;
4187
+ try {
4188
+ const recording = await recorder.stop();
4189
+ const fs = await import("fs/promises");
4190
+ await fs.writeFile(outputFile, JSON.stringify(recording, null, 2));
4191
+ const currentUrl = await page.url();
4192
+ await updateSession(session.id, { currentUrl });
4193
+ await browser.disconnect();
4194
+ console.log(`
4195
+ Saved ${recording.steps.length} steps to ${outputFile}`);
4196
+ if (globalOptions.output === "json") {
4197
+ output(
4198
+ {
4199
+ success: true,
4200
+ file: outputFile,
4201
+ steps: recording.steps.length,
4202
+ duration: recording.duration
4203
+ },
4204
+ "json"
4205
+ );
4206
+ }
4207
+ process.exit(0);
4208
+ } catch (error) {
4209
+ console.error("Error saving recording:", error);
4210
+ process.exit(1);
4211
+ }
4212
+ }
4213
+ process.on("SIGINT", stopAndSave);
4214
+ process.on("SIGTERM", stopAndSave);
4215
+ if (options.timeout && options.timeout > 0) {
4216
+ setTimeout(stopAndSave, options.timeout);
4217
+ }
4218
+ await recorder.start();
4219
+ console.log(`Recording... Press Ctrl+C to stop and save to ${outputFile}`);
4220
+ console.log(`Session: ${session.id}`);
4221
+ console.log(`URL: ${await page.url()}`);
4222
+ }
4223
+
3469
4224
  // src/cli/commands/screenshot.ts
3470
4225
  function parseScreenshotArgs(args) {
3471
4226
  const options = {};
@@ -3639,6 +4394,7 @@ Commands:
3639
4394
  quickstart Getting started guide (start here!)
3640
4395
  connect Create browser session
3641
4396
  exec Execute actions
4397
+ record Record browser actions to JSON
3642
4398
  snapshot Get page with element refs
3643
4399
  text Extract text content
3644
4400
  screenshot Take screenshot
@@ -3659,6 +4415,8 @@ Examples:
3659
4415
  bp exec '{"action":"goto","url":"https://example.com"}'
3660
4416
  bp snapshot --format text
3661
4417
  bp exec '{"action":"click","selector":"ref:e3"}'
4418
+ bp record # Record from local browser
4419
+ bp record -s -f login.json # Record from latest session
3662
4420
 
3663
4421
  Run 'bp quickstart' for CLI workflow guide.
3664
4422
  Run 'bp actions' for complete action reference.
@@ -3761,6 +4519,9 @@ async function main() {
3761
4519
  case "actions":
3762
4520
  await actionsCommand();
3763
4521
  break;
4522
+ case "record":
4523
+ await recordCommand(remaining, options);
4524
+ break;
3764
4525
  case "help":
3765
4526
  case "--help":
3766
4527
  case "-h":