@vincentbanana/bananacli 0.4.8 → 0.5.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.
@@ -155,6 +155,7 @@ function defaultConfig(workspace) {
155
155
  maxContextChars: 160_000,
156
156
  allowOutsideWorkspace: false,
157
157
  showReasoning: true,
158
+ disabledPlugins: [],
158
159
  };
159
160
  }
160
161
  async function readConfigFile(path) {
package/dist/index.js CHANGED
@@ -241,19 +241,21 @@ async function main() {
241
241
  if (resolved.noTools)
242
242
  ui.hint('已禁用全部工具(--no-tools)。');
243
243
  // 加载用户级 / 项目级插件工具(.banana/tools/*.mjs)
244
+ let pluginTools = [];
244
245
  if (!resolved.noTools) {
245
246
  const pluginResult = await loadPluginTools([join(bananaHome(), 'tools'), join(config.workspace, '.banana', 'tools')], new Set(allTools.map((tool) => tool.name)));
246
247
  for (const error of pluginResult.errors) {
247
248
  ui.warn(`插件未加载: ${error}`);
248
249
  }
249
- if (pluginResult.tools.length) {
250
- tools.push(...pluginResult.tools);
251
- ui.hint(`已加载 ${pluginResult.tools.length} 个插件工具: ${pluginResult.tools.map((tool) => tool.name).join(', ')}`);
250
+ pluginTools = pluginResult.tools.filter((tool) => !config.disabledPlugins.includes(tool.name));
251
+ if (pluginTools.length) {
252
+ tools.push(...pluginTools);
253
+ ui.hint(`已加载 ${pluginTools.length} 个插件工具: ${pluginTools.map((tool) => tool.name).join(', ')}`);
252
254
  }
253
255
  }
254
256
  // GUI 模式:本地服务 + 浏览器,不进入终端对话(不需要先有可用 provider)
255
257
  if (resolved.ui) {
256
- const handle = await startUiServer({ config, store, tools, port: resolved.port });
258
+ const handle = await startUiServer({ config, store, tools, pluginTools, port: resolved.port });
257
259
  ui.line();
258
260
  ui.ok(`GUI 已启动:${handle.url}`);
259
261
  ui.hint('只监听 127.0.0.1,URL 里的 token 是访问凭证,不要分享。按 Ctrl+C 退出。');
@@ -1,11 +1,19 @@
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
15
  import { WebSocketServer } from 'ws';
8
- import { fileURLToPath } from 'node:url';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
17
  import { createRequire } from 'node:module';
10
18
  import { TerminalManager, loadPty, normalizeCwd } from "./terminal.js";
11
19
  import { bananaHome } from "../config/config.js";
@@ -16,7 +24,7 @@ import { estimateCost, formatCost, resolvePricing } from "../providers/pricing.j
16
24
  import { SessionStore } from "../session/store.js";
17
25
  import { BackupStore, newTurnId } from "../session/backups.js";
18
26
  import { allTools, permissionFor, summarizeInput } from "../tools/index.js";
19
- import { loadPluginTools } from "../tools/plugins.js";
27
+ import { loadPluginTools, pluginTemplate } from "../tools/plugins.js";
20
28
  import { resolvePath } from "../tools/fs.js";
21
29
  import { commands, findCommand, suggestCommand } from "../cli/commands.js";
22
30
  import { completeCommand, completeFileReference } from "../cli/completion.js";
@@ -76,6 +84,111 @@ let activeAssetsDir = null;
76
84
  function assetRoot() {
77
85
  return activeAssetsDir ?? resolveAssetsDir();
78
86
  }
87
+ /** 行级 diff:小文件用 LCS,大文件退化成整段替换,避免把服务卡住。 */
88
+ function lineDiff(before, after) {
89
+ const left = before ? before.split(/\r?\n/) : [];
90
+ const right = after ? after.split(/\r?\n/) : [];
91
+ const n = left.length;
92
+ const m = right.length;
93
+ const ops = [];
94
+ let additions = 0;
95
+ let deletions = 0;
96
+ if (n * m > 1_200_000) {
97
+ for (const text of left) {
98
+ ops.push({ type: 'del', text });
99
+ deletions += 1;
100
+ }
101
+ for (const text of right) {
102
+ ops.push({ type: 'add', text });
103
+ additions += 1;
104
+ }
105
+ }
106
+ else {
107
+ const width = m + 1;
108
+ const table = new Uint32Array((n + 1) * width);
109
+ for (let i = n - 1; i >= 0; i -= 1) {
110
+ for (let j = m - 1; j >= 0; j -= 1) {
111
+ const diag = table[(i + 1) * width + j + 1] ?? 0;
112
+ const down = table[(i + 1) * width + j] ?? 0;
113
+ const rightward = table[i * width + j + 1] ?? 0;
114
+ table[i * width + j] = left[i] === right[j] ? diag + 1 : Math.max(down, rightward);
115
+ }
116
+ }
117
+ let i = 0;
118
+ let j = 0;
119
+ while (i < n && j < m) {
120
+ const a = left[i] ?? '';
121
+ const b = right[j] ?? '';
122
+ const down = table[(i + 1) * width + j] ?? 0;
123
+ const rightward = table[i * width + j + 1] ?? 0;
124
+ if (a === b) {
125
+ ops.push({ type: 'ctx', text: a });
126
+ i += 1;
127
+ j += 1;
128
+ }
129
+ else if (down >= rightward) {
130
+ ops.push({ type: 'del', text: a });
131
+ deletions += 1;
132
+ i += 1;
133
+ }
134
+ else {
135
+ ops.push({ type: 'add', text: b });
136
+ additions += 1;
137
+ j += 1;
138
+ }
139
+ }
140
+ while (i < n) {
141
+ ops.push({ type: 'del', text: left[i++] ?? '' });
142
+ deletions += 1;
143
+ }
144
+ while (j < m) {
145
+ ops.push({ type: 'add', text: right[j++] ?? '' });
146
+ additions += 1;
147
+ }
148
+ }
149
+ // 只保留改动附近 3 行上下文,跳过的部分用省略行代替
150
+ const keep = new Set();
151
+ ops.forEach((op, index) => {
152
+ if (op.type === 'ctx')
153
+ return;
154
+ for (let k = Math.max(0, index - 3); k <= Math.min(ops.length - 1, index + 3); k += 1)
155
+ keep.add(k);
156
+ });
157
+ const lines = [];
158
+ let oldLine = 1;
159
+ let newLine = 1;
160
+ let lastKept = true;
161
+ let truncated = lines.length >= 800;
162
+ ops.forEach((op, index) => {
163
+ if (!keep.has(index)) {
164
+ if (lastKept && lines.length < 800) {
165
+ lines.push({ type: 'ctx', text: '…', oldLine: null, newLine: null });
166
+ }
167
+ lastKept = false;
168
+ if (op.type !== 'add')
169
+ oldLine += 1;
170
+ if (op.type !== 'del')
171
+ newLine += 1;
172
+ return;
173
+ }
174
+ lastKept = true;
175
+ if (lines.length >= 800) {
176
+ truncated = true;
177
+ return;
178
+ }
179
+ lines.push({
180
+ type: op.type,
181
+ text: op.text,
182
+ oldLine: op.type === 'add' ? null : oldLine,
183
+ newLine: op.type === 'del' ? null : newLine,
184
+ });
185
+ if (op.type !== 'add')
186
+ oldLine += 1;
187
+ if (op.type !== 'del')
188
+ newLine += 1;
189
+ });
190
+ return { lines, additions, deletions, truncated };
191
+ }
79
192
  /** 启动只监听回环地址的本地 GUI 服务;token 保护所有接口。 */
80
193
  export async function startUiServer(options) {
81
194
  const { config, store } = options;
@@ -115,6 +228,9 @@ export async function startUiServer(options) {
115
228
  workspace: config.workspace,
116
229
  platform: process.platform,
117
230
  permissions: config.permissions,
231
+ systemPrompt: config.systemPrompt,
232
+ showReasoning: config.showReasoning,
233
+ disabledPlugins: config.disabledPlugins,
118
234
  tools: options.tools.map((tool) => ({ name: tool.name, approval: tool.approval, permission: permissionFor(config.permissions, tool) })),
119
235
  session: {
120
236
  id: session.id,
@@ -130,6 +246,29 @@ export async function startUiServer(options) {
130
246
  updatedAt: item.updatedAt,
131
247
  turns: item.turns,
132
248
  }));
249
+ // ---------- 插件:本地插件工坊 ----------
250
+ const builtinTools = options.tools.filter((tool) => !(options.pluginTools ?? []).some((plugin) => plugin.name === tool.name));
251
+ const pluginDirs = () => [
252
+ { id: 'global', label: '全局插件', path: join(bananaHome(), 'tools') },
253
+ { id: 'project', label: '项目插件', path: join(config.workspace, '.banana', 'tools') },
254
+ ];
255
+ const syncPlugins = async () => {
256
+ const dirs = pluginDirs();
257
+ const result = await loadPluginTools(dirs.map((dir) => dir.path), new Set(builtinTools.map((tool) => tool.name)));
258
+ const disabled = new Set(config.disabledPlugins);
259
+ const enabledTools = result.tools.filter((tool) => !disabled.has(tool.name));
260
+ options.tools.splice(0, options.tools.length, ...builtinTools, ...enabledTools);
261
+ return {
262
+ dirs,
263
+ plugins: result.plugins.map((plugin) => ({
264
+ ...plugin,
265
+ disabled: disabled.has(plugin.name),
266
+ enabled: plugin.ok && !disabled.has(plugin.name),
267
+ })),
268
+ errors: result.errors,
269
+ toolCount: options.tools.length,
270
+ };
271
+ };
133
272
  const server = createServer((req, res) => {
134
273
  handle(req, res).catch((error) => {
135
274
  if (!res.headersSent)
@@ -183,8 +322,12 @@ export async function startUiServer(options) {
183
322
  }
184
323
  let session = turn?.session ??
185
324
  (activeId ? await store.load(activeId) : null) ??
186
- (await store.lastActive().then(async (id) => (id ? await store.load(id) : null))) ??
187
- store.create();
325
+ (await store.lastActive().then(async (id) => (id ? await store.load(id) : null)));
326
+ if (!session) {
327
+ // 空会话也立刻落盘:否则每个请求都会重新 create,会话 id 一直变
328
+ session = store.create();
329
+ await store.save(session);
330
+ }
188
331
  activeId = session.id;
189
332
  if (path === '/api/info' && req.method === 'GET')
190
333
  return json(res, 200, await info(session));
@@ -273,6 +416,104 @@ export async function startUiServer(options) {
273
416
  const body = await readJson(req);
274
417
  return runCliCommand(body.input ?? '', res, session);
275
418
  }
419
+ if (path === '/api/find' && req.method === 'GET') {
420
+ const query = (url.searchParams.get('q') ?? '').trim().toLowerCase();
421
+ const limit = Math.max(1, Math.min(120, Number(url.searchParams.get('limit') ?? 60) || 60));
422
+ const skip = new Set([
423
+ '.git', 'node_modules', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt',
424
+ '.venv', 'venv', '__pycache__', '.idea', '.vscode', '.cache', '.banana', 'vendor',
425
+ ]);
426
+ const results = [];
427
+ let scanned = 0;
428
+ const walk = async (dir, rel, depth) => {
429
+ if (results.length >= limit || scanned > 6000 || depth > 8)
430
+ return;
431
+ let entries;
432
+ try {
433
+ entries = await readdir(dir, { withFileTypes: true });
434
+ }
435
+ catch {
436
+ return;
437
+ }
438
+ entries.sort((a, b) => a.name.localeCompare(b.name));
439
+ for (const entry of entries) {
440
+ if (results.length >= limit || scanned > 6000)
441
+ return;
442
+ if (entry.name.startsWith('.') && entry.name !== '.env.example')
443
+ continue;
444
+ if (skip.has(entry.name))
445
+ continue;
446
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
447
+ if (entry.isDirectory()) {
448
+ await walk(join(dir, entry.name), childRel, depth + 1);
449
+ continue;
450
+ }
451
+ if (!entry.isFile())
452
+ continue;
453
+ scanned += 1;
454
+ if (!query || childRel.toLowerCase().includes(query))
455
+ results.push(childRel);
456
+ }
457
+ };
458
+ try {
459
+ await walk(config.workspace, '', 0);
460
+ return json(res, 200, { files: results, scanned });
461
+ }
462
+ catch (error) {
463
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
464
+ }
465
+ }
466
+ if (path === '/api/changes' && req.method === 'GET') {
467
+ try {
468
+ const store = backups(session.id);
469
+ const manifest = await store.load();
470
+ const turns = [...manifest.turns].reverse().slice(0, 8);
471
+ const payload = [];
472
+ for (const turn of turns) {
473
+ const files = [];
474
+ for (const change of turn.changes.slice(0, 40)) {
475
+ let before = '';
476
+ let after = '';
477
+ if (change.backup) {
478
+ try {
479
+ before = await readFile(join(store.dir, turn.turnId, change.backup), 'utf8');
480
+ }
481
+ catch {
482
+ before = '';
483
+ }
484
+ }
485
+ try {
486
+ const info = await stat(change.path);
487
+ if (info.isFile() && info.size <= MAX_FILE_BYTES)
488
+ after = await readFile(change.path, 'utf8');
489
+ }
490
+ catch {
491
+ after = '';
492
+ }
493
+ const diff = lineDiff(before, after);
494
+ files.push({
495
+ path: change.relative,
496
+ absolute: change.path,
497
+ existed: change.existed,
498
+ missing: after === '' && change.existed,
499
+ additions: diff.additions,
500
+ deletions: diff.deletions,
501
+ truncated: diff.truncated,
502
+ lines: diff.lines,
503
+ });
504
+ }
505
+ if (files.length)
506
+ payload.push({ turnId: turn.turnId, at: turn.at, files });
507
+ }
508
+ return json(res, 200, {
509
+ turns: payload,
510
+ totalFiles: payload.reduce((sum, turn) => sum + turn.files.length, 0),
511
+ });
512
+ }
513
+ catch (error) {
514
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
515
+ }
516
+ }
276
517
  if (path === '/api/commands' && req.method === 'GET') {
277
518
  return json(res, 200, {
278
519
  commands: commands.map((command) => ({
@@ -310,6 +551,9 @@ export async function startUiServer(options) {
310
551
  else if (body.action === 'system' && body.value) {
311
552
  config.systemPrompt = body.value;
312
553
  }
554
+ else if (body.action === 'showReasoning') {
555
+ config.showReasoning = body.value === 'true' || body.value === '1';
556
+ }
313
557
  else if (body.action === 'workspace' && body.value) {
314
558
  const next = resolve(String(body.value).trim());
315
559
  if (!existsSync(next)) {
@@ -379,6 +623,92 @@ export async function startUiServer(options) {
379
623
  activeId = target.id;
380
624
  return chat(body, res, target);
381
625
  }
626
+ if (path === '/api/plugins' && req.method === 'GET') {
627
+ return json(res, 200, await syncPlugins());
628
+ }
629
+ if (path === '/api/plugins' && req.method === 'POST') {
630
+ const body = await readJson(req);
631
+ const action = String(body.action ?? '');
632
+ const { saveConfig } = await import("../config/config.js");
633
+ if (action === 'reload') {
634
+ return json(res, 200, await syncPlugins());
635
+ }
636
+ if (action === 'toggle' && body.name) {
637
+ const name = String(body.name);
638
+ const set = new Set(config.disabledPlugins);
639
+ if (body.disabled)
640
+ set.add(name);
641
+ else
642
+ set.delete(name);
643
+ config.disabledPlugins = [...set];
644
+ await saveConfig(config);
645
+ return json(res, 200, await syncPlugins());
646
+ }
647
+ if (action === 'create') {
648
+ const scope = body.scope === 'project' ? 'project' : 'global';
649
+ const dir = pluginDirs().find((item) => item.id === scope).path;
650
+ await mkdir(dir, { recursive: true });
651
+ const safe = String(body.name ?? 'my_tool').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 48) || 'my_tool';
652
+ let file = join(dir, `${safe}.mjs`);
653
+ let index = 2;
654
+ while (existsSync(file))
655
+ file = join(dir, `${safe}-${index++}.mjs`);
656
+ await writeFile(file, pluginTemplate(safe), 'utf8');
657
+ return json(res, 200, { file, ...(await syncPlugins()) });
658
+ }
659
+ if (action === 'open') {
660
+ const scope = body.scope === 'project' ? 'project' : 'global';
661
+ const dir = pluginDirs().find((item) => item.id === scope).path;
662
+ await mkdir(dir, { recursive: true });
663
+ const command = process.platform === 'darwin'
664
+ ? { file: 'open', args: [dir] }
665
+ : process.platform === 'win32'
666
+ ? { file: 'explorer.exe', args: [dir] }
667
+ : { file: 'xdg-open', args: [dir] };
668
+ try {
669
+ spawn(command.file, command.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
670
+ }
671
+ catch {
672
+ return json(res, 400, { error: '打不开文件管理器,请手动打开:' + dir });
673
+ }
674
+ return json(res, 200, { dir });
675
+ }
676
+ if (action === 'test' && body.file) {
677
+ const file = resolve(String(body.file));
678
+ const allowed = pluginDirs().some((dir) => {
679
+ const root = resolve(dir.path);
680
+ return file === root || file.startsWith(root + sep);
681
+ });
682
+ if (!allowed)
683
+ return json(res, 400, { error: '只能运行插件目录里的文件' });
684
+ if (!existsSync(file))
685
+ return json(res, 400, { error: '插件文件不存在' });
686
+ try {
687
+ const module = (await import(__rewriteRelativeImportExtension(`${pathToFileURL(file).href}?t=${Date.now()}`)));
688
+ const definition = module.default ?? module;
689
+ if (typeof definition.run !== 'function')
690
+ throw new Error('插件没有导出 run 函数');
691
+ const input = body.input && typeof body.input === 'object'
692
+ ? body.input
693
+ : (() => {
694
+ try {
695
+ return JSON.parse(String(body.input ?? '{}'));
696
+ }
697
+ catch {
698
+ return {};
699
+ }
700
+ })();
701
+ const result = await definition.run(input, {});
702
+ return json(res, 200, {
703
+ result: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
704
+ });
705
+ }
706
+ catch (error) {
707
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
708
+ }
709
+ }
710
+ return json(res, 400, { error: '未知的插件操作' });
711
+ }
382
712
  if (path === '/api/shells' && req.method === 'GET') {
383
713
  return json(res, 200, {
384
714
  shells: terminals.shells,
@@ -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') {