---
name: run
description: 启动并驱动此项目的应用以查看变更是否有效。
---

# 运行技能

**运行意味着启动实际的应用并与之交互**——不是测试套件，不是内部函数的 `import` 和 `console.log`。应用按照用户（人类或程序化）遇到它的方式：CLI 在其命令中，服务器在其 socket 中，GUI 在其窗口中。

## 首先：是否已有项目技能覆盖此项？

启动此应用的项目技能是该仓库的已验证路径——其作者已从 Linux 容器冷启动并提交了有效的方案：确切的 `apt-get` 行、环境变量、补丁、驱动程序。使用它而不是重新发现。

```bash
d=$PWD; while :; do
  grep -Hm1 '^description:' "$d"/.claude/skills/*/SKILL.md 2>/dev/null
  [ -e "$d/.git" ] || [ "$d" = / ] && break
  d=$(dirname "$d")
done
```

- **找到一个描述启动/驱动此应用的** → 阅读该 SKILL.md 并逐字遵循。不要改述；不要跳过补丁。
- **大型仓库，有几个看似候选，没有明确匹配** → 询问用户要运行哪个单元。
- **过时**（在与你的任务无关的机制上失败）→ 告诉用户；提出通过 `/run-skill-generator` 刷新它。
- **没有任何关于运行的** → 回退到以下模式。

## 否则：匹配形态，使用模式

选择最接近你项目的那一行。每个示例逐步介绍启动 + 首次交互；忽略任何尾随的"编写技能"章节——你正在使用配方，而不是创作配方。

| 项目类型 | 句柄 | 示例 |
|---|---|---|
| CLI 工具 | 直接调用，退出码，stdin/stdout | examples/cli.md |
| Web 服务器 / API | 后台启动 + `curl` 冒烟 | examples/server.md |
| TUI / 交互式终端 | tmux `send-keys` / `capture-pane` | examples/tui.md |
| Electron / 桌面 GUI | xvfb 下的 Playwright `_electron` REPL | examples/electron.md |
| 浏览器驱动 | 开发服务器 + `chromium-cli` 脚本 | examples/playwright.md |
| 库 / SDK | 在包边界的 import-and-call 冒烟脚本 | examples/library.md |

如果没有完全适合的，从最接近的匹配开始并适配。对于 Web 应用，使用 examples/playwright.md——用 `chromium-cli` 驱动它，不需要自定义驱动程序。对于桌面应用，使用 examples/electron.md——它有 `_electron` REPL 驱动程序骨干和 tmux 包装。

## 驱动它，而不仅仅是启动它

启动而不交互证明了入口点可以解析。这不是运行应用——这是带有额外步骤的类型检查。驱动它直到用户能看到某些东西：

- CLI → 输入一个代表性命令，检查退出码和输出。
- 服务器 → 用 `curl` 命中差异触及的路由，阅读响应体。
- TUI → `send-keys` 导航，`capture-pane` 捕获结果。
- GUI → 点击按钮，截图窗口。**查看截图。** 空白画面意味着启动失败。

如果回退模式没有完全生效——你必须安装包、设置环境变量、修补配置或编写驱动程序——在你的报告中推荐 `/run-skill-generator`，使该工作被捕获为项目技能。如果直接生效了，就不推荐。

---

# examples/cli.md — CLI 工具

CLI 是最简单的情况——通常没有后台进程要管理、没有端口、没有生命周期。该技能专注于**安装**、**代表性调用**和**测试**。

## 重要的内容

- **如何将二进制文件放到 `PATH` 上。** 全局安装？通过 `npx`/`uv run` 运行？构建到 `./target/release/foo`？要明确。
- **两到三个示例调用**，覆盖主要用例。包含预期输出，以便读者可以判断是否成功。
- **退出码**，如果它们有意义（例如 linter 在发现问题时返回 1）。
- **标准输入行为**，如果该工具从 stdin 读取。

## 示例片段

> ---
> name: run-mytool
> description: 构建、安装和运行 mytool。当要求运行 mytool、测试它或验证它是否正确安装时使用。
> ---
>
> ## 设置
>
> ```bash
> pip install -e .
> ```
>
> 这将 `mytool` 放在 PATH 上。验证：
>
> ```bash
> mytool --version
> # → mytool 0.3.1
> ```
>
> ## 运行
>
> 处理单个文件：
>
> ```bash
> mytool process input.json
> # → 已处理 42 条记录，写入 output.json
> ```
>
> 从 stdin 读取，写入 stdout：
>
> ```bash
> cat input.json | mytool process -
> ```
>
> 检查一个目录（发现问题时以非零退出）：
>
> ```bash
> mytool lint ./src
> echo $?  # 0 表示干净，1 表示发现问题
> ```
>
> ## 测试
>
> ```bash
> pytest
> ```

## 保持简短

CLI 的运行技能可以非常紧凑。不要用每个标志来填充——`--help` 输出已经涵盖了那些。只需展示足够让一个代理能够 (a) 构建它，(b) 确认它工作，(c) 运行测试。

---

# examples/server.md — Web 服务器 / API

服务器区别性关注点是**生命周期**：代理需要在后台启动服务器、验证它已启动、与之交互，然后干净地关闭它。一个阻塞 shell 的前台 `npm start` 对代理毫无用处。

## 应遵循的结构

一个好的服务器运行技能有：

1. **前置条件和设置** — 与任何项目相同。
2. **运行** — 后台启动模式（如下），不是一个阻塞命令。
3. **验证** — 一个 `curl` 或类似命令，确认服务器确实已启动。
4. **停止** — 如何干净地终止后台进程。

如果后台启动 + 就绪轮询 + 冒烟 curl 序列超过几行，将其放入技能目录中的 `smoke.sh`，并在 `SKILL.md` 中说"运行冒烟脚本"。一个命令，退出码告诉你服务器是否健康。

## 后台启动模式

不要写：

> ```bash
> npm start
> ```

那是阻塞的。改为展示如何在后台启动、等待就绪以及稍后找到 PID：

> ```bash
> npm start &> /tmp/server.log &
> SERVER_PID=$!
>
> # 等待服务器启动（根据需要调整超时/端口）
> for i in {1..30}; do
>   curl -sf http://localhost:3000/health > /dev/null && break
>   sleep 1
> done
> ```

然后验证步骤：

> ```bash
> curl http://localhost:3000/health
> # → {"status":"ok"}
> ```

以及停止：

> ```bash
> kill $SERVER_PID
> # 或者，如果丢失了 PID：
> pkill -f "node.*server.js"
> ```

## 值得记录的细节

- **哪个端口。** 明确说明，并说明如何覆盖它（`PORT=4000 npm start`）。
- **"就绪"的表现形式。** 指示就绪的特定日志行，或要访问的健康端点。
- **必需的环境变量。** 数据库 URL、API 密钥等——如果列表很长，附带一个 `.env` 模板。
- **热重载 vs 生产模式。** 如果它们在意义上有区别，说明每种模式在什么时候使用。
- **依赖的服务。** 如果服务器需要 Redis/Postgres 等，要么指出一个 docker-compose 来启动它们，要么直接包含 `docker run` 命令。

## 示例片段

以下是典型 Node API 的运行部分可能的样子：

> ## 运行
>
> 在后台启动开发服务器：
>
> ```bash
> npm run dev &> /tmp/api.log &
> ```
>
> 服务器在 3000 端口监听。等待它就绪，然后验证：
>
> ```bash
> for i in {1..20}; do
>   curl -sf http://localhost:3000/health && break
>   sleep 0.5
> done
> curl http://localhost:3000/health
> # → {"status":"ok","version":"1.2.3"}
> ```
>
> 日志在 `/tmp/api.log`。使用以下命令停止：
>
> ```bash
> pkill -f "tsx watch src/index.ts"
> ```
>
> ### 环境
>
> | 变量 | 必需 | 默认值 | 注释 |
> |---|---|---|---|
> | `DATABASE_URL` | 是 | — | Postgres 连接字符串 |
> | `PORT` | 否 | `3000` | |
> | `LOG_LEVEL` | 否 | `info` | `debug` / `info` / `warn` / `error` |

---

# examples/tui.md — TUI / 交互式终端应用

交互式终端应用（文本编辑器、REPL、基于 curses 的 UI）不能由代理的 bash 工具直接驱动——它们接管终端。技能必须展示如何将它们包装在 `tmux` 中，以便代理可以发送输入、捕获输出并截图。

## tmux 模式

这是标准方法：

1. 在分离的 tmux 会话中启动 TUI
2. 使用 `tmux send-keys` 发送按键
3. 使用 `tmux capture-pane` 读取屏幕内容
4. 使用 `tmux kill-session` 清理

技能的 `SKILL.md` 应将此呈现为驱动应用的主要方式。一个将启动+附加序列包装起来的小 `driver.sh` 可以放在技能目录中，但对于大多数 TUI，技能正文中的原始 tmux 命令就足够了。

## 示例片段

> ## 运行（交互式，供代理使用）
>
> 在 tmux 内启动 TUI：
>
> ```bash
> tmux new-session -d -s app -x 120 -y 40 './myapp'
> ```
>
> 轮询直到就绪标记出现（比固定的 sleep 更快、更可靠——应用一旦启动就立即返回，如果没有启动则大声失败）：
>
> ```bash
> timeout 10 bash -c 'until tmux capture-pane -t app -p | grep -q "Ready"; do sleep 0.2; done'
> tmux capture-pane -t app -p
> ```
>
> 发送输入（此例导航到设置屏幕并切换一个选项）：
>
> ```bash
> tmux send-keys -t app 's'
> timeout 5 bash -c 'until tmux capture-pane -t app -p | grep -q "Settings"; do sleep 0.2; done'
> tmux send-keys -t app 'Down' 'Down' 'Space'  # 导航 + 切换
> timeout 5 bash -c 'until tmux capture-pane -t app -p | grep -qF "[x]"; do sleep 0.2; done'
> tmux capture-pane -t app -p
> ```
>
> 如果你发现自己写了超过几条这样的轮询行，把它们放进技能旁边 `driver.sh` 中的 `wait_for()` 辅助函数。
>
> 退出：
>
> ```bash
> tmux send-keys -t app 'q'
> tmux kill-session -t app 2>/dev/null || true
> ```
>
> ### 按键参考
>
> | 键 | 操作 |
> |---|---|
> | `j` / `k` 或 `Down` / `Up` | 导航列表 |
> | `Enter` | 选择 |
> | `s` | 设置 |
> | `q` | 退出 |

## 值得记录的细节

- **终端尺寸。** 某些 TUI 在较小宽度下会崩溃或隐藏内容。在 `tmux new-session -x -y` 参数中指定一个已知良好的尺寸。
- **启动时间。** 轮询就绪标记（`until tmux capture-pane | grep -q X`）而不是固定的 `sleep N`——应用一旦启动就立即返回，而当它从未启动时则会有用的失败。说清楚什么字符串表示就绪。
- **按键绑定参考。** 主要按键的表格。这是 TUI 的"API"——代理需要它来驱动应用。
- **干净退出。** 展示退出按键 *以及* 作为回退的 `tmux kill-session`。
- **颜色/Unicode 怪异行为。** 如果 `capture-pane` 输出难以阅读，记下有帮助的标志（`-e` 用于转义序列，`-J` 用于连接折行）。

## 同时记录直接调用

对于交互式运行应用的人类，tmux 是过度设计。也包含单行命令：

> ## 运行（直接，供人类使用）
>
> ```bash
> ./myapp
> ```
>
> 按 `q` 退出。

---

# examples/electron.md — Electron / 桌面 GUI 应用

Electron 应用有窗口。未来在无头容器中的代理看不到窗口。所以你在这里的交付物不是一个说"`npm start` 打开一个窗口"的 markdown 文件——它是一个**驱动程序脚本**，在 xvfb 下启动应用，暴露一个命令 REPL（click、type、screenshot），让代理可以通过发送文本行来操控 UI。

技能的 `SKILL.md` 然后成为该驱动程序的简短手册。

## 你要构建的

```
apps/desktop/
  .claude/skills/run-desktop/
    SKILL.md               ← 简短。"运行驱动程序，以下是命令"
    driver.mjs             ← REPL：stdin 命令 → Playwright 操作
```

驱动程序就是产品。没有它，技能描述的只是一个代理永远无法触摸的 GUI。

**升级路径：** 如果驱动程序成长到项目的真实 e2e 套件想要共享的启动辅助功能，将其移到 `e2e-playwright/driver.mjs`（或 `scripts/drive.mjs`）并更新技能的路径。技能保留在 `.claude/skills/run-desktop/`；驱动程序找到一个更好的归宿。

## 步骤 1 — 让应用在 xvfb 下启动起来

这通常是最难的部分，产生最多的陷阱。README 会说"仅限 macOS/Windows"。忽略它。安装 xvfb + Chromium 共享库，找到 Electron 二进制文件，并启动它：

```bash
apt-get install -y xvfb libnss3 libgbm1 libasound2t64 libgtk-3-0 \
  libxss1 libxkbcommon0 libatk-bridge2.0-0 libcups2 libdrm2

# 首先构建应用。通常"dev"脚本是 electron-forge，它
# 先进行 Vite/webpack 构建，然后启动。你只需要构建：
npm install
npx electron-forge start &   # 构建到 .vite/build/ 或 dist/
sleep 20 && kill %1          # 构建完成后杀死它——你将自己启动

# 现在尝试原始启动
xvfb-run -a node -e "
  const { _electron } = require('playwright-core');
  _electron.launch({
    executablePath: './node_modules/electron/dist/electron',
    args: ['--no-sandbox', '.'],
    timeout: 30000,
  }).then(app => {
    console.log('已启动，窗口：', app.windows().map(w => w.url()));
    return app.close();
  });
"
```

迭代直到它启动。每个缺失的 `.so` → 再一个 `apt-get` 包 → 再一行 Prerequisites。每个启动超时 → 检查 `nodeCliInspect` 保险丝未被禁用，检查构建输出存在。

**`--no-sandbox` 在容器中几乎总是需要的。** Electron 的沙盒需要 CAP_SYS_ADMIN 或用户命名空间。默认情况下两者都没有。

## 步骤 2 — 构建 REPL 驱动程序

一旦你能够启动它，将那个一次性的脚本转换为 REPL。从最小化开始——你将根据需要添加命令。**REPL 是正确的形态**，因为代理可以在 tmux 内运行它，并在每次交互时无需重新启动（慢速的）应用即可迭代。

```javascript
// .claude/skills/run-<unit>/driver.mjs
// <app> 的 REPL 驱动程序。在无头 Linux 上通过 xvfb 运行。
// 为代理设计：包装在 tmux 中，通过 send-keys 发送命令，通过 capture-pane 捕获输出。
import { _electron as electron } from 'playwright-core';
import * as readline from 'node:readline';
import * as fs from 'node:fs';
import * as path from 'node:path';

const APP_DIR = path.resolve(import.meta.dirname, '../../..');
const SHOT_DIR = process.env.SCREENSHOT_DIR || '/tmp/shots';
fs.mkdirSync(SHOT_DIR, { recursive: true });

let app = null;
let page = null;   // 你实际交互的窗口/页面

const electronBin = process.platform === 'darwin'
  ? path.join(APP_DIR, 'node_modules/electron/dist/Electron.app/Contents/MacOS/Electron')
  : path.join(APP_DIR, 'node_modules/electron/dist/electron');

const COMMANDS = {
  async launch() {
    if (app) return console.log('已经启动');
    app = await electron.launch({
      executablePath: electronBin,
      args: ['--no-sandbox', APP_DIR],
      env: { ...process.env, DISPLAY: process.env.DISPLAY || ':99' },
      timeout: 30_000,
    });
    await new Promise(r => setTimeout(r, 8_000));
    page = app.windows().find(w => !w.url().startsWith('devtools://'))
        ?? await app.firstWindow();
    console.log('已启动。', app.windows().length, '个窗口：');
    for (const w of app.windows()) console.log(' ', w.url());
  },

  async ss(name) {
    if (!page) return console.log('错误：先启动');
    const f = path.join(SHOT_DIR, (name || `ss-${Date.now()}`) + '.png');
    await page.screenshot({ path: f });
    console.log('截图：', f);
  },

  async click(sel) {
    if (!page) return console.log('错误：先启动');
    const r = await page.evaluate(s => {
      const el = document.querySelector(s);
      if (!el) return 'NOT_FOUND';
      el.click(); return 'OK';
    }, sel);
    console.log('点击', sel, '→', r);
  },

  async 'click-text'(text) {
    if (!page) return console.log('错误：先启动');
    const r = await page.evaluate(t => {
      const els = [...document.querySelectorAll('button, a, [role="button"]')];
      const el = els.find(e => e.textContent?.trim() === t)
              ?? els.find(e => e.textContent?.includes(t));
      if (!el) return 'NOT_FOUND';
      el.click(); return 'OK: ' + el.tagName;
    }, text);
    console.log('click-text', JSON.stringify(text), '→', r);
  },

  async type(text)  { if (page) await page.keyboard.type(text, { delay: 30 }); },
  async press(key)  { if (page) await page.keyboard.press(key); },

  async wait(sel) {
    if (!page) return console.log('错误：先启动');
    try { await page.waitForSelector(sel, { timeout: 10_000 }); console.log('找到：', sel); }
    catch { console.log('超时：', sel); }
  },

  async eval(expr) {
    if (!page) return console.log('错误：先启动');
    try { console.log(JSON.stringify(await page.evaluate(expr))); }
    catch (e) { console.log('错误：', e.message); }
  },

  async text(sel) {
    if (!page) return console.log('错误：先启动');
    console.log(await page.evaluate(
      s => (s ? document.querySelector(s) : document.body)?.innerText ?? '(null)',
      sel || null));
  },

  async windows() {
    if (!app) return console.log('错误：先启动');
    for (const w of app.windows()) console.log(' ', w.url());
    const wcs = await app.evaluate(({ webContents }) =>
      webContents.getAllWebContents().map(w => ({ id: w.id, type: w.getType(), url: w.getURL() })));
    console.log('webContents:');
    for (const w of wcs) console.log(` [${w.id}] ${w.type}: ${w.url}`);
  },

  async quit() { if (app) await app.close().catch(()=>{}); app = null; page = null; },
  help() { console.log('命令：', Object.keys(COMMANDS).join(', ')); },
};

const stdin = fs.createReadStream(null, { fd: fs.openSync('/dev/stdin', 'r') });
const rl = readline.createInterface({ input: stdin, output: process.stdout, prompt: 'driver> ' });

rl.on('line', async line => {
  const [cmd, ...rest] = line.trim().split(/\s+/);
  if (!cmd) return rl.prompt();
  const fn = COMMANDS[cmd];
  if (!fn) { console.log('未知：', cmd, '— 尝试：help'); return rl.prompt(); }
  try { await fn(rest.join(' ')); } catch (e) { console.log('错误：', e.message); }
  if (cmd === 'quit') { rl.close(); process.exit(0); }
  rl.prompt();
});
rl.on('close', async () => { await COMMANDS.quit(); process.exit(0); });

console.log('<app> 驱动程序 — "help" 查看命令，"launch" 开始');
rl.prompt();
```

**这是一个起始骨干。** 当你试图触达应用的有趣部分时，你将添加特定于应用的命令。

## 步骤 3 — 自己通过 tmux 使用它

以未来代理将使用的相同方式运行驱动程序：

```bash
tmux new-session -d -s app -x 200 -y 50
tmux send-keys -t app 'cd /workspace/apps/desktop && xvfb-run -a node .claude/skills/run-desktop/driver.mjs' Enter
timeout 20 bash -c 'until tmux capture-pane -t app -p | grep -q "driver>"; do sleep 0.2; done'
tmux send-keys -t app 'launch' Enter
timeout 60 bash -c 'until tmux capture-pane -t app -p | grep -q "已启动"; do sleep 0.2; done'
tmux send-keys -t app 'ss 01-landing' Enter
timeout 10 bash -c 'until tmux capture-pane -t app -p | grep -q "截图:"; do sleep 0.2; done'
tmux send-keys -t app 'windows' Enter    # 哪个页面有真正的 UI？
tmux capture-pane -t app -p
```

然后实际打开 `/tmp/shots/01-landing.png`。这是应用吗？是空白的吗？是登录屏幕吗？每一种情况都告诉你下一步做什么。

## 步骤 4 — 编写 SKILL.md

保持简短。驱动程序是主要部分；`SKILL.md` 是手册。

## 你会遇到的障碍（它们应该进入陷阱章节）

- **`firstWindow()` 给你一个闪屏/加载屏幕，** 而不是应用。
- **真正的 UI 在 BrowserView 中，而不是 BrowserWindow 中。**
- **`locator.click()` 点击了错误的东西。** 使用 `page.evaluate(el => el.click())`。
- **功能门控阻止了你需要测试的东西。**
- **contentEditable 输入**（ProseMirror、Tiptap、Slate）不是 `<textarea>`。
- **Electron 抢夺 stdin。** `fs.openSync('/dev/stdin', 'r')` 技巧保护了你的 REPL 输入。
- **原生模块加载失败**（钥匙串、通知等）。

---

# examples/playwright.md — 浏览器驱动的 Web 应用

你有一个开发服务器，将 HTML 提供给浏览器。在无头容器中的代理无法打开浏览器窗口——因此"运行应用"意味着启动开发服务器，用无头 Chromium 驱动它，并生成证明页面已渲染的截图。

不要编写浏览器驱动程序。使用 `chromium-cli`。

## 开发服务器

找到开发命令（`package.json` `scripts.dev`、`Makefile`、README），在后台启动它，等待它实际提供服务：

```bash
npm run dev &   # 或 yarn dev、pnpm dev、make serve、./dev.sh
echo $! > /tmp/dev.pid
timeout 30 bash -c 'until curl -sf http://localhost:3000 >/dev/null; do sleep 1; done'
```

## 驱动

`chromium-cli` 是一个无头 Chromium REPL。将脚本管道到 stdin：

```bash
chromium-cli --session app <<'EOF'
nav http://localhost:3000
wait-for text=Dashboard
screenshot
click button:has-text("New item")
fill input[name="title"] Smoke test
press Enter
wait-for text=Smoke test
screenshot
console --errors
EOF
```

## 放在技能中的内容

仅特定于项目的部分。`chromium-cli` 处理了机制。

- **开发命令 + 端口 + 停止。**
- **认证。**
- **一个代表性交互。**
- **应用特定的陷阱。**

## 反复出现的陷阱

- **React 受控输入。** 使用 `fill` / `type`，而不是 `eval el.value = '…'`。
- **Websockets / 长轮询。** `wait-idle` 永远不会停。`wait-for` 你实际需要的元素。
- **缓慢的首屏绘制。** Vite/Next 按需编译路由；第一个 `nav` 可能需要 10 秒+。
- **`screenshot-element <sel>`** 裁剪到一个元素。
- **在声明成功之前检查 `console --errors`。**

---

# examples/library.md — 库 / SDK

库在过程意义上没有"运行"步骤。对于库，运行技能涉及：

1. **从源代码构建**库
2. **运行测试套件**
3. **一个执行库功能的最小可用示例**

## 冒烟测试示例

> ```bash
> python -c '
> from mylib import Client
> c = Client()
> print(c.ping())
> '
> # → pong
> ```

## 值得记录的事项

- **开发模式 vs 安装模式。**
- **可选依赖。**
- **生成的代码。**
