cld-pi 0.0.0-stage → 0.0.1

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 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
- # Temporary Holding Version
1
+ # cld-pi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -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)。
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim that execs the platform-specific prebuilt `claude-p` binary
3
+ // downloaded by scripts/install.js into ../prebuilt/.
4
+
5
+ const { spawnSync } = require("node:child_process");
6
+ const fs = require("node:fs");
7
+ const path = require("node:path");
8
+
9
+ function platformDir() {
10
+ const platform = process.platform;
11
+ const arch = process.arch;
12
+ return `${platform}-${arch}`;
13
+ }
14
+
15
+ function binaryPath() {
16
+ const dir = path.join(__dirname, "..", "prebuilt", platformDir());
17
+ const name = process.platform === "win32" ? "claude-p.exe" : "claude-p";
18
+ return path.join(dir, name);
19
+ }
20
+
21
+ function main() {
22
+ const bin = binaryPath();
23
+ if (!fs.existsSync(bin)) {
24
+ console.error(
25
+ `claude-p: prebuilt binary not found at ${bin}\n` +
26
+ `Re-run \`npm install claude-p\` or build from source with \`zig build\`.`,
27
+ );
28
+ process.exit(2);
29
+ }
30
+ // Tell the binary where the node sidecar (js/host.js) lives.
31
+ const env = { ...process.env, CLAUDE_P_JS: path.join(__dirname, "..", "js") };
32
+ const result = spawnSync(bin, process.argv.slice(2), {
33
+ stdio: "inherit",
34
+ env,
35
+ });
36
+ if (result.error) {
37
+ console.error("claude-p:", result.error.message);
38
+ process.exit(2);
39
+ }
40
+ if (result.signal) {
41
+ process.kill(process.pid, result.signal);
42
+ return;
43
+ }
44
+ process.exit(result.status ?? 0);
45
+ }
46
+
47
+ main();
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
+ }