@vincentbanana/bananacli 0.4.8 → 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.
@@ -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";
@@ -115,6 +123,9 @@ export async function startUiServer(options) {
115
123
  workspace: config.workspace,
116
124
  platform: process.platform,
117
125
  permissions: config.permissions,
126
+ systemPrompt: config.systemPrompt,
127
+ showReasoning: config.showReasoning,
128
+ disabledPlugins: config.disabledPlugins,
118
129
  tools: options.tools.map((tool) => ({ name: tool.name, approval: tool.approval, permission: permissionFor(config.permissions, tool) })),
119
130
  session: {
120
131
  id: session.id,
@@ -130,6 +141,29 @@ export async function startUiServer(options) {
130
141
  updatedAt: item.updatedAt,
131
142
  turns: item.turns,
132
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
+ };
133
167
  const server = createServer((req, res) => {
134
168
  handle(req, res).catch((error) => {
135
169
  if (!res.headersSent)
@@ -273,6 +307,53 @@ export async function startUiServer(options) {
273
307
  const body = await readJson(req);
274
308
  return runCliCommand(body.input ?? '', res, session);
275
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
+ }
276
357
  if (path === '/api/commands' && req.method === 'GET') {
277
358
  return json(res, 200, {
278
359
  commands: commands.map((command) => ({
@@ -310,6 +391,9 @@ export async function startUiServer(options) {
310
391
  else if (body.action === 'system' && body.value) {
311
392
  config.systemPrompt = body.value;
312
393
  }
394
+ else if (body.action === 'showReasoning') {
395
+ config.showReasoning = body.value === 'true' || body.value === '1';
396
+ }
313
397
  else if (body.action === 'workspace' && body.value) {
314
398
  const next = resolve(String(body.value).trim());
315
399
  if (!existsSync(next)) {
@@ -379,6 +463,92 @@ export async function startUiServer(options) {
379
463
  activeId = target.id;
380
464
  return chat(body, res, target);
381
465
  }
466
+ if (path === '/api/plugins' && req.method === 'GET') {
467
+ return json(res, 200, await syncPlugins());
468
+ }
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 });
513
+ }
514
+ return json(res, 200, { dir });
515
+ }
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
+ }
549
+ }
550
+ return json(res, 400, { error: '未知的插件操作' });
551
+ }
382
552
  if (path === '/api/shells' && req.method === 'GET') {
383
553
  return json(res, 200, {
384
554
  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') {