agent-screenplay 0.1.2 → 0.1.4

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # Screenplay
2
2
 
3
+ https://github.com/user-attachments/assets/dd9898da-4900-4a91-8620-57a860c46093
4
+
3
5
  **Screen Studio-quality screen recordings, made by your AI agent.**
4
6
 
5
7
  Ask Claude Code, Codex, Cursor or any agent: *"Make a video that shows how human handoff works"*. The agent explores your app and writes a short **scenario**; Screenplay plays it in a browser (or a desktop app), films it, and edits the video like Screen Studio would: smooth cursor, automatic zooms that follow the action, click effects, callouts, spotlight, captions, title cards, and an optional voice-over.
@@ -62,7 +64,7 @@ Requirements: Node 20+. ffmpeg and the drawing library ship with the package.
62
64
  - **Targets** name elements the way people see them: `text=…`, `role=button[name='…']`, `label=…`, `placeholder=…`, `testid=…`, `css=…` (web only), `at=x,y` (desktop apps without accessible elements only).
63
65
  - **Actions**: `goto`, `click`, `type`, `press`, `hover`, `scroll`, `select`, `wait`, `pause`.
64
66
  - **Direction**: `title` and `end` cards, `callout` (a bubble pointing at an element), `highlight` (spotlight), `zoomTo`, and on any step `say` (caption and voice), `zoom`, `hold`.
65
- - **Style**: background preset (soft light gradients: `dawn`, `iris`, `citrus`, `apricot`, `coral`, `blaze`, `cloud`, `glacier`, `tide`, `meadow`), your own gradient, a color or an image; padding, corner radius, shadow, cursor style, maximum zoom, browser frame, accent color. See [Style](#style).
67
+ - **Style**: background preset (soft light gradients: `dawn`, `iris`, `citrus`, `apricot`, `coral`, `blaze`, `cloud`, `glacier`, `tide`, `meadow`), your own gradient, a color or an image; padding, corner radius, shadow, the cursor (`arrow` by default, a rounded arrow in the accent color; `macos`, `dot`) and its click effect (`ripple` by default; `rays`, `confetti`, `sparkle`), maximum zoom, browser frame, accent color. See [Style](#style).
66
68
  - **Output**: 1920×1080 at 30 fps by default; any size up to 4K, 24 to 60 fps, optional GIF.
67
69
 
68
70
  The full format is the JSON schema in [`schema/scenario.schema.json`](schema/scenario.schema.json) (`npx agent-screenplay schema`), and the agent guide in [`skills/screenplay/SKILL.md`](skills/screenplay/SKILL.md). Examples are in [`examples/`](examples).
@@ -126,6 +128,8 @@ Use `"app": "<name>"` instead of `"url"`. The steps are played through the opera
126
128
 
127
129
  Desktop recordings need a real screen: they cannot run in a headless container. Websites can.
128
130
 
131
+ While Screenplay drives the computer, a pill at the top of the screen says so ("Screenplay is recording", or "rehearsing" during a check), with a **Stop** button that ends the run at once. The pill is left out of the capture, so it is never in the video. macOS and Windows; `SCREENPLAY_INDICATOR=0` turns it off.
132
+
129
133
  ### The whole screen
130
134
 
131
135
  `"screen": true` films the whole main screen instead of one window, for videos that go from one app to another or use the menu bar. `{ "open": "<app>" }` launches an app or brings it to the front, a step's `"app"` acts in another app, and `"surface": "menubar"` (or `menu`, `sheet`, `popover`, `alert`) finds elements outside the app's window. Everything on screen is filmed, desktop icons and notifications included: tidy the screen first; on macOS `"hideOthers": true` hides the other apps.
@@ -155,6 +159,20 @@ npm test # unit tests
155
159
  npm run test:e2e # records a real scenario in headless Chromium and talks to the MCP server
156
160
  ```
157
161
 
162
+ ### Releasing
163
+
164
+ ```bash
165
+ npm version patch --no-git-tag-version # or minor / major; also updates src/version.ts
166
+ ```
167
+
168
+ Merge that change, then tag the commit on main:
169
+
170
+ ```bash
171
+ git tag v0.1.4 origin/main && git push origin v0.1.4
172
+ ```
173
+
174
+ The tag starts `.github/workflows/release.yml`. It checks that the tag matches the version and is on main, runs the tests, publishes to npm (with provenance) and creates the GitHub release with notes from the merged pull requests. A version like `0.2.0-beta.1` goes to npm's `next` tag. Creating a release in GitHub's UI with a new tag works too.
175
+
158
176
  `examples/demo-app` is a small web app for trying scenarios; `examples/desktop-app/notes.py` is a GTK app for desktop recording on Linux.
159
177
 
160
178
  ## License
@@ -248,6 +248,14 @@ kbd { font: 11px ui-monospace, Menlo, monospace; color: var(--faint); }
248
248
  <div class="row"><span>Shadow</span><button class="switch" id="shadow" role="switch" aria-label="Shadow"></button></div>
249
249
  <div class="row" style="margin-top:16px"><span>Intro &amp; outro cards</span><button class="switch" id="cards" role="switch" aria-label="Intro and outro cards"></button></div>
250
250
  </div>
251
+
252
+ <div class="section">
253
+ <p class="label">Cursor</p>
254
+ <div class="seg" id="cursorKind"><span class="pill"></span><button data-k="arrow">Arrow</button><button data-k="macos">macOS</button><button data-k="dot">Dot</button></div>
255
+ <p class="label" style="margin-top:16px">Click</p>
256
+ <div class="seg" id="clickKind"><span class="pill"></span><button data-k="ripple">Ripple</button><button data-k="rays">Rays</button><button data-k="confetti">Confetti</button><button data-k="sparkle">Sparkle</button></div>
257
+ <p class="note on" style="display:block">Shown in the video: Render to see them.</p>
258
+ </div>
251
259
  </div>
252
260
 
253
261
  <div class="foot">
@@ -284,7 +292,7 @@ async function load() {
284
292
  S = await (await api("/api/state")).json();
285
293
  $("name").textContent = S.name;
286
294
  const eff = { ...S.defaults, ...S.projectStyle, ...S.scenarioStyle };
287
- style = { background: eff.background, padding: eff.padding, radius: eff.radius, shadow: eff.shadow, cards: eff.cards };
295
+ style = { background: eff.background, padding: eff.padding, radius: eff.radius, shadow: eff.shadow, cards: eff.cards, cursor: eff.cursor, click: eff.click };
288
296
  if (kindOf(style.background) === "image") imageRef = { path: style.background, from: S.scenarioStyle.background !== undefined ? "scenario" : "project" };
289
297
  saved = JSON.stringify(style);
290
298
  // A video that already has its own look keeps it; otherwise the look is for the whole project.
@@ -462,6 +470,8 @@ $("rad").oninput = (e) => { live(); style.radius = +e.target.value; change(); };
462
470
  $("shadow").onclick = () => { style.shadow = !style.shadow; change(); };
463
471
  // Cards are not in the frame preview: the rendered video shows them.
464
472
  $("cards").onclick = () => { style.cards = !style.cards; change(); };
473
+ $("cursorKind").querySelectorAll("button").forEach((b) => (b.onclick = () => { style.cursor = b.dataset.k; change(); }));
474
+ $("clickKind").querySelectorAll("button").forEach((b) => (b.onclick = () => { style.click = b.dataset.k; change(); }));
465
475
  $("reset").onclick = () => {
466
476
  style = { ...S.defaults };
467
477
  setKind(kindOf(style.background), true);
@@ -534,6 +544,8 @@ function sync() {
534
544
  }
535
545
  $("shadow").setAttribute("aria-checked", String(style.shadow));
536
546
  $("cards").setAttribute("aria-checked", String(style.cards));
547
+ setSeg($("cursorKind"), style.cursor);
548
+ setSeg($("clickKind"), style.click);
537
549
  $("reset").disabled = JSON.stringify(style) === JSON.stringify({ ...S.defaults });
538
550
  // The recording keeps its shape: when it is not the video's, the background shows on two sides even with no padding.
539
551
  const cap = S.capture || { width: 1440, height: 900 }, r = cap.width / cap.height, R = S.output.width / S.output.height;
@@ -0,0 +1,100 @@
1
+ // The pill that tells the person at the Mac that Screenplay is driving it (and filming).
2
+ // Run with `osascript -l JavaScript macos.js <label> <parent pid> [selftest]`.
3
+ // It is left out of screen captures (NSWindowSharingNone), stays on top on every Space,
4
+ // never takes focus, and survives "Hide others". Stop exits with code 3; the pill also
5
+ // quits by itself when the Screenplay process that started it is gone.
6
+ ObjC.import("Cocoa");
7
+ ObjC.import("stdlib");
8
+ ObjC.import("signal");
9
+
10
+ function run(argv) {
11
+ const text = argv[0] || "Screenplay is recording";
12
+ const parent = Number(argv[1] || 0);
13
+ const selftest = argv[2] === "selftest";
14
+
15
+ const app = $.NSApplication.sharedApplication;
16
+ app.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
17
+ app.finishLaunching;
18
+
19
+ ObjC.registerSubclass({
20
+ name: "SPStopTarget",
21
+ methods: { "stop:": { types: ["void", ["id"]], implementation: () => $.exit(3) } },
22
+ });
23
+ const target = $.SPStopTarget.alloc.init;
24
+
25
+ const rgba = (r, g, b, a) => $.NSColor.colorWithSRGBRedGreenBlueAlpha(r / 255, g / 255, b / 255, a);
26
+ const H = 36;
27
+
28
+ const label = $.NSTextField.labelWithString(text);
29
+ label.font = $.NSFont.systemFontOfSizeWeight(13, 0.23);
30
+ label.textColor = rgba(255, 255, 255, 0.95);
31
+ label.sizeToFit;
32
+ const lw = Math.ceil(label.frame.size.width);
33
+
34
+ const stop = $.NSButton.buttonWithTitleTargetAction("Stop", target, "stop:");
35
+ stop.bezelStyle = $.NSBezelStyleRounded;
36
+ stop.controlSize = $.NSControlSizeSmall;
37
+ stop.font = $.NSFont.systemFontOfSizeWeight(11, 0.3);
38
+ stop.sizeToFit;
39
+ const bw = Math.ceil(stop.frame.size.width);
40
+ const bh = Math.ceil(stop.frame.size.height);
41
+
42
+ const W = 14 + 8 + 9 + lw + 10 + bw + 8;
43
+ const vf = $.NSScreen.mainScreen.visibleFrame;
44
+ const rect = $.NSMakeRect(vf.origin.x + Math.round((vf.size.width - W) / 2), vf.origin.y + vf.size.height - H - 10, W, H);
45
+
46
+ const panel = $.NSPanel.alloc.initWithContentRectStyleMaskBackingDefer(rect, $.NSWindowStyleMaskBorderless | $.NSWindowStyleMaskNonactivatingPanel, $.NSBackingStoreBuffered, false);
47
+ panel.appearance = $.NSAppearance.appearanceNamed($.NSAppearanceNameDarkAqua);
48
+ panel.opaque = false;
49
+ panel.backgroundColor = $.NSColor.clearColor;
50
+ panel.hasShadow = true;
51
+ panel.level = $.NSStatusWindowLevel;
52
+ panel.sharingType = $.NSWindowSharingNone;
53
+ panel.canHide = false;
54
+ panel.hidesOnDeactivate = false;
55
+ panel.movableByWindowBackground = true;
56
+ panel.collectionBehavior =
57
+ $.NSWindowCollectionBehaviorCanJoinAllSpaces | $.NSWindowCollectionBehaviorStationary | $.NSWindowCollectionBehaviorIgnoresCycle | $.NSWindowCollectionBehaviorFullScreenAuxiliary;
58
+
59
+ // NSBox draws rounded fills without Core Animation colors, which JXA cannot pass.
60
+ const box = (frame, fill, radius, border) => {
61
+ const b = $.NSBox.alloc.initWithFrame(frame);
62
+ b.boxType = $.NSBoxCustom;
63
+ b.titlePosition = $.NSNoTitle;
64
+ b.contentViewMargins = $.NSMakeSize(0, 0);
65
+ b.fillColor = fill;
66
+ b.cornerRadius = radius;
67
+ b.borderWidth = border ? 1 : 0;
68
+ b.borderColor = border || $.NSColor.clearColor;
69
+ return b;
70
+ };
71
+ const pill = box($.NSMakeRect(0, 0, W, H), rgba(24, 24, 27, 0.92), H / 2, rgba(255, 255, 255, 0.14));
72
+ const dot = box($.NSMakeRect(14, (H - 8) / 2, 8, 8), rgba(255, 69, 58, 1), 4);
73
+
74
+ label.setFrameOrigin($.NSMakePoint(14 + 8 + 9, Math.round((H - label.frame.size.height) / 2)));
75
+ stop.setFrameOrigin($.NSMakePoint(W - bw - 8, Math.round((H - bh) / 2)));
76
+
77
+ const view = $.NSView.alloc.initWithFrame($.NSMakeRect(0, 0, W, H));
78
+ view.addSubview(pill);
79
+ view.addSubview(dot);
80
+ view.addSubview(label);
81
+ view.addSubview(stop);
82
+ panel.contentView = view;
83
+ panel.orderFrontRegardless;
84
+
85
+ if (selftest) stop.performClick(null);
86
+
87
+ // The dot blinks softly; the loop also handles clicks and watches the parent process.
88
+ let lit = true;
89
+ let next = Date.now();
90
+ for (;;) {
91
+ const ev = app.nextEventMatchingMaskUntilDateInModeDequeue($.NSEventMaskAny, $.NSDate.dateWithTimeIntervalSinceNow(0.25), $.NSDefaultRunLoopMode, true);
92
+ if (ev && !ev.isNil()) app.sendEvent(ev);
93
+ if (Date.now() >= next) {
94
+ lit = !lit;
95
+ dot.animator.alphaValue = lit ? 1 : 0.3;
96
+ next = Date.now() + 800;
97
+ if (parent && $.kill(parent, 0) !== 0) $.exit(0);
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,75 @@
1
+ # The pill that tells the person at the PC that Screenplay is driving it (and filming).
2
+ # Run with: powershell -NoProfile -ExecutionPolicy Bypass -File windows.ps1 <label> <parent pid> [selftest]
3
+ # It is left out of screen captures (WDA_EXCLUDEFROMCAPTURE), stays on top, never takes focus.
4
+ # Stop exits with code 3; the pill also quits when the Screenplay process that started it is gone.
5
+ param([string]$Label = "Screenplay is recording", [int]$ParentPid = 0, [string]$Mode = "")
6
+
7
+ Add-Type -ReferencedAssemblies System.Windows.Forms, System.Drawing -TypeDefinition @"
8
+ using System;
9
+ using System.Diagnostics;
10
+ using System.Drawing;
11
+ using System.Drawing.Drawing2D;
12
+ using System.Runtime.InteropServices;
13
+ using System.Windows.Forms;
14
+
15
+ public class ScreenplayPill : Form {
16
+ [DllImport("user32.dll")] static extern bool SetWindowDisplayAffinity(IntPtr hWnd, uint affinity);
17
+ const int WS_EX_TOPMOST = 0x8, WS_EX_TOOLWINDOW = 0x80, WS_EX_NOACTIVATE = 0x8000000;
18
+ readonly int parent;
19
+ bool lit = true;
20
+ readonly Panel dot;
21
+ public readonly Button StopButton;
22
+
23
+ public ScreenplayPill(string text, int parentPid) {
24
+ parent = parentPid;
25
+ FormBorderStyle = FormBorderStyle.None;
26
+ ShowInTaskbar = false;
27
+ TopMost = true;
28
+ StartPosition = FormStartPosition.Manual;
29
+ BackColor = Color.FromArgb(24, 24, 27);
30
+ Font = new Font("Segoe UI Semibold", 9.75f);
31
+ int h = 36;
32
+
33
+ dot = new Panel { BackColor = Color.FromArgb(255, 69, 58), Size = new Size(8, 8), Location = new Point(14, (h - 8) / 2) };
34
+ var dp = new GraphicsPath(); dp.AddEllipse(0, 0, 8, 8); dot.Region = new Region(dp);
35
+
36
+ var label = new Label { Text = text, ForeColor = Color.White, AutoSize = true, BackColor = Color.Transparent };
37
+ label.Location = new Point(31, (h - label.PreferredHeight) / 2);
38
+
39
+ StopButton = new Button { Text = "Stop", FlatStyle = FlatStyle.Flat, ForeColor = Color.White, BackColor = Color.FromArgb(60, 60, 66), Size = new Size(52, 24), TabStop = false };
40
+ StopButton.FlatAppearance.BorderSize = 0;
41
+ StopButton.Click += (s, e) => Environment.Exit(3);
42
+
43
+ int w = 31 + label.PreferredWidth + 10 + 52 + 8;
44
+ Size = new Size(w, h);
45
+ StopButton.Location = new Point(w - 52 - 8, (h - 24) / 2);
46
+ var area = Screen.PrimaryScreen.WorkingArea;
47
+ Location = new Point(area.X + (area.Width - w) / 2, area.Y + 10);
48
+ var path = new GraphicsPath();
49
+ path.AddArc(0, 0, h, h, 90, 180); path.AddArc(w - h, 0, h, h, 270, 180); path.CloseFigure();
50
+ Region = new Region(path);
51
+ Controls.Add(dot); Controls.Add(label); Controls.Add(StopButton);
52
+
53
+ var timer = new Timer { Interval = 800 };
54
+ timer.Tick += (s, e) => {
55
+ lit = !lit;
56
+ dot.BackColor = lit ? Color.FromArgb(255, 69, 58) : Color.FromArgb(110, 40, 38);
57
+ if (parent > 0) { try { Process.GetProcessById(parent); } catch (ArgumentException) { Environment.Exit(0); } }
58
+ };
59
+ timer.Start();
60
+ }
61
+
62
+ protected override bool ShowWithoutActivation { get { return true; } }
63
+ protected override CreateParams CreateParams {
64
+ get { var cp = base.CreateParams; cp.ExStyle |= WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE; return cp; }
65
+ }
66
+ protected override void OnHandleCreated(EventArgs e) {
67
+ base.OnHandleCreated(e);
68
+ SetWindowDisplayAffinity(Handle, 0x11); // WDA_EXCLUDEFROMCAPTURE (Windows 10 2004+)
69
+ }
70
+ }
71
+ "@
72
+
73
+ $pill = New-Object ScreenplayPill($Label, $ParentPid)
74
+ if ($Mode -eq "selftest") { $pill.add_Shown({ $pill.StopButton.PerformClick() }) }
75
+ [System.Windows.Forms.Application]::Run($pill)
@@ -21,14 +21,36 @@ interface Envelope<T = any> {
21
21
  details?: unknown;
22
22
  };
23
23
  }
24
+ /** The error a run ends with when the person presses Stop on the pill. */
25
+ export declare class StoppedError extends Error {
26
+ constructor();
27
+ }
28
+ /**
29
+ * Stop for one run: `pause` is a sleep that Stop ends at once (its timer cleared, so nothing keeps
30
+ * the process alive), `signal` rejects with StoppedError on Stop, `check` throws once stopped.
31
+ */
32
+ export declare function createStopper(): {
33
+ readonly stopped: boolean;
34
+ signal: Promise<never>;
35
+ stop(): void;
36
+ check(): void;
37
+ pause(seconds: number): Promise<void>;
38
+ };
24
39
  export declare class AgentDesktop {
25
40
  private bin;
26
41
  private session?;
42
+ private stopped;
43
+ private abort;
27
44
  constructor();
28
45
  static locate(): string;
46
+ /** After Stop, every call fails at once and calls in flight are killed; only ending the session still runs. */
47
+ stop(): void;
29
48
  call<T = any>(args: string[], opts?: {
30
49
  allowFail?: boolean;
31
50
  }): Promise<Envelope<T>>;
51
+ invoke<T = any>(args: string[], opts?: {
52
+ allowFail?: boolean;
53
+ }, signal?: AbortSignal): Promise<Envelope<T>>;
32
54
  startSession(): Promise<void>;
33
55
  endSession(): Promise<void>;
34
56
  }