llmer-demo

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Demo Flow Runner

演示流程运行器

You generate and run Playwright automation scripts from natural language scenario descriptions, with full recording (video + click/keystroke visualization). Supports both browser demos (HAR + video) and terminal/CLI demos (video of real terminal sessions via xterm.js + node-pty).
本工具可基于自然语言场景描述生成并运行Playwright自动化脚本,支持全量录制(视频+点击/按键操作可视化)。同时支持浏览器演示(HAR + 视频)和终端/CLI演示(通过xterm.js + node-pty录制真实终端会话的视频)。

Preamble (run first)

前置检查(首先运行)

bash
undefined
bash
undefined

Check skill lib

Check skill lib

if [ -f ".claude/skills/llmer-demo/lib/index.js" ]; then echo "SKILL_LIB: ready" else echo "SKILL_LIB: missing" fi
if [ -f ".claude/skills/llmer-demo/lib/index.js" ]; then echo "SKILL_LIB: ready" else echo "SKILL_LIB: missing" fi

Check Playwright

Check Playwright

if npx playwright --version >/dev/null 2>&1; then echo "PLAYWRIGHT: $(npx playwright --version 2>&1)" else echo "PLAYWRIGHT: missing" fi
if npx playwright --version >/dev/null 2>&1; then echo "PLAYWRIGHT: $(npx playwright --version 2>&1)" else echo "PLAYWRIGHT: missing" fi

Check Chromium

Check Chromium

if [ -d "$(npx playwright install --dry-run chromium 2>/dev/null | head -1)" ] || npx playwright install --dry-run chromium 2>&1 | grep -q "is already installed"; then echo "CHROMIUM: ready" else echo "CHROMIUM: missing" fi
if [ -d "$(npx playwright install --dry-run chromium 2>/dev/null | head -1)" ] || npx playwright install --dry-run chromium 2>&1 | grep -q "is already installed"; then echo "CHROMIUM: ready" else echo "CHROMIUM: missing" fi

Check ffmpeg (required for video)

Check ffmpeg (required for video)

if command -v ffmpeg >/dev/null 2>&1; then echo "FFMPEG: $(ffmpeg -version 2>&1 | head -1)" else echo "FFMPEG: missing (video conversion will fail)" fi
if command -v ffmpeg >/dev/null 2>&1; then echo "FFMPEG: $(ffmpeg -version 2>&1 | head -1)" else echo "FFMPEG: missing (video conversion will fail)" fi

Check node-pty (required for terminal demos, optional otherwise)

Check node-pty (required for terminal demos, optional otherwise)

if node -e "require('node-pty')" 2>/dev/null; then echo "NODE_PTY: ready" else echo "NODE_PTY: missing (install with 'npm install node-pty' for terminal demos)" fi
if node -e "require('node-pty')" 2>/dev/null; then echo "NODE_PTY: ready" else echo "NODE_PTY: missing (install with 'npm install node-pty' for terminal demos)" fi

Check for .demoflow

Check for .demoflow

if [ -d ".demoflow" ]; then echo "DEMOFLOW: initialized" ls .demoflow/scenarios/ 2>/dev/null | head -10 else echo "DEMOFLOW: not initialized (run /llmer-demo init)" fi

If `SKILL_LIB` is `missing`: the skill was not installed correctly. Tell the user to run `npx skills add llmer/skill-demoflow`.

If `PLAYWRIGHT` is `missing`: run `npm install --save-dev @playwright/test`.

If `CHROMIUM` is `missing`: run `npx playwright install chromium`.

If `FFMPEG` is `missing`: warn the user that video conversion requires ffmpeg. They can install it with `brew install ffmpeg` (macOS) or `apt install ffmpeg` (Linux). Recording will still work but MP4 output will be skipped.

If `NODE_PTY` is `missing` and the user requests a terminal demo: run `npm install node-pty`. This is only required for terminal/CLI demos, not browser demos.
if [ -d ".demoflow" ]; then echo "DEMOFLOW: initialized" ls .demoflow/scenarios/ 2>/dev/null | head -10 else echo "DEMOFLOW: not initialized (run /llmer-demo init)" fi

如果`SKILL_LIB`显示`missing`:说明技能安装异常,告知用户运行`npx skills add llmer/skill-demoflow`。

如果`PLAYWRIGHT`显示`missing`:运行`npm install --save-dev @playwright/test`。

如果`CHROMIUM`显示`missing`:运行`npx playwright install chromium`。

如果`FFMPEG`显示`missing`:警告用户视频转换需要ffmpeg,可通过`brew install ffmpeg`(macOS)或`apt install ffmpeg`(Linux)安装。录制功能仍可正常使用,但会跳过MP4输出。

如果`NODE_PTY`显示`missing`且用户需要终端演示:运行`npm install node-pty`。仅终端/CLI演示需要该依赖,浏览器演示不需要。

Subcommands

子命令

Check
$ARGUMENTS
first:
  • init
    → Run the Init Flow to explore the project and scaffold
    .demoflow/
  • list
    → List available scenarios from
    .demoflow/scenarios/
    and targets from
    .demoflow/targets/
  • studio
    → Launch the DemoFlow Studio web UI for adjusting frame options on existing recordings:
    npx tsx src/studio.ts
    (or
    npm run studio
    if built). Opens at http://localhost:3274
  • render [scenario-name] [--style macos|windows-xp|windows-98|macos-terminal|vscode|none] [--title "..."]
    → Re-render an existing capture without re-recording. Only works if
    output/{name}/recording.webm
    exists.
  • Anything else → Run a scenario (see Run Flow below)

首先检查
$ARGUMENTS
参数:
  • init
    → 运行初始化流程,扫描项目并生成
    .demoflow/
    脚手架
  • list
    → 列出
    .demoflow/scenarios/
    下的可用场景和
    .demoflow/targets/
    下的目标环境
  • studio
    → 启动DemoFlow Studio网页UI,用于调整已有录制的帧选项:
    npx tsx src/studio.ts
    (如果已构建可运行
    npm run studio
    ),访问地址为http://localhost:3274
  • render [scenario-name] [--style macos|windows-xp|windows-98|macos-terminal|vscode|none] [--title "..."]
    → 无需重新录制,重新渲染已有捕获文件,仅当
    output/{name}/recording.webm
    存在时生效
  • 其他参数 → 运行对应场景(参考下方运行流程

Init Flow

初始化流程

When
$ARGUMENTS
is
init
, you explore the project and bootstrap
.demoflow/
.
$ARGUMENTS
init
时,扫描项目并初始化
.demoflow/
目录。

What to do

操作步骤

  1. Ensure runtime dependencies are installed:
    • Verify the skill lib exists:
      .claude/skills/llmer-demo/lib/index.js
      (installed by
      skills add
      )
    • Check if
      @playwright/test
      is in
      package.json
      . If not:
      npm install --save-dev @playwright/test
    • Check if Playwright Chromium is available. If not:
      npx playwright install chromium
  2. Explore the codebase using the Explore agent:
    • Find all routes/pages (look for
      app/
      or
      pages/
      directory structures, route files, page components)
    • Identify the auth flow (login, signup, verify, OAuth — look for auth pages, middleware, session handling)
    • Find key interactive components (forms, wizards, modals, dialogs)
    • Detect the framework (Next.js, Remix, SvelteKit, etc.) and UI library (shadcn, MUI, etc.)
    • Look for existing environment config (.env.example, .env.local) to discover URLs, API endpoints, email services
    • Check if Supabase, Mailpit, or other local services are configured (docker-compose, supabase/config.toml)
  3. Generate
    .demoflow/context.md
    from what you discover:
    • Auth flow (login page selectors, OTP input, submit buttons)
    • Key pages and their interactive elements
    • Navigation patterns (sidebar, breadcrumbs, tabs, header links)
    • Modals/dialogs that might appear during flows
    • Rate limits or gotchas you spot in the code
    • Keep it concise — focus on what a Playwright script would need to know
  4. Generate target configs in
    .demoflow/targets/
    :
    • local.md
      — detect the dev server URL (from package.json scripts, .env), and if there's a local email server (Mailpit/Inbucket), configure auto-OTP
    • production.md
      (if a prod URL is discoverable) — use prompt strategy for OTP
    • Ask the user to confirm URLs and credentials you can't find
  5. Suggest 3-5 scenarios based on the flows you discover:
    • The primary happy path (signup → first action → core feature)
    • A CRUD flow (create, read, update, delete of the main entity)
    • Navigation coverage (visit every major section)
    • An edge case or error flow if obvious
    • Present these to the user and ask which to generate. Write the selected ones to
      .demoflow/scenarios/
  6. Create the directory structure if it doesn't exist:
    .demoflow/
    ├── context.md
    ├── targets/
    │   ├── local.md
    │   └── production.md
    └── scenarios/
        └── (suggested scenarios)
  7. Ensure
    scripts/
    is excluded from the build
    :
    • Read
      tsconfig.json
      and check if
      scripts
      is in the
      exclude
      array
    • If not, add
      "scripts"
      to the
      exclude
      array — Playwright imports are Node-only and will break framework builds (Next.js, Remix, etc.) if included in the compilation scope
    • If there's no
      exclude
      array, create one with
      ["scripts"]
  8. Report what was created and suggest the user review context.md for accuracy.

  1. 确保运行时依赖已安装
    • 校验技能库文件存在:
      .claude/skills/llmer-demo/lib/index.js
      (通过
      skills add
      命令安装)
    • 检查
      package.json
      中是否包含
      @playwright/test
      ,如果没有则运行
      npm install --save-dev @playwright/test
    • 检查Playwright Chromium是否可用,如果没有则运行
      npx playwright install chromium
  2. 使用Explore Agent扫描代码库
    • 查找所有路由/页面(查找
      app/
      pages/
      目录结构、路由文件、页面组件)
    • 识别认证流程(登录、注册、验证、OAuth — 查找认证页面、中间件、会话处理逻辑)
    • 查找核心交互组件(表单、向导、弹窗、对话框)
    • 检测使用的框架(Next.js、Remix、SvelteKit等)和UI库(shadcn、MUI等)
    • 查找现有环境配置(.env.example、.env.local)以发现URL、API端点、邮件服务
    • 检查是否配置了Supabase、Mailpit或其他本地服务(docker-compose、supabase/config.toml)
  3. 基于扫描结果生成
    .demoflow/context.md
    • 认证流程(登录页选择器、OTP输入框、提交按钮)
    • 核心页面及其交互元素
    • 导航模式(侧边栏、面包屑、标签页、头部链接)
    • 流程中可能出现的弹窗/对话框
    • 代码中发现的速率限制或注意事项
    • 保持内容简洁,聚焦于Playwright脚本需要的信息
  4. .demoflow/targets/
    下生成目标环境配置
    • local.md
      — 检测开发服务器URL(从package.json脚本、.env中获取),如果存在本地邮件服务器(Mailpit/Inbucket)则配置自动OTP
    • production.md
      (如果能发现生产环境URL) — 使用提示策略获取OTP
    • 对于无法找到的URL和凭证,询问用户确认
  5. 基于发现的流程建议3-5个场景
    • 核心 happy path(注册 → 首次操作 → 核心功能)
    • CRUD流程(主实体的创建、查询、更新、删除)
    • 导航覆盖(访问所有主要板块)
    • 明显的边界 case 或错误流程
    • 将这些场景展示给用户并询问要生成哪些,将选中的场景写入
      .demoflow/scenarios/
  6. 如果目录不存在则创建目录结构
    .demoflow/
    ├── context.md
    ├── targets/
    │   ├── local.md
    │   └── production.md
    └── scenarios/
        └── (suggested scenarios)
  7. 确保
    scripts/
    目录被排除在构建之外
    • 读取
      tsconfig.json
      ,检查
      exclude
      数组中是否包含
      scripts
    • 如果没有,将
      "scripts"
      添加到
      exclude
      数组 — Playwright导入仅适用于Node环境,如果被包含在编译范围内会破坏框架构建(Next.js、Remix等)
    • 如果不存在
      exclude
      数组,则创建该数组并添加
      ["scripts"]
  8. 告知用户已创建的内容,建议用户核对context.md的准确性

Run Flow

运行流程

Step 1: Resolve the scenario

步骤1:解析场景

If
$ARGUMENTS
matches a file in
.demoflow/scenarios/
(by name, with or without
.md
), read that file. Otherwise treat
$ARGUMENTS
as an inline flow description.
If
--target <name>
is present in arguments, use that target. Otherwise use the
target:
field from the scenario's
## Config
section.
如果
$ARGUMENTS
匹配
.demoflow/scenarios/
下的文件(按名称匹配,可带或不带
.md
后缀),则读取该文件。否则将
$ARGUMENTS
视为内联流程描述。
如果参数中包含
--target <name>
,则使用该目标环境。否则使用场景
## Config
部分的
target:
字段指定的目标。

Step 2: Read target + context

步骤2:读取目标配置和上下文

  1. Read
    .demoflow/targets/{target-name}.md
    — this is the DUT (Device Under Test) config that tells you the base URL, how to handle auth/OTP, timeouts, and environment-specific behavior
  2. Read
    .demoflow/context.md
    if it exists — app-specific UI patterns, selectors, navigation hints
  3. Read the scenario file (or use the inline description)
  4. If needed, read relevant source files to understand selectors and page structure
  1. 读取
    .demoflow/targets/{target-name}.md
    — 这是被测系统(DUT)配置,包含基础URL、认证/OTP处理方式、超时时间和环境特定行为
  2. 如果存在
    .demoflow/context.md
    则读取 — 包含应用特定的UI模式、选择器、导航提示
  3. 读取场景文件(或使用内联描述)
  4. 必要时读取相关源文件以理解选择器和页面结构

Step 2b: Check for valid existing capture

步骤2b:检查是否存在有效的已有捕获

Before generating and running a new script, check if a valid capture already exists:
typescript
import { isCaptureValid, render } from '../.claude/skills/llmer-demo/lib/index.js'

if (isCaptureValid('output/scenario-name', { scenarioPath, targetPath })) {
  // Skip re-recording — just re-render with current options
  const result = await render('output/scenario-name', { frameStyle: 'macos' })
  console.log('Re-rendered from cache:', result.mp4Path)
} else {
  // Capture is stale — run the full recording flow below
}
If
isCaptureValid()
returns true, skip to rendering. This avoids expensive browser replay when only frame options changed.
生成并运行新脚本之前,检查是否已存在有效的捕获:
typescript
import { isCaptureValid, render } from '../.claude/skills/llmer-demo/lib/index.js'

if (isCaptureValid('output/scenario-name', { scenarioPath, targetPath })) {
  // Skip re-recording — just re-render with current options
  const result = await render('output/scenario-name', { frameStyle: 'macos' })
  console.log('Re-rendered from cache:', result.mp4Path)
} else {
  // Capture is stale — run the full recording flow below
}
如果
isCaptureValid()
返回true,直接跳转到渲染步骤。避免仅修改帧选项时重复执行耗时的浏览器回放。

Step 3: Generate a step-based script

步骤3:生成基于步骤的脚本

Write a script to
scripts/demo-{scenario-name}.ts
(e.g.
demo-full-workflow.ts
) that uses
runSteps()
with a declarative
Step[]
array. Do not write imperative Playwright code — the step runner handles selectors, waits, retries, and error screenshots automatically.
  • Imports from the skill lib (path relative to
    scripts/
    ):
    typescript
    import { launchWithRecording, finalize, runSteps, isCaptureValid, render, type Step } from '../.claude/skills/llmer-demo/lib/index.js'
  • Config variables at the top from the target config:
    BASE_URL
    ,
    TEST_EMAIL
    , timeouts
  • A
    steps: Step[]
    array that maps 1:1 from the scenario
  • Fixed boilerplate:
    launchWithRecording()
    runSteps()
    finalize()
Output directory:
output/{scenario-name}/
将脚本写入
scripts/demo-{scenario-name}.ts
(例如
demo-full-workflow.ts
),使用
runSteps()
和声明式的
Step[]
数组。不要编写命令式的Playwright代码 — 步骤运行器会自动处理选择器解析、等待、重试和错误截图。
  • 从技能库导入(路径相对于
    scripts/
    ):
    typescript
    import { launchWithRecording, finalize, runSteps, isCaptureValid, render, type Step } from '../.claude/skills/llmer-demo/lib/index.js'
  • 顶部的配置变量来自目标配置
    BASE_URL
    TEST_EMAIL
    、超时时间
  • 与场景一一映射的
    steps: Step[]
    数组
  • 固定样板代码:
    launchWithRecording()
    runSteps()
    finalize()
输出目录
output/{scenario-name}/

Step Type Reference

步骤类型参考

ActionFieldsWhat it does
navigate
url
,
waitUntil?
Go to URL, auto-waits for networkidle
click
target
Click element — runner resolves text to the right selector
fill
target
,
value
Type into input — clears first by default
select
target
,
value
Choose dropdown option
press
key
Press keyboard key (e.g.
'Enter'
,
'Tab'
)
wait
ms?
or
for?
Wait for time, URL pattern (
**/path
), or element text
assert
target
,
state?
Verify element is visible/hidden/attached
screenshot
name
Save screenshot to output dir
save
name
,
from
(
url
/
text
/
value
),
target?
,
pattern?
Extract value into a variable
input
message
,
saveAs?
Prompt user for input (OTP, etc.) — auto-pauses video
pause
/
resume
Manually trim idle time from video
exec
fn(ctx)
Escape hatch for custom logic (OTP fetch, API calls)
ActionFieldsWhat it does
navigate
url
,
waitUntil?
Go to URL, auto-waits for networkidle
click
target
Click element — runner resolves text to the right selector
fill
target
,
value
Type into input — clears first by default
select
target
,
value
Choose dropdown option
press
key
Press keyboard key (e.g.
'Enter'
,
'Tab'
)
wait
ms?
or
for?
Wait for time, URL pattern (
**/path
), or element text
assert
target
,
state?
Verify element is visible/hidden/attached
screenshot
name
Save screenshot to output dir
save
name
,
from
(
url
/
text
/
value
),
target?
,
pattern?
Extract value into a variable
input
message
,
saveAs?
Prompt user for input (OTP, etc.) — auto-pauses video
pause
/
resume
Manually trim idle time from video
exec
fn(ctx)
Escape hatch for custom logic (OTP fetch, API calls)

Target resolution (
target
field)

目标解析(
target
字段)

The runner tries multiple Playwright strategies automatically. Just use the visible text:
  • "Create Workspace"
    → tries getByRole(button), getByText, etc. — finds what works
  • "label:Email"
    page.getByLabel('Email')
    — use when text is ambiguous
  • "placeholder:Enter name"
    page.getByPlaceholder('Enter name')
  • "role:link[Dashboard]"
    page.getByRole('link', { name: 'Dashboard' })
  • "testid:submit-btn"
    page.getByTestId('submit-btn')
  • "css:.custom-selector"
    page.locator('.custom-selector')
    — last resort
Default: just use the visible text. Only add prefixes when you need to disambiguate.
运行器会自动尝试多种Playwright策略,直接使用可见文本即可:
  • "Create Workspace"
    → 尝试getByRole(button)、getByText等 — 自动匹配可用的选择器
  • "label:Email"
    page.getByLabel('Email')
    — 文本存在歧义时使用
  • "placeholder:Enter name"
    page.getByPlaceholder('Enter name')
  • "role:link[Dashboard]"
    page.getByRole('link', { name: 'Dashboard' })
  • "testid:submit-btn"
    page.getByTestId('submit-btn')
  • "css:.custom-selector"
    page.locator('.custom-selector')
    — 最后兜底方案
默认规则:直接使用可见文本,仅当需要消除歧义时添加前缀。

Variable interpolation

变量插值

save
and
input
steps store values. Use
${name}
in any later step's string fields:
typescript
{ action: 'save', name: 'ws_id', from: 'url', pattern: '/workspaces/([^/]+)' },
{ action: 'navigate', url: `${BASE_URL}/workspaces/\${ws_id}/board` },
Note: use
\${}
in template literals so JavaScript doesn't interpolate — the runner handles it at runtime.
save
input
步骤会存储值,后续步骤的字符串字段中可使用
${name}
引用:
typescript
{ action: 'save', name: 'ws_id', from: 'url', pattern: '/workspaces/([^/]+)' },
{ action: 'navigate', url: `${BASE_URL}/workspaces/\${ws_id}/board` },
注意:在模板字符串中使用
\${}
避免JavaScript提前插值,运行器会在运行时处理插值逻辑。

Key pattern

核心代码模板

typescript
import { launchWithRecording, finalize, runSteps, isCaptureValid, render, type Step } from '../.claude/skills/llmer-demo/lib/index.js'

const BASE_URL = '...'         // from target
const TEST_EMAIL = '...'       // from target
const SCENARIO_PATH = '.demoflow/scenarios/scenario-name.md'
const TARGET_PATH = '.demoflow/targets/local.md'

const steps: Step[] = [
  { action: 'navigate', url: `${BASE_URL}/login` },
  { action: 'fill', target: 'label:Email', value: TEST_EMAIL },
  { action: 'click', target: 'Continue with Email' },
  { action: 'wait', for: '**/verify' },
  // OTP via Mailpit (custom logic → use exec)
  { action: 'exec', description: 'Fetch OTP from Mailpit', fn: async (ctx) => {
    const res = await fetch('http://127.0.0.1:8025/api/v1/messages')
    const msgs = await res.json() as any
    const body = msgs.messages[0]?.Text ?? ''
    ctx.vars.otp = (body.match(/(\d{6})/)?.[1]) ?? ''
  }},
  { action: 'fill', target: 'placeholder:Enter code', value: '\${otp}' },
  { action: 'click', target: 'Verify' },
  { action: 'wait', for: '**/dashboard' },
  // OR: OTP via user prompt
  // { action: 'input', message: 'Enter the OTP code', saveAs: 'otp' },
]

async function main() {
  if (isCaptureValid('output/scenario-name', { scenarioPath: SCENARIO_PATH, targetPath: TARGET_PATH })) {
    console.log('Valid capture exists — re-rendering only')
    const result = await render('output/scenario-name', { frameStyle: 'macos' })
    console.log('Video:', result.mp4Path)
    return
  }

  const session = await launchWithRecording({
    outputDir: 'output/scenario-name',
    headed: true,
    slowMo: 100,
    desktopFrame: true,
    scenarioPath: SCENARIO_PATH,
    targetPath: TARGET_PATH,
  })

  try {
    await runSteps(session, steps, {
      onStep: (i, step, status) => console.log(`[${status}] Step ${i + 1}: ${step.description ?? step.action}`)
    })
  } finally {
    const result = await finalize(session)
    console.log('HAR:', result.harPath)
    console.log('Video:', result.mp4Path)
  }
}

main()
typescript
import { launchWithRecording, finalize, runSteps, isCaptureValid, render, type Step } from '../.claude/skills/llmer-demo/lib/index.js'

const BASE_URL = '...'         // from target
const TEST_EMAIL = '...'       // from target
const SCENARIO_PATH = '.demoflow/scenarios/scenario-name.md'
const TARGET_PATH = '.demoflow/targets/local.md'

const steps: Step[] = [
  { action: 'navigate', url: `${BASE_URL}/login` },
  { action: 'fill', target: 'label:Email', value: TEST_EMAIL },
  { action: 'click', target: 'Continue with Email' },
  { action: 'wait', for: '**/verify' },
  // OTP via Mailpit (custom logic → use exec)
  { action: 'exec', description: 'Fetch OTP from Mailpit', fn: async (ctx) => {
    const res = await fetch('http://127.0.0.1:8025/api/v1/messages')
    const msgs = await res.json() as any
    const body = msgs.messages[0]?.Text ?? ''
    ctx.vars.otp = (body.match(/(\d{6})/)?.[1]) ?? ''
  }},
  { action: 'fill', target: 'placeholder:Enter code', value: '\${otp}' },
  { action: 'click', target: 'Verify' },
  { action: 'wait', for: '**/dashboard' },
  // OR: OTP via user prompt
  // { action: 'input', message: 'Enter the OTP code', saveAs: 'otp' },
]

async function main() {
  if (isCaptureValid('output/scenario-name', { scenarioPath: SCENARIO_PATH, targetPath: TARGET_PATH })) {
    console.log('Valid capture exists — re-rendering only')
    const result = await render('output/scenario-name', { frameStyle: 'macos' })
    console.log('Video:', result.mp4Path)
    return
  }

  const session = await launchWithRecording({
    outputDir: 'output/scenario-name',
    headed: true,
    slowMo: 100,
    desktopFrame: true,
    scenarioPath: SCENARIO_PATH,
    targetPath: TARGET_PATH,
  })

  try {
    await runSteps(session, steps, {
      onStep: (i, step, status) => console.log(`[${status}] Step ${i + 1}: ${step.description ?? step.action}`)
    })
  } finally {
    const result = await finalize(session)
    console.log('HAR:', result.harPath)
    console.log('Video:', result.mp4Path)
  }
}

main()

Step 4: Run the script

步骤4:运行脚本

  1. Run the script in the background:
    npx tsx scripts/demo-{scenario-name}.ts
  2. Monitor for the input signal file: poll
    output/{name}/.waiting-for-input
  3. When the signal appears, read its contents (the prompt message) and ask the user for the value
  4. When the user responds, write their answer to
    output/{name}/.input-value
  5. The script will pick it up and continue automatically
  6. Wait for the script to complete
  1. 后台运行脚本:
    npx tsx scripts/demo-{scenario-name}.ts
  2. 监听输入信号文件:轮询
    output/{name}/.waiting-for-input
  3. 当信号文件出现时,读取其内容(提示消息)并向用户询问对应值
  4. 获取用户响应后,将回答写入
    output/{name}/.input-value
  5. 脚本会自动读取该值并继续执行
  6. 等待脚本执行完成

Step 5: Report results

步骤5:上报结果

Tell the user:
  • Target used (local/production/etc.)
  • HAR file path
  • Video file path (MP4)
  • Any errors that occurred
  • Summary of what was captured
告知用户以下信息:
  • 使用的目标环境(local/production等)
  • HAR文件路径
  • 视频文件路径(MP4)
  • 发生的所有错误
  • 捕获内容的摘要

Step 6: Offer adjustments

步骤6:提供调整选项

After reporting results, ask the user if they'd like to adjust the video. Present these options:
  • Change frame style — re-render with
    macos
    ,
    windows-xp
    ,
    windows-98
    ,
    macos-terminal
    ,
    vscode
    , or
    none
    (raw viewport)
  • Change title — update the text shown in the frame's titlebar/tab
  • Open Studio — launch the DemoFlow Studio web UI at http://localhost:3274 for live preview and adjustments
  • Keep as-is — done
If the user picks a re-render option, call
render()
on the existing capture (no re-recording needed):
typescript
import { render } from '../.claude/skills/llmer-demo/lib/index.js'

const result = await render('output/{scenario-name}', {
  frameStyle: 'windows-xp',  // or 'macos', 'none'
  title: 'Custom Title',     // optional
})
console.log('Updated video:', result.mp4Path)
If the user picks Studio, run:
node -e "import('./.claude/skills/llmer-demo/lib/studio.js').then(m => m.startStudio())"
After any adjustment, report the updated file path and offer again — the user may want to try multiple styles.

上报结果后,询问用户是否需要调整视频,提供以下选项:
  • 修改帧样式 — 重新渲染为
    macos
    windows-xp
    windows-98
    macos-terminal
    vscode
    none
    (原始视口)样式
  • 修改标题 — 更新帧标题栏/标签中显示的文本
  • 打开Studio — 启动DemoFlow Studio网页UI(地址http://localhost:3274)进行实时预览和调整
  • 保持原样 — 完成操作
如果用户选择重新渲染选项,调用现有捕获的
render()
方法(无需重新录制):
typescript
import { render } from '../.claude/skills/llmer-demo/lib/index.js'

const result = await render('output/{scenario-name}', {
  frameStyle: 'windows-xp',  // or 'macos', 'none'
  title: 'Custom Title',     // optional
})
console.log('Updated video:', result.mp4Path)
如果用户选择Studio,运行:
node -e "import('./.claude/skills/llmer-demo/lib/studio.js').then(m => m.startStudio())"
任何调整完成后,上报更新后的文件路径并再次询问是否需要调整,用户可能需要尝试多种样式。

Terminal Demo Flow

终端演示流程

For CLI-based product demos (agentic coding tools, CLIs, terminal applications). Uses xterm.js rendered in Playwright connected to a real PTY via node-pty.
适用于CLI类产品演示(智能编码工具、CLI、终端应用)。使用Playwright中渲染的xterm.js,通过node-pty连接到真实PTY。

Detecting terminal scenarios

识别终端场景

A scenario is a terminal demo if:
  • Its
    ## Config
    section includes
    type: terminal
  • The scenario only describes CLI commands (no URLs, no browser navigation)
  • The user explicitly asks for a terminal/CLI demo
满足以下任一条件则为终端演示场景:
  • ## Config
    部分包含
    type: terminal
  • 场景仅描述CLI命令(无URL、无浏览器导航)
  • 用户明确要求终端/CLI演示

Terminal scenario format

终端场景格式

markdown
undefined
markdown
undefined

Claude Code Refactoring Demo

Claude Code Refactoring Demo

Config

Config

type: terminal shell: /bin/zsh cwd: ~/projects/demo-app frame: macos-terminal typing_speed: 40ms theme: dark-plus
type: terminal shell: /bin/zsh cwd: ~/projects/demo-app frame: macos-terminal typing_speed: 40ms theme: dark-plus

Steps

Steps

[require: node npx] [hide] cd ~/projects/demo-app && git checkout demo-branch [show]
  1. Show the project:
    ls -la
  2. [pause: 2s]
  3. Show the code:
    cat server.ts
  4. [pause: 3s]
  5. Run Claude Code:
    npx claude "refactor server.ts to use async/await"
  6. [wait-for: /✓|Done/ timeout: 120s]
  7. Show result:
    cat server.ts
  8. [pause: 3s]
  9. Run tests:
    npm test
  10. [wait-for: "passing" timeout: 30s]
undefined
[require: node npx] [hide] cd ~/projects/demo-app && git checkout demo-branch [show]
  1. Show the project:
    ls -la
  2. [pause: 2s]
  3. Show the code:
    cat server.ts
  4. [pause: 3s]
  5. Run Claude Code:
    npx claude "refactor server.ts to use async/await"
  6. [wait-for: /✓|Done/ timeout: 120s]
  7. Show result:
    cat server.ts
  8. [pause: 3s]
  9. Run tests:
    npm test
  10. [wait-for: "passing" timeout: 30s]
undefined

Terminal directives

终端指令

DirectiveGenerated Code
`command`
await session.exec('command')
[type: "text"]
await session.type('text')
[type@100ms: "text"]
await session.type('text', { delay: 100 })
[enter]
await session.press('Enter')
[tab]
await session.press('Tab')
[ctrl+c]
await session.press('Ctrl+C')
[pause: 3s]
await new Promise(r => setTimeout(r, 3000))
[wait-for: /pattern/ timeout: 30s]
await session.waitForOutput(/pattern/, { timeout: 30000 })
[wait-for-prompt timeout: 10s]
await session.waitForPrompt({ timeout: 10000 })
[hide]
/
[show]
pauseRecording(session)
/
resumeRecording(session)
(hides setup from video)
[require: node npm]
Check dependencies exist before running
[env: KEY=value]
Set via
TerminalRecordingOptions.env
[clear]
await session.clear()
[screenshot: name]
await page.screenshot({ path: ... })
[prompt: "message"]
await requestInput(outputDir, 'message', { session })
DirectiveGenerated Code
`command`
await session.exec('command')
[type: "text"]
await session.type('text')
[type@100ms: "text"]
await session.type('text', { delay: 100 })
[enter]
await session.press('Enter')
[tab]
await session.press('Tab')
[ctrl+c]
await session.press('Ctrl+C')
[pause: 3s]
await new Promise(r => setTimeout(r, 3000))
[wait-for: /pattern/ timeout: 30s]
await session.waitForOutput(/pattern/, { timeout: 30000 })
[wait-for-prompt timeout: 10s]
await session.waitForPrompt({ timeout: 10000 })
[hide]
/
[show]
pauseRecording(session)
/
resumeRecording(session)
(hides setup from video)
[require: node npm]
Check dependencies exist before running
[env: KEY=value]
Set via
TerminalRecordingOptions.env
[clear]
await session.clear()
[screenshot: name]
await page.screenshot({ path: ... })
[prompt: "message"]
await requestInput(outputDir, 'message', { session })

Terminal script pattern

终端脚本模板

typescript
import { launchTerminal, finalize, pauseRecording, resumeRecording } from '../.claude/skills/llmer-demo/lib/index.js'

const session = await launchTerminal({
  outputDir: 'output/cli-demo',
  shell: '/bin/zsh',
  cwd: '/path/to/project',
  desktopFrame: { style: 'macos-terminal', title: 'Terminal' },
  theme: 'dark-plus',
  typingSpeed: 50,
})

try {
  // [hide] — setup commands hidden from video
  pauseRecording(session)
  await session.exec('cd ~/projects/demo-app')
  resumeRecording(session)

  // Visible demo steps
  await session.exec('ls -la')
  await new Promise(r => setTimeout(r, 2000))
  await session.exec('npx claude "refactor server.ts"')
  await session.waitForOutput(/Done|/, { timeout: 120_000 })
  await session.exec('cat server.ts')
  await new Promise(r => setTimeout(r, 3000))
} catch (err) {
  await session.page.screenshot({ path: 'output/cli-demo/error.png' })
  throw err
} finally {
  const result = await finalize(session, { pageTitle: 'Claude Code Demo' })
  console.log('Video:', result.mp4Path)
}
typescript
import { launchTerminal, finalize, pauseRecording, resumeRecording } from '../.claude/skills/llmer-demo/lib/index.js'

const session = await launchTerminal({
  outputDir: 'output/cli-demo',
  shell: '/bin/zsh',
  cwd: '/path/to/project',
  desktopFrame: { style: 'macos-terminal', title: 'Terminal' },
  theme: 'dark-plus',
  typingSpeed: 50,
})

try {
  // [hide] — setup commands hidden from video
  pauseRecording(session)
  await session.exec('cd ~/projects/demo-app')
  resumeRecording(session)

  // Visible demo steps
  await session.exec('ls -la')
  await new Promise(r => setTimeout(r, 2000))
  await session.exec('npx claude "refactor server.ts"')
  await session.waitForOutput(/Done|/, { timeout: 120_000 })
  await session.exec('cat server.ts')
  await new Promise(r => setTimeout(r, 3000))
} catch (err) {
  await session.page.screenshot({ path: 'output/cli-demo/error.png' })
  throw err
} finally {
  const result = await finalize(session, { pageTitle: 'Claude Code Demo' })
  console.log('Video:', result.mp4Path)
}

launchTerminal(options) → TerminalSession

launchTerminal(options) → TerminalSession

Launch a terminal session with Playwright recording: xterm.js in browser connected to real PTY.
OptionTypeDefaultDescription
outputDir
string
requiredOutput directory for video, screenshots.
viewport
{ width, height }
960x600
Terminal canvas pixel size.
headed
boolean
true
Show the browser window.
desktopFrame
boolean | DesktopFrameOptions
true
(macos-terminal)
Desktop frame style.
shell
string
$SHELL
or
/bin/zsh
Shell to spawn.
cwd
string
process.cwd()
Working directory for PTY.
env
Record<string, string>
{}
Extra environment variables.
theme
string | TerminalTheme
'dark-plus'
Color theme:
'dark-plus'
,
'dracula'
,
'monokai'
, or custom object.
fontSize
number
14
Font size in px.
fontFamily
string
'Menlo, Monaco, monospace'
Font family.
typingSpeed
number
50
Default ms delay per character.
通过Playwright录制启动终端会话:浏览器中的xterm.js连接到真实PTY。
OptionTypeDefaultDescription
outputDir
string
requiredOutput directory for video, screenshots.
viewport
{ width, height }
960x600
Terminal canvas pixel size.
headed
boolean
true
Show the browser window.
desktopFrame
boolean | DesktopFrameOptions
true
(macos-terminal)
Desktop frame style.
shell
string
$SHELL
or
/bin/zsh
Shell to spawn.
cwd
string
process.cwd()
Working directory for PTY.
env
Record<string, string>
{}
Extra environment variables.
theme
string | TerminalTheme
'dark-plus'
Color theme:
'dark-plus'
,
'dracula'
,
'monokai'
, or custom object.
fontSize
number
14
Font size in px.
fontFamily
string
'Menlo, Monaco, monospace'
Font family.
typingSpeed
number
50
Default ms delay per character.

TerminalSession methods

TerminalSession方法

MethodDescription
type(text, { delay? })
Type text character-by-character with visual delay.
press(key)
Send keystroke:
'Enter'
,
'Tab'
,
'Ctrl+C'
,
'Up'
,
'Down'
, etc.
exec(command, { timeout? })
Type command + Enter + wait for prompt to return.
waitForOutput(pattern, { timeout? })
Wait for regex/string to appear in terminal buffer. Default timeout: 30s.
waitForPrompt({ timeout? })
Wait for shell prompt to return (command finished). Default timeout: 30s.
clear()
Clear the terminal screen.
The session also has
browser
,
context
,
page
,
outputDir
like
RecordingSession
. Use
pauseRecording(session)
/
resumeRecording(session)
for pause trimming. Pass to
finalize(session, { pageTitle: '...' })
at the end.

MethodDescription
type(text, { delay? })
Type text character-by-character with visual delay.
press(key)
Send keystroke:
'Enter'
,
'Tab'
,
'Ctrl+C'
,
'Up'
,
'Down'
, etc.
exec(command, { timeout? })
Type command + Enter + wait for prompt to return.
waitForOutput(pattern, { timeout? })
Wait for regex/string to appear in terminal buffer. Default timeout: 30s.
waitForPrompt({ timeout? })
Wait for shell prompt to return (command finished). Default timeout: 30s.
clear()
Clear the terminal screen.
会话还包含
browser
context
page
outputDir
等属性,和
RecordingSession
一致。可使用
pauseRecording(session)
/
resumeRecording(session)
进行暂停裁剪,最后调用
finalize(session, { pageTitle: '...' })
结束。

Recording Library Reference

录制库参考

All functions are exported from the skill lib at
.claude/skills/llmer-demo/lib/index.js
.
所有函数都从技能库
.claude/skills/llmer-demo/lib/index.js
导出。

runSteps(session, steps, options?) → { vars }

runSteps(session, steps, options?) → { vars }

Execute a declarative
Step[]
array against a recording session. Handles selector resolution, waits, inter-step delays, error screenshots, and variable interpolation.
OptionTypeDefaultDescription
actionDelay
number
500
Ms between steps for video readability.
actionTimeout
number
30000
Ms to wait for elements before failing.
screenshotOnError
boolean
true
Take error screenshot when a step fails.
onStep
(index, step, status) => void
Progress callback for logging.
Returns
{ vars: Record<string, string> }
— all saved variables from
save
and
input
steps.
On failure, throws with message:
Step N failed (description): original error
. Takes
error-step-N.png
screenshot automatically.
针对录制会话执行声明式
Step[]
数组,自动处理选择器解析、等待、步骤间延迟、错误截图和变量插值。
OptionTypeDefaultDescription
actionDelay
number
500
Ms between steps for video readability.
actionTimeout
number
30000
Ms to wait for elements before failing.
screenshotOnError
boolean
true
Take error screenshot when a step fails.
onStep
(index, step, status) => void
Progress callback for logging.
返回
{ vars: Record<string, string> }
— 所有
save
input
步骤保存的变量。
失败时抛出异常,消息格式为:
Step N failed (description): original error
,自动拍摄
error-step-N.png
截图。

launchWithRecording(options) → RecordingSession

launchWithRecording(options) → RecordingSession

Launch a Chromium browser with full recording: HAR capture, video, and click visualization.
OptionTypeDefaultDescription
outputDir
string
requiredOutput directory for HAR, video, screenshots. Created if missing.
viewport
{ width, height }
1280x720
Browser viewport size. Also sets video resolution.
headed
boolean
true
Show the browser window. Set
false
for CI.
slowMo
number
100
Delay between actions in ms. Higher = more readable video.
ignoreHTTPSErrors
boolean
true
Bypass HTTPS certificate errors (useful for local dev).
desktopFrame
boolean | DesktopFrameOptions
true
Wrap video in desktop chrome. See Desktop Frame.
Returns a
RecordingSession
with
browser
,
context
,
page
, and
outputDir
.
启动Chromium浏览器并开启全量录制:HAR捕获、视频、点击可视化。
OptionTypeDefaultDescription
outputDir
string
requiredOutput directory for HAR, video, screenshots. Created if missing.
viewport
{ width, height }
1280x720
Browser viewport size. Also sets video resolution.
headed
boolean
true
Show the browser window. Set
false
for CI.
slowMo
number
100
Delay between actions in ms. Higher = more readable video.
ignoreHTTPSErrors
boolean
true
Bypass HTTPS certificate errors (useful for local dev).
desktopFrame
boolean | DesktopFrameOptions
true
Wrap video in desktop chrome. See Desktop Frame.
返回包含
browser
context
page
outputDir
RecordingSession
对象。

finalize(session, overrides?) → RecordingResult

finalize(session, overrides?) → RecordingResult

Close the browser, save a capture manifest, convert video to MP4, and apply desktop frame compositing. Must be called in a finally block — skipping this loses the HAR and video.
Optional
overrides
:
{ pageTitle?: string, pageUrl?: string }
— use to set meaningful metadata for terminal sessions (which otherwise show
localhost:XXXXX
).
Returns:
FieldTypeDescription
harPath
string
Path to the HAR file (always present)
mp4Path
string | null
Path to MP4 video (
null
if ffmpeg missing)
webmPath
string | null
Path to raw WebM video
Pipeline: close browser → rename WebM → save manifest.json (git hash, viewport, pauses, page info) → trim pauses (if any) → convert to MP4 → composite with desktop frame → clean up temps.
关闭浏览器、保存捕获清单、将视频转换为MP4并应用桌面帧合成。必须在finally块中调用 — 跳过该调用会丢失HAR和视频。
可选
overrides
参数:
{ pageTitle?: string, pageUrl?: string }
— 用于为终端会话设置有意义的元数据(否则会显示
localhost:XXXXX
)。
返回值:
FieldTypeDescription
harPath
string
Path to the HAR file (always present)
mp4Path
string | null
Path to MP4 video (
null
if ffmpeg missing)
webmPath
string | null
Path to raw WebM video
处理流程:关闭浏览器 → 重命名WebM → 保存manifest.json(git哈希、视口、暂停点、页面信息) → 裁剪暂停片段(如果有) → 转换为MP4 → 合成桌面帧 → 清理临时文件。

render(outputDir, options?) → RenderResult

render(outputDir, options?) → RenderResult

Re-render an existing capture to MP4 without re-recording. Reads viewport and pause data from
manifest.json
. Use this to change frame style, title, or resolution on an already-captured recording.
OptionTypeDefaultDescription
frameStyle
'macos' | 'windows-xp' | 'windows-98' | 'macos-terminal' | 'vscode' | 'none'
'macos'
Frame style.
'none'
produces raw viewport video.
title
string
manifest page titleWindow title / tab text.
url
string
manifest page URLAddress bar URL (XP style).
resolution
{ width, height }
1920x1080
Desktop resolution for the frame.
Returns
{ mp4Path: string | null }
.
重新渲染已有捕获为MP4,无需重新录制。从
manifest.json
读取视口和暂停数据,可用于修改已捕获录制的帧样式、标题或分辨率。
OptionTypeDefaultDescription
frameStyle
'macos' | 'windows-xp' | 'windows-98' | 'macos-terminal' | 'vscode' | 'none'
'macos'
Frame style.
'none'
produces raw viewport video.
title
string
manifest page titleWindow title / tab text.
url
string
manifest page URLAddress bar URL (XP style).
resolution
{ width, height }
1920x1080
Desktop resolution for the frame.
返回
{ mp4Path: string | null }

isCaptureValid(outputDir, options?) → boolean

isCaptureValid(outputDir, options?) → boolean

Check whether a previous capture can be reused. Returns
true
if:
  • manifest.json
    and
    recording.webm
    exist in
    outputDir
  • Current git HEAD matches the manifest's commit hash
  • Working tree is clean (not dirty)
  • Skill lib hash matches (detects lib code changes across linked repos)
  • Scenario/target file hashes match (if paths provided)
OptionTypeDescription
scenarioPath
string
Path to scenario file — hash is compared to manifest.
targetPath
string
Path to target file — hash is compared to manifest.
Use this before recording to skip re-capture when only render options changed.
检查是否可以复用之前的捕获,满足以下条件时返回
true
  • outputDir
    中存在
    manifest.json
    recording.webm
  • 当前git HEAD与清单中的commit哈希匹配
  • 工作树干净(无改动)
  • 技能库哈希匹配(检测链接仓库之间的库代码变更)
  • 场景/目标文件哈希匹配(如果提供了路径)
OptionTypeDescription
scenarioPath
string
Path to scenario file — hash is compared to manifest.
targetPath
string
Path to target file — hash is compared to manifest.
录制前调用该方法,可在仅修改渲染选项时跳过重新捕获。

requestInput(outputDir, message, options?) → string

requestInput(outputDir, message, options?) → string

Pause the script and wait for external input (OTP codes, 2FA tokens, manual confirmation).
OptionTypeDefaultDescription
session
RecordingSession
If provided, video is auto-paused while waiting and idle time is trimmed from final video.
timeoutMs
number
300000
Max wait time in ms before throwing.
Writes
.waiting-for-input
signal file. Polls for
.input-value
response file. Auto-cleans both files after input is received.
暂停脚本并等待外部输入(OTP码、2FA令牌、手动确认)。
OptionTypeDefaultDescription
session
RecordingSession
If provided, video is auto-paused while waiting and idle time is trimmed from final video.
timeoutMs
number
300000
Max wait time in ms before throwing.
写入
.waiting-for-input
信号文件,轮询
.input-value
响应文件,收到输入后自动清理两个文件。

pauseRecording(session)
/
resumeRecording(session)

pauseRecording(session)
/
resumeRecording(session)

Manually mark idle periods. The paused segments are trimmed from the final MP4 using ffmpeg trim + concat filters. Use when you have a known wait that isn't handled by
requestInput
.
typescript
pauseRecording(session)
await someSlowOperation()
resumeRecording(session)
手动标记空闲时段,暂停的片段会通过ffmpeg裁剪+拼接过滤器从最终MP4中移除。当存在未被
requestInput
处理的已知等待时使用。
typescript
pauseRecording(session)
await someSlowOperation()
resumeRecording(session)

provideInput(outputDir, value)

provideInput(outputDir, value)

Write input from the skill/CLI side. Called by the skill runner after getting the value from the user.
从技能/CLI侧写入输入,技能运行器从用户获取值后调用该方法。

checkWaiting(outputDir) → string | null

checkWaiting(outputDir) → string | null

Check if a script is waiting for input. Returns the prompt message or
null
.

检查脚本是否在等待输入,返回提示消息或
null

Desktop Frame

桌面帧

Videos are composited onto a desktop OS frame (titlebar + window chrome + wallpaper background) for polished output.
视频会被合成到桌面操作系统帧(标题栏+窗口边框+壁纸背景)上,输出效果更专业。

Options

选项

Pass to
launchWithRecording({ desktopFrame: ... })
:
ValueBehavior
true
(default)
macOS Sonoma style frame
false
No frame — raw viewport video
{ style: 'macos' }
macOS Sonoma with traffic lights and tab
{ style: 'windows-xp' }
Windows XP with IE chrome, taskbar, Start button (uses XP.css)
{ style: 'windows-98' }
Windows 98 with classic grey chrome (uses 98.css)
{ style: 'macos-terminal' }
macOS Terminal.app style — dark titlebar, traffic lights, no address bar
{ style: 'vscode' }
VS Code integrated terminal — dark chrome, tab bar, blue status bar
{ title: 'My App' }
Override the tab/titlebar text (default: page title at finalize)
{ resolution: { width: 1920, height: 1080 } }
Desktop resolution (default: 1920x1080)
{ windowOffsetY: -50 }
Shift window up/down from center (negative = up)
{ wallpaperColor: '#008080' }
Solid wallpaper color (overrides default gradient)
传入
launchWithRecording({ desktopFrame: ... })
ValueBehavior
true
(default)
macOS Sonoma style frame
false
No frame — raw viewport video
{ style: 'macos' }
macOS Sonoma with traffic lights and tab
{ style: 'windows-xp' }
Windows XP with IE chrome, taskbar, Start button (uses XP.css)
{ style: 'windows-98' }
Windows 98 with classic grey chrome (uses 98.css)
{ style: 'macos-terminal' }
macOS Terminal.app style — dark titlebar, traffic lights, no address bar
{ style: 'vscode' }
VS Code integrated terminal — dark chrome, tab bar, blue status bar
{ title: 'My App' }
Override the tab/titlebar text (default: page title at finalize)
{ resolution: { width: 1920, height: 1080 } }
Desktop resolution (default: 1920x1080)
{ windowOffsetY: -50 }
Shift window up/down from center (negative = up)
{ wallpaperColor: '#008080' }
Solid wallpaper color (overrides default gradient)

Frame anatomy

帧结构

macOS (default):
  • Dark gradient wallpaper (purple/blue tones)
  • Window vertically centered with rounded corners, drop shadow
  • Titlebar: traffic lights (red/yellow/green) + centered tab with page title
  • Content area: your recorded video
Windows XP (via XP.css):
  • Blue sky + green hills wallpaper
  • Authentic XP title bar with minimize/maximize/close
  • Address bar with URL + Go button, status bar
  • XP taskbar at bottom with green Start button + clock
Windows 98 (via 98.css):
  • Teal wallpaper (classic default)
  • Classic grey window chrome with beveled edges
  • Address bar with URL, status bar
  • Grey taskbar with Start button
macOS Terminal:
  • Same purple/blue gradient wallpaper as macOS browser frame
  • Dark titlebar (#3c3c3c) with traffic lights
  • Centered title text (shell name or custom)
  • No address bar, no tab — clean terminal window look
  • Best for CLI/terminal demos
VS Code:
  • Dark chrome (#1f1f1f) with traffic lights
  • Tab bar with terminal tab icon (
    >_
    )
  • Blue status bar with branch name + line/col info
  • Best for agentic coding tool demos shown in IDE context
macOS(默认):
  • 深色渐变壁纸(紫/蓝色调)
  • 垂直居中的窗口,带圆角和阴影
  • 标题栏:红绿灯(红/黄/绿)+ 居中的页面标题标签
  • 内容区域:录制的视频
Windows XP(基于XP.css):
  • 蓝天+绿山壁纸
  • 正宗的XP标题栏,带最小化/最大化/关闭按钮
  • 带URL和Go按钮的地址栏、状态栏
  • 底部的XP任务栏,带绿色开始按钮和时钟
Windows 98(基于98.css):
  • 青色壁纸(经典默认)
  • 经典灰色窗口边框,带斜角边缘
  • 带URL的地址栏、状态栏
  • 带开始按钮的灰色任务栏
macOS Terminal:
  • 和macOS浏览器帧相同的紫/蓝渐变壁纸
  • 深色标题栏(#3c3c3c),带红绿灯
  • 居中的标题文本(shell名称或自定义)
  • 无地址栏、无标签,简洁的终端窗口外观
  • 最适合CLI/终端演示
VS Code:
  • 深色边框(#1f1f1f),带红绿灯
  • 标签栏,带终端标签图标(
    >_
  • 蓝色状态栏,带分支名称+行/列信息
  • 最适合在IDE上下文展示的智能编码工具演示

How it works

实现原理

  1. Browser records WebM video at viewport size
  2. finalize()
    converts WebM → MP4 (with pause trimming if needed)
  3. Playwright renders the frame HTML to a PNG at the desktop resolution
  4. ffmpeg overlays the MP4 onto the frame PNG at the calculated content position
  5. Framed MP4 replaces the original
The frame is a static PNG — it doesn't change during the video. The page title shown in the tab is captured from the page at finalize time.

  1. 浏览器录制视口尺寸的WebM视频
  2. finalize()
    将WebM转换为MP4(如果需要则裁剪暂停片段)
  3. Playwright将帧HTML渲染为桌面分辨率的PNG
  4. ffmpeg将MP4叠加到帧PNG的计算内容位置上
  5. 带帧的MP4替换原文件
帧是静态PNG,视频播放过程中不会变化。标签中显示的页面标题是finalize阶段从页面捕获的。

Click Visualization

点击可视化

Every page automatically gets a click visualization script injected via
addInitScript
. When the user clicks anywhere:
  1. A red circle (30px, semi-transparent) appears at the click point
  2. The circle expands to 2.5x and fades out over 900ms
  3. Removed from DOM after 1200ms
This is captured in the video recording — no post-processing needed. The visualization works across all page navigations (re-injected on each new page load).

每个页面都会通过
addInitScript
自动注入点击可视化脚本,用户点击任意位置时:
  1. 点击位置会出现一个红色半透明圆圈(30px)
  2. 圆圈在900ms内扩大到2.5倍并逐渐消失
  3. 1200ms后从DOM中移除
该效果会被录制到视频中,无需后期处理。可视化效果在所有页面导航中都生效(每次加载新页面时重新注入)。

Output Files

输出文件

After a successful run,
output/{scenario-name}/
contains:
FileDescription
recording.har
Full network capture (importable in Chrome DevTools Network tab)
recording.mp4
Polished video with click indicators + desktop frame
recording.webm
Raw video from Playwright (pre-conversion)
manifest.json
Capture metadata (git hash, viewport, pauses) + render options
error.png
Screenshot at point of failure (only on error)
If desktop frame is disabled,
recording.mp4
is the raw viewport video without chrome.

成功运行后,
output/{scenario-name}/
目录包含:
FileDescription
recording.har
Full network capture (importable in Chrome DevTools Network tab)
recording.mp4
Polished video with click indicators + desktop frame
recording.webm
Raw video from Playwright (pre-conversion)
manifest.json
Capture metadata (git hash, viewport, pauses) + render options
error.png
Screenshot at point of failure (only on error)
如果禁用了桌面帧,
recording.mp4
是没有边框的原始视口视频。

Target Resolution

目标环境解析

Targets are Markdown files in
.demoflow/targets/
that describe the runtime environment. They contain:
  • Connection: URL and email/credentials
  • Auth: How to handle OTP (auto via Mailpit, or prompt the user)
  • Behavior: Timeouts, headed/headless, slow motion
  • Notes: Environment-specific gotchas
When generating the script, read the target file and use its values. If a value uses
${RUN_ID}
, generate a unique ID (e.g.
Date.now().toString(36)
).
目标环境是
.demoflow/targets/
下的Markdown文件,描述运行时环境,包含:
  • 连接信息:URL和邮箱/凭证
  • 认证:OTP处理方式(通过Mailpit自动获取,或提示用户输入)
  • 行为:超时时间、 headed/headless模式、慢动作参数
  • 注意事项:环境特定的问题
生成脚本时,读取目标文件并使用其值。如果值使用
${RUN_ID}
,则生成唯一ID(例如
Date.now().toString(36)
)。

Handling Directives

指令处理

When you see these in the scenario, map them to
Step
objects:
DirectiveStep
[prompt: message]
{ action: 'input', message: '...', saveAs: 'varName' }
[save: var from url]
{ action: 'save', name: 'var', from: 'url', pattern: '...' }
[pause: Ns]
{ action: 'wait', ms: N * 1000 }
[assert: condition]
{ action: 'assert', target: '...', state: 'visible' }
[screenshot: name]
{ action: 'screenshot', name: '...' }
在场景中看到以下指令时,映射为对应的
Step
对象:
DirectiveStep
[prompt: message]
{ action: 'input', message: '...', saveAs: 'varName' }
[save: var from url]
{ action: 'save', name: 'var', from: 'url', pattern: '...' }
[pause: Ns]
{ action: 'wait', ms: N * 1000 }
[assert: condition]
{ action: 'assert', target: '...', state: 'visible' }
[screenshot: name]
{ action: 'screenshot', name: '...' }

Script Generation Guidelines

脚本生成规范

  • Map scenario text directly to Step objects. "Click Create Trust" →
    { action: 'click', target: 'Create Trust' }
    . Don't overthink selectors.
  • Use plain text targets by default. The runner resolves them automatically. Only add prefixes (
    label:
    ,
    css:
    , etc.) when the text is ambiguous.
  • Don't add manual waits or delays. The runner injects
    actionDelay
    between steps and auto-waits for navigation after
    navigate
    steps.
  • Use
    exec
    steps sparingly
    — only for custom logic like fetching OTP from Mailpit or calling APIs. Most scenarios need 0-2 exec steps.
  • Never skip
    finalize()
    — even on error, it saves the HAR and whatever video was captured. The try/finally structure in the template handles this.
  • Use variable interpolation (
    \${varName}
    ) for dynamic values from
    save
    or
    input
    steps. Don't thread local variables through code.
  • The boilerplate never changes. Only modify the
    steps
    array and the config constants at the top.

  • 直接将场景文本映射为Step对象。"点击Create Trust" →
    { action: 'click', target: 'Create Trust' }
    ,无需过度纠结选择器。
  • 默认使用纯文本目标,运行器会自动解析。仅当文本存在歧义时添加前缀(
    label:
    css:
    等)。
  • 不要添加手动等待或延迟,运行器会在步骤之间注入
    actionDelay
    ,且
    navigate
    步骤后自动等待导航完成。
  • 尽量少用
    exec
    步骤
    — 仅用于自定义逻辑,例如从Mailpit获取OTP或调用API。大多数场景需要0-2个exec步骤。
  • 永远不要跳过
    finalize()
    — 即使发生错误,它也会保存HAR和已捕获的视频内容。模板中的try/finally结构已经处理了这一点。
  • 使用变量插值
    \${varName}
    )引用
    save
    input
    步骤的动态值,不要在代码中传递局部变量。
  • 样板代码固定不变,仅修改
    steps
    数组和顶部的配置常量即可。

Tips

提示

  1. Always call
    finalize()
    in finally.
    If you skip it, the HAR is lost and the browser process leaks.
  2. Pass
    { session }
    to
    requestInput()
    .
    This auto-pauses video during idle waits so the final MP4 doesn't have dead time.
  3. Use
    slowMo
    for video quality.
    100ms is a good default. Bump to 200-300ms for demos where you want the viewer to see each step clearly.
  4. Use
    pauseRecording
    /
    resumeRecording
    around long waits.
    Any operation where the screen is static for >2s should be trimmed.
  5. Set viewport to match your target audience. 1280x720 is a safe default. Use 1920x1080 for full-HD demos, but note the desktop frame adds chrome around it.
  6. The desktop frame title is captured at finalize. Navigate to the most meaningful page before the script ends so the title bar shows something useful.
  7. Use
    desktopFrame: { style: 'windows-xp' }
    or
    'windows-98'
    for retro frames.
    XP uses authentic XP.css styling with IE chrome and taskbar. 98 gives classic grey beveled chrome. Both use the XP.css library for faithful rendering.
  8. Use
    macos-terminal
    or
    vscode
    frame for terminal demos.
    macos-terminal
    gives a clean Terminal.app look;
    vscode
    wraps the terminal in VS Code chrome — great for agentic coding demos.
  9. For terminal demos, pass
    { pageTitle: '...' }
    to
    finalize()
    .
    Terminal sessions run at
    localhost:XXXXX
    so the default page title/URL are meaningless.
  10. Check for ffmpeg before running. Without it, you get HAR + WebM but no MP4 and no desktop frame compositing.

  1. 始终在finally中调用
    finalize()
    。如果跳过,HAR会丢失,浏览器进程会泄漏。
  2. requestInput()
    传递
    { session }
    参数
    。这会在空闲等待期间自动暂停视频,避免最终MP4出现空白片段。
  3. 调整
    slowMo
    提升视频质量
    。100ms是不错的默认值,如果希望观众清晰看到每个步骤,可提升到200-300ms。
  4. 长时间等待前后使用
    pauseRecording
    /
    resumeRecording
    。任何屏幕静止超过2秒的操作都应该被裁剪。
  5. 根据目标受众设置视口。1280x720是安全的默认值,全高清演示可使用1920x1080,但注意桌面帧会在周围添加边框。
  6. 桌面帧标题在finalize阶段捕获。脚本结束前导航到最有意义的页面,确保标题栏显示有用的内容。
  7. 使用
    desktopFrame: { style: 'windows-xp' }
    'windows-98'
    实现复古帧效果
    。XP使用正宗的XP.css样式,带IE边框和任务栏;98提供经典的灰色斜角边框,两者都使用XP.css库实现逼真渲染。
  8. 终端演示使用
    macos-terminal
    vscode
    macos-terminal
    提供简洁的Terminal.app外观;
    vscode
    将终端包裹在VS Code边框中 — 非常适合智能编码演示。
  9. 终端演示给
    finalize()
    传递
    { pageTitle: '...' }
    参数
    。终端会话运行在
    localhost:XXXXX
    ,默认页面标题/URL没有意义。
  10. 运行前检查ffmpeg是否存在。没有ffmpeg时,你会得到HAR + WebM,但没有MP4和桌面帧合成。

Completion Status

完成状态

When the run finishes, report status:
  • DONE — Scenario completed. HAR + video saved. Report file paths.
  • DONE_WITH_CONCERNS — Completed, but with issues (flaky selectors, slow loads, skipped steps). List each concern.
  • BLOCKED — Cannot proceed. State what is blocking (missing dependency, app not running, auth failed) and what was tried.
  • NEEDS_CONTEXT — Missing information required to continue (no target URL, unknown auth flow, ambiguous scenario steps). State exactly what you need.

运行结束后,上报状态:
  • DONE — 场景完成,HAR + 视频已保存,上报文件路径。
  • DONE_WITH_CONCERNS — 已完成,但存在问题(选择器不稳定、加载缓慢、跳过步骤),列出所有问题。
  • BLOCKED — 无法继续,说明阻塞原因(缺失依赖、应用未运行、认证失败)和已尝试的操作。
  • NEEDS_CONTEXT — 缺少继续所需的信息(无目标URL、未知认证流程、场景步骤歧义),明确说明需要的信息。

Example Invocations

调用示例

  • /llmer-demo init
    — explore the project, generate context + targets + suggested scenarios
  • /llmer-demo list
    — show available scenarios and targets
  • /llmer-demo workspace-switching
    — runs
    .demoflow/scenarios/workspace-switching.md
    with its default target
  • /llmer-demo workspace-switching --target production
    — override to use production target
  • /llmer-demo "log in and navigate to the dashboard"
    — generates from inline description using default target
  • /llmer-demo "run npm install then npm test and show the output"
    — terminal demo from inline description (auto-detected: no URLs)
  • /llmer-demo cli-onboarding
    — runs a terminal scenario from
    .demoflow/scenarios/cli-onboarding.md
    (detected via
    type: terminal
    in Config)
  • /llmer-demo init
    — 扫描项目,生成上下文+目标配置+建议场景
  • /llmer-demo list
    — 展示可用场景和目标环境
  • /llmer-demo workspace-switching
    — 使用默认目标运行
    .demoflow/scenarios/workspace-switching.md
  • /llmer-demo workspace-switching --target production
    — 覆盖为生产环境目标
  • /llmer-demo "log in and navigate to the dashboard"
    — 使用默认目标从内联描述生成场景
  • /llmer-demo "run npm install then npm test and show the output"
    — 从内联描述生成终端演示(自动识别:无URL)
  • /llmer-demo cli-onboarding
    — 从
    .demoflow/scenarios/cli-onboarding.md
    运行终端场景(通过Config中的
    type: terminal
    识别)