Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
50 changes: 50 additions & 0 deletions electron/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,54 @@ describe("parseCliArgs", () => {
expect(parse(["help"])).toMatchObject({ kind: "help" });
expect(parse(["--help"])).toMatchObject({ kind: "help" });
});

it("parses cursor import from a CSV next to a video", () => {
const cmd = parse([
"cursor",
"import",
"--csv",
"mouse.csv",
"--clicks",
"clicks.txt",
"--video",
"take.mp4",
"--width",
"1280",
"--height",
"720",
"--json",
]);
expect(cmd).toMatchObject({
kind: "cursor-import",
csvPath: inCwd("mouse.csv"),
clicksPath: inCwd("clicks.txt"),
videoPath: inCwd("take.mp4"),
outputPath: null,
width: 1280,
height: 720,
clickHoldMs: 60,
json: true,
});
});

it("parses cursor import with --output and no --video", () => {
expect(
parse(["cursor", "import", "--csv", "mouse.csv", "--output", "out.cursor.json"]),
).toMatchObject({
kind: "cursor-import",
csvPath: inCwd("mouse.csv"),
videoPath: null,
outputPath: inCwd("out.cursor.json"),
});
});

it("rejects cursor import without a csv or an output target", () => {
expect(parse(["cursor"])).toMatchObject({ kind: "error" });
expect(parse(["cursor", "import"])).toMatchObject({ kind: "error" });
expect(parse(["cursor", "import", "--csv", "m.csv"])).toMatchObject({
kind: "error",
message: expect.stringContaining("--video"),
});
expect(parse(["cursor", "export"])).toMatchObject({ kind: "error" });
});
});
132 changes: 132 additions & 0 deletions electron/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,22 @@ export interface CliMcpCommand {
kind: "mcp";
}

export interface CliCursorImportCommand {
kind: "cursor-import";
csvPath: string;
clicksPath: string | null;
videoPath: string | null;
outputPath: string | null;
width: number;
height: number;
clickHoldMs: number;
}

export type CliCommand = (
| CliRequest
| CliInfoCommand
| CliPackCommand
| CliCursorImportCommand
| CliMcpCommand
| CliHelpCommand
| CliErrorCommand
Expand Down Expand Up @@ -76,6 +88,7 @@ const SUBCOMMANDS = new Set([
"pack",
"captions",
"info",
"cursor",
"mcp",
"help",
"--help",
Expand All @@ -92,6 +105,7 @@ Usage:
openscreen captions <project.openscreen> Transcribe (on-device Whisper) and turn captions on
[--min-words <1-12>] [--max-words <1-12>]
openscreen info <project.openscreen> [--json] Inspect a project file
openscreen cursor import [options] Write <video>.cursor.json from a CSV mouse track
openscreen mcp Serve the agent tools over MCP on stdio
openscreen help Show this help

Expand Down Expand Up @@ -132,6 +146,20 @@ Record options (recording is saved into the app's recordings directory):

Stopping a recording: send SIGINT/SIGTERM, type "stop" + Enter on stdin,
or pass --duration.

Cursor import options (for takes recorded outside OpenScreen, or a CSV already on disk):
--csv <mouse.csv> Required. Columns t,x,y (seconds + pixel coords). t/time/time_s accepted
--clicks <clicks.txt> Optional. One seconds-timestamp per line
--video <file.mp4> Sidecar path becomes <file.mp4>.cursor.json
-o, --output <file> Explicit sidecar path (overrides --video's default)
--width <px> Capture width used to normalise x (default 1920)
--height <px> Capture height used to normalise y (default 1200)
--click-hold-ms <ms> Synthesized mouseup this long after each click (default 60)
--json Machine-readable result on stdout

On Linux X11 hosts where the ScreenCast portal has no METADATA cursor mode,
openscreen record --cursor editable-overlay samples the pointer automatically
and writes the sidecar itself — cursor import is only needed for an external track.
`;

function takeValue(argv: string[], i: number, flag: string): [string, number] {
Expand Down Expand Up @@ -194,6 +222,7 @@ export function parseCliArgs(
if (args.length > 1) throw new Error(`Unexpected argument for mcp: ${args[1]}`);
return { kind: "mcp" };
}
if (sub === "cursor") return parseCursor(args.slice(1), cwd);
return parseInfo(args.slice(1), cwd);
} catch (error) {
return { kind: "error", message: error instanceof Error ? error.message : String(error) };
Expand Down Expand Up @@ -546,6 +575,109 @@ function parseCaptions(args: string[], cwd: string): CliCommand {
return { kind: "captions", projectPath, minWordsPerCaption, maxWordsPerCaption, json };
}

function parseCursor(args: string[], cwd: string): CliCommand {
const action = args[0];
if (!action || action === "--help" || action === "-h") {
throw new Error("cursor requires a subcommand: import");
}
if (action !== "import") {
throw new Error(`Unknown cursor subcommand: ${action} (expected import)`);
}
return parseCursorImport(args.slice(1), cwd);
}

function parseCursorImport(args: string[], cwd: string): CliCommand {
let csvPath = "";
let clicksPath: string | null = null;
let videoPath: string | null = null;
let outputPath: string | null = null;
let width = 1920;
let height = 1200;
let clickHoldMs = 60;
let json = false;

for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "--csv": {
const [value, next] = takeValue(args, i, arg);
csvPath = resolvePath(value, cwd);
i = next;
break;
}
case "--clicks": {
const [value, next] = takeValue(args, i, arg);
clicksPath = resolvePath(value, cwd);
i = next;
break;
}
case "--video": {
const [value, next] = takeValue(args, i, arg);
videoPath = resolvePath(value, cwd);
i = next;
break;
}
case "-o":
case "--output":
case "--out": {
const [value, next] = takeValue(args, i, arg);
outputPath = resolvePath(value, cwd);
i = next;
break;
}
case "--width": {
const [value, next] = takeValue(args, i, arg);
width = Number(value);
if (!Number.isInteger(width) || width <= 0) {
throw new Error(`--width must be a positive integer, got "${value}"`);
}
i = next;
break;
}
case "--height": {
const [value, next] = takeValue(args, i, arg);
height = Number(value);
if (!Number.isInteger(height) || height <= 0) {
throw new Error(`--height must be a positive integer, got "${value}"`);
}
i = next;
break;
}
case "--click-hold-ms": {
const [value, next] = takeValue(args, i, arg);
clickHoldMs = Number(value);
if (!Number.isInteger(clickHoldMs) || clickHoldMs < 0) {
throw new Error(`--click-hold-ms must be a non-negative integer, got "${value}"`);
}
i = next;
break;
}
case "--json":
json = true;
break;
default:
if (arg.startsWith("-")) throw new Error(`Unknown cursor import option: ${arg}`);
throw new Error(`Unexpected extra argument: ${arg}`);
}
}

if (!csvPath) throw new Error("cursor import requires --csv <mouse.csv>");
if (!outputPath && !videoPath) {
throw new Error("cursor import requires --video <file> or --output <file>");
}
return {
kind: "cursor-import",
csvPath,
clicksPath,
videoPath,
outputPath,
width,
height,
clickHoldMs,
json,
};
}

function parseInfo(args: string[], cwd: string): CliCommand {
let projectPath = "";
let json = false;
Expand Down
7 changes: 7 additions & 0 deletions electron/cli/cliMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { registerSttIpc, shutdownStt } from "../stt";
import { sendUsagePing, USAGE_PING_INTERVAL_MS } from "../usage-ping";
import { createRunnerWindow } from "../windows";
import { CLI_USAGE, type CliCommand } from "./args";
import { runCursorImportCommand } from "./cursorCommands";
import { openGuiFromCli } from "./openGui";
import { runInfoCommand, runPackCommand } from "./projectCommands";
import { createRunnerHost } from "./runnerHost";
Expand Down Expand Up @@ -323,6 +324,12 @@ export function runCli(command: CliCommand): void {
return;
}

if (command.kind === "cursor-import") {
const code = await runCursorImportCommand(command, command.json === true, writeStdout);
app.exit(code);
return;
}

milestone("resolving userData path");
const userDataDir = app.getPath("userData");
milestone(`userData = ${userDataDir}`);
Expand Down
110 changes: 110 additions & 0 deletions electron/cli/cursorCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runCursorImportCommand } from "./cursorCommands";

describe("runCursorImportCommand", () => {
let dir: string;

beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-cli-"));
});

afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});

it("writes the sidecar and a human summary", async () => {
const csvPath = path.join(dir, "mouse.csv");
const videoPath = path.join(dir, "clip.mp4");
await fs.writeFile(csvPath, "t,x,y\n0,10,20\n", "utf8");
await fs.writeFile(videoPath, "v", "utf8");
const lines: string[] = [];
const code = await runCursorImportCommand(
{
csvPath,
clicksPath: null,
videoPath,
outputPath: null,
width: 100,
height: 100,
clickHoldMs: 60,
},
false,
(text) => {
lines.push(text);
},
);
expect(code).toBe(0);
expect(lines.join("")).toContain("Wrote ");
expect(lines.join("")).toContain("samples=1");
await expect(fs.stat(`${videoPath}.cursor.json`)).resolves.toMatchObject({
size: expect.any(Number),
});
});

it("emits JSON when asked", async () => {
const csvPath = path.join(dir, "mouse.csv");
await fs.writeFile(csvPath, "t,x,y\n0,1,2\n", "utf8");
const outputPath = path.join(dir, "out.cursor.json");
const lines: string[] = [];
await runCursorImportCommand(
{
csvPath,
clicksPath: null,
videoPath: null,
outputPath,
width: 10,
height: 10,
clickHoldMs: 60,
},
true,
(text) => {
lines.push(text);
},
);
const payload = JSON.parse(lines.join(""));
expect(payload).toMatchObject({
event: "done",
success: true,
outputPath,
samples: 1,
moves: 1,
clicks: 0,
mouseups: 0,
});
});

it("counts click and mouseup samples in JSON", async () => {
const csvPath = path.join(dir, "mouse.csv");
const clicksPath = path.join(dir, "clicks.txt");
const outputPath = path.join(dir, "out.cursor.json");
await fs.writeFile(csvPath, "t,x,y\n0,0,0\n1,10,10\n", "utf8");
await fs.writeFile(clicksPath, "0.5\n", "utf8");
const lines: string[] = [];
await runCursorImportCommand(
{
csvPath,
clicksPath,
videoPath: null,
outputPath,
width: 10,
height: 10,
clickHoldMs: 60,
},
true,
(text) => {
lines.push(text);
},
);
expect(JSON.parse(lines.join(""))).toMatchObject({
event: "done",
success: true,
samples: 4,
moves: 2,
clicks: 1,
mouseups: 1,
});
});
});
Loading
Loading