@vincentbanana/bananacli 0.4.7 → 0.4.9

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.
@@ -1,10 +1,21 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { createServer } from 'node:http';
2
10
  import { randomBytes } from 'node:crypto';
3
11
  import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
12
  import { existsSync } from 'node:fs';
5
- import { extname, join, resolve, dirname } from 'node:path';
13
+ import { extname, join, resolve, dirname, sep } from 'node:path';
6
14
  import { spawn } from 'node:child_process';
7
- import { fileURLToPath } from 'node:url';
15
+ import { WebSocketServer } from 'ws';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+ import { createRequire } from 'node:module';
18
+ import { TerminalManager, loadPty, normalizeCwd } from "./terminal.js";
8
19
  import { bananaHome } from "../config/config.js";
9
20
  import { runTurn } from "../agent/loop.js";
10
21
  import { compactMessages } from "../agent/context.js";
@@ -13,7 +24,7 @@ import { estimateCost, formatCost, resolvePricing } from "../providers/pricing.j
13
24
  import { SessionStore } from "../session/store.js";
14
25
  import { BackupStore, newTurnId } from "../session/backups.js";
15
26
  import { allTools, permissionFor, summarizeInput } from "../tools/index.js";
16
- import { loadPluginTools } from "../tools/plugins.js";
27
+ import { loadPluginTools, pluginTemplate } from "../tools/plugins.js";
17
28
  import { resolvePath } from "../tools/fs.js";
18
29
  import { commands, findCommand, suggestCommand } from "../cli/commands.js";
19
30
  import { completeCommand, completeFileReference } from "../cli/completion.js";
@@ -24,6 +35,12 @@ const MIME = {
24
35
  '.js': 'text/javascript; charset=utf-8',
25
36
  '.svg': 'image/svg+xml',
26
37
  };
38
+ const VENDOR_FILES = {
39
+ 'xterm.js': '@xterm/xterm/lib/xterm.js',
40
+ 'xterm.css': '@xterm/xterm/css/xterm.css',
41
+ 'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
42
+ 'highlight.min.js': '@highlightjs/cdn-assets/highlight.min.js',
43
+ };
27
44
  const IMAGE_TYPES = {
28
45
  '.png': 'image/png',
29
46
  '.jpg': 'image/jpeg',
@@ -106,6 +123,9 @@ export async function startUiServer(options) {
106
123
  workspace: config.workspace,
107
124
  platform: process.platform,
108
125
  permissions: config.permissions,
126
+ systemPrompt: config.systemPrompt,
127
+ showReasoning: config.showReasoning,
128
+ disabledPlugins: config.disabledPlugins,
109
129
  tools: options.tools.map((tool) => ({ name: tool.name, approval: tool.approval, permission: permissionFor(config.permissions, tool) })),
110
130
  session: {
111
131
  id: session.id,
@@ -121,6 +141,29 @@ export async function startUiServer(options) {
121
141
  updatedAt: item.updatedAt,
122
142
  turns: item.turns,
123
143
  }));
144
+ // ---------- 插件:本地插件工坊 ----------
145
+ const builtinTools = options.tools.filter((tool) => !(options.pluginTools ?? []).some((plugin) => plugin.name === tool.name));
146
+ const pluginDirs = () => [
147
+ { id: 'global', label: '全局插件', path: join(bananaHome(), 'tools') },
148
+ { id: 'project', label: '项目插件', path: join(config.workspace, '.banana', 'tools') },
149
+ ];
150
+ const syncPlugins = async () => {
151
+ const dirs = pluginDirs();
152
+ const result = await loadPluginTools(dirs.map((dir) => dir.path), new Set(builtinTools.map((tool) => tool.name)));
153
+ const disabled = new Set(config.disabledPlugins);
154
+ const enabledTools = result.tools.filter((tool) => !disabled.has(tool.name));
155
+ options.tools.splice(0, options.tools.length, ...builtinTools, ...enabledTools);
156
+ return {
157
+ dirs,
158
+ plugins: result.plugins.map((plugin) => ({
159
+ ...plugin,
160
+ disabled: disabled.has(plugin.name),
161
+ enabled: plugin.ok && !disabled.has(plugin.name),
162
+ })),
163
+ errors: result.errors,
164
+ toolCount: options.tools.length,
165
+ };
166
+ };
124
167
  const server = createServer((req, res) => {
125
168
  handle(req, res).catch((error) => {
126
169
  if (!res.headersSent)
@@ -129,6 +172,31 @@ export async function startUiServer(options) {
129
172
  res.end();
130
173
  });
131
174
  });
175
+ // 内嵌终端:WebSocket 双向通道 + 真实 PTY(cmd / pwsh / bash)
176
+ const terminals = new TerminalManager();
177
+ void loadPty();
178
+ const wss = new WebSocketServer({ noServer: true });
179
+ server.on('upgrade', (req, socket, head) => {
180
+ try {
181
+ const url = new URL(req.url ?? '/', `http://${host}`);
182
+ if (url.pathname !== `${prefix}/api/terminal/ws`) {
183
+ socket.destroy();
184
+ return;
185
+ }
186
+ wss.handleUpgrade(req, socket, head, (ws) => {
187
+ const shellId = url.searchParams.get('shell') ?? terminals.defaultShell().id;
188
+ const cwd = normalizeCwd(url.searchParams.get('cwd'), config.workspace);
189
+ const existing = url.searchParams.get('id');
190
+ const session = (existing ? terminals.find(existing) : undefined) ??
191
+ terminals.create(shellId, cwd, Number(url.searchParams.get('cols')) || 100, Number(url.searchParams.get('rows')) || 28);
192
+ ws.send(JSON.stringify({ type: 'id', id: session.id }));
193
+ session.attach(ws);
194
+ });
195
+ }
196
+ catch {
197
+ socket.destroy();
198
+ }
199
+ });
132
200
  async function handle(req, res) {
133
201
  const url = new URL(req.url ?? '/', `http://${host}`);
134
202
  if (!url.pathname.startsWith(prefix))
@@ -140,6 +208,13 @@ export async function startUiServer(options) {
140
208
  return sendFile(res, 'style.css');
141
209
  if (path === '/app.js')
142
210
  return sendFile(res, 'app.js');
211
+ // 终端与语法高亮的本地资源:只放行固定文件,避免任意路径读取
212
+ if (path.startsWith('/vendor/')) {
213
+ const name = path.slice('/vendor/'.length);
214
+ if (Object.hasOwn(VENDOR_FILES, name))
215
+ return sendFile(res, `vendor/${name}`);
216
+ return notFound(res);
217
+ }
143
218
  let session = turn?.session ??
144
219
  (activeId ? await store.load(activeId) : null) ??
145
220
  (await store.lastActive().then(async (id) => (id ? await store.load(id) : null))) ??
@@ -232,6 +307,53 @@ export async function startUiServer(options) {
232
307
  const body = await readJson(req);
233
308
  return runCliCommand(body.input ?? '', res, session);
234
309
  }
310
+ if (path === '/api/find' && req.method === 'GET') {
311
+ const query = (url.searchParams.get('q') ?? '').trim().toLowerCase();
312
+ const limit = Math.max(1, Math.min(120, Number(url.searchParams.get('limit') ?? 60) || 60));
313
+ const skip = new Set([
314
+ '.git', 'node_modules', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt',
315
+ '.venv', 'venv', '__pycache__', '.idea', '.vscode', '.cache', '.banana', 'vendor',
316
+ ]);
317
+ const results = [];
318
+ let scanned = 0;
319
+ const walk = async (dir, rel, depth) => {
320
+ if (results.length >= limit || scanned > 6000 || depth > 8)
321
+ return;
322
+ let entries;
323
+ try {
324
+ entries = await readdir(dir, { withFileTypes: true });
325
+ }
326
+ catch {
327
+ return;
328
+ }
329
+ entries.sort((a, b) => a.name.localeCompare(b.name));
330
+ for (const entry of entries) {
331
+ if (results.length >= limit || scanned > 6000)
332
+ return;
333
+ if (entry.name.startsWith('.') && entry.name !== '.env.example')
334
+ continue;
335
+ if (skip.has(entry.name))
336
+ continue;
337
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
338
+ if (entry.isDirectory()) {
339
+ await walk(join(dir, entry.name), childRel, depth + 1);
340
+ continue;
341
+ }
342
+ if (!entry.isFile())
343
+ continue;
344
+ scanned += 1;
345
+ if (!query || childRel.toLowerCase().includes(query))
346
+ results.push(childRel);
347
+ }
348
+ };
349
+ try {
350
+ await walk(config.workspace, '', 0);
351
+ return json(res, 200, { files: results, scanned });
352
+ }
353
+ catch (error) {
354
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
355
+ }
356
+ }
235
357
  if (path === '/api/commands' && req.method === 'GET') {
236
358
  return json(res, 200, {
237
359
  commands: commands.map((command) => ({
@@ -269,6 +391,9 @@ export async function startUiServer(options) {
269
391
  else if (body.action === 'system' && body.value) {
270
392
  config.systemPrompt = body.value;
271
393
  }
394
+ else if (body.action === 'showReasoning') {
395
+ config.showReasoning = body.value === 'true' || body.value === '1';
396
+ }
272
397
  else if (body.action === 'workspace' && body.value) {
273
398
  const next = resolve(String(body.value).trim());
274
399
  if (!existsSync(next)) {
@@ -338,107 +463,99 @@ export async function startUiServer(options) {
338
463
  activeId = target.id;
339
464
  return chat(body, res, target);
340
465
  }
341
- if (path === '/api/terminal' && req.method === 'POST') {
342
- const body = await readJson(req);
343
- return runTerminal(String(body.command ?? ''), res);
466
+ if (path === '/api/plugins' && req.method === 'GET') {
467
+ return json(res, 200, await syncPlugins());
344
468
  }
345
- return notFound(res);
346
- }
347
- /**
348
- * 内嵌终端:把一条命令跑在工作区里,输出按行流式推给界面。
349
- * 这里刻意不依赖 node-pty(原生编译在 Windows 上太重),用管道 + 目录标记实现,
350
- * 足以跑 git/npm/测试这类常用命令;cd 之后目录会被记住,Ctrl+C 会杀掉子进程。
351
- */
352
- async function runTerminal(command, res) {
353
- const cwd = terminalWorkdir();
354
- const trimmed = command.trim();
355
- if (!trimmed)
356
- return json(res, 200, { cwd });
357
- if (terminalChild)
358
- return json(res, 409, { error: '上一条命令还在跑,先按停止。' });
359
- res.writeHead(200, {
360
- 'Content-Type': 'text/event-stream; charset=utf-8',
361
- 'Cache-Control': 'no-cache, no-transform',
362
- Connection: 'keep-alive',
363
- 'X-Accel-Buffering': 'no',
364
- });
365
- const send = (type, payload = {}) => {
366
- if (!res.writableEnded)
367
- res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
368
- };
369
- send('start', { cwd });
370
- const isWin = process.platform === 'win32';
371
- const marker = '__BANANA_CWD__';
372
- // 命令跑完后回显一次当前目录,用来记住 cd 的结果
373
- const wrapped = isWin ? `${trimmed} & echo ${marker}%CD%` : `${trimmed}\n echo ${marker}$(pwd)`;
374
- let child;
375
- try {
376
- child = isWin
377
- ? spawn('cmd.exe', ['/d', '/s', '/c', wrapped], { cwd, env: process.env, windowsHide: true })
378
- : spawn('/bin/sh', ['-c', wrapped], { cwd, env: process.env, windowsHide: true });
379
- }
380
- catch (error) {
381
- send('exit', { code: -1, error: error instanceof Error ? error.message : String(error), cwd });
382
- res.end();
383
- return;
384
- }
385
- terminalChild = child;
386
- let tailOut = '';
387
- let tailErr = '';
388
- const pump = (text, stream) => {
389
- let buffer = (stream === 'stdout' ? tailOut : tailErr) + text;
390
- const lines = buffer.split('\n');
391
- buffer = lines.pop() ?? '';
392
- for (const raw of lines) {
393
- const line = raw.replace(/\r$/, '');
394
- if (line.startsWith(marker)) {
395
- const next = line.slice(marker.length).trim();
396
- if (next && existsSync(next))
397
- terminalCwd = next;
398
- continue;
469
+ if (path === '/api/plugins' && req.method === 'POST') {
470
+ const body = await readJson(req);
471
+ const action = String(body.action ?? '');
472
+ const { saveConfig } = await import("../config/config.js");
473
+ if (action === 'reload') {
474
+ return json(res, 200, await syncPlugins());
475
+ }
476
+ if (action === 'toggle' && body.name) {
477
+ const name = String(body.name);
478
+ const set = new Set(config.disabledPlugins);
479
+ if (body.disabled)
480
+ set.add(name);
481
+ else
482
+ set.delete(name);
483
+ config.disabledPlugins = [...set];
484
+ await saveConfig(config);
485
+ return json(res, 200, await syncPlugins());
486
+ }
487
+ if (action === 'create') {
488
+ const scope = body.scope === 'project' ? 'project' : 'global';
489
+ const dir = pluginDirs().find((item) => item.id === scope).path;
490
+ await mkdir(dir, { recursive: true });
491
+ const safe = String(body.name ?? 'my_tool').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 48) || 'my_tool';
492
+ let file = join(dir, `${safe}.mjs`);
493
+ let index = 2;
494
+ while (existsSync(file))
495
+ file = join(dir, `${safe}-${index++}.mjs`);
496
+ await writeFile(file, pluginTemplate(safe), 'utf8');
497
+ return json(res, 200, { file, ...(await syncPlugins()) });
498
+ }
499
+ if (action === 'open') {
500
+ const scope = body.scope === 'project' ? 'project' : 'global';
501
+ const dir = pluginDirs().find((item) => item.id === scope).path;
502
+ await mkdir(dir, { recursive: true });
503
+ const command = process.platform === 'darwin'
504
+ ? { file: 'open', args: [dir] }
505
+ : process.platform === 'win32'
506
+ ? { file: 'explorer.exe', args: [dir] }
507
+ : { file: 'xdg-open', args: [dir] };
508
+ try {
509
+ spawn(command.file, command.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
510
+ }
511
+ catch {
512
+ return json(res, 400, { error: '打不开文件管理器,请手动打开:' + dir });
399
513
  }
400
- send('out', { stream, data: line + '\n' });
514
+ return json(res, 200, { dir });
401
515
  }
402
- if (stream === 'stdout')
403
- tailOut = buffer;
404
- else
405
- tailErr = buffer;
406
- };
407
- const flush = () => {
408
- for (const [buffer, stream] of [[tailOut, 'stdout'], [tailErr, 'stderr']]) {
409
- if (!buffer)
410
- continue;
411
- if (!buffer.startsWith(marker))
412
- send('out', { stream, data: buffer.replace(/\r$/, '') });
516
+ if (action === 'test' && body.file) {
517
+ const file = resolve(String(body.file));
518
+ const allowed = pluginDirs().some((dir) => {
519
+ const root = resolve(dir.path);
520
+ return file === root || file.startsWith(root + sep);
521
+ });
522
+ if (!allowed)
523
+ return json(res, 400, { error: '只能运行插件目录里的文件' });
524
+ if (!existsSync(file))
525
+ return json(res, 400, { error: '插件文件不存在' });
526
+ try {
527
+ const module = (await import(__rewriteRelativeImportExtension(`${pathToFileURL(file).href}?t=${Date.now()}`)));
528
+ const definition = module.default ?? module;
529
+ if (typeof definition.run !== 'function')
530
+ throw new Error('插件没有导出 run 函数');
531
+ const input = body.input && typeof body.input === 'object'
532
+ ? body.input
533
+ : (() => {
534
+ try {
535
+ return JSON.parse(String(body.input ?? '{}'));
536
+ }
537
+ catch {
538
+ return {};
539
+ }
540
+ })();
541
+ const result = await definition.run(input, {});
542
+ return json(res, 200, {
543
+ result: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
544
+ });
545
+ }
546
+ catch (error) {
547
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
548
+ }
413
549
  }
414
- tailOut = '';
415
- tailErr = '';
416
- };
417
- child.stdout?.setEncoding('utf8');
418
- child.stderr?.setEncoding('utf8');
419
- child.stdout?.on('data', (chunk) => pump(chunk, 'stdout'));
420
- child.stderr?.on('data', (chunk) => pump(chunk, 'stderr'));
421
- // 前端断开(按停止 / 关页面)时连同子进程一起收掉
422
- res.on('close', () => {
423
- if (terminalChild === child)
424
- terminalChild = null;
425
- if (!child.killed)
426
- child.kill();
427
- });
428
- await new Promise((resolvePromise) => {
429
- child.on('error', (error) => {
430
- send('out', { stream: 'stderr', data: `启动失败:${error.message}\n` });
431
- });
432
- child.on('close', (code) => {
433
- flush();
434
- if (terminalChild === child)
435
- terminalChild = null;
436
- send('exit', { code: code ?? 0, cwd: terminalWorkdir() });
437
- if (!res.writableEnded)
438
- res.end();
439
- resolvePromise();
550
+ return json(res, 400, { error: '未知的插件操作' });
551
+ }
552
+ if (path === '/api/shells' && req.method === 'GET') {
553
+ return json(res, 200, {
554
+ shells: terminals.shells,
555
+ default: terminals.defaultShell().id,
440
556
  });
441
- });
557
+ }
558
+ return notFound(res);
442
559
  }
443
560
  /**
444
561
  * 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
@@ -672,6 +789,8 @@ export async function startUiServer(options) {
672
789
  token,
673
790
  port,
674
791
  close: async () => {
792
+ await terminals.dispose();
793
+ wss.close();
675
794
  // 浏览器可能留着 keep-alive 连接,先断开再关,否则 Windows 上会触发 libuv 断言
676
795
  server.closeAllConnections?.();
677
796
  await new Promise((resolvePromise) => server.close(() => resolvePromise()));
@@ -726,7 +845,22 @@ function notFound(res) {
726
845
  async function sendFile(res, name) {
727
846
  const { readFile } = await import('node:fs/promises');
728
847
  try {
729
- const file = await readFile(join(assetRoot(), name));
848
+ let target = join(assetRoot(), name);
849
+ // 源码模式(npm start)下 src/ui 里没有打包拷贝的 vendor;
850
+ // 直接从依赖包读,开发时也能秒开终端,不用先 build。
851
+ if (name.startsWith('vendor/') && !existsSync(target)) {
852
+ const vendorName = name.slice('vendor/'.length);
853
+ const specifier = VENDOR_FILES[vendorName];
854
+ if (specifier) {
855
+ try {
856
+ target = createRequire(import.meta.url).resolve(specifier);
857
+ }
858
+ catch {
859
+ /* 依赖缺失,走下面的 404 */
860
+ }
861
+ }
862
+ }
863
+ const file = await readFile(target);
730
864
  res.writeHead(200, {
731
865
  'Content-Type': MIME[extname(name)] ?? 'application/octet-stream',
732
866
  'Content-Length': file.byteLength,
@@ -78,6 +78,7 @@ function toTool(definition, source) {
78
78
  export async function loadPluginTools(dirs, existingNames = new Set()) {
79
79
  const tools = [];
80
80
  const errors = [];
81
+ const plugins = [];
81
82
  const seen = new Set(existingNames);
82
83
  for (const dir of dirs) {
83
84
  const absolute = resolve(dir);
@@ -99,38 +100,65 @@ export async function loadPluginTools(dirs, existingNames = new Set()) {
99
100
  if (entry.name.endsWith('.test.mjs'))
100
101
  continue;
101
102
  const file = join(absolute, entry.name);
103
+ const base = {
104
+ file,
105
+ dir: absolute,
106
+ name: entry.name,
107
+ description: '',
108
+ approval: 'never',
109
+ ok: false,
110
+ };
102
111
  try {
103
112
  const module = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
104
113
  const definition = (module.default ?? module);
105
114
  if (!definition || typeof definition !== 'object') {
106
- errors.push(`${entry.name}: 需要一个默认导出的对象`);
115
+ base.error = '需要一个默认导出的对象';
116
+ errors.push(`${entry.name}: ${base.error}`);
117
+ plugins.push(base);
107
118
  continue;
108
119
  }
109
120
  if (typeof definition.name !== 'string' || !TOOL_NAME.test(definition.name)) {
110
- errors.push(`${entry.name}: name 必须是字母开头的 1-64 位字母/数字/下划线`);
121
+ base.error = 'name 必须是字母开头的 1-64 位字母/数字/下划线';
122
+ errors.push(`${entry.name}: ${base.error}`);
123
+ plugins.push(base);
111
124
  continue;
112
125
  }
113
- if (typeof definition.description !== 'string' || !definition.description.trim()) {
114
- errors.push(`${entry.name}: 缺少 description`);
126
+ base.name = definition.name;
127
+ base.description = typeof definition.description === 'string' ? definition.description : '';
128
+ base.approval = VALID_APPROVALS.includes(definition.approval)
129
+ ? definition.approval
130
+ : 'never';
131
+ if (!definition.description || !definition.description.trim()) {
132
+ base.error = '缺少 description';
133
+ errors.push(`${entry.name}: ${base.error}`);
134
+ plugins.push(base);
115
135
  continue;
116
136
  }
117
137
  if (typeof definition.run !== 'function') {
118
- errors.push(`${entry.name}: 缺少 run(input, ctx) 函数`);
138
+ base.error = '缺少 run(input, ctx) 函数';
139
+ errors.push(`${entry.name}: ${base.error}`);
140
+ plugins.push(base);
119
141
  continue;
120
142
  }
121
143
  if (seen.has(definition.name)) {
122
- errors.push(`${entry.name}: 工具名 ${definition.name} 与已有工具冲突`);
144
+ base.error = `工具名 ${definition.name} 与已有工具冲突`;
145
+ errors.push(`${entry.name}: ${base.error}`);
146
+ plugins.push(base);
123
147
  continue;
124
148
  }
125
149
  seen.add(definition.name);
126
150
  tools.push(toTool(definition, entry.name));
151
+ base.ok = true;
152
+ plugins.push(base);
127
153
  }
128
154
  catch (error) {
129
- errors.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
155
+ base.error = error instanceof Error ? error.message : String(error);
156
+ errors.push(`${entry.name}: ${base.error}`);
157
+ plugins.push(base);
130
158
  }
131
159
  }
132
160
  }
133
- return { tools, errors };
161
+ return { tools, errors, plugins };
134
162
  }
135
163
  /** 生成插件模板,方便用户照着写。 */
136
164
  export function pluginTemplate(name = 'my_tool') {