cld-pi 0.0.0-stage → 0.0.2
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/LICENSE +22 -0
- package/README.md +179 -2
- package/README.zh-CN.md +166 -0
- package/bin/claude-p.js +57 -0
- package/js/cdp.js +247 -0
- package/js/host.js +131 -0
- package/js/package.json +1 -0
- package/js/plugins/add-dir.js +15 -0
- package/js/plugins/f12.js +296 -0
- package/js/plugins/herdry.js +52 -0
- package/js/plugins/img.js +35 -0
- package/js/plugins/magpie.js +14 -0
- package/js/plugins/notify.js +13 -0
- package/js/plugins/pace.js +75 -0
- package/js/plugins/panel.js +230 -0
- package/js/plugins/webpane.js +108 -0
- package/js/rpc.js +772 -0
- package/js/session.js +186 -0
- package/js/stubs.js +72 -0
- package/js/tab.js +94 -0
- package/package.json +55 -4
- package/scripts/install.js +212 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 smithersai (claude-p)
|
|
4
|
+
Copyright (c) 2026 cld-pi contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,180 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cld-pi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**English** · [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
An interactive shell around [Claude Code](https://code.claude.com), forked from
|
|
6
|
+
[smithersai/claude-p](https://github.com/smithersai/claude-p).
|
|
7
|
+
|
|
8
|
+
- `npx cld-pi` with **no arguments** opens Claude Code (fullscreen TUI) inside a
|
|
9
|
+
thin wrapper that adds prefix completion, overlays and JS plugins on top.
|
|
10
|
+
- `npx cld-pi "prompt"` keeps the original behaviour: a drop-in for `claude -p`.
|
|
11
|
+
|
|
12
|
+
The wrapper never rewrites Claude Code's output. It forwards every byte,
|
|
13
|
+
mirrors the screen in a [ghostty-vt](https://github.com/ghostty-org/ghostty)
|
|
14
|
+
grid, and paints its own layer on top.
|
|
15
|
+
|
|
16
|
+
## Run
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx cld-pi # interactive
|
|
20
|
+
npx cld-pi -r [id] # interactive, resume a session (-c: continue)
|
|
21
|
+
npx cld-pi "explain this repo" # same output as `claude -p`
|
|
22
|
+
npx cld-pi .json "summarize" < diff.txt # .text / .json / .stream-json = --output-format
|
|
23
|
+
npx cld-pi -r <id> "and now?" # -p mode in an existing session
|
|
24
|
+
npx cld-pi -a codex # wrap another TUI (set pi.arg.prompt / busy / … for its screen)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires `claude` on `$PATH`, Node ≥ 18, macOS or Linux.
|
|
28
|
+
|
|
29
|
+
`npm i -g cld-pi` installs the same thing as the `claude-p` command (and
|
|
30
|
+
`cld-pi`). On first run it prints a one-liner that binds **Ctrl+P** in your
|
|
31
|
+
shell to `claude-p`. Nothing is written to your rc files for you.
|
|
32
|
+
|
|
33
|
+
Interactive mode forces `/tui fullscreen` and registers `Notification`,
|
|
34
|
+
`Stop` and `SessionStart` hooks through `--settings`; Stop / Notification
|
|
35
|
+
pop a desktop notification (`notify-send`) when the terminal isn't focused, and the
|
|
36
|
+
default `herdry` plugin spins the tab title (`✻ message`) in Yakuake / Konsole / Guake
|
|
37
|
+
(window title elsewhere) until you focus it. It keeps its files in
|
|
38
|
+
`~/claude/-p/`:
|
|
39
|
+
|
|
40
|
+
| File | What |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `scr` | Plain-text dump of the current screen, refreshed every frame — only while the file exists (`touch` it to turn it on; removed on exit) |
|
|
43
|
+
| `host.log` | Log of the node sidecar — same opt-in: written only if it exists, removed on exit |
|
|
44
|
+
| `inspect` | DevTools URL of the sidecar |
|
|
45
|
+
| `*.js` | Your plugins: `export default pi => { … }` (ES module) |
|
|
46
|
+
|
|
47
|
+
The `◆ plugins n/m` badge in the top-right corner lists every plugin; click it
|
|
48
|
+
to turn one off — its listeners, `pi.sed`s and timers go away with it. A plugin can
|
|
49
|
+
put its own text there with `pi.notify(msg, 'snack')` (bottom-right: `'snackRD'`);
|
|
50
|
+
the default `pace` plugin shows how long the last ● took and what the last 3 minutes
|
|
51
|
+
cost in the bottom-right corner. Both corners open the plugin list.
|
|
52
|
+
|
|
53
|
+
## Keys
|
|
54
|
+
|
|
55
|
+
| Key | In the input box |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `./…` + Tab | fzf over files in CC's current dir → inserts `@path ` (cycles; ctrl-d: the file's folder) |
|
|
58
|
+
| `/~/…` + Tab | same, under `~` |
|
|
59
|
+
| `//…` + Tab | fzf over your Chrome tabs → screenshots the pick, inserts `@/tmp/….png ` |
|
|
60
|
+
| `//gh/…` + Tab | URLs on that site (`gh` → github.com) from your tabs and Chrome history |
|
|
61
|
+
| Ctrl+C | clears the caret's line and the lines below, caret stays on a fresh line; empty box → Claude Code's own Ctrl+C |
|
|
62
|
+
| Ctrl+Enter | paste the clipboard and submit (needs a kitty-protocol or modifyOtherKeys terminal) |
|
|
63
|
+
| Ctrl+P | settings panel: session name + cost, model / effort / mode, file / web permissions (the web toggle also revokes the sites `//` added to `~/.F12-mcp.json`), hide Bash output, CtrlO / CtrlR / Undo / Copy |
|
|
64
|
+
| quitting with Ctrl+C | moves that conversation out of `~/.claude/projects/` to `~/claude/last` (replacing the previous one) and prints `To restore #> mv ~/claude/last …`; an empty session prints nothing (quit with `/exit` to keep it) |
|
|
65
|
+
| mouse wheel | goes to Claude Code untouched; overlays step aside until it stops (`pi.arg.whlSpeed = n`: n notches = one PgUp / PgDn instead) |
|
|
66
|
+
| double click | pages (PgUp / PgDn) the way the wheel last went |
|
|
67
|
+
| click a row ending in `.png` / `.webp` | opens that file in `imv` (`xdg-open` where `imv` is missing); clicks on the input box and the status line still go to CC |
|
|
68
|
+
| Enter on a single line `/~/name` | creates `~/claude/name`; in Yakuake / Guake it opens a new tab and runs `cd … && npx cld-pi` there, elsewhere it copies the command to paste in a new terminal |
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
Typed as a single line in the input box; Claude Code never sees them.
|
|
73
|
+
Every registered name also gets a tiny stub in a throwaway
|
|
74
|
+
`--plugin-dir` (inside claude-p's temp dir, never the project), so it shows
|
|
75
|
+
up in Claude Code's `/` autocomplete as `cldp-pi:<name> (<name>)`.
|
|
76
|
+
|
|
77
|
+
| Command | |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `/ad <dir>` | `/add-dir` it (confirms the dialog), then `!tree -L 2 <dir>` |
|
|
80
|
+
| `/w` | cloudcli web UI on `:3001` (started in the background if nothing serves it); shows localhost / LAN / tailscale URLs, with buttons to copy the LAN or tailscale URL or show the tailscale one as a QR code (`npx qrcode-terminal`) |
|
|
81
|
+
| `/m` | `~/.local/bin/magpie panel` |
|
|
82
|
+
|
|
83
|
+
## Chrome
|
|
84
|
+
|
|
85
|
+
`//` needs remote debugging on in your Chrome: open
|
|
86
|
+
`chrome://inspect/#remote-debugging` and allow it (Chrome 144+; Chrome asks you
|
|
87
|
+
to *Allow* cld-pi once per session). Every site you pick with `//` or `//gh/` is
|
|
88
|
+
added to `allowedUrlPattern` in `~/.F12-mcp.json`, the config of the
|
|
89
|
+
chrome-devtools MCP that lets Claude drive the same browser. The first `//`
|
|
90
|
+
offers its install command:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
claude mcp add chrome-devtools --scope user -- npx -y chrome-devtools-mcp@latest \
|
|
94
|
+
--autoConnect --config ~/.F12-mcp.json --watchConfig
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Picking a tab types `@/tmp/…png` plus `@/tmp/…f12.md`: the tab's URL, what
|
|
98
|
+
F12 shows (console messages, uncaught exceptions, network errors) and how to
|
|
99
|
+
reach the same tab through the MCP. Plugins, and the F12 inspector, can also
|
|
100
|
+
read a tab live:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
await pi.ext.f12.devtools('https://github.com/', { reload: false, ms: 500 })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`reload: true` reloads the page first, so the console replay starts from the
|
|
107
|
+
fresh page. `target` is a URL, a URL prefix, or a CDP targetId.
|
|
108
|
+
|
|
109
|
+
`CLAUDE_P_CDP=9222` (a port or a `ws://` URL) connects to another browser instead.
|
|
110
|
+
|
|
111
|
+
## Plugins
|
|
112
|
+
|
|
113
|
+
A plugin is an ES module in `~/claude/-p/` exporting a function of `pi`:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
// ~/claude/-p/hello.js
|
|
117
|
+
export default pi => {
|
|
118
|
+
pi.command('hi', ([name = 'there']) => pi.dialog({ body: `hello ${name}`, timeout: 2000 }))
|
|
119
|
+
pi.complete('#', tok => ['#bug', '#feat', '#docs'].filter(t => t.startsWith(tok)))
|
|
120
|
+
pi.on({ CtrlG: () => (pi.BUFFER = 'git status', false) }) // false: skip the default
|
|
121
|
+
setInterval(() => {}, 60_000) // cleared when turned off
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Debugging plugins (F12)
|
|
126
|
+
|
|
127
|
+
The node sidecar runs with the inspector on by default, listening on
|
|
128
|
+
`127.0.0.1:9229` (a random port if taken; see `~/claude/-p/inspect`). Open
|
|
129
|
+
`chrome://inspect` → *inspect*, and the global `pi` is there:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
pi`/help` // type a line into Claude Code + Enter
|
|
133
|
+
pi.BUFFER = 'draft' // replace the input box text, no Enter (write-only)
|
|
134
|
+
pi.BUFFER=await pi.menu('abc'.split(''))
|
|
135
|
+
|
|
136
|
+
pi.arg.fzf = "--pointer '▶' --height=60%" // fzf options for Tab completion
|
|
137
|
+
pi.arg.whlSpeed = 5 // wheel notches per PgUp / PgDn (0: pass through)
|
|
138
|
+
pi.arg.prompt = '>' // also border, bash, busy, running: how the TUI's screen reads (CC's by default)
|
|
139
|
+
pi.notify('3 tests left', 'snack') // text in the top-right badge ('' takes it down)
|
|
140
|
+
pi.notify('busy', 'snackRD', { bg: 236 }) // bottom-right; bg: 0-255, '#rrggbb', 'none'
|
|
141
|
+
await pi.dialog({ title: 'Hi', body: 'gum-style', OK: () => {}, Stat: () => pi`/status` })
|
|
142
|
+
pi.dialog({ body: 'saved', timeout: 3000, or: 'OK', OK: () => {} }) // non-modal, top-right, counts down "(3s)"; ←/→ / click stops the countdown
|
|
143
|
+
pi.sed(ln => ln.includes('secret') ? '' : null) // blank / rewrite screen rows
|
|
144
|
+
pi.click(row => …) // a plain click on grid row `row` above the input box (img.js: .png / .webp rows → imv)
|
|
145
|
+
await pi.on.stop // wait for the next Stop hook
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`CLAUDE_P_INSPECT=9223` picks another port. (0=OFF).
|
|
149
|
+
|
|
150
|
+
> [Refer this](./goal-v1.ts) for more · [goal-v1.ipynb](./goal-v1.ipynb): a `%%js` Jupyter magic that runs these against your session (CDP :9229), with examples
|
|
151
|
+
|
|
152
|
+
## Roadmap
|
|
153
|
+
|
|
154
|
+
| Phase | Scope | Status |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| P0 | Passthrough, ghostty-vt grid, `scr`, hooks, node sidecar | ✅ done |
|
|
157
|
+
| P1 | Input box detection, `./` `/~/` completion, Ctrl+C, path → `cd` | ✅ done |
|
|
158
|
+
| P2 | Overlays (menu / dialog / paint), Ctrl+Enter, `notify-send` | ✅ done |
|
|
159
|
+
| P3 | Plugin API, `/m` `/w` `/ad` | ✅ done |
|
|
160
|
+
| P4 | Ctrl+P settings panel | ✅ done |
|
|
161
|
+
| P5 | Chrome tabs: `//` screenshot, `//gh/` URLs, WebMCP | ✅ done |
|
|
162
|
+
| P6 | Conversation outline WebUI | |
|
|
163
|
+
|
|
164
|
+
Full design: [goal-v1.md](./goal-v1.md) · plugin API draft: [goal-v1.ts](./goal-v1.ts).
|
|
165
|
+
|
|
166
|
+
## Build from source
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
mise install # Zig 0.15.2, pinned in mise.toml
|
|
170
|
+
zig build test # Zig unit tests
|
|
171
|
+
node --test js/test/*.test.js
|
|
172
|
+
zig build -Doptimize=ReleaseSafe
|
|
173
|
+
./zig-out/bin/claude-p
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
On Linux the default target is musl (static binary, same as releases).
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
[MIT](./LICENSE). Based on claude-p by smithersai, also MIT.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# cld-pi
|
|
2
|
+
|
|
3
|
+
[English](./README.md) · **简体中文**
|
|
4
|
+
|
|
5
|
+
套在 [Claude Code](https://code.claude.com) 外面的交互式外壳,fork 自
|
|
6
|
+
[smithersai/claude-p](https://github.com/smithersai/claude-p)。
|
|
7
|
+
|
|
8
|
+
- **不带参数**运行 `npx cld-pi`:打开 Claude Code(fullscreen TUI),外面包一层薄壳,
|
|
9
|
+
提供前缀补全、叠加层和 JS 插件。
|
|
10
|
+
- `npx cld-pi "prompt"`:保持原有行为,可直接替代 `claude -p`。
|
|
11
|
+
|
|
12
|
+
外壳从不改写 Claude Code 的输出:每个字节原样转发,同时用
|
|
13
|
+
[ghostty-vt](https://github.com/ghostty-org/ghostty) 网格镜像屏幕,自己的内容画在上层。
|
|
14
|
+
|
|
15
|
+
## 运行
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx cld-pi # 交互模式
|
|
19
|
+
npx cld-pi -r [id] # 交互模式,恢复会话(-c:继续上次)
|
|
20
|
+
npx cld-pi "解释一下这个仓库" # 输出与 `claude -p` 一致
|
|
21
|
+
npx cld-pi .json "总结" < diff.txt # .text / .json / .stream-json 即 --output-format
|
|
22
|
+
npx cld-pi -r <id> "接着呢?" # 在已有会话里以 -p 模式提问
|
|
23
|
+
npx cld-pi -a codex # 套别的 TUI(用 pi.arg.prompt / busy / … 描述它的界面)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
需要 `$PATH` 里有 `claude`、Node ≥ 18,支持 macOS 和 Linux。
|
|
27
|
+
|
|
28
|
+
`npm i -g cld-pi` 会安装同一个程序,命令名仍是 `claude-p`(另有 `cld-pi`)。首次运行时
|
|
29
|
+
会打印一行命令,用来在 shell 里把 **Ctrl+P** 绑定为启动 `claude-p`。它只打印,不会替你改
|
|
30
|
+
rc 文件。
|
|
31
|
+
|
|
32
|
+
交互模式会强制 `/tui fullscreen`,并通过 `--settings` 注入 `Notification`、`Stop`、
|
|
33
|
+
`SessionStart` 三个 hook;Stop / Notification 在终端没有焦点时弹桌面通知(`notify-send`),默认插件
|
|
34
|
+
`herdry` 还会让 Yakuake / Konsole / Guake 的标签标题转起来(`✻ 消息`,其他终端改窗口标题),直到你切回来。相关文件放在 `~/claude/-p/`:
|
|
35
|
+
|
|
36
|
+
| 文件 | 说明 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `scr` | 当前屏幕的纯文本快照,每帧刷新;只在文件存在时写(`touch` 一下开启,退出时删除) |
|
|
39
|
+
| `host.log` | node sidecar 的日志;同样存在才写,退出时删除 |
|
|
40
|
+
| `inspect` | sidecar 的 DevTools 地址 |
|
|
41
|
+
| `*.js` | 你的插件:`export default pi => { … }`(ES module) |
|
|
42
|
+
|
|
43
|
+
右上角的 `◆ plugins n/m` 角标列出所有插件,点它可以关掉某个插件——它注册的监听、`pi.sed` 和定时器会一起撤掉。插件可以用
|
|
44
|
+
`pi.notify(msg, 'snack')` 把自己的文字放到这个位置(右下角用 `'snackRD'`);默认插件 `pace`
|
|
45
|
+
在右下角显示上一条 ● 的耗时和近 3 分钟的花费。两个角点进去都是插件列表。
|
|
46
|
+
|
|
47
|
+
## 按键
|
|
48
|
+
|
|
49
|
+
| 按键 | 在输入框里 |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `./…` + Tab | 用 fzf 选 CC 当前目录下的文件 → 插入 `@路径 `(可循环滚动;ctrl-d:选该文件所在文件夹) |
|
|
52
|
+
| `/~/…` + Tab | 同上,范围是 `~` |
|
|
53
|
+
| `//…` + Tab | 用 fzf 选 Chrome 标签页 → 截图,插入 `@/tmp/….png ` |
|
|
54
|
+
| `//gh/…` + Tab | 补全该站点(`gh` → github.com)的 URL,来源是标签页和 Chrome 历史 |
|
|
55
|
+
| Ctrl+C | 清掉光标所在行及下面的行,光标停在新的空行;输入框为空时交给 Claude Code 自己处理 |
|
|
56
|
+
| 用 Ctrl+C 退出 | 把这次对话从 `~/.claude/projects/` 移到 `~/claude/last`(覆盖上一次的),并打印 `To restore #> mv ~/claude/last …`;空会话不输出;想保留就用 `/exit` 退出 |
|
|
57
|
+
| 鼠标滚轮 | 原样交给 Claude Code,滚动时叠加层让开,停下再画回来(`pi.arg.whlSpeed = n`:改成 n 格一次 PgUp / PgDn) |
|
|
58
|
+
| 双击 | 顺着上次滚轮的方向翻一页(PgUp / PgDn) |
|
|
59
|
+
| 点击以 `.png` / `.webp` 结尾的一行 | 用 `imv` 打开该文件(没有 `imv` 时用 `xdg-open`);输入框和状态栏上的点击仍交给 CC |
|
|
60
|
+
| Ctrl+Enter | 粘贴剪贴板并提交(终端需支持 kitty 键盘协议或 modifyOtherKeys) |
|
|
61
|
+
| Ctrl+P | 设置面板:会话名和花费、model / effort / mode、file / web 权限(web 开关还会撤销 `//` 加进 `~/.F12-mcp.json` 的站点)、隐藏 Bash 输出、CtrlO / CtrlR / Undo / Copy |
|
|
62
|
+
| 单行输入 `/~/名字` 后回车 | 创建 `~/claude/名字`;Yakuake / Guake 里直接开新 tab 运行 `cd … && npx cld-pi`,其他终端把命令复制到剪贴板,在新终端粘贴即可打开 |
|
|
63
|
+
|
|
64
|
+
## 命令
|
|
65
|
+
|
|
66
|
+
在输入框里单独一行输入;Claude Code 看不到这些行。每个注册的名字还会在 claude-p 每次运行的临时 `--plugin-dir` 里放一个很小的 stub(不会碰项目目录),让它在 Claude Code 的 `/` 自动补齐里以 `cldp-pi:<名字> (<名字>)` 出现。
|
|
67
|
+
|
|
68
|
+
| 命令 | |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `/ad <目录>` | `/add-dir`(自动确认对话框),然后 `!tree -L 2 <目录>` |
|
|
71
|
+
| `/w` | `:3001` 上的 cloudcli Web UI(没有服务就在后台启动),列出 localhost / 局域网 / tailscale 地址;三个按钮:复制局域网地址、复制 tailscale 地址、显示 tailscale 地址的二维码(`npx qrcode-terminal`) |
|
|
72
|
+
| `/m` | `~/.local/bin/magpie panel` |
|
|
73
|
+
|
|
74
|
+
## Chrome
|
|
75
|
+
|
|
76
|
+
`//` 需要 Chrome 打开远程调试:访问 `chrome://inspect/#remote-debugging` 并允许
|
|
77
|
+
(Chrome 144+;每次会话 Chrome 会让你点一次 *Allow*)。用 `//` 或 `//gh/` 选过的站点
|
|
78
|
+
会追加到 `~/.F12-mcp.json` 的 `allowedUrlPattern`,这是 chrome-devtools MCP 的配置,
|
|
79
|
+
Claude 通过它操作同一个浏览器。第一次用 `//` 时会提示安装命令:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
claude mcp add chrome-devtools --scope user -- npx -y chrome-devtools-mcp@latest \
|
|
83
|
+
--autoConnect --config ~/.F12-mcp.json --watchConfig
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
选中标签页会输入 `@/tmp/…png` 和 `@/tmp/…f12.md`:后者是标签页 URL、F12 能看到的东西
|
|
87
|
+
(控制台消息、未捕获异常、网络错误)以及如何通过 MCP 找到同一个标签页。插件和 F12 inspector
|
|
88
|
+
也可以实时读取:
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
await pi.ext.f12.devtools('https://github.com/', { reload: false, ms: 500 })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`reload: true` 会先刷新页面,让控制台重放从新页面开始。`target` 可以是 URL、URL 前缀或 CDP targetId。
|
|
95
|
+
|
|
96
|
+
`CLAUDE_P_CDP=9222`(端口或 `ws://` URL)改连别的浏览器。
|
|
97
|
+
|
|
98
|
+
## 插件
|
|
99
|
+
|
|
100
|
+
插件是放在 `~/claude/-p/` 的 ES module,默认导出一个接收 `pi` 的函数:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
// ~/claude/-p/hello.js
|
|
104
|
+
export default pi => {
|
|
105
|
+
pi.command('hi', ([name = 'there']) => pi.dialog({ body: `hello ${name}`, timeout: 2000 }))
|
|
106
|
+
pi.complete('#', tok => ['#bug', '#feat', '#docs'].filter(t => t.startsWith(tok)))
|
|
107
|
+
pi.on({ CtrlG: () => (pi.BUFFER = 'git status', false) }) // 返回 false:不走默认行为
|
|
108
|
+
setInterval(() => {}, 60_000) // 插件被关掉时自动清除
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 调试插件(F12)
|
|
113
|
+
|
|
114
|
+
[goal-v1.ipynb](./goal-v1.ipynb):Jupyter 的 `%%js` magic,通过 CDP :9229 在你的会话里执行代码,附整套接口示例。
|
|
115
|
+
|
|
116
|
+
node sidecar 默认开启 inspector,监听 `127.0.0.1:9229`(被占用时换随机端口,地址见
|
|
117
|
+
`~/claude/-p/inspect`)。打开 `chrome://inspect` → *inspect*,就能直接使用全局变量 `pi`:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
pi`/help` // type a line into Claude Code + Enter
|
|
121
|
+
pi.BUFFER = 'draft' // replace the input box text, no Enter (write-only)
|
|
122
|
+
pi.BUFFER=await pi.menu('abc'.split(''))
|
|
123
|
+
|
|
124
|
+
pi.arg.fzf = "--pointer '▶' --height=60%" // fzf options for Tab completion
|
|
125
|
+
pi.arg.whlSpeed = 5 // 几格滚轮算一次 PgUp / PgDn(0:透传)
|
|
126
|
+
pi.arg.prompt = '>' // 还有 border、bash、busy、running:怎样识别 TUI 的界面(默认是 CC 的)
|
|
127
|
+
pi.notify('3 tests left', 'snack') // 显示在右上角角标('' 撤下)
|
|
128
|
+
pi.notify('busy', 'snackRD', { bg: 236 }) // 右下角;bg:0-255、'#rrggbb'、'none'(透明)
|
|
129
|
+
await pi.dialog({ title: 'Hi', body: 'gum-style', OK: () => {}, Stat: () => pi`/status` })
|
|
130
|
+
pi.dialog({ body: 'saved', timeout: 3000, or: 'OK', OK: () => {} }) // 非模态,右上角,正文实时倒计时 "(3s)";←/→、点击会停止计时
|
|
131
|
+
pi.sed(ln => ln.includes('secret') ? '' : null) // blank / rewrite screen rows
|
|
132
|
+
pi.click(row => …) // 点击输入框上方的第 row 行(img.js:点到 .png / .webp 行就用 imv 打开)
|
|
133
|
+
await pi.on.stop // wait for the next Stop hook
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`CLAUDE_P_INSPECT=<端口>` 指定端口,0关掉共享。
|
|
137
|
+
|
|
138
|
+
## 路线图
|
|
139
|
+
|
|
140
|
+
| 阶段 | 内容 | 状态 |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| P0 | 透传、ghostty-vt 网格、`scr`、hook、node sidecar | ✅ 完成 |
|
|
143
|
+
| P1 | 识别输入框、`./` `/~/` 补全、Ctrl+C、输入路径即 `cd` | ✅ 完成 |
|
|
144
|
+
| P2 | 叠加层(菜单 / dialog / 涂抹)、Ctrl+Enter、`notify-send` | ✅ 完成 |
|
|
145
|
+
| P3 | 插件 API,`/m` `/w` `/ad` | ✅ 完成 |
|
|
146
|
+
| P4 | Ctrl+P 设置面板 | ✅ 完成 |
|
|
147
|
+
| P5 | Chrome 标签页:`//` 截图、`//gh/` 补全 URL、WebMCP | ✅ 完成 |
|
|
148
|
+
| P6 | 会话目录 WebUI | |
|
|
149
|
+
|
|
150
|
+
完整设计见 [goal-v1.md](./goal-v1.md),插件 API 草案见 [goal-v1.ts](./goal-v1.ts)。
|
|
151
|
+
|
|
152
|
+
## 从源码构建
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
mise install # Zig 0.15.2,版本固定在 mise.toml
|
|
156
|
+
zig build test # Zig 单元测试
|
|
157
|
+
node --test js/test/*.test.js
|
|
158
|
+
zig build -Doptimize=ReleaseSafe
|
|
159
|
+
./zig-out/bin/claude-p
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Linux 上默认目标是 musl(静态链接,与发布版相同)。
|
|
163
|
+
|
|
164
|
+
## 许可证
|
|
165
|
+
|
|
166
|
+
[MIT](./LICENSE)。基于 smithersai 的 claude-p(同为 MIT)。
|
package/bin/claude-p.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin shim that execs the platform-specific prebuilt `claude-p` binary
|
|
3
|
+
// downloaded into ../prebuilt/.
|
|
4
|
+
//
|
|
5
|
+
// The download normally happens in `postinstall`, but npm 11.19+ gates
|
|
6
|
+
// dependency install scripts behind its allow-scripts allowlist and `npx`
|
|
7
|
+
// skips them silently, so the shim also ensures the binary itself. That is
|
|
8
|
+
// what makes `npx cld-pi` work on a fresh machine.
|
|
9
|
+
|
|
10
|
+
const { spawnSync } = require("node:child_process");
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
const { ensureInstalled } = require("../scripts/install.js");
|
|
14
|
+
|
|
15
|
+
function platformDir() {
|
|
16
|
+
const platform = process.platform;
|
|
17
|
+
const arch = process.arch;
|
|
18
|
+
return `${platform}-${arch}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function binaryPath() {
|
|
22
|
+
const dir = path.join(__dirname, "..", "prebuilt", platformDir());
|
|
23
|
+
const name = process.platform === "win32" ? "claude-p.exe" : "claude-p";
|
|
24
|
+
return path.join(dir, name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
const bin = binaryPath();
|
|
29
|
+
if (!fs.existsSync(bin) && !(await ensureInstalled())) {
|
|
30
|
+
console.error(
|
|
31
|
+
`claude-p: prebuilt binary not found at ${bin}\n` +
|
|
32
|
+
`The download failed (see above). If you are behind a proxy, check ` +
|
|
33
|
+
`HTTPS_PROXY/NO_PROXY, or build from source with \`zig build\`.`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
// Tell the binary where the node sidecar (js/host.js) lives.
|
|
38
|
+
const env = { ...process.env, CLAUDE_P_JS: path.join(__dirname, "..", "js") };
|
|
39
|
+
const result = spawnSync(bin, process.argv.slice(2), {
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
env,
|
|
42
|
+
});
|
|
43
|
+
if (result.error) {
|
|
44
|
+
console.error("claude-p:", result.error.message);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
if (result.signal) {
|
|
48
|
+
process.kill(process.pid, result.signal);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
process.exit(result.status ?? 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main().catch((err) => {
|
|
55
|
+
console.error("claude-p:", err.stack || err.message);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
});
|
package/js/cdp.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Chrome DevTools Protocol, stdlib only (global WebSocket: Node 22+), plus
|
|
2
|
+
// where to find the user's Chrome: its DevToolsActivePort (written once
|
|
3
|
+
// remote debugging is allowed in chrome://inspect/#remote-debugging) and its
|
|
4
|
+
// History database.
|
|
5
|
+
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
/** Chrome's user data dirs, the usual one first. */
|
|
10
|
+
export function chromeDirs(home, platform = process.platform) {
|
|
11
|
+
if (platform === 'darwin') {
|
|
12
|
+
const base = join(home, 'Library', 'Application Support')
|
|
13
|
+
return [join(base, 'Google', 'Chrome'), join(base, 'Google', 'Chrome Beta'), join(base, 'Chromium')]
|
|
14
|
+
}
|
|
15
|
+
const base = join(home, '.config')
|
|
16
|
+
return [join(base, 'google-chrome'), join(base, 'google-chrome-beta'), join(base, 'chromium')]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** "9222\n/devtools/browser/<id>" → the browser's WebSocket URL. */
|
|
20
|
+
export function activePortUrl(text) {
|
|
21
|
+
const [port, path] = String(text).split('\n').map(s => s.trim())
|
|
22
|
+
if (!/^\d+$/.test(port) || !path?.startsWith('/devtools/browser/')) return null
|
|
23
|
+
return `ws://127.0.0.1:${port}${path}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where to connect: `CLAUDE_P_CDP` (a port, http:// or ws:// URL; agent tests
|
|
28
|
+
* use 9224) or the first Chrome with a DevToolsActivePort. `dir` is that
|
|
29
|
+
* Chrome's user data dir (for History), null for CLAUDE_P_CDP.
|
|
30
|
+
*/
|
|
31
|
+
export async function findBrowser({ env = process.env, home = env.HOME, platform } = {}) {
|
|
32
|
+
const pinned = env.CLAUDE_P_CDP
|
|
33
|
+
if (pinned) {
|
|
34
|
+
if (/^wss?:/.test(pinned)) return { url: pinned, dir: null }
|
|
35
|
+
const base = /^\d+$/.test(pinned) ? `http://127.0.0.1:${pinned}` : pinned.replace(/\/$/, '')
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(`${base}/json/version`, { signal: AbortSignal.timeout(1500) })
|
|
38
|
+
return { url: (await res.json()).webSocketDebuggerUrl, dir: null }
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const dir of chromeDirs(home, platform)) {
|
|
44
|
+
let text
|
|
45
|
+
try {
|
|
46
|
+
text = readFileSync(join(dir, 'DevToolsActivePort'), 'utf8')
|
|
47
|
+
} catch {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
const url = activePortUrl(text)
|
|
51
|
+
if (url) return { url, dir }
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open a CDP connection. `send(method, params, sessionId, ms)` rejects after
|
|
58
|
+
* `ms` (default 10 s) with `err.timeout` set. Chrome may hold the handshake
|
|
59
|
+
* until the user clicks Allow, hence the long `ms` here.
|
|
60
|
+
*/
|
|
61
|
+
export function connect(url, ms = 30_000) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
if (typeof WebSocket !== 'function') return reject(new Error('needs Node 22+ (global WebSocket)'))
|
|
64
|
+
const ws = new WebSocket(url)
|
|
65
|
+
const calls = new Map()
|
|
66
|
+
const listeners = new Set()
|
|
67
|
+
let next = 1
|
|
68
|
+
const cdp = {
|
|
69
|
+
closed: false,
|
|
70
|
+
/** Events: fn(method, params, sessionId); returns the unsubscriber. */
|
|
71
|
+
on(fn) {
|
|
72
|
+
listeners.add(fn)
|
|
73
|
+
return () => listeners.delete(fn)
|
|
74
|
+
},
|
|
75
|
+
send(method, params = {}, sessionId, ms = 10_000) {
|
|
76
|
+
if (cdp.closed) return Promise.reject(new Error('CDP connection closed'))
|
|
77
|
+
const id = next++
|
|
78
|
+
const msg = { id, method, params }
|
|
79
|
+
if (sessionId) msg.sessionId = sessionId
|
|
80
|
+
return new Promise((ok, fail) => {
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
calls.delete(id)
|
|
83
|
+
fail(Object.assign(new Error(`${method} timed out`), { timeout: true }))
|
|
84
|
+
}, ms)
|
|
85
|
+
calls.set(id, { ok, fail, timer })
|
|
86
|
+
ws.send(JSON.stringify(msg))
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
close: () => ws.close(),
|
|
90
|
+
}
|
|
91
|
+
const giveUp = setTimeout(() => {
|
|
92
|
+
ws.close()
|
|
93
|
+
reject(new Error(`no answer from ${url}`))
|
|
94
|
+
}, ms)
|
|
95
|
+
ws.addEventListener('open', () => {
|
|
96
|
+
clearTimeout(giveUp)
|
|
97
|
+
resolve(cdp)
|
|
98
|
+
})
|
|
99
|
+
ws.addEventListener('message', e => {
|
|
100
|
+
const m = JSON.parse(e.data)
|
|
101
|
+
if (m.id == null && m.method) {
|
|
102
|
+
for (const fn of listeners) fn(m.method, m.params, m.sessionId)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const c = calls.get(m.id)
|
|
106
|
+
if (!c) return
|
|
107
|
+
calls.delete(m.id)
|
|
108
|
+
clearTimeout(c.timer)
|
|
109
|
+
if (m.error) c.fail(new Error(`${m.error.message} (${m.error.code})`))
|
|
110
|
+
else c.ok(m.result)
|
|
111
|
+
})
|
|
112
|
+
const down = () => {
|
|
113
|
+
clearTimeout(giveUp)
|
|
114
|
+
cdp.closed = true
|
|
115
|
+
for (const c of calls.values()) {
|
|
116
|
+
clearTimeout(c.timer)
|
|
117
|
+
c.fail(new Error('CDP connection closed'))
|
|
118
|
+
}
|
|
119
|
+
calls.clear()
|
|
120
|
+
reject(new Error(`cannot connect to ${url}`))
|
|
121
|
+
}
|
|
122
|
+
ws.addEventListener('close', down)
|
|
123
|
+
ws.addEventListener('error', down)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The browser's tabs (Target.getTargets, pages only). */
|
|
128
|
+
export async function tabs(cdp) {
|
|
129
|
+
const { targetInfos } = await cdp.send('Target.getTargets')
|
|
130
|
+
return targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('devtools://'))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* PNG of a tab. A background tab doesn't paint, so a capture that times out
|
|
135
|
+
* is retried after bringing the tab to the front.
|
|
136
|
+
*/
|
|
137
|
+
export async function screenshot(cdp, targetId, ms = 4000) {
|
|
138
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true })
|
|
139
|
+
const shot = async () => Buffer.from((await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId, ms)).data, 'base64')
|
|
140
|
+
try {
|
|
141
|
+
return await shot()
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (!e.timeout) throw e
|
|
144
|
+
await cdp.send('Target.activateTarget', { targetId })
|
|
145
|
+
return await shot()
|
|
146
|
+
} finally {
|
|
147
|
+
cdp.send('Target.detachFromTarget', { sessionId }).catch(() => {})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reload a tab and wait for its load event (`ms` at most). Detached again
|
|
153
|
+
* afterwards, so a following `devtools()` sees the fresh page's console from
|
|
154
|
+
* the start.
|
|
155
|
+
*/
|
|
156
|
+
export async function reload(cdp, targetId, ms = 5000) {
|
|
157
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true })
|
|
158
|
+
try {
|
|
159
|
+
await cdp.send('Page.enable', {}, sessionId)
|
|
160
|
+
const loaded = new Promise(done => {
|
|
161
|
+
const off = cdp.on((method, p, sid) => {
|
|
162
|
+
if (sid === sessionId && method === 'Page.loadEventFired') {
|
|
163
|
+
off()
|
|
164
|
+
done()
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
off()
|
|
169
|
+
done()
|
|
170
|
+
}, ms)
|
|
171
|
+
})
|
|
172
|
+
await cdp.send('Page.reload', {}, sessionId)
|
|
173
|
+
await loaded
|
|
174
|
+
} finally {
|
|
175
|
+
cdp.send('Target.detachFromTarget', { sessionId }).catch(() => {})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A console argument as DevTools prints it, roughly. */
|
|
180
|
+
const show = o => (o.value !== undefined ? (typeof o.value === 'string' ? o.value : JSON.stringify(o.value)) : o.description ?? o.unserializableValue ?? o.type)
|
|
181
|
+
const where = f => (f?.url ? `${f.url}:${(f.lineNumber ?? 0) + 1}` : '')
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What F12 would show for a tab: console messages, uncaught exceptions and
|
|
185
|
+
* the browser's log (network errors, violations). V8 keeps the messages
|
|
186
|
+
* logged so far and replays them on Runtime.enable, as does Log.enable, so
|
|
187
|
+
* attaching late still sees them; `ms` is how long to collect the replay.
|
|
188
|
+
* Entries: { kind: 'console' | 'exception' | 'log', level, text, at }.
|
|
189
|
+
*/
|
|
190
|
+
export async function devtools(cdp, targetId, ms = 300) {
|
|
191
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true })
|
|
192
|
+
const out = []
|
|
193
|
+
const off = cdp.on((method, p, sid) => {
|
|
194
|
+
if (sid !== sessionId) return
|
|
195
|
+
if (method === 'Runtime.consoleAPICalled')
|
|
196
|
+
out.push({ kind: 'console', level: p.type, text: (p.args ?? []).map(show).join(' '), at: where(p.stackTrace?.callFrames?.[0]) })
|
|
197
|
+
else if (method === 'Runtime.exceptionThrown') {
|
|
198
|
+
const d = p.exceptionDetails ?? {}
|
|
199
|
+
out.push({ kind: 'exception', level: 'error', text: d.exception?.description ?? d.text ?? 'exception', at: where({ url: d.url, lineNumber: d.lineNumber }) })
|
|
200
|
+
} else if (method === 'Log.entryAdded') {
|
|
201
|
+
const e = p.entry ?? {}
|
|
202
|
+
out.push({ kind: 'log', level: e.level, text: `${e.source}: ${e.text}`, at: e.url ?? '' })
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
try {
|
|
206
|
+
await Promise.all([cdp.send('Runtime.enable', {}, sessionId), cdp.send('Log.enable', {}, sessionId)])
|
|
207
|
+
await new Promise(r => setTimeout(r, ms))
|
|
208
|
+
return out
|
|
209
|
+
} finally {
|
|
210
|
+
off()
|
|
211
|
+
cdp.send('Target.detachFromTarget', { sessionId }).catch(() => {})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Recently visited URLs from a Chrome user data dir (its last used profile),
|
|
217
|
+
* newest first. Read from a copy: Chrome keeps the database open. [] when
|
|
218
|
+
* there's none or node:sqlite is missing (Node < 22.5).
|
|
219
|
+
*/
|
|
220
|
+
export async function history(dir, limit = 5000) {
|
|
221
|
+
if (!dir) return []
|
|
222
|
+
let sqlite
|
|
223
|
+
try {
|
|
224
|
+
sqlite = await import('node:sqlite')
|
|
225
|
+
} catch {
|
|
226
|
+
return []
|
|
227
|
+
}
|
|
228
|
+
let profile = 'Default'
|
|
229
|
+
try {
|
|
230
|
+
profile = JSON.parse(readFileSync(join(dir, 'Local State'), 'utf8')).profile?.last_used || profile
|
|
231
|
+
} catch {}
|
|
232
|
+
const tmp = mkdtempSync(join(tmpdir(), 'cld-pi-history-'))
|
|
233
|
+
try {
|
|
234
|
+
const copy = join(tmp, 'History')
|
|
235
|
+
copyFileSync(join(dir, profile, 'History'), copy)
|
|
236
|
+
const db = new sqlite.DatabaseSync(copy, { readOnly: true })
|
|
237
|
+
try {
|
|
238
|
+
return db.prepare('SELECT url, title FROM urls ORDER BY last_visit_time DESC LIMIT ?').all(limit)
|
|
239
|
+
} finally {
|
|
240
|
+
db.close()
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
return []
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
246
|
+
}
|
|
247
|
+
}
|