@vincentbanana/bananacli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/cli/ui.js +18 -1
- package/dist/index.js +18 -7
- package/dist/server/ui-server.js +258 -25
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -31,6 +31,19 @@
|
|
|
31
31
|
- **改前先看 diff、改后能回滚**:写文件前展示具体增删行,`/undo` 还原上一轮所有文件改动。
|
|
32
32
|
- **多模态与扩展**:`/image` 附加截图,`.banana/tools/` 放脚本即可注册自定义工具,成本按 token 估算。
|
|
33
33
|
|
|
34
|
+
## 图形界面
|
|
35
|
+
|
|
36
|
+
同一个内核,两种界面。CLI 加 `--ui` 就能开图形界面:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
banana --ui # 打开图形界面
|
|
40
|
+
npm install -g @vincentbanana/banana-gui && banana-gui # 或装独立的 GUI 包
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
图形界面与终端**共用同一套内核**(agent 循环、工具、权限、`commands.ts` 里的斜杠命令),行为完全一致,配置也共用 `~/.banana/config.json`。
|
|
44
|
+
|
|
45
|
+
界面提供:会话管理与导出(Markdown / JSON)、`/` 命令面板、`@` 文件补全、项目文件面板、工具输出详情、三态主题、API Key 设置。
|
|
46
|
+
|
|
34
47
|
## 平台支持
|
|
35
48
|
|
|
36
49
|
| 平台 | 状态 | 说明 |
|
package/dist/cli/ui.js
CHANGED
|
@@ -35,11 +35,24 @@ export const c = {
|
|
|
35
35
|
magenta: paint(35, 39),
|
|
36
36
|
gray: paint(90, 39),
|
|
37
37
|
};
|
|
38
|
+
let sink = null;
|
|
39
|
+
/** 把输出转到别处(GUI 用);返回还原函数。 */
|
|
40
|
+
export function captureOutput(target) {
|
|
41
|
+
const previous = sink;
|
|
42
|
+
sink = target;
|
|
43
|
+
return () => {
|
|
44
|
+
sink = previous;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
38
47
|
export function write(text) {
|
|
48
|
+
if (sink) {
|
|
49
|
+
sink(text);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
39
52
|
process.stdout.write(text);
|
|
40
53
|
}
|
|
41
54
|
export function line(text = '') {
|
|
42
|
-
|
|
55
|
+
write(`${text}\n`);
|
|
43
56
|
}
|
|
44
57
|
export function info(text) {
|
|
45
58
|
line(c.cyan(text));
|
|
@@ -174,6 +187,10 @@ export function relativeTime(iso) {
|
|
|
174
187
|
return `${Math.round(hours / 24)} 天前`;
|
|
175
188
|
}
|
|
176
189
|
export function clearScreen() {
|
|
190
|
+
if (sink) {
|
|
191
|
+
sink('\u001b[clear]\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
177
194
|
if (process.stdout.isTTY)
|
|
178
195
|
process.stdout.write('\u001b[2J\u001b[H');
|
|
179
196
|
}
|
package/dist/index.js
CHANGED
|
@@ -220,13 +220,23 @@ async function main() {
|
|
|
220
220
|
provider = createProvider(config.current, config);
|
|
221
221
|
}
|
|
222
222
|
catch (error) {
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
ui.
|
|
226
|
-
|
|
223
|
+
// GUI 模式下不因缺 key 而退出:界面本身可以用来填 key
|
|
224
|
+
if (resolved.ui) {
|
|
225
|
+
ui.warn(error instanceof Error ? error.message : String(error));
|
|
226
|
+
ui.hint(' 图形界面会照常打开,在设置里填入 API Key 即可开始使用。');
|
|
227
|
+
provider = null;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
231
|
+
if (interactive)
|
|
232
|
+
ui.hint(' 运行 `banana --setup` 运行配置向导,或设置对应环境变量后重试。');
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (provider) {
|
|
237
|
+
session.provider = provider.name;
|
|
238
|
+
session.model = provider.model;
|
|
227
239
|
}
|
|
228
|
-
session.provider = provider.name;
|
|
229
|
-
session.model = provider.model;
|
|
230
240
|
const tools = resolved.noTools ? [] : [...allTools];
|
|
231
241
|
if (resolved.noTools)
|
|
232
242
|
ui.hint('已禁用全部工具(--no-tools)。');
|
|
@@ -256,10 +266,11 @@ async function main() {
|
|
|
256
266
|
});
|
|
257
267
|
return;
|
|
258
268
|
}
|
|
269
|
+
// 走到这里说明 provider 可用(GUI 分支已在上方返回)
|
|
259
270
|
const outcome = await startRepl({
|
|
260
271
|
config,
|
|
261
272
|
store,
|
|
262
|
-
provider,
|
|
273
|
+
provider: provider,
|
|
263
274
|
tools,
|
|
264
275
|
session,
|
|
265
276
|
oneShot: resolved.oneShot,
|
package/dist/server/ui-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { extname, join, resolve, dirname } from 'node:path';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,6 +14,9 @@ import { BackupStore, newTurnId } from "../session/backups.js";
|
|
|
14
14
|
import { allTools, permissionFor, summarizeInput } from "../tools/index.js";
|
|
15
15
|
import { loadPluginTools } from "../tools/plugins.js";
|
|
16
16
|
import { resolvePath } from "../tools/fs.js";
|
|
17
|
+
import { commands, findCommand, suggestCommand } from "../cli/commands.js";
|
|
18
|
+
import { completeCommand, completeFileReference } from "../cli/completion.js";
|
|
19
|
+
import { captureOutput } from "../cli/ui.js";
|
|
17
20
|
const MIME = {
|
|
18
21
|
'.html': 'text/html; charset=utf-8',
|
|
19
22
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -28,6 +31,16 @@ const IMAGE_TYPES = {
|
|
|
28
31
|
'.webp': 'image/webp',
|
|
29
32
|
};
|
|
30
33
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
34
|
+
const MAX_FILE_BYTES = 400 * 1024;
|
|
35
|
+
const TEXT_EXT = new Set([
|
|
36
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.txt', '.yml', '.yaml',
|
|
37
|
+
'.toml', '.css', '.scss', '.html', '.sh', '.bash', '.zsh', '.py', '.go', '.rs', '.java',
|
|
38
|
+
'.c', '.h', '.cpp', '.hpp', '.sql', '.xml', '.ini', '.conf', '.env', '.gitignore', '.lock',
|
|
39
|
+
]);
|
|
40
|
+
const SKIP_DIRS = new Set([
|
|
41
|
+
'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', 'coverage', '__pycache__',
|
|
42
|
+
'.venv', 'venv', 'target', '.cache', '.idea', '.gradle',
|
|
43
|
+
]);
|
|
31
44
|
/** 静态资源目录:默认是 CLI 包自带的 ui/,GUI 包启动时可以传自己的目录进来。 */
|
|
32
45
|
function defaultAssetsDir() {
|
|
33
46
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
|
|
@@ -57,6 +70,8 @@ export async function startUiServer(options) {
|
|
|
57
70
|
current: name === config.current,
|
|
58
71
|
keySaved: Boolean(item.apiKey),
|
|
59
72
|
keyEnv: item.apiKeyEnv ?? null,
|
|
73
|
+
// 环境变量名只是"配置里写了",不代表真的设了值,所以把可用性也算出来
|
|
74
|
+
keyReady: Boolean(item.apiKey || (item.apiKeyEnv && process.env[item.apiKeyEnv])),
|
|
60
75
|
})),
|
|
61
76
|
workspace: config.workspace,
|
|
62
77
|
platform: process.platform,
|
|
@@ -100,6 +115,36 @@ export async function startUiServer(options) {
|
|
|
100
115
|
return json(res, 200, await info(session));
|
|
101
116
|
if (path === '/api/sessions' && req.method === 'GET')
|
|
102
117
|
return json(res, 200, { sessions: await listSessions(), current: session.id });
|
|
118
|
+
if (path === '/api/files' && req.method === 'GET') {
|
|
119
|
+
const target = url.searchParams.get('path') ?? '.';
|
|
120
|
+
try {
|
|
121
|
+
return json(res, 200, { entries: await listTree(config, target) });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (path === '/api/file' && req.method === 'GET') {
|
|
128
|
+
const requested = url.searchParams.get('path') ?? '';
|
|
129
|
+
try {
|
|
130
|
+
const context = sandboxContext(config);
|
|
131
|
+
const absolute = resolvePath(requested, context);
|
|
132
|
+
const info = await stat(absolute);
|
|
133
|
+
if (!info.isFile())
|
|
134
|
+
return json(res, 400, { error: '不是文件' });
|
|
135
|
+
if (info.size > MAX_FILE_BYTES)
|
|
136
|
+
return json(res, 200, { path: requested, tooLarge: true, bytes: info.size });
|
|
137
|
+
const ext = extname(absolute).toLowerCase();
|
|
138
|
+
if (!TEXT_EXT.has(ext) && ext !== '') {
|
|
139
|
+
return json(res, 200, { path: requested, binary: true, bytes: info.size });
|
|
140
|
+
}
|
|
141
|
+
const content = await readFile(absolute, 'utf8');
|
|
142
|
+
return json(res, 200, { path: requested, content, bytes: info.size, truncated: info.size >= MAX_FILE_BYTES });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
103
148
|
if (path === '/api/session' && req.method === 'POST') {
|
|
104
149
|
const body = await readJson(req);
|
|
105
150
|
if (body.action === 'new') {
|
|
@@ -137,6 +182,31 @@ export async function startUiServer(options) {
|
|
|
137
182
|
turn.session = session;
|
|
138
183
|
return json(res, 200, { session: session.id, sessions: await listSessions(), info: await info(session) });
|
|
139
184
|
}
|
|
185
|
+
if (path === '/api/complete' && req.method === 'GET') {
|
|
186
|
+
const line = url.searchParams.get('line') ?? '';
|
|
187
|
+
// 与 CLI 共用同一份补全实现
|
|
188
|
+
const files = completeFileReference(line, config.workspace, { limit: 12 });
|
|
189
|
+
if (files)
|
|
190
|
+
return json(res, 200, { kind: 'file', replace: files[1], candidates: files[0] });
|
|
191
|
+
const commandHits = completeCommand(line, commands.map((command) => command.name));
|
|
192
|
+
if (commandHits)
|
|
193
|
+
return json(res, 200, { kind: 'command', replace: commandHits[1], candidates: commandHits[0] });
|
|
194
|
+
return json(res, 200, { kind: 'none', replace: '', candidates: [] });
|
|
195
|
+
}
|
|
196
|
+
if (path === '/api/command' && req.method === 'POST') {
|
|
197
|
+
const body = await readJson(req);
|
|
198
|
+
return runCliCommand(body.input ?? '', res, session);
|
|
199
|
+
}
|
|
200
|
+
if (path === '/api/commands' && req.method === 'GET') {
|
|
201
|
+
return json(res, 200, {
|
|
202
|
+
commands: commands.map((command) => ({
|
|
203
|
+
name: command.name,
|
|
204
|
+
aliases: command.aliases ?? [],
|
|
205
|
+
usage: command.usage,
|
|
206
|
+
summary: command.summary,
|
|
207
|
+
})),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
140
210
|
if (path === '/api/action' && req.method === 'POST') {
|
|
141
211
|
const body = await readJson(req);
|
|
142
212
|
if (body.action === 'provider' && body.name && config.providers[body.name]) {
|
|
@@ -221,6 +291,98 @@ export async function startUiServer(options) {
|
|
|
221
291
|
}
|
|
222
292
|
return notFound(res);
|
|
223
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
|
|
296
|
+
* 输出逐行推给前端,行为和终端里一致。
|
|
297
|
+
*/
|
|
298
|
+
async function runCliCommand(input, res, session) {
|
|
299
|
+
const trimmed = input.trim();
|
|
300
|
+
const [token = '', ...rest] = trimmed.replace(/^\//, '').split(/\s+/);
|
|
301
|
+
const command = findCommand(token);
|
|
302
|
+
if (!command) {
|
|
303
|
+
const suggestion = suggestCommand(token);
|
|
304
|
+
json(res, 200, {
|
|
305
|
+
ok: false,
|
|
306
|
+
output: suggestion
|
|
307
|
+
? `未知命令 /${token},你是不是想用 /${suggestion}?`
|
|
308
|
+
: `未知命令 /${token}。输入 /help 看可用命令。`,
|
|
309
|
+
info: await info(session),
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const chunks = [];
|
|
314
|
+
const restore = captureOutput((text) => chunks.push(text));
|
|
315
|
+
let next = session;
|
|
316
|
+
let failure = '';
|
|
317
|
+
// provider 惰性创建:像 /help、/cwd 这类命令不需要 key 也能跑
|
|
318
|
+
let lazyProvider = null;
|
|
319
|
+
const needProvider = () => {
|
|
320
|
+
if (!lazyProvider) {
|
|
321
|
+
try {
|
|
322
|
+
lazyProvider = provider();
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\n请先在设置里填入 API Key。`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return lazyProvider;
|
|
329
|
+
};
|
|
330
|
+
try {
|
|
331
|
+
await command.run(rest.join(' '), {
|
|
332
|
+
config,
|
|
333
|
+
store,
|
|
334
|
+
session,
|
|
335
|
+
get provider() {
|
|
336
|
+
return needProvider();
|
|
337
|
+
},
|
|
338
|
+
tools: options.tools,
|
|
339
|
+
ask: async (question) => {
|
|
340
|
+
throw new Error(`${question}(这个命令需要交互输入,请用界面上的对应功能)`);
|
|
341
|
+
},
|
|
342
|
+
confirm: async () => false,
|
|
343
|
+
setSession: (value) => {
|
|
344
|
+
next = value;
|
|
345
|
+
},
|
|
346
|
+
setSessionAndSave: async (value) => {
|
|
347
|
+
next = value;
|
|
348
|
+
await store.save(value);
|
|
349
|
+
},
|
|
350
|
+
setProvider: () => { },
|
|
351
|
+
setConfig: (value) => Object.assign(config, value),
|
|
352
|
+
setTools: () => { },
|
|
353
|
+
saveSession: async () => {
|
|
354
|
+
await store.save(next);
|
|
355
|
+
},
|
|
356
|
+
reloadProvider: () => { },
|
|
357
|
+
compactNow: async () => { },
|
|
358
|
+
runSetup: async () => {
|
|
359
|
+
throw new Error('配置向导请在终端运行 banana --setup');
|
|
360
|
+
},
|
|
361
|
+
undo: async () => backups(next.id).undo(),
|
|
362
|
+
clearBackups: async (sessionId) => {
|
|
363
|
+
await backups(sessionId).clear();
|
|
364
|
+
},
|
|
365
|
+
attachImage: async () => ({ ok: false, message: '请直接在界面里粘贴图片' }),
|
|
366
|
+
listImages: () => [],
|
|
367
|
+
clearImages: () => { },
|
|
368
|
+
clearScreen: () => { },
|
|
369
|
+
exit: () => { },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
restore();
|
|
377
|
+
}
|
|
378
|
+
return json(res, 200, {
|
|
379
|
+
ok: !failure,
|
|
380
|
+
output: chunks.join(''),
|
|
381
|
+
error: failure,
|
|
382
|
+
info: await info(next),
|
|
383
|
+
sessions: await listSessions(),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
224
386
|
async function chat(body, res, session) {
|
|
225
387
|
const controller = new AbortController();
|
|
226
388
|
const active = { controller, pending: new Map(), session };
|
|
@@ -235,11 +397,28 @@ export async function startUiServer(options) {
|
|
|
235
397
|
if (!res.writableEnded)
|
|
236
398
|
res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
|
|
237
399
|
};
|
|
400
|
+
const finish = () => {
|
|
401
|
+
turn = null;
|
|
402
|
+
if (!res.writableEnded)
|
|
403
|
+
res.end();
|
|
404
|
+
};
|
|
238
405
|
send('start', { session: session.id });
|
|
239
406
|
res.on('close', () => {
|
|
240
407
|
if (!res.writableEnded)
|
|
241
408
|
controller.abort();
|
|
242
409
|
});
|
|
410
|
+
// 没配 key 之类的问题要在流里说清楚,否则前端会一直转圈
|
|
411
|
+
const current = config.providers[config.current];
|
|
412
|
+
const hasKey = Boolean(current?.apiKey || (current?.apiKeyEnv && process.env[current.apiKeyEnv]));
|
|
413
|
+
if (!hasKey) {
|
|
414
|
+
send('error', {
|
|
415
|
+
message: `还没有配置 ${config.current} 的 API key。点右上角的模型按钮,在设置里填入 API Key 就能开始对话。`,
|
|
416
|
+
hint: '也可以在终端运行 banana --setup 走一遍配置向导。',
|
|
417
|
+
code: 'no_api_key',
|
|
418
|
+
});
|
|
419
|
+
finish();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
243
422
|
const dataUrls = await Promise.all((body.images ?? []).map(async (file) => {
|
|
244
423
|
const absolute = resolve(file);
|
|
245
424
|
const type = IMAGE_TYPES[extname(absolute).toLowerCase()];
|
|
@@ -271,30 +450,49 @@ export async function startUiServer(options) {
|
|
|
271
450
|
todos: session.todos,
|
|
272
451
|
};
|
|
273
452
|
const started = Date.now();
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
453
|
+
let result;
|
|
454
|
+
try {
|
|
455
|
+
result = await runTurn({
|
|
456
|
+
provider: provider(),
|
|
457
|
+
tools: options.tools,
|
|
458
|
+
permissions: config.permissions,
|
|
459
|
+
system: config.systemPrompt,
|
|
460
|
+
maxSteps: config.maxSteps,
|
|
461
|
+
maxContextChars: config.maxContextChars,
|
|
462
|
+
toolsWorkspace: config.workspace,
|
|
463
|
+
allowOutsideWorkspace: config.allowOutsideWorkspace,
|
|
464
|
+
signal: controller.signal,
|
|
465
|
+
todos: session.todos,
|
|
466
|
+
recordChange: (path, beforeContent) => recorder.record(path, beforeContent),
|
|
467
|
+
onText: (chunk) => send('text', { chunk }),
|
|
468
|
+
onReasoning: (chunk) => send('reasoning', { chunk }),
|
|
469
|
+
onToolStart: (name, input) => send('tool_start', { name, detail: summarizeInput(input) }),
|
|
470
|
+
onToolEnd: (name, toolResult) => send('tool_end', {
|
|
471
|
+
name,
|
|
472
|
+
ok: toolResult.ok,
|
|
473
|
+
display: toolResult.display ?? '',
|
|
474
|
+
error: toolResult.ok ? '' : toolResult.content.slice(0, 400),
|
|
475
|
+
// 详情面板用:命令输出 / 搜索结果等,最多 8KB
|
|
476
|
+
detail: toolResult.content.slice(0, 8000),
|
|
477
|
+
}),
|
|
478
|
+
onNotice: (message) => send('status', { message }),
|
|
479
|
+
onCompact: () => send('status', { message: '已压缩历史对话' }),
|
|
480
|
+
onRetry: (info) => send('status', { message: `网络重试 ${info.attempt}:${info.reason}` }),
|
|
481
|
+
approve,
|
|
482
|
+
}, session.messages, body.message, dataUrls.filter((item) => Boolean(item)));
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
// 任何异常都要以 error 事件收尾,否则前端会一直等下去
|
|
486
|
+
send('error', {
|
|
487
|
+
message: error instanceof Error ? error.message : String(error),
|
|
488
|
+
code: 'turn_failed',
|
|
489
|
+
});
|
|
490
|
+
finish();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
295
493
|
session.messages = result.messages;
|
|
296
494
|
session.provider = config.current;
|
|
297
|
-
session.model = provider().model;
|
|
495
|
+
session.model = session.model || provider().model;
|
|
298
496
|
const pricing = resolvePricing(session.model, config.providers[config.current]?.pricing);
|
|
299
497
|
const cost = estimateCost(result.usage, pricing);
|
|
300
498
|
session.usage = {
|
|
@@ -304,7 +502,6 @@ export async function startUiServer(options) {
|
|
|
304
502
|
};
|
|
305
503
|
await store.save(session);
|
|
306
504
|
await recorder.commit().catch(() => { });
|
|
307
|
-
turn = null;
|
|
308
505
|
send('done', {
|
|
309
506
|
stopReason: result.stopReason,
|
|
310
507
|
error: result.error ?? '',
|
|
@@ -315,7 +512,7 @@ export async function startUiServer(options) {
|
|
|
315
512
|
todos: JSON.stringify(session.todos ?? []) !== todosBefore ? session.todos : undefined,
|
|
316
513
|
info: await info(session),
|
|
317
514
|
});
|
|
318
|
-
|
|
515
|
+
finish();
|
|
319
516
|
}
|
|
320
517
|
const port = await new Promise((resolvePromise, rejectPromise) => {
|
|
321
518
|
server.once('error', rejectPromise);
|
|
@@ -337,6 +534,42 @@ function json(res, status, payload) {
|
|
|
337
534
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
|
|
338
535
|
res.end(body);
|
|
339
536
|
}
|
|
537
|
+
function sandboxContext(config) {
|
|
538
|
+
return {
|
|
539
|
+
workspace: config.workspace,
|
|
540
|
+
signal: new AbortController().signal,
|
|
541
|
+
approve: async () => false,
|
|
542
|
+
log: () => { },
|
|
543
|
+
allowOutsideWorkspace: false,
|
|
544
|
+
permissions: config.permissions,
|
|
545
|
+
todos: [],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/** 目录树:只返回一层子项,按需展开,避免大仓库一次拉全量。 */
|
|
549
|
+
async function listTree(config, requested) {
|
|
550
|
+
const { readdir, stat: statFile } = await import('node:fs/promises');
|
|
551
|
+
const root = resolvePath(requested || '.', sandboxContext(config));
|
|
552
|
+
const info = await statFile(root);
|
|
553
|
+
if (!info.isDirectory())
|
|
554
|
+
throw new Error(`${root} 不是目录`);
|
|
555
|
+
const items = await readdir(root, { withFileTypes: true });
|
|
556
|
+
const entries = [];
|
|
557
|
+
for (const item of items) {
|
|
558
|
+
if (item.name.startsWith('.'))
|
|
559
|
+
continue;
|
|
560
|
+
if (item.isDirectory() && SKIP_DIRS.has(item.name))
|
|
561
|
+
continue;
|
|
562
|
+
const rel = join(requested === '.' ? '' : requested, item.name).split('\\').join('/');
|
|
563
|
+
if (item.isDirectory()) {
|
|
564
|
+
entries.push({ name: item.name, path: rel, type: 'dir' });
|
|
565
|
+
}
|
|
566
|
+
else if (item.isFile()) {
|
|
567
|
+
entries.push({ name: item.name, path: rel, type: 'file' });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
571
|
+
return entries.slice(0, 500);
|
|
572
|
+
}
|
|
340
573
|
function notFound(res) {
|
|
341
574
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
342
575
|
res.end('not found');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentbanana/bananacli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A multi-provider AI agent for your terminal: file tools, shell, web search, persistent sessions, diff preview and undo",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -49,4 +49,4 @@
|
|
|
49
49
|
"@types/node": "^22.20.5",
|
|
50
50
|
"typescript": "^5.7.3"
|
|
51
51
|
}
|
|
52
|
-
}
|
|
52
|
+
}
|