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/js/session.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// What the Ctrl+P panel reads about the running CC session: cost and name
|
|
2
|
+
// from the transcript JSONL, model / effort / permission mode off the
|
|
3
|
+
// screen, Bash output blocks for the hide toggle. Pure functions.
|
|
4
|
+
|
|
5
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
// $ per million tokens. From the Claude API reference bundled with Claude
|
|
8
|
+
// Code (model table cached 2026-10-06). cacheRead: models whose cache reads
|
|
9
|
+
// aren't the usual 0.1x of input. Cache writes: 1.25x input (5-minute TTL),
|
|
10
|
+
// 2x (1-hour). Haiku 5.5 is billed higher above 100K-token prompts; the
|
|
11
|
+
// base rate is used here.
|
|
12
|
+
const prices = {
|
|
13
|
+
'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25 },
|
|
14
|
+
'claude-mythos-5-1': { input: 10, output: 50, cacheRead: 0.25 },
|
|
15
|
+
'claude-fable-5': { input: 10, output: 50, cacheRead: 1 },
|
|
16
|
+
'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, fast: { input: 8, output: 40 } },
|
|
17
|
+
'claude-opus-5': { input: 5, output: 25, fast: { input: 10, output: 50 } },
|
|
18
|
+
'claude-opus-4-8': { input: 5, output: 25 },
|
|
19
|
+
'claude-opus-4-7': { input: 5, output: 25 },
|
|
20
|
+
'claude-opus-4-6': { input: 5, output: 25 },
|
|
21
|
+
'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.2 },
|
|
22
|
+
'claude-sonnet-5': { input: 2, output: 10 },
|
|
23
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
24
|
+
'claude-haiku-5-5': { input: 0.1, output: 0.5 },
|
|
25
|
+
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "a/anthropic/claude-opus-5.5[1m]" → "claude-opus-5-5" (gateway prefixes, dots, context tags). */
|
|
29
|
+
export function normalizeModel(m) {
|
|
30
|
+
return String(m ?? '')
|
|
31
|
+
.split('/')
|
|
32
|
+
.pop()
|
|
33
|
+
.replace(/\[.*\]$/, '')
|
|
34
|
+
.replace(/\./g, '-')
|
|
35
|
+
.replace(/-\d{8}$/, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Cost in $ of one assistant message's usage on `model`, or null if unpriced. */
|
|
39
|
+
export function usageCost(model, u) {
|
|
40
|
+
const p = prices[normalizeModel(model)]
|
|
41
|
+
if (!p || !u) return null
|
|
42
|
+
const rate = u.speed === 'fast' && p.fast ? { ...p, ...p.fast } : p
|
|
43
|
+
const w5 = u.cache_creation?.ephemeral_5m_input_tokens ?? u.cache_creation_input_tokens ?? 0
|
|
44
|
+
const w1 = u.cache_creation?.ephemeral_1h_input_tokens ?? 0
|
|
45
|
+
const read = p.cacheRead ?? rate.input * 0.1
|
|
46
|
+
return (
|
|
47
|
+
((u.input_tokens ?? 0) * rate.input +
|
|
48
|
+
(u.output_tokens ?? 0) * rate.output +
|
|
49
|
+
w5 * rate.input * 1.25 +
|
|
50
|
+
w1 * rate.input * 2 +
|
|
51
|
+
(u.cache_read_input_tokens ?? 0) * read) /
|
|
52
|
+
1e6
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Entries of a JSONL text; lines that don't parse are skipped. */
|
|
57
|
+
export function parseEntries(text) {
|
|
58
|
+
const out = []
|
|
59
|
+
for (const line of text.split('\n')) {
|
|
60
|
+
if (!line.trim()) continue
|
|
61
|
+
try {
|
|
62
|
+
out.push(JSON.parse(line))
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An entry's cost the first time its assistant message shows up, else
|
|
70
|
+
* undefined. A message is logged once per content block with the same
|
|
71
|
+
* usage, so `seen` (message ids) makes it count once. null: unpriced model.
|
|
72
|
+
*/
|
|
73
|
+
export function messageCost(d, seen) {
|
|
74
|
+
const m = d.message
|
|
75
|
+
if (d.type !== 'assistant' || !m?.usage) return undefined
|
|
76
|
+
const id = m.id ?? d.uuid
|
|
77
|
+
if (seen.has(id)) return undefined
|
|
78
|
+
seen.add(id)
|
|
79
|
+
return usageCost(m.model, m.usage)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Summarize a transcript (JSONL text): total cost, session name, last model. */
|
|
83
|
+
export function summarizeTranscript(text) {
|
|
84
|
+
const seen = new Set()
|
|
85
|
+
let cost = 0
|
|
86
|
+
let unpriced = 0
|
|
87
|
+
let title = null
|
|
88
|
+
let aiTitle = null
|
|
89
|
+
let model = null
|
|
90
|
+
for (const d of parseEntries(text)) {
|
|
91
|
+
if (d.type === 'custom-title' && d.customTitle) title = d.customTitle
|
|
92
|
+
if (d.type === 'ai-title' && d.aiTitle) aiTitle = d.aiTitle
|
|
93
|
+
if (d.type === 'assistant' && d.message?.usage && d.message.model) model = d.message.model
|
|
94
|
+
const c = messageCost(d, seen)
|
|
95
|
+
if (c === null) unpriced++
|
|
96
|
+
else if (c !== undefined) cost += c
|
|
97
|
+
}
|
|
98
|
+
return { cost, unpriced, name: title ?? aiTitle, model }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Follow a growing JSONL file: each call returns the entries appended since
|
|
103
|
+
* the last one (a partial last line waits for its newline). Starts over
|
|
104
|
+
* when the path changes or the file shrinks.
|
|
105
|
+
*/
|
|
106
|
+
export function tailEntries(read = readFrom) {
|
|
107
|
+
let path = null
|
|
108
|
+
let offset = 0
|
|
109
|
+
let partial = Buffer.alloc(0)
|
|
110
|
+
return p => {
|
|
111
|
+
if (p !== path) [path, offset, partial] = [p, 0, Buffer.alloc(0)]
|
|
112
|
+
if (!p) return []
|
|
113
|
+
const r = read(p, offset)
|
|
114
|
+
if (!r) return []
|
|
115
|
+
if (r.from < offset) partial = Buffer.alloc(0)
|
|
116
|
+
offset = r.from + r.buf.length
|
|
117
|
+
const buf = Buffer.concat([partial, r.buf])
|
|
118
|
+
const nl = buf.lastIndexOf(10)
|
|
119
|
+
partial = buf.subarray(nl + 1)
|
|
120
|
+
return nl < 0 ? [] : parseEntries(buf.subarray(0, nl + 1).toString('utf8'))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Bytes of `path` from `offset` on (from 0 if it shrank): { buf, from }. */
|
|
125
|
+
function readFrom(path, offset) {
|
|
126
|
+
try {
|
|
127
|
+
const size = statSync(path).size
|
|
128
|
+
const from = size < offset ? 0 : offset
|
|
129
|
+
const buf = Buffer.alloc(size - from)
|
|
130
|
+
if (buf.length) {
|
|
131
|
+
const fd = openSync(path, 'r')
|
|
132
|
+
try {
|
|
133
|
+
readSync(fd, buf, 0, buf.length, from)
|
|
134
|
+
} finally {
|
|
135
|
+
closeSync(fd)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { buf, from }
|
|
139
|
+
} catch {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- reading CC's status line ----
|
|
145
|
+
|
|
146
|
+
export const efforts = ['low', 'medium', 'high', 'xhigh', 'max']
|
|
147
|
+
export const modes = ['plan', 'acc', 'auto']
|
|
148
|
+
export const models = ['haiku', 'sonnet', 'opus', 'fable']
|
|
149
|
+
|
|
150
|
+
/** "◐ medium · /effort" → "medium". */
|
|
151
|
+
export function effortOf(text) {
|
|
152
|
+
return text.match(/\b(low|medium|high|xhigh|max)\s*·\s*\/effort/)?.[1] ?? null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Permission mode from the status line: plan / norm (manual) / auto / acc (accept edits). */
|
|
156
|
+
export function modeOf(text) {
|
|
157
|
+
if (/plan mode on/.test(text)) return 'plan'
|
|
158
|
+
if (/auto mode on/.test(text)) return 'auto'
|
|
159
|
+
if (/accept edits on/.test(text)) return 'acc'
|
|
160
|
+
if (/manual mode on/.test(text)) return 'norm'
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Which of `models` a model id / display name is. */
|
|
165
|
+
export function modelFamily(m) {
|
|
166
|
+
const s = String(m ?? '').toLowerCase()
|
|
167
|
+
return models.find(f => s.includes(f)) ?? null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rows of Bash tool output: after a "● Bash(…)" header, the "⎿" row and
|
|
172
|
+
* the indented continuation rows below it.
|
|
173
|
+
*/
|
|
174
|
+
export function bashOutputRows(rows) {
|
|
175
|
+
const out = new Set()
|
|
176
|
+
let inBlock = false
|
|
177
|
+
rows.forEach((r, i) => {
|
|
178
|
+
if (/^[●⏺] Bash\(/.test(r)) {
|
|
179
|
+
inBlock = true
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (inBlock && (/^\s{2}⎿/.test(r) || /^\s{5}\S/.test(r))) out.add(i)
|
|
183
|
+
else inBlock = false
|
|
184
|
+
})
|
|
185
|
+
return out
|
|
186
|
+
}
|
package/js/stubs.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// pi.command names must also show up in CC's `/` autocomplete. CC discovers
|
|
2
|
+
// command files in the project / user `.claude/commands` dirs (which we must
|
|
3
|
+
// not write to) or in a plugin passed with `--plugin-dir`. Plugin commands
|
|
4
|
+
// are namespaced (`cldp-pi:ad`), but a frontmatter `name:` registers the bare
|
|
5
|
+
// name as an alias, so typing `/ad` matches `cldp-pi:ad (ad)` in the menu.
|
|
6
|
+
// The plugin dir lives in claude-p's per-run temp dir and is filled before
|
|
7
|
+
// claude starts (src/pi/main.zig waits for host.js's `ready`). The wrapper
|
|
8
|
+
// intercepts the line, so a stub body never runs.
|
|
9
|
+
import { mkdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** A plugin command stub: bare alias, with a description CC shows as empty. */
|
|
13
|
+
export const stub = name => `---\nname: ${name}\ndescription: ""\n---\n`
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sync CC command stubs to `names` in the plugin dir `dir`. Returns a
|
|
17
|
+
* function; `.cleanup()` removes every file it made. Without a dir (no
|
|
18
|
+
* sidecar plugin), it does nothing.
|
|
19
|
+
*/
|
|
20
|
+
export function commandStubs(dir, log = () => {}) {
|
|
21
|
+
const ours = new Map() // name -> path
|
|
22
|
+
if (!dir) {
|
|
23
|
+
const noop = () => {}
|
|
24
|
+
noop.cleanup = () => {}
|
|
25
|
+
return noop
|
|
26
|
+
}
|
|
27
|
+
const commands = join(dir, 'commands')
|
|
28
|
+
const want = names => {
|
|
29
|
+
const keep = new Set(names.filter(n => /^[A-Za-z0-9_-]{1,64}$/.test(n)))
|
|
30
|
+
for (const [name, path] of ours) {
|
|
31
|
+
if (keep.has(name)) continue
|
|
32
|
+
try {
|
|
33
|
+
rmSync(path)
|
|
34
|
+
} catch {}
|
|
35
|
+
ours.delete(name)
|
|
36
|
+
}
|
|
37
|
+
for (const name of keep) {
|
|
38
|
+
if (ours.has(name)) continue
|
|
39
|
+
const path = join(commands, name + '.md')
|
|
40
|
+
let old = null
|
|
41
|
+
try {
|
|
42
|
+
old = readFileSync(path, 'utf8')
|
|
43
|
+
} catch {}
|
|
44
|
+
if (old != null && old !== stub(name)) continue // someone else's file
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(commands, { recursive: true })
|
|
47
|
+
if (old == null) writeFileSync(path, stub(name))
|
|
48
|
+
ours.set(name, path)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
log('command stub', name, e)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Once every stub is gone, take the (now empty) dir with it.
|
|
54
|
+
if (!ours.size) {
|
|
55
|
+
try {
|
|
56
|
+
rmdirSync(commands) // only succeeds while empty
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
want.cleanup = () => {
|
|
61
|
+
for (const path of ours.values()) {
|
|
62
|
+
try {
|
|
63
|
+
rmSync(path)
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
ours.clear()
|
|
67
|
+
try {
|
|
68
|
+
rmdirSync(commands) // never take anyone else's files with it
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
return want
|
|
72
|
+
}
|
package/js/tab.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Tab titles for terminals that name tabs themselves (pi.tui.title):
|
|
2
|
+
// Yakuake, Konsole and Guake over D-Bus. Others use the window title (OSC 2).
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
const run = (cmd, args) =>
|
|
6
|
+
new Promise(resolve => execFile(cmd, args, { timeout: 2000 }, (err, out) => resolve(err ? null : String(out).trim())))
|
|
7
|
+
|
|
8
|
+
const gdbus = (dest, path, method, ...args) =>
|
|
9
|
+
run('gdbus', ['call', '--session', '--dest', dest, '--object-path', path, '--method', method, ...args])
|
|
10
|
+
|
|
11
|
+
// gdbus prints GVariant tuples: ('text',) or (3,)
|
|
12
|
+
const unwrap = r => r?.match(/^\((?:'((?:[^'\\]|\\.)*)'|(-?\d+)),?\)$/s)?.slice(1).find(x => x != null)
|
|
13
|
+
const gstr = s => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
14
|
+
|
|
15
|
+
async function qdbusBin() {
|
|
16
|
+
for (const b of ['qdbus6', 'qdbus']) if ((await run(b, ['--version'])) != null) return b
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Yakuake: Konsole session /Sessions/N is Yakuake terminal N-1 (Konsole
|
|
21
|
+
// counts sessions from 1, Yakuake terminals from 0, one each per terminal).
|
|
22
|
+
async function yakuake(env, q) {
|
|
23
|
+
const owner = unwrap(await gdbus('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus.GetNameOwner', 'org.kde.yakuake'))
|
|
24
|
+
if (!owner || owner !== env.KONSOLE_DBUS_SERVICE) return null
|
|
25
|
+
const n = Number(env.KONSOLE_DBUS_SESSION?.split('/').pop())
|
|
26
|
+
if (!n) return null
|
|
27
|
+
const id = await run(q, ['org.kde.yakuake', '/yakuake/sessions', 'org.kde.yakuake.sessionIdForTerminalId', String(n - 1)])
|
|
28
|
+
if (id == null || Number(id) < 0) return null
|
|
29
|
+
return {
|
|
30
|
+
get: () => run(q, ['org.kde.yakuake', '/yakuake/tabs', 'org.kde.yakuake.tabTitle', id]),
|
|
31
|
+
set: t => run(q, ['org.kde.yakuake', '/yakuake/tabs', 'org.kde.yakuake.setTabTitle', id, t]),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Konsole: the tab shows the session's displayed title (role 1).
|
|
36
|
+
function konsole(env, q) {
|
|
37
|
+
const call = (...a) => run(q, [env.KONSOLE_DBUS_SERVICE, env.KONSOLE_DBUS_SESSION, ...a])
|
|
38
|
+
return {
|
|
39
|
+
get: () => call('org.kde.konsole.Session.title', '1'),
|
|
40
|
+
set: t => call('org.kde.konsole.Session.setTitle', '1', t),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Guake: every tab's shell has GUAKE_TAB_UUID.
|
|
45
|
+
function guake(env) {
|
|
46
|
+
const call = (m, ...a) => gdbus('org.guake3.RemoteControl', '/org/guake3/RemoteControl', `org.guake3.RemoteControl.${m}`, ...a)
|
|
47
|
+
const uuid = gstr(env.GUAKE_TAB_UUID)
|
|
48
|
+
return {
|
|
49
|
+
get: async () => {
|
|
50
|
+
const i = unwrap(await call('get_index_from_uuid', uuid))
|
|
51
|
+
return i == null ? null : unwrap(await call('get_tab_name', i))
|
|
52
|
+
},
|
|
53
|
+
set: t => call('rename_tab_uuid', uuid, gstr(t)),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The tab-title backend for this terminal ({ get, set }), or null. */
|
|
58
|
+
export async function pickTab(env = process.env) {
|
|
59
|
+
if (env.GUAKE_TAB_UUID) return guake(env)
|
|
60
|
+
if (env.KONSOLE_DBUS_SERVICE && env.KONSOLE_DBUS_SESSION) {
|
|
61
|
+
const q = await qdbusBin()
|
|
62
|
+
if (q) return (await yakuake(env, q)) ?? konsole(env, q)
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Open a new tab in a dropdown terminal and run `command` there. Only
|
|
69
|
+
* Yakuake and Guake have a D-Bus call that does this (Konsole's terminals
|
|
70
|
+
* can't add a tab over D-Bus). Returns true once the command was handed
|
|
71
|
+
* over; false means the caller should fall back to "paste this".
|
|
72
|
+
*/
|
|
73
|
+
export async function openTab(command, env = process.env) {
|
|
74
|
+
if (env.GUAKE_TAB_UUID) {
|
|
75
|
+
const call = (m, ...a) => gdbus('org.guake3.RemoteControl', '/org/guake3/RemoteControl', `org.guake3.RemoteControl.${m}`, ...a)
|
|
76
|
+
// execute_command() opens and focuses the new tab itself (guake's
|
|
77
|
+
// dbusiface.py calls guake.add_tab()), so calling add_tab here too
|
|
78
|
+
// would leave an empty extra tab behind.
|
|
79
|
+
return (await call('execute_command', gstr(command))) != null
|
|
80
|
+
}
|
|
81
|
+
if (env.KONSOLE_DBUS_SERVICE && env.KONSOLE_DBUS_SESSION) {
|
|
82
|
+
const q = await qdbusBin()
|
|
83
|
+
if (!q) return false
|
|
84
|
+
// Konsole sets the same env vars; only act when the owner is Yakuake.
|
|
85
|
+
const owner = unwrap(await gdbus('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus.GetNameOwner', 'org.kde.yakuake'))
|
|
86
|
+
if (!owner || owner !== env.KONSOLE_DBUS_SERVICE) return false
|
|
87
|
+
// addSession() creates and focuses a tab and returns its session id;
|
|
88
|
+
// runCommandInTerminal() types the line into that session's shell.
|
|
89
|
+
const sid = await run(q, ['org.kde.yakuake', '/yakuake/sessions', 'org.kde.yakuake.addSession'])
|
|
90
|
+
if (sid == null || Number(sid) < 0) return false
|
|
91
|
+
return (await run(q, ['org.kde.yakuake', '/yakuake/sessions', 'org.kde.yakuake.runCommandInTerminal', String(sid), command])) != null
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cld-pi",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "claude-p fork: `claude -p` drop-in plus an interactive TUI shell around Claude Code (fullscreen), with prefix completion, overlays and JS plugins.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"anthropic",
|
|
9
|
+
"cli",
|
|
10
|
+
"tui",
|
|
11
|
+
"zig",
|
|
12
|
+
"pty",
|
|
13
|
+
"zmux",
|
|
14
|
+
"ghostty"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"bin": {
|
|
18
|
+
"cld-pi": "bin/claude-p.js",
|
|
19
|
+
"claude-p": "bin/claude-p.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin/claude-p.js",
|
|
23
|
+
"scripts/install.js",
|
|
24
|
+
"js/",
|
|
25
|
+
"!js/test/",
|
|
26
|
+
"README.md",
|
|
27
|
+
"README.zh-CN.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"postinstall": "node scripts/install.js",
|
|
32
|
+
"test": "zig build test && node --test js/test/*.test.js",
|
|
33
|
+
"build": "zig build -Doptimize=ReleaseSafe",
|
|
34
|
+
"release": "node scripts/publish.mjs",
|
|
35
|
+
"release:dry-run": "node scripts/publish.mjs --dry-run",
|
|
36
|
+
"release:local": "node scripts/publish.mjs --local-only"
|
|
37
|
+
},
|
|
38
|
+
"os": [
|
|
39
|
+
"darwin",
|
|
40
|
+
"linux"
|
|
41
|
+
],
|
|
42
|
+
"cpu": [
|
|
43
|
+
"x64",
|
|
44
|
+
"arm64"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/duangsuse/claude-pi.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/duangsuse/claude-pi/issues"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/duangsuse/claude-pi#readme"
|
|
57
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Download the prebuilt `claude-p` binary for the current platform/arch from
|
|
3
|
+
// a GitHub release and drop it into prebuilt/<plat>-<arch>/.
|
|
4
|
+
//
|
|
5
|
+
// Used both as the npm `postinstall` script and from `bin/claude-p.js`, so
|
|
6
|
+
// `npx cld-pi` still works when npm skips install scripts (npm 11.19+ gates
|
|
7
|
+
// dependency scripts behind its allow-scripts allowlist and npx skips them
|
|
8
|
+
// silently by default).
|
|
9
|
+
//
|
|
10
|
+
// Strategy:
|
|
11
|
+
// 1. If a binary already exists at the expected path (e.g. installed from
|
|
12
|
+
// cache or a previous run), do nothing.
|
|
13
|
+
// 2. Otherwise, try `prebuilt/<plat>-<arch>/claude-p` shipped in the
|
|
14
|
+
// package (publishers can pre-bundle for the most common targets).
|
|
15
|
+
// 3. Failing that, download from
|
|
16
|
+
// https://github.com/<repository>/releases/download/v<version>/claude-p-<plat>-<arch>.gz
|
|
17
|
+
// (repo taken from package.json), gunzip, and chmod +x.
|
|
18
|
+
//
|
|
19
|
+
// Proxy: Node's https client ignores HTTPS_PROXY, so when a proxy is
|
|
20
|
+
// configured (and curl is available) the download goes through curl, which
|
|
21
|
+
// honours the standard proxy variables. A failed direct request also falls
|
|
22
|
+
// back to curl.
|
|
23
|
+
//
|
|
24
|
+
// If `CLAUDE_P_SKIP_DOWNLOAD=1` is set, do nothing (useful for monorepo
|
|
25
|
+
// bootstraps where the binary is provided out-of-band).
|
|
26
|
+
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const os = require("node:os");
|
|
29
|
+
const path = require("node:path");
|
|
30
|
+
const https = require("node:https");
|
|
31
|
+
const zlib = require("node:zlib");
|
|
32
|
+
const { spawnSync } = require("node:child_process");
|
|
33
|
+
|
|
34
|
+
const pkg = require("../package.json");
|
|
35
|
+
|
|
36
|
+
function log(msg) {
|
|
37
|
+
process.stderr.write(`claude-p (install): ${msg}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function platformDir() {
|
|
41
|
+
return `${process.platform}-${process.arch}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function exeName() {
|
|
45
|
+
return process.platform === "win32" ? "claude-p.exe" : "claude-p";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function targetPath() {
|
|
49
|
+
return path.join(__dirname, "..", "prebuilt", platformDir(), exeName());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureDir(p) {
|
|
53
|
+
fs.mkdirSync(p, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function alreadyInstalled() {
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.statSync(targetPath());
|
|
59
|
+
return stat.isFile() && stat.size > 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isSupported() {
|
|
66
|
+
const okPlatform = ["darwin", "linux"].includes(process.platform);
|
|
67
|
+
const okArch = ["x64", "arm64"].includes(process.arch);
|
|
68
|
+
return okPlatform && okArch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function proxyConfigured() {
|
|
72
|
+
return Boolean(
|
|
73
|
+
process.env.HTTPS_PROXY ||
|
|
74
|
+
process.env.https_proxy ||
|
|
75
|
+
process.env.HTTP_PROXY ||
|
|
76
|
+
process.env.http_proxy ||
|
|
77
|
+
process.env.ALL_PROXY ||
|
|
78
|
+
process.env.all_proxy,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let curlChecked = null;
|
|
83
|
+
function hasCurl() {
|
|
84
|
+
if (curlChecked === null) {
|
|
85
|
+
const r = spawnSync("curl", ["--version"], { stdio: "ignore" });
|
|
86
|
+
curlChecked = !r.error && r.status === 0;
|
|
87
|
+
}
|
|
88
|
+
return curlChecked;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getHttps(url) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const req = https.get(url, { timeout: 30_000 }, (res) => {
|
|
94
|
+
if (
|
|
95
|
+
res.statusCode &&
|
|
96
|
+
res.statusCode >= 300 &&
|
|
97
|
+
res.statusCode < 400 &&
|
|
98
|
+
res.headers.location
|
|
99
|
+
) {
|
|
100
|
+
// Follow redirect (GitHub release assets do this).
|
|
101
|
+
res.resume();
|
|
102
|
+
resolve(getHttps(res.headers.location));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (res.statusCode !== 200) {
|
|
106
|
+
res.resume();
|
|
107
|
+
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const chunks = [];
|
|
111
|
+
res.on("data", (c) => chunks.push(c));
|
|
112
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
113
|
+
res.on("error", reject);
|
|
114
|
+
});
|
|
115
|
+
req.on("timeout", () => req.destroy(new Error(`timed out fetching ${url}`)));
|
|
116
|
+
req.on("error", reject);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getCurl(url) {
|
|
121
|
+
const tmp = path.join(os.tmpdir(), `claude-p-${process.pid}-${Date.now()}.gz`);
|
|
122
|
+
try {
|
|
123
|
+
const r = spawnSync(
|
|
124
|
+
"curl",
|
|
125
|
+
[
|
|
126
|
+
"-fsSL",
|
|
127
|
+
"--retry",
|
|
128
|
+
"3",
|
|
129
|
+
"--connect-timeout",
|
|
130
|
+
"20",
|
|
131
|
+
"--max-time",
|
|
132
|
+
"300",
|
|
133
|
+
"-o",
|
|
134
|
+
tmp,
|
|
135
|
+
url,
|
|
136
|
+
],
|
|
137
|
+
{ stdio: ["ignore", "ignore", "inherit"] },
|
|
138
|
+
);
|
|
139
|
+
if (r.error) throw r.error;
|
|
140
|
+
if (r.status !== 0) throw new Error(`curl exited ${r.status} for ${url}`);
|
|
141
|
+
return fs.readFileSync(tmp);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(tmp, { force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function get(url) {
|
|
148
|
+
if (proxyConfigured() && hasCurl()) return getCurl(url);
|
|
149
|
+
try {
|
|
150
|
+
return await getHttps(url);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (hasCurl()) {
|
|
153
|
+
log(`direct download failed (${err.message}); retrying with curl`);
|
|
154
|
+
return getCurl(url);
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// GitHub repo the release assets live on, from package.json `repository`.
|
|
161
|
+
function releaseRepo() {
|
|
162
|
+
const url = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url;
|
|
163
|
+
const m = url && url.match(/github\.com[/:]([^/]+\/[^/.]+)/);
|
|
164
|
+
return m ? m[1] : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function download(say) {
|
|
168
|
+
const repo = releaseRepo();
|
|
169
|
+
if (!repo) throw new Error("package.json has no GitHub `repository`; no release to download from");
|
|
170
|
+
const url = `https://github.com/${repo}/releases/download/v${pkg.version}/claude-p-${platformDir()}.gz`;
|
|
171
|
+
say(`downloading ${url}`);
|
|
172
|
+
const gz = await get(url);
|
|
173
|
+
const bin = zlib.gunzipSync(gz);
|
|
174
|
+
const dest = targetPath();
|
|
175
|
+
ensureDir(path.dirname(dest));
|
|
176
|
+
fs.writeFileSync(dest, bin, { mode: 0o755 });
|
|
177
|
+
say(`installed ${dest} (${bin.length} bytes)`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Returns true when the binary is present afterwards. Never throws.
|
|
181
|
+
async function ensureInstalled({ quiet = false } = {}) {
|
|
182
|
+
const say = quiet ? () => {} : log;
|
|
183
|
+
if (process.env.CLAUDE_P_SKIP_DOWNLOAD === "1") {
|
|
184
|
+
say("CLAUDE_P_SKIP_DOWNLOAD set; skipping");
|
|
185
|
+
return alreadyInstalled();
|
|
186
|
+
}
|
|
187
|
+
if (!isSupported()) {
|
|
188
|
+
say(`platform ${platformDir()} not supported; skipping`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (alreadyInstalled()) {
|
|
192
|
+
say(`already installed at ${targetPath()}`);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await download(say);
|
|
197
|
+
return true;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
say(`download failed: ${err.message}`);
|
|
200
|
+
say("you can build from source with: zig build -Doptimize=ReleaseSafe");
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { ensureInstalled, targetPath, platformDir, isSupported };
|
|
206
|
+
|
|
207
|
+
if (require.main === module) {
|
|
208
|
+
ensureInstalled().catch((err) => {
|
|
209
|
+
log(`unexpected error: ${err.stack || err.message}`);
|
|
210
|
+
// Don't break the npm install; the shim reports the missing binary.
|
|
211
|
+
});
|
|
212
|
+
}
|