goatchain 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +529 -0
- package/cli/args.mjs +113 -0
- package/cli/clack.mjs +111 -0
- package/cli/clipboard.mjs +320 -0
- package/cli/files.mjs +247 -0
- package/cli/index.mjs +299 -0
- package/cli/itermPaste.mjs +147 -0
- package/cli/persist.mjs +205 -0
- package/cli/repl.mjs +3141 -0
- package/cli/sdk.mjs +341 -0
- package/cli/sessionTransfer.mjs +118 -0
- package/cli/turn.mjs +751 -0
- package/cli/ui.mjs +138 -0
- package/cli.mjs +5 -0
- package/dist/index.cjs +4860 -0
- package/dist/index.d.cts +3479 -0
- package/dist/index.d.ts +3479 -0
- package/dist/index.js +4795 -0
- package/package.json +68 -0
package/cli/clack.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional clack integration.
|
|
3
|
+
*
|
|
4
|
+
* We load `@clack/prompts` dynamically so the CLI can run without it.
|
|
5
|
+
* This file intentionally has no static import of clack.
|
|
6
|
+
*/
|
|
7
|
+
export async function loadClack() {
|
|
8
|
+
try {
|
|
9
|
+
const mod = await import('@clack/prompts')
|
|
10
|
+
if (mod && typeof mod.select === 'function')
|
|
11
|
+
return mod
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function selectWithClack({ message, options, initialValue }) {
|
|
20
|
+
const clack = await loadClack()
|
|
21
|
+
if (!clack)
|
|
22
|
+
return { ok: false, reason: 'missing' }
|
|
23
|
+
|
|
24
|
+
const res = await clack.select({
|
|
25
|
+
message,
|
|
26
|
+
options,
|
|
27
|
+
initialValue,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
if (clack.isCancel(res))
|
|
31
|
+
return { ok: false, reason: 'cancel' }
|
|
32
|
+
|
|
33
|
+
return { ok: true, value: res }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function multiselectWithClack({ message, options, required = false }) {
|
|
37
|
+
const clack = await loadClack()
|
|
38
|
+
if (!clack)
|
|
39
|
+
return { ok: false, reason: 'missing' }
|
|
40
|
+
|
|
41
|
+
if (typeof clack.multiselect !== 'function')
|
|
42
|
+
return { ok: false, reason: 'unsupported' }
|
|
43
|
+
|
|
44
|
+
const res = await clack.multiselect({
|
|
45
|
+
message,
|
|
46
|
+
options,
|
|
47
|
+
required,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (clack.isCancel(res))
|
|
51
|
+
return { ok: false, reason: 'cancel' }
|
|
52
|
+
|
|
53
|
+
return { ok: true, value: res }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function confirmWithClack({ message, initialValue = false }) {
|
|
57
|
+
const clack = await loadClack()
|
|
58
|
+
if (!clack)
|
|
59
|
+
return { ok: false, reason: 'missing' }
|
|
60
|
+
|
|
61
|
+
if (typeof clack.confirm !== 'function')
|
|
62
|
+
return { ok: false, reason: 'unsupported' }
|
|
63
|
+
|
|
64
|
+
const res = await clack.confirm({
|
|
65
|
+
message,
|
|
66
|
+
initialValue,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (clack.isCancel(res))
|
|
70
|
+
return { ok: false, reason: 'cancel' }
|
|
71
|
+
|
|
72
|
+
return { ok: true, value: Boolean(res) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function textWithClack({ message, placeholder }) {
|
|
76
|
+
const clack = await loadClack()
|
|
77
|
+
if (!clack)
|
|
78
|
+
return { ok: false, reason: 'missing' }
|
|
79
|
+
|
|
80
|
+
if (typeof clack.text !== 'function')
|
|
81
|
+
return { ok: false, reason: 'unsupported' }
|
|
82
|
+
|
|
83
|
+
const res = await clack.text({
|
|
84
|
+
message,
|
|
85
|
+
placeholder,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
if (clack.isCancel(res))
|
|
89
|
+
return { ok: false, reason: 'cancel' }
|
|
90
|
+
|
|
91
|
+
return { ok: true, value: String(res ?? '') }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function passwordWithClack({ message, placeholder }) {
|
|
95
|
+
const clack = await loadClack()
|
|
96
|
+
if (!clack)
|
|
97
|
+
return { ok: false, reason: 'missing' }
|
|
98
|
+
|
|
99
|
+
if (typeof clack.password !== 'function')
|
|
100
|
+
return { ok: false, reason: 'unsupported' }
|
|
101
|
+
|
|
102
|
+
const res = await clack.password({
|
|
103
|
+
message,
|
|
104
|
+
placeholder,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (clack.isCancel(res))
|
|
108
|
+
return { ok: false, reason: 'cancel' }
|
|
109
|
+
|
|
110
|
+
return { ok: true, value: String(res ?? '') }
|
|
111
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile)
|
|
8
|
+
|
|
9
|
+
async function runAppleScript(lines, maxBuffer = 1024 * 1024) {
|
|
10
|
+
if (process.platform !== 'darwin')
|
|
11
|
+
return null
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await execFileAsync('osascript', lines.flatMap(l => ['-e', l]), { encoding: 'utf8', maxBuffer })
|
|
14
|
+
return String(stdout ?? '')
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseClipboardFileUrl(text) {
|
|
22
|
+
const urls = parseClipboardFileUrls(text)
|
|
23
|
+
return urls.length > 0 ? urls[0] : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseClipboardFileUrls(text) {
|
|
27
|
+
const s = String(text ?? '').trim()
|
|
28
|
+
if (!s)
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
// Finder sometimes provides multiple lines or null-separated records.
|
|
32
|
+
const parts = s
|
|
33
|
+
.split(/\u0000|\r?\n/)
|
|
34
|
+
.map(x => x.trim())
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
|
|
37
|
+
/** @type {string[]} */
|
|
38
|
+
const out = []
|
|
39
|
+
for (const part of parts) {
|
|
40
|
+
if (part.startsWith('file://')) {
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(part)
|
|
43
|
+
if (u.protocol !== 'file:')
|
|
44
|
+
continue
|
|
45
|
+
const p = decodeURIComponent(u.pathname)
|
|
46
|
+
if (p)
|
|
47
|
+
out.push(p)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Some apps paste absolute paths directly.
|
|
56
|
+
if (part.startsWith('/'))
|
|
57
|
+
out.push(part)
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sniffMimeFromBuffer(buf) {
|
|
63
|
+
if (!Buffer.isBuffer(buf) || buf.length < 12)
|
|
64
|
+
return null
|
|
65
|
+
// PNG
|
|
66
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47 && buf[4] === 0x0D && buf[5] === 0x0A && buf[6] === 0x1A && buf[7] === 0x0A)
|
|
67
|
+
return { mime: 'image/png', ext: '.png' }
|
|
68
|
+
// JPEG
|
|
69
|
+
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF)
|
|
70
|
+
return { mime: 'image/jpeg', ext: '.jpg' }
|
|
71
|
+
// GIF
|
|
72
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38)
|
|
73
|
+
return { mime: 'image/gif', ext: '.gif' }
|
|
74
|
+
// WEBP
|
|
75
|
+
if (buf.subarray(0, 4).toString('ascii') === 'RIFF' && buf.subarray(8, 12).toString('ascii') === 'WEBP')
|
|
76
|
+
return { mime: 'image/webp', ext: '.webp' }
|
|
77
|
+
// TIFF: II*\0 or MM\0*
|
|
78
|
+
if ((buf[0] === 0x49 && buf[1] === 0x49 && buf[2] === 0x2A && buf[3] === 0x00) || (buf[0] === 0x4D && buf[1] === 0x4D && buf[2] === 0x00 && buf[3] === 0x2A))
|
|
79
|
+
return { mime: 'image/tiff', ext: '.tiff' }
|
|
80
|
+
// BMP: "BM"
|
|
81
|
+
if (buf[0] === 0x42 && buf[1] === 0x4D)
|
|
82
|
+
return { mime: 'image/bmp', ext: '.bmp' }
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readClipboardTextDarwin() {
|
|
87
|
+
if (process.platform !== 'darwin')
|
|
88
|
+
return null
|
|
89
|
+
try {
|
|
90
|
+
const { stdout } = await execFileAsync('pbpaste', [], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 })
|
|
91
|
+
return String(stdout ?? '')
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readClipboardTypesDarwin() {
|
|
99
|
+
if (process.platform !== 'darwin')
|
|
100
|
+
return null
|
|
101
|
+
const out = await runAppleScript(['clipboard info'])
|
|
102
|
+
const s = String(out ?? '').trim()
|
|
103
|
+
return s || null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readClipboardPreferredTextDarwin(uti) {
|
|
107
|
+
if (process.platform !== 'darwin')
|
|
108
|
+
return null
|
|
109
|
+
try {
|
|
110
|
+
const { stdout } = await execFileAsync('pbpaste', ['-Prefer', String(uti)], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 })
|
|
111
|
+
return String(stdout ?? '')
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readClipboardFilePathsDarwinViaOsa() {
|
|
119
|
+
if (process.platform !== 'darwin')
|
|
120
|
+
return []
|
|
121
|
+
const out = await runAppleScript([
|
|
122
|
+
'try',
|
|
123
|
+
'set thePaths to {}',
|
|
124
|
+
'try',
|
|
125
|
+
'set theItems to the clipboard as alias list',
|
|
126
|
+
'on error',
|
|
127
|
+
'set theItems to {(the clipboard as alias)}',
|
|
128
|
+
'end try',
|
|
129
|
+
'repeat with a in theItems',
|
|
130
|
+
'set end of thePaths to POSIX path of a',
|
|
131
|
+
'end repeat',
|
|
132
|
+
'set AppleScript\'s text item delimiters to "\\n"',
|
|
133
|
+
'return thePaths as text',
|
|
134
|
+
'end try',
|
|
135
|
+
])
|
|
136
|
+
const raw = String(out ?? '').trim()
|
|
137
|
+
if (!raw)
|
|
138
|
+
return []
|
|
139
|
+
return raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function readFinderSelectionPathsDarwin() {
|
|
143
|
+
if (process.platform !== 'darwin')
|
|
144
|
+
return []
|
|
145
|
+
const out = await runAppleScript([
|
|
146
|
+
'try',
|
|
147
|
+
'tell application "Finder"',
|
|
148
|
+
'if (count of selection) is 0 then return ""',
|
|
149
|
+
'set thePaths to {}',
|
|
150
|
+
'repeat with i in selection',
|
|
151
|
+
'try',
|
|
152
|
+
'set end of thePaths to POSIX path of (i as alias)',
|
|
153
|
+
'end try',
|
|
154
|
+
'end repeat',
|
|
155
|
+
'set AppleScript\'s text item delimiters to "\\n"',
|
|
156
|
+
'return thePaths as text',
|
|
157
|
+
'end tell',
|
|
158
|
+
'end try',
|
|
159
|
+
])
|
|
160
|
+
const raw = String(out ?? '').trim()
|
|
161
|
+
if (!raw)
|
|
162
|
+
return []
|
|
163
|
+
return raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function readClipboardPreferredBinaryDarwin(uti, maxBytes = 12 * 1024 * 1024) {
|
|
167
|
+
if (process.platform !== 'darwin')
|
|
168
|
+
return null
|
|
169
|
+
try {
|
|
170
|
+
const { stdout } = await execFileAsync('pbpaste', ['-Prefer', String(uti)], { encoding: 'buffer', maxBuffer: maxBytes })
|
|
171
|
+
if (!Buffer.isBuffer(stdout) || stdout.length < 8)
|
|
172
|
+
return null
|
|
173
|
+
return stdout
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function readClipboardPngDarwin() {
|
|
181
|
+
if (process.platform !== 'darwin')
|
|
182
|
+
return null
|
|
183
|
+
try {
|
|
184
|
+
const { stdout } = await execFileAsync('pbpaste', ['-Prefer', 'png'], { encoding: 'buffer', maxBuffer: 12 * 1024 * 1024 })
|
|
185
|
+
if (!Buffer.isBuffer(stdout) || stdout.length < 8)
|
|
186
|
+
return null
|
|
187
|
+
// Confirm it looks like a PNG.
|
|
188
|
+
const sniff = sniffMimeFromBuffer(stdout)
|
|
189
|
+
if (!sniff || sniff.mime !== 'image/png')
|
|
190
|
+
return null
|
|
191
|
+
return { buf: stdout, mime: sniff.mime, ext: sniff.ext }
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function convertWithSipsToPng(inputBuf, inExt) {
|
|
199
|
+
if (process.platform !== 'darwin')
|
|
200
|
+
return null
|
|
201
|
+
if (!Buffer.isBuffer(inputBuf) || inputBuf.length === 0)
|
|
202
|
+
return null
|
|
203
|
+
const ext = String(inExt ?? '').toLowerCase() || '.bin'
|
|
204
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'goatchain-clip-'))
|
|
205
|
+
const inPath = path.join(dir, `in${ext}`)
|
|
206
|
+
const outPath = path.join(dir, 'out.png')
|
|
207
|
+
try {
|
|
208
|
+
await writeFile(inPath, inputBuf)
|
|
209
|
+
await execFileAsync('sips', ['-s', 'format', 'png', inPath, '--out', outPath], { encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
|
210
|
+
const out = await readFile(outPath)
|
|
211
|
+
const sniff = sniffMimeFromBuffer(out)
|
|
212
|
+
if (!sniff || sniff.mime !== 'image/png')
|
|
213
|
+
return null
|
|
214
|
+
const dataUrl = `data:${sniff.mime};base64,${out.toString('base64')}`
|
|
215
|
+
return { buf: out, mime: sniff.mime, ext: sniff.ext, dataUrl }
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
try {
|
|
222
|
+
await rm(dir, { recursive: true, force: true })
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function readImageFileAsDataUrl(absPath, maxBytes = 4 * 1024 * 1024) {
|
|
231
|
+
const st = await stat(absPath)
|
|
232
|
+
if (!st.isFile())
|
|
233
|
+
return null
|
|
234
|
+
if (st.size > maxBytes)
|
|
235
|
+
return null
|
|
236
|
+
const buf = await readFile(absPath)
|
|
237
|
+
const sniff = sniffMimeFromBuffer(buf)
|
|
238
|
+
if (!sniff || !sniff.mime.startsWith('image/'))
|
|
239
|
+
return null
|
|
240
|
+
const dataUrl = `data:${sniff.mime};base64,${buf.toString('base64')}`
|
|
241
|
+
return { buf, mime: sniff.mime, ext: sniff.ext, dataUrl }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function readClipboardImageCandidatesDarwin(maxBytes = 4 * 1024 * 1024) {
|
|
245
|
+
if (process.platform !== 'darwin')
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
/** @type {Array<{ source: string, absPath: string | null, buf: Buffer, mime: string, ext: string, dataUrl: string }>} */
|
|
249
|
+
const out = []
|
|
250
|
+
|
|
251
|
+
// 1) Prefer file-url(s) if present.
|
|
252
|
+
const preferred = await readClipboardPreferredTextDarwin('public.file-url')
|
|
253
|
+
const txt = preferred && preferred.trim() ? preferred : await readClipboardTextDarwin()
|
|
254
|
+
let paths = parseClipboardFileUrls(txt)
|
|
255
|
+
if (paths.length === 0) {
|
|
256
|
+
// Some environments don't expose file-url to pbpaste; use AppleScript as a fallback.
|
|
257
|
+
paths = await readClipboardFilePathsDarwinViaOsa()
|
|
258
|
+
}
|
|
259
|
+
if (paths.length > 0) {
|
|
260
|
+
for (const p of paths) {
|
|
261
|
+
try {
|
|
262
|
+
// eslint-disable-next-line no-await-in-loop
|
|
263
|
+
const file = await readImageFileAsDataUrl(p, maxBytes)
|
|
264
|
+
if (file)
|
|
265
|
+
out.push({ source: 'file', absPath: p, ...file })
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// ignore
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (out.length > 0)
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
// 2) Try common binary clipboard formats.
|
|
277
|
+
const formats = [
|
|
278
|
+
'png',
|
|
279
|
+
'public.png',
|
|
280
|
+
'jpeg',
|
|
281
|
+
'public.jpeg',
|
|
282
|
+
'jpg',
|
|
283
|
+
'public.jpg',
|
|
284
|
+
'public.tiff',
|
|
285
|
+
'tiff',
|
|
286
|
+
'public.bmp',
|
|
287
|
+
'bmp',
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
for (const fmt of formats) {
|
|
291
|
+
// eslint-disable-next-line no-await-in-loop
|
|
292
|
+
const buf = await readClipboardPreferredBinaryDarwin(fmt, Math.max(12 * 1024 * 1024, maxBytes))
|
|
293
|
+
if (!buf)
|
|
294
|
+
continue
|
|
295
|
+
const sniff = sniffMimeFromBuffer(buf)
|
|
296
|
+
if (!sniff || !sniff.mime.startsWith('image/'))
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
if (sniff.mime === 'image/tiff' || sniff.mime === 'image/bmp') {
|
|
300
|
+
// Try to convert unsupported formats into PNG for model compatibility.
|
|
301
|
+
// eslint-disable-next-line no-await-in-loop
|
|
302
|
+
const converted = await convertWithSipsToPng(buf, sniff.ext)
|
|
303
|
+
if (converted) {
|
|
304
|
+
out.push({ source: fmt, absPath: null, ...converted })
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const dataUrl = `data:${sniff.mime};base64,${buf.toString('base64')}`
|
|
310
|
+
out.push({ source: fmt, absPath: null, buf, mime: sniff.mime, ext: sniff.ext, dataUrl })
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return out
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function readClipboardImageCandidateDarwin(maxBytes = 4 * 1024 * 1024) {
|
|
318
|
+
const all = await readClipboardImageCandidatesDarwin(maxBytes)
|
|
319
|
+
return all.length > 0 ? all[0] : null
|
|
320
|
+
}
|
package/cli/files.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { open, readdir, stat } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_IGNORE_DIRS = new Set([
|
|
5
|
+
'node_modules',
|
|
6
|
+
'.git',
|
|
7
|
+
'.goatchain',
|
|
8
|
+
'dist',
|
|
9
|
+
'tmp',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
function isSubpath(parent, child) {
|
|
13
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child))
|
|
14
|
+
if (rel === '')
|
|
15
|
+
return true
|
|
16
|
+
if (rel === '..')
|
|
17
|
+
return false
|
|
18
|
+
return !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ensureAllowedFile(workspaceCwd, filePath) {
|
|
22
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceCwd, filePath)
|
|
23
|
+
if (!isSubpath(workspaceCwd, abs)) {
|
|
24
|
+
throw new Error(`Access denied: ${abs}\nAllowed directory: ${workspaceCwd}`)
|
|
25
|
+
}
|
|
26
|
+
// block cache dir by default
|
|
27
|
+
const cacheDir = path.join(workspaceCwd, '.goatchain')
|
|
28
|
+
if (isSubpath(cacheDir, abs)) {
|
|
29
|
+
throw new Error(`Access denied: ${abs}\nRestricted: ${cacheDir}`)
|
|
30
|
+
}
|
|
31
|
+
return abs
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function listWorkspaceFiles(workspaceCwd, options = {}) {
|
|
35
|
+
const maxFiles = typeof options.maxFiles === 'number' ? options.maxFiles : 5000
|
|
36
|
+
const ignoreDirs = new Set(options.ignoreDirs ?? DEFAULT_IGNORE_DIRS)
|
|
37
|
+
|
|
38
|
+
/** @type {string[]} */
|
|
39
|
+
const out = []
|
|
40
|
+
/** @type {string[]} */
|
|
41
|
+
const queue = ['.']
|
|
42
|
+
|
|
43
|
+
while (queue.length > 0) {
|
|
44
|
+
const relDir = queue.shift()
|
|
45
|
+
if (!relDir)
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
const absDir = path.resolve(workspaceCwd, relDir)
|
|
49
|
+
let entries
|
|
50
|
+
try {
|
|
51
|
+
// eslint-disable-next-line no-await-in-loop
|
|
52
|
+
entries = await readdir(absDir, { withFileTypes: true })
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const ent of entries) {
|
|
59
|
+
const name = ent.name
|
|
60
|
+
if (!name)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if (ent.isDirectory()) {
|
|
64
|
+
if (ignoreDirs.has(name))
|
|
65
|
+
continue
|
|
66
|
+
if (name.startsWith('.env'))
|
|
67
|
+
continue
|
|
68
|
+
const nextRel = relDir === '.' ? name : path.join(relDir, name)
|
|
69
|
+
queue.push(nextRel)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!ent.isFile())
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
const rel = relDir === '.' ? name : path.join(relDir, name)
|
|
77
|
+
out.push(rel)
|
|
78
|
+
if (out.length >= maxFiles)
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function safeSliceLines(lines, n) {
|
|
87
|
+
if (n <= 0)
|
|
88
|
+
return []
|
|
89
|
+
return lines.slice(0, Math.min(lines.length, n))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function summarizeText(text, opts = {}) {
|
|
93
|
+
const headLines = typeof opts.headLines === 'number' ? opts.headLines : 40
|
|
94
|
+
const tailLines = typeof opts.tailLines === 'number' ? opts.tailLines : 20
|
|
95
|
+
const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : 60_000
|
|
96
|
+
const s = String(text ?? '')
|
|
97
|
+
|
|
98
|
+
const asBytes = Buffer.byteLength(s, 'utf8')
|
|
99
|
+
const clipped = asBytes > maxBytes ? s.slice(0, Math.max(0, maxBytes)) : s
|
|
100
|
+
const lines = clipped.split('\n')
|
|
101
|
+
|
|
102
|
+
if (lines.length <= headLines + tailLines)
|
|
103
|
+
return { text: clipped, truncated: asBytes > maxBytes }
|
|
104
|
+
|
|
105
|
+
const head = safeSliceLines(lines, headLines)
|
|
106
|
+
const tail = lines.slice(Math.max(headLines, lines.length - tailLines))
|
|
107
|
+
return {
|
|
108
|
+
text: `${head.join('\n')}\n…\n${tail.join('\n')}`,
|
|
109
|
+
truncated: true,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readTextFile(absPath, maxBytes, totalBytes) {
|
|
114
|
+
const toRead = Math.max(0, Math.min(Number(maxBytes) || 0, totalBytes))
|
|
115
|
+
const fh = await open(absPath, 'r')
|
|
116
|
+
try {
|
|
117
|
+
const buf = Buffer.allocUnsafe(toRead)
|
|
118
|
+
const { bytesRead } = await fh.read(buf, 0, toRead, 0)
|
|
119
|
+
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
120
|
+
return { text, truncated: totalBytes > maxBytes, bytes: totalBytes }
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await fh.close()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseFileMentions(input) {
|
|
128
|
+
const s = String(input ?? '')
|
|
129
|
+
/** @type {Array<{ raw: string, mode: 'path'|'summary'|'full', filePath: string }>} */
|
|
130
|
+
const mentions = []
|
|
131
|
+
const re = /@file(?:\((path|summary|full)\))?:([^\s]+)/g
|
|
132
|
+
let m
|
|
133
|
+
while ((m = re.exec(s)) != null) {
|
|
134
|
+
const raw = m[0]
|
|
135
|
+
const mode = (m[1] || 'summary')
|
|
136
|
+
const filePath = m[2]
|
|
137
|
+
mentions.push({ raw, mode, filePath })
|
|
138
|
+
}
|
|
139
|
+
return mentions
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function expandFileMentions(workspaceCwd, input, options = {}) {
|
|
143
|
+
const maxTotalBytes = typeof options.maxTotalBytes === 'number' ? options.maxTotalBytes : 220_000
|
|
144
|
+
const maxBytesPerFile = typeof options.maxBytesPerFile === 'number' ? options.maxBytesPerFile : 120_000
|
|
145
|
+
const maxBytesPerFileSummary = typeof options.maxBytesPerFileSummary === 'number' ? options.maxBytesPerFileSummary : 40_000
|
|
146
|
+
const headLines = typeof options.headLines === 'number' ? options.headLines : 60
|
|
147
|
+
const tailLines = typeof options.tailLines === 'number' ? options.tailLines : 20
|
|
148
|
+
|
|
149
|
+
const mentions = parseFileMentions(input)
|
|
150
|
+
if (mentions.length === 0)
|
|
151
|
+
return { text: String(input ?? ''), files: [], omitted: [] }
|
|
152
|
+
|
|
153
|
+
const unique = new Map()
|
|
154
|
+
for (const m of mentions) {
|
|
155
|
+
const key = `${m.mode}:${m.filePath}`
|
|
156
|
+
if (!unique.has(key))
|
|
157
|
+
unique.set(key, m)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @type {Array<{ mode: string, relPath: string, absPath: string, bytes: number, truncated: boolean, content?: string }>} */
|
|
161
|
+
const files = []
|
|
162
|
+
/** @type {Array<{ mode: string, filePath: string, reason: 'budget'|'not_file' }>} */
|
|
163
|
+
const omitted = []
|
|
164
|
+
let used = 0
|
|
165
|
+
|
|
166
|
+
const uniqMentions = [...unique.values()]
|
|
167
|
+
for (let idx = 0; idx < uniqMentions.length; idx++) {
|
|
168
|
+
const m = uniqMentions[idx]
|
|
169
|
+
const abs = ensureAllowedFile(workspaceCwd, m.filePath)
|
|
170
|
+
// eslint-disable-next-line no-await-in-loop
|
|
171
|
+
const st = await stat(abs)
|
|
172
|
+
if (!st.isFile()) {
|
|
173
|
+
omitted.push({ mode: m.mode, filePath: m.filePath, reason: 'not_file' })
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (m.mode === 'path') {
|
|
178
|
+
files.push({ mode: 'path', relPath: path.relative(workspaceCwd, abs), absPath: abs, bytes: st.size, truncated: false })
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const budgetLeft = Math.max(0, maxTotalBytes - used)
|
|
183
|
+
if (budgetLeft <= 0) {
|
|
184
|
+
omitted.push({ mode: m.mode, filePath: m.filePath, reason: 'budget' })
|
|
185
|
+
for (let j = idx + 1; j < uniqMentions.length; j++) {
|
|
186
|
+
const rest = uniqMentions[j]
|
|
187
|
+
omitted.push({ mode: rest.mode, filePath: rest.filePath, reason: 'budget' })
|
|
188
|
+
}
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const perFileMax = m.mode === 'summary' ? maxBytesPerFileSummary : maxBytesPerFile
|
|
193
|
+
const perFile = Math.min(perFileMax, budgetLeft)
|
|
194
|
+
// eslint-disable-next-line no-await-in-loop
|
|
195
|
+
const raw = await readTextFile(abs, perFile, st.size)
|
|
196
|
+
used += Math.min(raw.bytes, perFile)
|
|
197
|
+
|
|
198
|
+
if (m.mode === 'full') {
|
|
199
|
+
files.push({
|
|
200
|
+
mode: 'full',
|
|
201
|
+
relPath: path.relative(workspaceCwd, abs),
|
|
202
|
+
absPath: abs,
|
|
203
|
+
bytes: raw.bytes,
|
|
204
|
+
truncated: raw.truncated,
|
|
205
|
+
content: raw.text,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const summary = summarizeText(raw.text, { headLines, tailLines, maxBytes: perFile })
|
|
210
|
+
files.push({
|
|
211
|
+
mode: 'summary',
|
|
212
|
+
relPath: path.relative(workspaceCwd, abs),
|
|
213
|
+
absPath: abs,
|
|
214
|
+
bytes: raw.bytes,
|
|
215
|
+
truncated: raw.truncated || summary.truncated,
|
|
216
|
+
content: summary.text,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let cleaned = String(input ?? '')
|
|
222
|
+
for (const m of mentions) {
|
|
223
|
+
cleaned = cleaned.replace(m.raw, '').replace(/\s{2,}/g, ' ')
|
|
224
|
+
}
|
|
225
|
+
cleaned = cleaned.trim()
|
|
226
|
+
|
|
227
|
+
const blocks = files
|
|
228
|
+
.filter(f => f.mode !== 'path')
|
|
229
|
+
.map((f) => {
|
|
230
|
+
const head = `---\nfile: ${f.relPath}\nmode: ${f.mode}${f.truncated ? ' (truncated)' : ''}\n---\n`
|
|
231
|
+
return `${head}${String(f.content ?? '')}`.trimEnd()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const pathsOnly = files.filter(f => f.mode === 'path').map(f => f.relPath)
|
|
235
|
+
const preface = [
|
|
236
|
+
blocks.length > 0 || pathsOnly.length > 0 ? '[attached files]' : '',
|
|
237
|
+
pathsOnly.length > 0 ? `paths: ${pathsOnly.join(', ')}` : '',
|
|
238
|
+
].filter(Boolean).join('\n')
|
|
239
|
+
|
|
240
|
+
const expanded = [
|
|
241
|
+
cleaned,
|
|
242
|
+
preface,
|
|
243
|
+
...blocks,
|
|
244
|
+
].filter(Boolean).join('\n\n')
|
|
245
|
+
|
|
246
|
+
return { text: expanded, files, omitted }
|
|
247
|
+
}
|