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/README.md +39 -0
- package/dist/actions.cjs +2 -1
- package/dist/actions.d.cts +2 -2
- package/dist/actions.d.ts +2 -2
- package/dist/actions.mjs +1 -1
- package/dist/browser.cjs +26 -4
- package/dist/browser.d.cts +2 -2
- package/dist/browser.d.ts +2 -2
- package/dist/browser.mjs +2 -2
- package/dist/{chunk-YEHK2XY3.mjs → chunk-6RB3GKQP.mjs} +2 -1
- package/dist/{chunk-CWSTSVWO.mjs → chunk-PCNEJAJ7.mjs} +25 -4
- package/dist/cli.cjs +948 -88
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +842 -4
- package/dist/index.cjs +26 -4
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{types-BJv2dzu0.d.ts → types-CbdmaocU.d.ts} +9 -0
- package/dist/{types-C6m0bT04.d.cts → types-TVlTA7nH.d.cts} +9 -0
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
import "./chunk-ZIQA4JOT.mjs";
|
|
3
3
|
import {
|
|
4
4
|
connect
|
|
5
|
-
} from "./chunk-
|
|
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-
|
|
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":
|