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/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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }