@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.
- package/dist/config/config.js +1 -0
- package/dist/index.js +6 -4
- package/dist/server/ui-server.js +173 -3
- package/dist/tools/plugins.js +36 -8
- package/dist/ui/app.js +1251 -242
- package/dist/ui/index.html +81 -30
- package/dist/ui/style.css +906 -1
- package/package.json +1 -1
package/dist/config/config.js
CHANGED
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 退出。');
|
package/dist/server/ui-server.js
CHANGED
|
@@ -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,
|
package/dist/tools/plugins.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
+
base.error = 'name 必须是字母开头的 1-64 位字母/数字/下划线';
|
|
122
|
+
errors.push(`${entry.name}: ${base.error}`);
|
|
123
|
+
plugins.push(base);
|
|
111
124
|
continue;
|
|
112
125
|
}
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|