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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Ctrl+P: the settings panel. Every control is a button whose label is
|
|
2
|
+
// hot-swapped; a change goes to CC and the label only moves once the new
|
|
3
|
+
// state is read back off the screen (or the settings file).
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { bashOutputRows, effortOf, efforts, modeOf, modes, modelFamily, models, summarizeTranscript } from '../session.js'
|
|
8
|
+
import { clearTrust } from './f12.js'
|
|
9
|
+
|
|
10
|
+
// Permission checkboxes → allow rules in the project's settings.local.json
|
|
11
|
+
// (CC reloads it on change).
|
|
12
|
+
const perms = { file: ['Edit', 'Write'], web: ['WebFetch', 'WebSearch'] }
|
|
13
|
+
|
|
14
|
+
export const settingsPath = (cwd = process.cwd()) => join(cwd, '.claude', 'settings.local.json')
|
|
15
|
+
|
|
16
|
+
export function readSettings(path) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
19
|
+
} catch {
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const isAllowed = (settings, key) => perms[key].every(r => settings.permissions?.allow?.includes(r))
|
|
25
|
+
|
|
26
|
+
/** Add / remove `key`'s allow rules, keeping everything else in the file. */
|
|
27
|
+
export function setAllowed(path, key, on) {
|
|
28
|
+
const s = readSettings(path)
|
|
29
|
+
const allow = new Set(s.permissions?.allow ?? [])
|
|
30
|
+
for (const r of perms[key]) on ? allow.add(r) : allow.delete(r)
|
|
31
|
+
s.permissions = { ...s.permissions, allow: [...allow] }
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
33
|
+
writeFileSync(path, JSON.stringify(s, null, 2) + '\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const radio = (v, cur) => `${v === cur ? '●' : '○'} ${v}`
|
|
37
|
+
export const checkbox = (name, on) => `[${on ? 'x' : ' '}] ${name}`
|
|
38
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
39
|
+
|
|
40
|
+
/** "3h7m": whole hours and minutes since an epoch-seconds time. */
|
|
41
|
+
export const sinceLabel = sec => `${Math.floor(sec / 3600)}h${Math.floor((sec % 3600) / 60)}m`
|
|
42
|
+
|
|
43
|
+
/** ~/.F12-mcp.json: the sites the f12 plugin's addTrust let Chrome reach. */
|
|
44
|
+
export const trustPath = join(homedir(), '.F12-mcp.json')
|
|
45
|
+
|
|
46
|
+
/** The sites pi.ext.f12.addTrust has trusted (~/.F12-mcp.json), as hosts. */
|
|
47
|
+
export function trustedSites(path = trustPath) {
|
|
48
|
+
try {
|
|
49
|
+
const list = JSON.parse(readFileSync(path, 'utf8')).allowedUrlPattern
|
|
50
|
+
if (!Array.isArray(list)) return []
|
|
51
|
+
return list.map(p => {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(String(p)).host
|
|
54
|
+
} catch {
|
|
55
|
+
return String(p)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Files git reports as changed, and how long ago the last commit was. */
|
|
64
|
+
export function gitSummary(status, commitTime, now = Date.now() / 1000) {
|
|
65
|
+
const n = String(status ?? '').split('\n').filter(l => l.trim()).length
|
|
66
|
+
const t = Number(String(commitTime ?? '').trim())
|
|
67
|
+
const age = Number.isFinite(t) && t > 0 ? sinceLabel(Math.max(0, now - t)) : null
|
|
68
|
+
return `${n} changed file${n === 1 ? '' : 's'}${age ? ` · ${age} since last commit` : ''}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// CC's answer to `/model x`: "Set model to `Name`" / "Kept model as `Name`".
|
|
72
|
+
// A gateway (ANTHROPIC_DEFAULT_*_MODEL) may resolve every alias to the same
|
|
73
|
+
// model, so the family picked is not in the text — match the message only.
|
|
74
|
+
const modelSet = text => /Set model to|Kept model as/.test(text)
|
|
75
|
+
const actions = ['CtrlO', 'CtrlR', 'Undo', 'Copy']
|
|
76
|
+
|
|
77
|
+
export default pi => {
|
|
78
|
+
const { tui } = pi
|
|
79
|
+
let hideBash = null // disposer of the sed that blanks Bash output
|
|
80
|
+
let open = false
|
|
81
|
+
|
|
82
|
+
// A fresh frame: waiting subscribes to frames, and Zig dumps one at once.
|
|
83
|
+
const screen = async () => {
|
|
84
|
+
await tui.wait(() => true, 500)
|
|
85
|
+
return tui.state.scr?.text ?? ''
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const bashRows = new WeakMap()
|
|
89
|
+
const hideOutput = () =>
|
|
90
|
+
pi.sed((ln, i, rows) => {
|
|
91
|
+
if (!bashRows.has(rows)) bashRows.set(rows, bashOutputRows(rows))
|
|
92
|
+
return bashRows.get(rows).has(i) ? `\x1b[48;5;236m${' '.repeat(tui.state.cols || 80)}` : null
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
async function show() {
|
|
96
|
+
open = true
|
|
97
|
+
const text = await screen()
|
|
98
|
+
let sum = { cost: 0, unpriced: 0, name: null, model: null }
|
|
99
|
+
try {
|
|
100
|
+
if (tui.state.transcript) sum = summarizeTranscript(readFileSync(tui.state.transcript, 'utf8'))
|
|
101
|
+
} catch {}
|
|
102
|
+
const path = settingsPath()
|
|
103
|
+
const cur = { model: modelFamily(sum.model), effort: effortOf(text), mode: modeOf(text) }
|
|
104
|
+
let file = isAllowed(readSettings(path), 'file')
|
|
105
|
+
let web = isAllowed(readSettings(path), 'web')
|
|
106
|
+
// The permission checkboxes' hints: what git sees right now, and the
|
|
107
|
+
// sites f12's addTrust has let Chrome reach (nothing else).
|
|
108
|
+
const [status, commit] = await Promise.all([
|
|
109
|
+
pi.sh`git status --porcelain 2>/dev/null`,
|
|
110
|
+
pi.sh`git log -1 --format=%ct 2>/dev/null`,
|
|
111
|
+
])
|
|
112
|
+
const fileHint = gitSummary(status.stdout, commit.stdout)
|
|
113
|
+
const webHint = trustedSites().join(' · ')
|
|
114
|
+
|
|
115
|
+
// Rows of buttons; a radio row is relabeled as a whole once CC agrees.
|
|
116
|
+
const rows = { model: models, effort: efforts, mode: modes }
|
|
117
|
+
const offset = { model: 0, effort: models.length, mode: models.length + efforts.length }
|
|
118
|
+
// file / web / bash sit right after the radio rows.
|
|
119
|
+
const checkboxBase = models.length + efforts.length + modes.length
|
|
120
|
+
let dlg
|
|
121
|
+
const pick = (row, v) => {
|
|
122
|
+
cur[row] = v
|
|
123
|
+
rows[row].forEach((x, i) => dlg.relabel(offset[row] + i, radio(x, v)))
|
|
124
|
+
return radio(v, v)
|
|
125
|
+
}
|
|
126
|
+
const radios = (row, apply) =>
|
|
127
|
+
Object.fromEntries(rows[row].map(v => [radio(v, cur[row]), async () => (v === cur[row] ? radio(v, v) : apply(v))]))
|
|
128
|
+
const radioHints = (row, hint) => Object.fromEntries(rows[row].map(v => [radio(v, cur[row]), hint(v)]))
|
|
129
|
+
|
|
130
|
+
const setModel = async to => {
|
|
131
|
+
const before = await screen()
|
|
132
|
+
await pi`/model ${to}`
|
|
133
|
+
// The frame has to change too: an older "Set model to" line may still
|
|
134
|
+
// be on screen from a previous switch.
|
|
135
|
+
if (await tui.wait(s => s.text !== before && modelSet(s.text), 3000)) return pick('model', to)
|
|
136
|
+
return radio(to, cur.model)
|
|
137
|
+
}
|
|
138
|
+
const setEffort = async to => {
|
|
139
|
+
await pi`/effort ${to}`
|
|
140
|
+
let ok = await tui.wait(s => effortOf(s.text) === to, 2000)
|
|
141
|
+
// No argument support: CC opened its slider instead; walk it.
|
|
142
|
+
if (!ok && (await screen()).includes('to adjust')) {
|
|
143
|
+
const steps = efforts.indexOf(to) - efforts.indexOf(cur.effort)
|
|
144
|
+
for (let i = 0; i < Math.abs(steps); i++) await tui.press(steps > 0 ? 'right' : 'left')
|
|
145
|
+
await tui.press('enter')
|
|
146
|
+
ok = await tui.wait(s => effortOf(s.text) === to, 2000)
|
|
147
|
+
}
|
|
148
|
+
const now = effortOf(await screen()) ?? cur.effort
|
|
149
|
+
return now === to ? pick('effort', to) : radio(to, now)
|
|
150
|
+
}
|
|
151
|
+
const setMode = async to => {
|
|
152
|
+
let now = cur.mode
|
|
153
|
+
for (let i = 0; i < 4 && now !== to; i++) {
|
|
154
|
+
const before = now
|
|
155
|
+
await tui.press('shift-tab')
|
|
156
|
+
await tui.wait(s => modeOf(s.text) !== before, 1000)
|
|
157
|
+
now = modeOf(await screen())
|
|
158
|
+
}
|
|
159
|
+
if (now === to) return pick('mode', to)
|
|
160
|
+
cur.mode = now
|
|
161
|
+
modes.forEach((x, i) => dlg.relabel(offset.mode + i, radio(x, now)))
|
|
162
|
+
return radio(to, now)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cost = `$${sum.cost.toFixed(2)}${sum.unpriced ? '+?' : ''}`
|
|
166
|
+
const labels = { file: checkbox('file', file), web: checkbox('web', web), bash: checkbox('bash', !hideBash) }
|
|
167
|
+
dlg = pi.dialog({
|
|
168
|
+
title: `${sum.name ?? 'this session'} · ${cost}`,
|
|
169
|
+
footer: '↑↓←→ move · Enter apply · Esc close',
|
|
170
|
+
eachRow: [models.length, efforts.length, modes.length, 3, actions.length],
|
|
171
|
+
hints: {
|
|
172
|
+
...radioHints('model', v => `/model ${v}`),
|
|
173
|
+
...radioHints('effort', v => `/effort ${v}`),
|
|
174
|
+
...radioHints('mode', v => `${v === 'acc' ? 'accept edits' : v} mode (Shift+Tab until the status line agrees)`),
|
|
175
|
+
[labels.file]: fileHint,
|
|
176
|
+
[labels.web]: webHint,
|
|
177
|
+
[labels.bash]: 'Show or hide Bash tool output on screen',
|
|
178
|
+
CtrlO: 'Open the transcript (Ctrl+O)',
|
|
179
|
+
CtrlR: 'Search prompt history (Ctrl+R)',
|
|
180
|
+
Undo: 'Rewind the conversation (Esc Esc)',
|
|
181
|
+
Copy: 'Copy the last reply (/copy)',
|
|
182
|
+
},
|
|
183
|
+
...radios('model', setModel),
|
|
184
|
+
...radios('effort', setEffort),
|
|
185
|
+
...radios('mode', setMode),
|
|
186
|
+
[labels.file]: () => {
|
|
187
|
+
setAllowed(path, 'file', !file)
|
|
188
|
+
file = isAllowed(readSettings(path), 'file')
|
|
189
|
+
dlg.hint(checkboxBase, '')
|
|
190
|
+
return checkbox('file', file)
|
|
191
|
+
},
|
|
192
|
+
[labels.web]: async () => {
|
|
193
|
+
setAllowed(path, 'web', !web)
|
|
194
|
+
web = isAllowed(readSettings(path), 'web')
|
|
195
|
+
// Toggling also revokes what addTrust granted, not just the hint.
|
|
196
|
+
try {
|
|
197
|
+
await clearTrust(trustPath)
|
|
198
|
+
} catch (e) {
|
|
199
|
+
pi.dialog({ body: `allowedUrlPattern not cleared: ${e.message}`, timeout: 4000 })
|
|
200
|
+
}
|
|
201
|
+
dlg.hint(checkboxBase + 1, '')
|
|
202
|
+
return checkbox('web', web)
|
|
203
|
+
},
|
|
204
|
+
[labels.bash]: () => {
|
|
205
|
+
if (hideBash) {
|
|
206
|
+
hideBash()
|
|
207
|
+
hideBash = null
|
|
208
|
+
} else hideBash = hideOutput()
|
|
209
|
+
return checkbox('bash', !hideBash)
|
|
210
|
+
},
|
|
211
|
+
CtrlO: () => tui.press('ctrl-o').then(() => {}),
|
|
212
|
+
CtrlR: () => tui.press('ctrl-r').then(() => {}),
|
|
213
|
+
Undo: async () => {
|
|
214
|
+
await tui.press('esc')
|
|
215
|
+
await sleep(200) // two separate presses, not one ESC ESC burst
|
|
216
|
+
await tui.press('esc')
|
|
217
|
+
},
|
|
218
|
+
Copy: () => pi`/copy`.then(() => {}),
|
|
219
|
+
})
|
|
220
|
+
await dlg
|
|
221
|
+
open = false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pi.on({
|
|
225
|
+
CtrlP: () => {
|
|
226
|
+
if (!open) show().catch(() => (open = false))
|
|
227
|
+
return false // not CC's Ctrl+P
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// pi.ext.webpane + /w. webpane.boundPort(port): free → returns the port
|
|
2
|
+
// (the caller starts its server), taken → null; either way a dialog lists
|
|
3
|
+
// where it can be reached, http-server style, with buttons to copy the LAN /
|
|
4
|
+
// tailscale URL or show the tailscale one as a QR code (for a phone). /w does
|
|
5
|
+
// that for the cloudcli web UI on :3001 and starts it in the background when
|
|
6
|
+
// nothing serves it.
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { openSync } from 'node:fs'
|
|
9
|
+
import net from 'node:net'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const port = 3001
|
|
14
|
+
|
|
15
|
+
/** Whether something already accepts connections on localhost:port. */
|
|
16
|
+
export const portInUse = (port, host = '127.0.0.1') =>
|
|
17
|
+
new Promise(resolve => {
|
|
18
|
+
const sock = net.connect({ port, host })
|
|
19
|
+
const done = used => {
|
|
20
|
+
sock.destroy()
|
|
21
|
+
resolve(used)
|
|
22
|
+
}
|
|
23
|
+
sock.once('connect', () => done(true))
|
|
24
|
+
sock.once('error', () => done(false))
|
|
25
|
+
sock.setTimeout(500, () => done(false))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/** Where a server on `port` can be reached: localhost, LAN, tailscale. */
|
|
29
|
+
export function addresses(port, ifaces = os.networkInterfaces()) {
|
|
30
|
+
const out = [{ url: `http://localhost:${port}`, kind: 'local' }]
|
|
31
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
32
|
+
for (const a of addrs ?? []) {
|
|
33
|
+
if (a.family !== 'IPv4' || a.internal) continue
|
|
34
|
+
// Tailscale hands out CGNAT addresses (100.64.0.0/10).
|
|
35
|
+
const [o1, o2] = a.address.split('.').map(Number)
|
|
36
|
+
const tailscale = name.startsWith('tailscale') || (o1 === 100 && o2 >= 64 && o2 < 128)
|
|
37
|
+
out.push({ url: `http://${a.address}:${port}`, kind: tailscale ? 'tailscale' : 'LAN' })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* qrcode-terminal's output (two spaces per module, white \e[47m / black
|
|
45
|
+
* \e[40m) as half blocks: two module rows per line, light modules drawn in
|
|
46
|
+
* the foreground colour, so it reads right on a dark background.
|
|
47
|
+
*/
|
|
48
|
+
export function halfBlocks(ansi) {
|
|
49
|
+
const rows = ansi
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter(l => l.includes('\x1b['))
|
|
52
|
+
.map(l => [...l.matchAll(/\x1b\[(4[07])m {2}/g)].map(m => m[1] === '47'))
|
|
53
|
+
const out = []
|
|
54
|
+
for (let y = 0; y < rows.length; y += 2) {
|
|
55
|
+
const top = rows[y]
|
|
56
|
+
const bottom = rows[y + 1] ?? []
|
|
57
|
+
out.push(top.map((t, x) => (t && bottom[x] ? '█' : t ? '▀' : bottom[x] ? '▄' : ' ')).join(''))
|
|
58
|
+
}
|
|
59
|
+
return out.join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const copy = (pi, text) => pi.sh`printf %s ${text} | (wl-copy || xclip -selection clipboard || pbcopy) 2>/dev/null`
|
|
63
|
+
|
|
64
|
+
export default pi => {
|
|
65
|
+
pi.ext.webpane = {
|
|
66
|
+
async boundPort(port) {
|
|
67
|
+
const free = !(await portInUse(port))
|
|
68
|
+
const all = addresses(port)
|
|
69
|
+
const body = all.map(a => `${a.url}${a.kind === 'local' ? '' : ` (${a.kind})`}`).join('\n')
|
|
70
|
+
const url = kind => all.find(a => a.kind === kind)?.url
|
|
71
|
+
const missing = (kind, label) => (dlg.text({ footer: `no ${kind} address` }), label)
|
|
72
|
+
const copied = (kind, label) => async () => {
|
|
73
|
+
if (!url(kind)) return missing(kind, label)
|
|
74
|
+
await copy(pi, url(kind))
|
|
75
|
+
dlg.text({ footer: `copied ${url(kind)}` })
|
|
76
|
+
return label
|
|
77
|
+
}
|
|
78
|
+
const dlg = pi.dialog({
|
|
79
|
+
title: `:${port} ${free ? 'starting…' : 'already running'}`,
|
|
80
|
+
body,
|
|
81
|
+
footer: 'Esc closes',
|
|
82
|
+
'Copy LAN': copied('LAN', 'Copy LAN'),
|
|
83
|
+
'Copy tailscale': copied('tailscale', 'Copy tailscale'),
|
|
84
|
+
'tailscale QR': async () => {
|
|
85
|
+
const ts = url('tailscale')
|
|
86
|
+
if (!ts) return missing('tailscale', 'tailscale QR')
|
|
87
|
+
dlg.text({ footer: 'npx qrcode-terminal …' })
|
|
88
|
+
const qr = await pi.sh`printf %s ${ts} | npx -y qrcode-terminal`
|
|
89
|
+
if (qr.code !== 0) return (dlg.text({ footer: `qrcode-terminal failed (exit ${qr.code})` }), 'tailscale QR')
|
|
90
|
+
dlg.text({ body: `${halfBlocks(qr.stdout)}\n${ts}`, footer: 'scan with your phone · Esc closes' })
|
|
91
|
+
return 'tailscale QR'
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
return free ? port : null
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
pi.tui.dispose(() => delete pi.ext.webpane)
|
|
98
|
+
|
|
99
|
+
pi.command('w', async () => {
|
|
100
|
+
if ((await pi.ext.webpane.boundPort(port)) == null) return
|
|
101
|
+
const log = openSync(join(pi.tui.state.init?.dataDir ?? '.', 'cloudcli.log'), 'a')
|
|
102
|
+
spawn('npx', ['-y', '@cloudcli-ai/cloudcli'], {
|
|
103
|
+
detached: true,
|
|
104
|
+
stdio: ['ignore', log, log],
|
|
105
|
+
env: { ...process.env, PORT: String(port) },
|
|
106
|
+
}).unref()
|
|
107
|
+
})
|
|
108
|
+
}
|