Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/browse-macro-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"browse": minor
---

Add `browse macro` commands to record and replay driver command sequences.
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
"mouse": {
"description": "Send raw mouse coordinate input."
},
"macro": {
"description": "Record and replay browse driver command sequences."
},
"network": {
"description": "Capture browser network traffic for the active session."
},
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/commands/macro/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Args, Flags } from "@oclif/core";
import { promises as fs } from "node:fs";

import { BrowseCommand } from "../../base.js";
import { macroFilePath } from "../../lib/macro/store.js";
import { outputJson } from "../../lib/output.js";

export default class MacroDelete extends BrowseCommand {
static override description = "Delete a saved browse macro.";

static override examples = [
"browse macro delete login-flow",
"browse macro delete login-flow --force",
];

static override args = {
name: Args.string({
description: "Macro name to delete.",
required: true,
}),
};

static override flags = {
force: Flags.boolean({
default: false,
description: "Delete without confirmation.",
}),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(MacroDelete);
const file = macroFilePath(args.name);

if (!flags.force) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
throw new Error(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
`Refusing to delete macro "${args.name}" without --force.`,
);
}

await fs.unlink(file);
outputJson({
deleted: true,
name: args.name,
});
}
}
22 changes: 22 additions & 0 deletions packages/cli/src/commands/macro/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { BrowseCommand } from "../../base.js";
import { listMacroNames } from "../../lib/macro/store.js";
import { getActiveRecordingName } from "../../lib/macro/recording.js";
import { outputJson } from "../../lib/output.js";

export default class MacroList extends BrowseCommand {
static override description = "List saved browse macros.";

static override examples = ["browse macro list"];

async run(): Promise<void> {
const [macros, recording] = await Promise.all([
listMacroNames(),
getActiveRecordingName(),
]);

outputJson({
macros,
recording,
});
}
}
32 changes: 32 additions & 0 deletions packages/cli/src/commands/macro/record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Args } from "@oclif/core";

import { BrowseCommand } from "../../base.js";
import { startMacroRecording } from "../../lib/macro/recording.js";
import { outputJson } from "../../lib/output.js";

export default class MacroRecord extends BrowseCommand {
static override description =
"Start recording browse driver commands into a named macro.";

static override examples = [
"browse macro record login-flow",
"browse macro record checkout --session research",
];

static override args = {
name: Args.string({
description: "Macro name to create.",
required: true,
}),
};

async run(): Promise<void> {
const { args } = await this.parse(MacroRecord);
await startMacroRecording(args.name);
outputJson({
message: `Recording macro "${args.name}". Run browse commands, then browse macro stop.`,
name: args.name,
recording: true,
});
}
}
55 changes: 55 additions & 0 deletions packages/cli/src/commands/macro/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Args, Flags } from "@oclif/core";

import { BrowseCommand } from "../../base.js";
import {
driverCommandFlags,
resolveTargetForCommand,
type DriverFlags,
} from "../../lib/driver/command-cli.js";
import { sessionName } from "../../lib/driver/flags.js";
import { replayMacro } from "../../lib/macro/replay.js";
import { outputJson } from "../../lib/output.js";

export default class MacroRun extends BrowseCommand {
static override description =
"Replay a saved macro in the active browse driver session.";

static override examples = [
"browse macro run login-flow",
"browse macro run checkout --session research --delay 250",
];

static override args = {
name: Args.string({
description: "Macro name to replay.",
required: true,
}),
};

static override flags = {
...driverCommandFlags,
delay: Flags.integer({
default: 0,
description: "Delay in milliseconds between macro steps.",
helpValue: "<ms>",
}),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(MacroRun);
const session = sessionName(flags.session);
const target = await resolveTargetForCommand(session, flags as DriverFlags);
const { macro, results } = await replayMacro({
delayMs: flags.delay,
name: args.name,
session,
target,
});

outputJson({
name: macro.name,
results,
steps: macro.steps.length,
});
}
}
23 changes: 23 additions & 0 deletions packages/cli/src/commands/macro/show.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Args } from "@oclif/core";

import { BrowseCommand } from "../../base.js";
import { loadMacro } from "../../lib/macro/store.js";
import { outputJson } from "../../lib/output.js";

export default class MacroShow extends BrowseCommand {
static override description = "Show the steps in a saved browse macro.";

static override examples = ["browse macro show login-flow"];

static override args = {
name: Args.string({
description: "Macro name to inspect.",
required: true,
}),
};

async run(): Promise<void> {
const { args } = await this.parse(MacroShow);
outputJson(await loadMacro(args.name));
}
}
19 changes: 19 additions & 0 deletions packages/cli/src/commands/macro/stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { BrowseCommand } from "../../base.js";
import { stopMacroRecording } from "../../lib/macro/recording.js";
import { outputJson } from "../../lib/output.js";

export default class MacroStop extends BrowseCommand {
static override description = "Stop the active macro recording and save it.";

static override examples = ["browse macro stop"];

async run(): Promise<void> {
const macro = await stopMacroRecording();
outputJson({
createdAt: macro.createdAt,
message: `Saved macro "${macro.name}" with ${macro.steps.length} step(s).`,
name: macro.name,
steps: macro.steps.length,
});
}
}
10 changes: 8 additions & 2 deletions packages/cli/src/lib/driver/command-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type DriverModeFlags,
} from "./mode.js";
import type { ConnectionTarget } from "./types.js";
import { appendMacroStepIfRecording } from "../macro/recording.js";
import { outputJson } from "../output.js";
import { runDriverCommandWithTarget } from "./runtime.js";

Expand Down Expand Up @@ -70,9 +71,14 @@ export async function runDriverCommandFromFlags(
): Promise<void> {
const session = sessionName(flags.session);
const target = await resolveTargetForCommand(session, flags);
outputJson(
await runDriverCommandWithTarget(session, target, command, params),
const result = await runDriverCommandWithTarget(
session,
target,
command,
params,
);
await appendMacroStepIfRecording(command, params);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
outputJson(result);
}

export async function resolveTargetForCommand(
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/src/lib/macro/recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { DriverCommandName } from "../driver/commands/types.js";
import {
clearRecordingState,
loadMacro,
readRecordingState,
saveMacro,
writeRecordingState,
} from "./store.js";
import type { BrowseMacro, MacroStep } from "./types.js";

const NON_RECORDABLE_COMMANDS = new Set<DriverCommandName>([
"cursor",
"refs",
"snapshot",
"tab.list",
]);

export async function startMacroRecording(name: string): Promise<void> {
const active = await readRecordingState();
if (active) {
throw new Error(
`Already recording macro "${active.name}". Run browse macro stop first.`,
);
}

try {
await loadMacro(name);
throw new Error(
`Macro "${name}" already exists. Choose a different name or delete the existing macro first.`,
);
} catch (error) {
if (!(error instanceof Error) || !error.message.includes("not found")) {
throw error;
}
}

await writeRecordingState({
name,
startedAt: new Date().toISOString(),
steps: [],
});
}

export async function stopMacroRecording(): Promise<BrowseMacro> {
const active = await readRecordingState();
if (!active) {
throw new Error(
"No macro recording in progress. Run browse macro record <name> first.",
);
}

const macro: BrowseMacro = {
createdAt: active.startedAt,
name: active.name,
steps: active.steps,
};

await saveMacro(macro);
await clearRecordingState();
return macro;
}

export async function appendMacroStepIfRecording(
command: DriverCommandName,
params: unknown,
): Promise<void> {
if (NON_RECORDABLE_COMMANDS.has(command)) {
return;
}

const active = await readRecordingState();
if (!active) {
return;
}

const step: MacroStep = { command, params };
active.steps.push(step);
await writeRecordingState(active);
}

export async function getActiveRecordingName(): Promise<string | null> {
const active = await readRecordingState();
return active?.name ?? null;
}
44 changes: 44 additions & 0 deletions packages/cli/src/lib/macro/replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { DriverCommandName } from "../driver/commands/types.js";
import { runDriverCommandWithTarget } from "../driver/runtime.js";
import type { ConnectionTarget } from "../driver/types.js";
import { loadMacro } from "./store.js";
import type { BrowseMacro } from "./types.js";

export interface ReplayMacroOptions {
delayMs: number;
name: string;
session: string;
target: ConnectionTarget;
}

export interface ReplayMacroResult {
macro: BrowseMacro;
results: unknown[];
}

export async function replayMacro(
options: ReplayMacroOptions,
): Promise<ReplayMacroResult> {
const macro = await loadMacro(options.name);
const results: unknown[] = [];

for (const step of macro.steps) {
const result = await runDriverCommandWithTarget(
options.session,
options.target,
step.command as DriverCommandName,
step.params,
);
results.push(result);

if (options.delayMs > 0) {
await sleep(options.delayMs);
}
}

return { macro, results };
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Loading
Loading