@caph42/pi-ccline 0.0.0-stage → 0.1.0

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Caph
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # pi-ccline
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
+ 把 pi 的 footer 交给原版 [CCometixLine](https://github.com/Haleclipse/CCometixLine)(`ccline`)渲染。
4
+
5
+ 这个扩展不做任何渲染:它从 pi 会话采集状态,拼成 Claude Code `statusLine` 形状的 JSON,喂给 `ccline` 二进制的 stdin,再把它的 stdout 当成 footer 显示。配色、主题、图标、折行全部由上游决定;上游升级或改主题,这里不需要跟着改。
6
+
7
+ ```
8
+ pi 会话状态 → buildCclineInput() → JSON → ccline(spawn,COLUMNS=当前宽度)→ ANSI 行 → footer
9
+ ```
10
+
11
+ ## 能力
12
+
13
+ | 显示内容 | 来源 |
14
+ |---|---|
15
+ | 模型名(含 provider) | `ctx.model`(`display_name` 取 `model.name`,回退 `model.id`;`showProvider` 为真时追加 ` (provider)`,如 `Sonnet 4.5 (anthropic)`) |
16
+ | 推理强度 | `ctx.thinkingLevel` → `effort.level` |
17
+ | 工作目录 | `ctx.cwd` → `cwd` / `workspace.current_dir` |
18
+ | 上下文百分比 / token 数 | `ctx.getContextUsage()` → 当前上下文(聚合估计值);**不发会话累计 token** |
19
+ | 成本、会话时长 | assistant 消息 `usage.cost.total` 累加;时长 = 当前时间 − 分支最早 entry |
20
+ | 会话 id / 名称 | `ctx.sessionManager` |
21
+ | git 分支等 ccline 自行探测的信息 | ccline 自己读磁盘 |
22
+ | 其他扩展的 status | `footerData.getExtensionStatuses()`,按 key 排序拼成一行放在 ccline 帧下方 |
23
+
24
+ 不显示、也不会伪造的:5h / 7d 配额段(pi 没有 `rate_limits` 数据源,字段不传,该段自动消失)。
25
+
26
+ ## 安装
27
+
28
+ 先准备外部二进制(扩展不自带渲染器,必须有它):
29
+
30
+ ```sh
31
+ npm i -g @cometix/ccline
32
+ # 或者只放一份到 ~/.claude/ccline/ccline
33
+ ```
34
+
35
+ **已在 ccline 2.0.1 上验证。** 更老的 1.1.x 会把 `model` / `workspace` / `transcript_path` 当必填字段,收到这里的输入会直接报
36
+ `missing field ...` 并非 0 退出(footer 会停在上一帧并标 stale),所以旧安装请先升级。
37
+
38
+ 再装 pi 包(任选其一):
39
+
40
+ ```sh
41
+ # 从 npm 正式安装(会写 ~/.pi/agent/settings.json)
42
+ pi install npm:@caph42/pi-ccline
43
+
44
+ # 从 npm 试用一次,不写 settings.json
45
+ pi -e npm:@caph42/pi-ccline
46
+
47
+ # 本地源码试用 / 正式安装(在包根目录执行)
48
+ pi -e .
49
+ pi install .
50
+ ```
51
+
52
+ 启动后 footer 直接变成 ccline 的渲染结果。终端需要 Nerd Font v3+ 才能显示图标;`style.mode = "plain"` 的 ccline 主题可以退化成 emoji。
53
+
54
+ ## 命令
55
+
56
+ | 命令 | 作用 |
57
+ |---|---|
58
+ | `/pi-ccline on` | 打开 footer(写入配置,立即生效) |
59
+ | `/pi-ccline off` | 关闭并恢复 pi 自带 footer(写入配置) |
60
+ | `/pi-ccline status` | 启用状态、模式、解析到的二进制、节流/超时/定时刷新、刷新次数、上一帧是否 stale |
61
+ | `/pi-ccline path` | 解析到的二进制路径、来源(config/home/path)与 `ccline --version` |
62
+
63
+ ## 配置
64
+
65
+ `~/.pi/agent/pi-ccline/config.json`(文件不存在、JSON 坏了、字段类型不对,一律回退默认值,不报错):
66
+
67
+ ```json
68
+ {
69
+ "enabled": true,
70
+ "binary": null,
71
+ "timeoutMs": 5000,
72
+ "refreshThrottleMs": 1000,
73
+ "liveRefreshMs": 1000,
74
+ "showProvider": true,
75
+ "env": {}
76
+ }
77
+ ```
78
+
79
+ | 字段 | 默认 | 说明 |
80
+ |---|---|---|
81
+ | `enabled` | `true` | 会话启动时是否安装 footer |
82
+ | `binary` | `null` | 显式指定 ccline 路径;`null` 走下面的解析顺序。路径不存在时静默回退 |
83
+ | `timeoutMs` | `5000` | 单次调用的硬超时(200–60000) |
84
+ | `refreshThrottleMs` | `1000` | 两次刷新最小间隔,低于 1000 会被抬到 1000 |
85
+ | `liveRefreshMs` | `1000` | agent 运行期间的定时刷新间隔(让 `耗时` 逐秒走);`0` 关闭定时器,只保留事件刷新。非 0 时低于 250 会被抬到 250 |
86
+ | `showProvider` | `true` | 是否在模型段追加 provider,如 `Sonnet 4.5 (anthropic)`;`false` 只显示模型名。终端窄到模型段被 ccline 截断时 provider 可能不可见 |
87
+ | `env` | `{}` | 追加给 ccline 的环境变量;同名变量覆盖 pi 继承的值(`COLUMNS` 除外,它由扩展按当前宽度写入) |
88
+
89
+ 二进制解析顺序:`config.binary` → `~/.claude/ccline/ccline` → `PATH` 中的 `ccline`。
90
+
91
+ ## 实现要点
92
+
93
+ - `render(width)` 是同步热路径,**绝不在里面 spawn**:只返回缓存帧;宽度变化只记录并投递一次刷新请求。
94
+ - 刷新由 `session_start` / `turn_start` / `turn_end` / `model_select` / `thinking_level_select` / `session_info_changed` / git 分支变化触发,经 `RefreshScheduler` 单飞 + 节流 + latest-wins:任何时刻最多一个子进程,排队请求合并为一次,读的是发起时刻的最新状态。
95
+ - **耗时这类随时间变化的段靠定时器推进**:纯事件刷新只能在事件发生时算一次「现在 − 起点」,所以 agent 运行期间(`agent_start` → `agent_end`)另有 `RefreshTicker` 按 `liveRefreshMs` 投递刷新请求,`耗时` 才会逐秒走;run 结束即停表,之后只有一次已排队的刷新可能落地,再没有周期刷新。下一轮的 `turn_start` 会重新起表;如果在 run 途中才装上/打开 footer,`attach()` 读 `ctx.isIdle()` 后直接起表。
96
+ - 超时 5s(可配)或非 0 退出:保留上一帧并在行尾加 ` ·stale`(放得下才加),不刷屏、不抛错。
97
+ - 找不到二进制:footer 显示一行安装提示。
98
+ - 行宽一律用 `visibleWidth` / `truncateToWidth` 计算,不用 `.length`。
99
+ - **其他扩展的 `ctx.ui.setStatus` 照样可见**:`render(width)` 读 `footerData.getExtensionStatuses()`,按 key 排序、用空格拼成一行、超宽时用 dim `…` 截断,追加在 ccline 帧下方——行为对齐 pi 内置 footer。读的是实时 map,`setStatus` 自己会触发重绘,所以状态变化不受 1Hz 节流限制。
100
+ - 因为 footer 槽位全局只有一个,本扩展装上后其他扩展的自定义 footer(例如 pi-cursor-provider 的 fast 徽标)会被顶掉;它们的 status 行仍会显示。反过来它们抢占时 ccline 帧会消失,需要 `/pi-ccline on` 取回。
101
+ - **provider 只能拼进模型段。** ccline 2.0.1 的输入 schema 没有 provider 字段,`model` 段只渲染 `display_name`,所以 provider 以 `名称 (provider)` 的形式追加在模型名后;名字里已经带同样的后缀时不重复追加。`showProvider: false` 可关掉。**窄宽度下 provider 可能看不到**:ccline 按 `COLUMNS` 自己折行/截断,实测宽度 40 时模型段只剩 `GPT-5.6`,这是上游布局行为,本扩展只保证把 provider 写进 `display_name`。
102
+ - 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
103
+ - 没有运行时依赖,只用 `node:child_process` / `node:fs` / `node:os` / `node:path` 和 pi 提供的 peer 包 `@earendil-works/pi-tui`。
104
+ - 非 TUI 模式(`-p`、json、rpc)不安装 footer、不 spawn。
105
+ - 扩展自己的配置写在 `~/.pi/agent/pi-ccline/config.json`,不碰 pi 的 `settings.json`。
106
+
107
+ ## 验证
108
+
109
+ ```sh
110
+ # 单元测试(输入映射、宽度截断、节流/latest-wins、二进制解析、超时、footer 生命周期)
111
+ npm test
112
+
113
+ # 用 fixture 跑真实 ccline,宽 120 与 40 各一次(打印可见宽度,验证不越界)
114
+ node --experimental-strip-types scripts/render-once.ts --fixture fixtures/basic.json
115
+
116
+ # 只跑某个宽度 / 指定二进制 / 隔离 HOME
117
+ node --experimental-strip-types scripts/render-once.ts --width 40 --binary ~/.claude/ccline/ccline --raw
118
+
119
+ # 完全隔离:配置从该 HOME 读,子进程 HOME 也指向它(state 不会写进你的 ~/.claude)
120
+ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --binary ~/.claude/ccline/ccline
121
+ ```
122
+
123
+ `--home` 会把该目录同时用于三处:查 `~/.claude/ccline/ccline`、读 `<home>/.pi/agent/pi-ccline/config.json`、以及给子进程设置 `HOME`/`USERPROFILE`。如果解析到的是 npm 包装脚本(`@cometix/ccline/bin/ccline.js`),它可能自己往这个 HOME 里铺主题等资源,想得到干净结果就同时用 `--binary` 钉死真实二进制。
124
+
125
+ `scripts/render-once.ts` 不启动 pi、不发模型请求,只验证「输入 → 二进制 → 行」这一段。
126
+
127
+ ## 已知限制
128
+
129
+ - **5h / 7d 配额段永远为空。** pi 不提供 `rate_limits`,字段整段省略,ccline 自然不显示。
130
+ - **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。
131
+ - **依赖外部二进制。** 没有 `ccline` 时 footer 只有一行安装提示;升级 `ccline` 后无需重启扩展,但二进制路径会被缓存到下一次解析(`/pi-ccline on` 或新会话会重算)。
132
+ - **上游兼容性只覆盖已测版本。** ccline 2.0.1 的 `input.rs` 每个字段都是 `Option` + lenient 反序列化,同一大版本内字段换形状一般只会让对应段消失;但大版本之间已经发生过必填字段变化(1.1.x → 2.0.x),升级 ccline 后请用 `scripts/render-once.ts` 复跑一次。
133
+ - **需要 Nerd Font v3+。** 图标字体缺失时会显示成方块;Windows 未测试。
134
+ - **上下文 token 只有一套口径:当前上下文。** `context_window` 只发 `current_usage.input_tokens`(= `getContextUsage().tokens`)和 `used_percentage`,不发 `total_input_tokens` / `total_output_tokens`。原因是 ccline 优先取累计值,那样进度条百分比会和 token 数互相矛盾(长会话显示成 `15% · 512k/272k`)。代价:会话累计输入/输出 token 不出现在 footer 里;成本与时长仍来自 `cost` 段。pi 只给聚合估计值,没有 input/cache 拆分,所以 `current_usage` 里只填 `input_tokens`。
135
+ - **分支视角。** 汇总只覆盖 `getBranch()` 可见的当前分支;compaction 之后仍按分支上现存记录计算,不等于「整个会话生命周期总量」。切分支后数字会变。
136
+ - **不传 `transcript_path`。** pi 的 session jsonl 不是 Claude Code 的 transcript 格式,传过去只会让上游读到无法解析的内容;因此在那个位置上显示的信息来自 JSON 本身而不是重新解析会话文件。
137
+ - **刷新频率上限 1Hz。** 流式输出期间上下文用量变化不会逐 token 刷新,只在节流窗口后更新一次。
138
+ - **`耗时` 只在 agent 运行期间逐秒走。** 定时器随 run 起停,空闲时停在最后一刻的值,直到下一个事件(发消息、切模型、切分支等)再刷新;`liveRefreshMs` 只控制运行期间的刷新请求节奏,不开启空闲刷新。`liveRefreshMs` 与 `refreshThrottleMs` 都是 1000 时,稳态约 1s 一次,实际间隔受两者共同限制。若以后想让空闲也逐秒走,需要改 `agent_end` 的停表逻辑,属于待评估的耗电取舍。
139
+ - **每次定时刷新都是一次进程创建。** 本机实测 spawn ccline 约 24ms(二进制自身 4.4ms),所以运行期间约每秒一次;`liveRefreshMs: 0` 可以彻底关掉。
140
+ - **首次刷新可能用 100/120 列。** 首帧宽度优先取 `process.stdout.columns`,拿不到时用 120,pi 第一次绘制后会按真实宽度重刷一次(多一次 4ms 级 spawn)。
141
+
142
+ ## 目录
143
+
144
+ ```
145
+ extensions/pi-ccline/
146
+ index.ts 生命周期、setFooter、命令、CclineFooter(缓存帧 + 单飞刷新)
147
+ input.ts pi 状态 → ccline 输入 JSON(纯函数)
148
+ binary.ts 路径解析/缓存、带超时与中止的 spawn、--version 探测
149
+ render.ts 输出归一化:分行、按可见宽度截断、stale 标记
150
+ refresh.ts 单飞 + 节流 + latest-wins 调度器,以及驱动 `耗时` 的定时器
151
+ config.ts 配置读写
152
+ *.test.ts node --test
153
+ scripts/render-once.ts 用 fixture 跑一次真实 ccline
154
+ fixtures/basic.json 人工验收用输入
155
+ ```
@@ -0,0 +1,154 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+
7
+ import { clearBinaryCache, isExecutableFile, probeVersion, resolveBinary, spawnCcline } from "./binary.ts";
8
+
9
+ function tempDir(): string {
10
+ return mkdtempSync(join(tmpdir(), "pi-ccline-bin-"));
11
+ }
12
+
13
+ function makeExecutable(path: string): string {
14
+ mkdirSync(join(path, ".."), { recursive: true });
15
+ writeFileSync(path, "#!/bin/sh\nexit 0\n", "utf8");
16
+ chmodSync(path, 0o755);
17
+ return path;
18
+ }
19
+
20
+ function makeHome(): string {
21
+ return tempDir();
22
+ }
23
+
24
+ test("resolveBinary prefers config.binary, then ~/.claude/ccline/ccline, then PATH", () => {
25
+ clearBinaryCache();
26
+ const home = makeHome();
27
+ const pathDir = tempDir();
28
+ const fromPath = makeExecutable(join(pathDir, "ccline"));
29
+ const fromHome = makeExecutable(join(home, ".claude", "ccline", "ccline"));
30
+ const fromConfig = makeExecutable(join(tempDir(), "custom-ccline"));
31
+
32
+ assert.deepEqual(resolveBinary(fromConfig, { home, pathEnv: pathDir }), { path: fromConfig, source: "config" });
33
+ assert.deepEqual(resolveBinary(null, { home, pathEnv: pathDir }), { path: fromHome, source: "home" });
34
+
35
+ // Same home without the home binary falls through to PATH.
36
+ const emptyHome = makeHome();
37
+ assert.deepEqual(resolveBinary(null, { home: emptyHome, pathEnv: pathDir }), { path: fromPath, source: "path" });
38
+ });
39
+
40
+ test("resolveBinary reports missing instead of throwing", () => {
41
+ clearBinaryCache();
42
+ const result = resolveBinary(null, { home: makeHome(), pathEnv: tempDir() });
43
+ assert.deepEqual(result, { path: null, source: "missing" });
44
+ });
45
+
46
+ test("an unusable config.binary falls through to the default order", () => {
47
+ clearBinaryCache();
48
+ const home = makeHome();
49
+ const fromHome = makeExecutable(join(home, ".claude", "ccline", "ccline"));
50
+ assert.deepEqual(resolveBinary("/does/not/exist", { home, pathEnv: "" }), { path: fromHome, source: "home" });
51
+ });
52
+
53
+ test("a non-executable file is not accepted", () => {
54
+ const path = join(tempDir(), "plain.txt");
55
+ writeFileSync(path, "x", "utf8");
56
+ chmodSync(path, 0o644);
57
+ assert.equal(isExecutableFile(path), false);
58
+ });
59
+
60
+ test("a directory is not accepted", () => {
61
+ assert.equal(isExecutableFile(tempDir()), false);
62
+ });
63
+
64
+ test("spawnCcline passes COLUMNS and returns stdout", async () => {
65
+ const result = await spawnCcline({
66
+ binary: process.execPath,
67
+ args: ["-e", "process.stdout.write(`cols=${process.env.COLUMNS}|extra=${process.env.PI_CCLINE_TEST}`)"],
68
+ payload: "",
69
+ timeoutMs: 5000,
70
+ columns: 40,
71
+ env: { PI_CCLINE_TEST: "yes" },
72
+ });
73
+ assert.equal(result.ok, true);
74
+ assert.equal(result.stdout, "cols=40|extra=yes");
75
+ assert.equal(result.code, 0);
76
+ });
77
+
78
+ test("spawnCcline reports a non-zero exit with stderr", async () => {
79
+ const result = await spawnCcline({
80
+ binary: process.execPath,
81
+ args: ["-e", "process.stderr.write('nope'); process.exit(3)"],
82
+ payload: "",
83
+ timeoutMs: 5000,
84
+ });
85
+ assert.equal(result.ok, false);
86
+ assert.equal(result.code, 3);
87
+ assert.equal(result.stderr, "nope");
88
+ assert.equal(result.timedOut, false);
89
+ });
90
+
91
+ test("spawnCcline kills a hung child at the timeout", async () => {
92
+ const started = Date.now();
93
+ const result = await spawnCcline({
94
+ binary: process.execPath,
95
+ args: ["-e", "setTimeout(() => {}, 30000)"],
96
+ payload: "",
97
+ timeoutMs: 300,
98
+ });
99
+ assert.equal(result.timedOut, true);
100
+ assert.equal(result.ok, false);
101
+ assert.ok(Date.now() - started < 10_000);
102
+ });
103
+
104
+ test("spawnCcline survives a child that exits before reading stdin", async () => {
105
+ const result = await spawnCcline({
106
+ binary: process.execPath,
107
+ args: ["-e", "process.exit(0)"],
108
+ payload: "x".repeat(4 * 1024 * 1024),
109
+ timeoutMs: 5000,
110
+ });
111
+ assert.equal(result.ok, true);
112
+ assert.equal(result.timedOut, false);
113
+ });
114
+
115
+ test("spawnCcline resolves an error for a missing binary", async () => {
116
+ const result = await spawnCcline({ binary: join(tempDir(), "nope"), payload: "", timeoutMs: 2000 });
117
+ assert.equal(result.ok, false);
118
+ assert.equal(typeof result.error, "string");
119
+ });
120
+
121
+ test("spawnCcline honors an already aborted signal", async () => {
122
+ const controller = new AbortController();
123
+ controller.abort();
124
+ const result = await spawnCcline({
125
+ binary: process.execPath,
126
+ args: ["-e", "setTimeout(() => {}, 5000)"],
127
+ payload: "",
128
+ timeoutMs: 5000,
129
+ signal: controller.signal,
130
+ });
131
+ assert.equal(result.aborted, true);
132
+ });
133
+
134
+ test("spawnCcline aborts a running child", async () => {
135
+ const controller = new AbortController();
136
+ const promise = spawnCcline({
137
+ binary: process.execPath,
138
+ args: ["-e", "setTimeout(() => {}, 30000)"],
139
+ payload: "",
140
+ timeoutMs: 20000,
141
+ signal: controller.signal,
142
+ });
143
+ setTimeout(() => controller.abort(), 100);
144
+ const result = await promise;
145
+ assert.equal(result.aborted, true);
146
+ });
147
+
148
+ test("probeVersion reads --version output", async () => {
149
+ const binary = join(tempDir(), "fake-ccline");
150
+ writeFileSync(binary, "#!/bin/sh\necho 'ccline 2.0.1'\n", "utf8");
151
+ chmodSync(binary, 0o755);
152
+ assert.equal(await probeVersion(binary, 3000), "ccline 2.0.1");
153
+ assert.equal(await probeVersion(join(tempDir(), "missing"), 1000), null);
154
+ });
@@ -0,0 +1,228 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { delimiter, join, resolve } from "node:path";
5
+
6
+ export type BinarySource = "config" | "home" | "path" | "missing";
7
+
8
+ export interface ResolvedBinary {
9
+ path: string | null;
10
+ source: BinarySource;
11
+ }
12
+
13
+ export interface ResolveOptions {
14
+ home?: string;
15
+ pathEnv?: string;
16
+ platform?: string;
17
+ }
18
+
19
+ /** Cap on captured output per stream; the footer only needs a couple of lines. */
20
+ const MAX_STREAM_BYTES = 256 * 1024;
21
+
22
+ export function isExecutableFile(path: string): boolean {
23
+ try {
24
+ const stats = statSync(path);
25
+ if (!stats.isFile()) return false;
26
+ if (process.platform === "win32") return true;
27
+ accessSync(path, constants.X_OK);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function expandHome(path: string, home: string): string {
35
+ if (path === "~") return home;
36
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
37
+ return path;
38
+ }
39
+
40
+ let cached: { key: string; result: ResolvedBinary } | null = null;
41
+
42
+ /** Drop the resolution cache, e.g. after the config or PATH changed. */
43
+ export function clearBinaryCache(): void {
44
+ cached = null;
45
+ }
46
+
47
+ /**
48
+ * Resolve the ccline binary in the documented order:
49
+ * config.binary -> ~/.claude/ccline/ccline -> PATH.
50
+ * A configured path that does not exist falls through instead of failing.
51
+ */
52
+ export function resolveBinary(configured: string | null | undefined, options: ResolveOptions = {}): ResolvedBinary {
53
+ const home = options.home ?? homedir();
54
+ const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
55
+ const platform = options.platform ?? process.platform;
56
+ const key = [configured ?? "", home, pathEnv, platform].join("\u0000");
57
+
58
+ if (cached && cached.key === key) return cached.result;
59
+
60
+ const result = resolveBinaryUncached(configured, home, pathEnv, platform);
61
+ cached = { key, result };
62
+ return result;
63
+ }
64
+
65
+ function resolveBinaryUncached(configured: string | null | undefined, home: string, pathEnv: string, platform: string): ResolvedBinary {
66
+ const configuredPath = typeof configured === "string" ? configured.trim() : "";
67
+ if (configuredPath) {
68
+ const candidate = expandHome(configuredPath, home);
69
+ if (isExecutableFile(candidate)) return { path: candidate, source: "config" };
70
+ }
71
+
72
+ const homeCandidate = join(home, ".claude", "ccline", "ccline");
73
+ if (isExecutableFile(homeCandidate)) return { path: homeCandidate, source: "home" };
74
+
75
+ const names = platform === "win32" ? ["ccline.exe", "ccline.cmd", "ccline"] : ["ccline"];
76
+ for (const directory of pathEnv.split(delimiter)) {
77
+ const trimmedDirectory = directory.trim();
78
+ if (!trimmedDirectory) continue;
79
+ for (const name of names) {
80
+ const candidate = resolve(trimmedDirectory, name);
81
+ if (isExecutableFile(candidate)) return { path: candidate, source: "path" };
82
+ }
83
+ }
84
+
85
+ return { path: null, source: "missing" };
86
+ }
87
+
88
+ export interface SpawnOptions {
89
+ binary: string;
90
+ args?: string[];
91
+ /** Written to stdin, then stdin is closed. */
92
+ payload?: string;
93
+ timeoutMs: number;
94
+ /** Exported to the child as COLUMNS, which drives ccline's line wrapping. */
95
+ columns?: number;
96
+ env?: Record<string, string>;
97
+ signal?: AbortSignal;
98
+ }
99
+
100
+ export interface SpawnResult {
101
+ ok: boolean;
102
+ stdout: string;
103
+ stderr: string;
104
+ code: number | null;
105
+ timedOut: boolean;
106
+ aborted: boolean;
107
+ error?: string;
108
+ }
109
+
110
+ function emptyResult(overrides: Partial<SpawnResult> = {}): SpawnResult {
111
+ return { ok: false, stdout: "", stderr: "", code: null, timedOut: false, aborted: false, ...overrides };
112
+ }
113
+
114
+ /**
115
+ * Run the binary once. Never rejects: timeouts, spawn failures, early exits and
116
+ * aborts all resolve to a result the caller can degrade from.
117
+ */
118
+ export function spawnCcline(options: SpawnOptions): Promise<SpawnResult> {
119
+ return new Promise<SpawnResult>((resolvePromise) => {
120
+ const stdoutChunks: Buffer[] = [];
121
+ const stderrChunks: Buffer[] = [];
122
+ let stdoutBytes = 0;
123
+ let stderrBytes = 0;
124
+ let timedOut = false;
125
+ let settled = false;
126
+ let timer: ReturnType<typeof setTimeout> | undefined;
127
+ let child: ReturnType<typeof spawn> | undefined;
128
+
129
+ const onAbort = () => {
130
+ if (settled) return;
131
+ try {
132
+ child?.kill("SIGKILL");
133
+ } catch {
134
+ // already gone
135
+ }
136
+ finish(emptyResult({ aborted: true, stdout: collect(stdoutChunks), stderr: collect(stderrChunks) }));
137
+ };
138
+
139
+ const finish = (result: SpawnResult) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ if (timer) clearTimeout(timer);
143
+ options.signal?.removeEventListener("abort", onAbort);
144
+ try {
145
+ child?.stdin?.destroy();
146
+ child?.stdout?.destroy();
147
+ child?.stderr?.destroy();
148
+ } catch {
149
+ // ignore teardown errors
150
+ }
151
+ resolvePromise(result);
152
+ };
153
+
154
+ const collect = (chunks: Buffer[]): string => Buffer.concat(chunks).toString("utf8");
155
+
156
+ if (options.signal?.aborted) {
157
+ resolvePromise(emptyResult({ aborted: true }));
158
+ return;
159
+ }
160
+
161
+ const env: NodeJS.ProcessEnv = { ...process.env };
162
+ if (options.columns !== undefined && Number.isFinite(options.columns)) {
163
+ env.COLUMNS = String(Math.max(1, Math.round(options.columns)));
164
+ }
165
+ Object.assign(env, options.env ?? {});
166
+
167
+ try {
168
+ child = spawn(options.binary, options.args ?? [], { env, stdio: ["pipe", "pipe", "pipe"] });
169
+ } catch (error) {
170
+ finish(emptyResult({ error: error instanceof Error ? error.message : String(error) }));
171
+ return;
172
+ }
173
+
174
+ timer = setTimeout(() => {
175
+ timedOut = true;
176
+ try {
177
+ child?.kill("SIGKILL");
178
+ } catch {
179
+ // already gone
180
+ }
181
+ finish(emptyResult({ timedOut: true, stdout: collect(stdoutChunks), stderr: collect(stderrChunks) }));
182
+ }, Math.max(1, options.timeoutMs));
183
+
184
+ options.signal?.addEventListener("abort", onAbort, { once: true });
185
+
186
+ child.on("error", (error) => {
187
+ finish(emptyResult({ error: error.message, stdout: collect(stdoutChunks), stderr: collect(stderrChunks) }));
188
+ });
189
+
190
+ child.stdout?.on("data", (chunk: Buffer) => {
191
+ stdoutBytes += chunk.length;
192
+ if (stdoutBytes <= MAX_STREAM_BYTES) stdoutChunks.push(chunk);
193
+ });
194
+ child.stderr?.on("data", (chunk: Buffer) => {
195
+ stderrBytes += chunk.length;
196
+ if (stderrBytes <= MAX_STREAM_BYTES) stderrChunks.push(chunk);
197
+ });
198
+
199
+ // The child may exit before reading stdin; a failed write is expected then.
200
+ child.stdin?.on("error", () => {});
201
+ try {
202
+ if (options.payload !== undefined) child.stdin?.write(options.payload);
203
+ child.stdin?.end();
204
+ } catch {
205
+ // ignore: the close handler still settles the promise
206
+ }
207
+
208
+ child.on("close", (code) => {
209
+ finish(
210
+ emptyResult({
211
+ ok: code === 0 && !timedOut,
212
+ stdout: collect(stdoutChunks),
213
+ stderr: collect(stderrChunks),
214
+ code,
215
+ timedOut,
216
+ }),
217
+ );
218
+ });
219
+ });
220
+ }
221
+
222
+ /** Read `ccline --version`. Returns null when the binary cannot be probed. */
223
+ export async function probeVersion(binary: string, timeoutMs = 2000, signal?: AbortSignal): Promise<string | null> {
224
+ const result = await spawnCcline({ binary, args: ["--version"], timeoutMs, signal });
225
+ if (!result.ok) return null;
226
+ const line = result.stdout.split("\n")[0]?.trim();
227
+ return line ? line : null;
228
+ }
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+
7
+ import { DEFAULT_CONFIG, MIN_LIVE_REFRESH_MS, MIN_THROTTLE_MS, configPath, loadConfig, normalizeConfig, saveConfig } from "./config.ts";
8
+
9
+ function tempDir(): string {
10
+ return mkdtempSync(join(tmpdir(), "pi-ccline-config-"));
11
+ }
12
+
13
+ test("uses defaults when the config file is missing", () => {
14
+ assert.deepEqual(loadConfig(join(tempDir(), "config.json")), DEFAULT_CONFIG);
15
+ });
16
+
17
+ test("falls back to defaults on broken JSON instead of throwing", () => {
18
+ const path = join(tempDir(), "config.json");
19
+ writeFileSync(path, "{ not json", "utf8");
20
+ assert.deepEqual(loadConfig(path), DEFAULT_CONFIG);
21
+ });
22
+
23
+ test("normalizes wrong types and clamps the throttle to one second", () => {
24
+ const config = normalizeConfig({
25
+ enabled: "yes",
26
+ binary: " ",
27
+ timeoutMs: 10,
28
+ refreshThrottleMs: 5,
29
+ liveRefreshMs: 10,
30
+ env: { A: "1", B: 2 },
31
+ });
32
+ assert.equal(config.enabled, true);
33
+ assert.equal(config.binary, null);
34
+ assert.equal(config.timeoutMs, 200);
35
+ assert.equal(config.refreshThrottleMs, MIN_THROTTLE_MS);
36
+ assert.equal(config.liveRefreshMs, MIN_LIVE_REFRESH_MS);
37
+ assert.deepEqual(config.env, { A: "1" });
38
+ });
39
+
40
+ test("liveRefreshMs 0 disables ticker and bad values fall back to the default", () => {
41
+ assert.equal(normalizeConfig({ liveRefreshMs: 0 }).liveRefreshMs, 0);
42
+ assert.equal(normalizeConfig({ liveRefreshMs: "fast" }).liveRefreshMs, DEFAULT_CONFIG.liveRefreshMs);
43
+ assert.equal(normalizeConfig({ liveRefreshMs: 2500 }).liveRefreshMs, 2500);
44
+ });
45
+
46
+ test("keeps valid overrides", () => {
47
+ const config = normalizeConfig({ enabled: false, binary: "/tmp/ccline", timeoutMs: 9000, refreshThrottleMs: 2500 });
48
+ assert.deepEqual(config, {
49
+ enabled: false,
50
+ binary: "/tmp/ccline",
51
+ timeoutMs: 9000,
52
+ refreshThrottleMs: 2500,
53
+ liveRefreshMs: DEFAULT_CONFIG.liveRefreshMs,
54
+ showProvider: DEFAULT_CONFIG.showProvider,
55
+ env: {},
56
+ });
57
+ });
58
+
59
+ test("showProvider defaults on and ignores non-boolean values", () => {
60
+ assert.equal(DEFAULT_CONFIG.showProvider, true);
61
+ assert.equal(normalizeConfig({}).showProvider, true);
62
+ assert.equal(normalizeConfig({ showProvider: false }).showProvider, false);
63
+ assert.equal(normalizeConfig({ showProvider: "no" }).showProvider, true);
64
+ });
65
+
66
+ test("round-trips through saveConfig and loadConfig", () => {
67
+ const home = tempDir();
68
+ const config = { ...DEFAULT_CONFIG, enabled: false, binary: "/opt/ccline", env: { FOO: "bar" } };
69
+ assert.equal(saveConfig(config, configPath(home)), true);
70
+ assert.deepEqual(loadConfig(configPath(home)), config);
71
+ });
72
+
73
+ test("saveConfig reports failure instead of throwing", () => {
74
+ const home = tempDir();
75
+ const path = join(home, "file-as-dir", "config.json");
76
+ writeFileSync(join(home, "file-as-dir"), "blocked", "utf8");
77
+ assert.equal(saveConfig(DEFAULT_CONFIG, path), false);
78
+ });