@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.
@@ -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 退出。');
@@ -0,0 +1,305 @@
1
+ // 内嵌终端:真实 PTY + 多会话。
2
+ // 用 @lydell/node-pty(自带预编译二进制),Windows 走 ConPTY,输出天然是 UTF-8,
3
+ // 所以中文路径和中文输出不会再乱码,提示符也由 shell 自己打印(D:\banana>)。
4
+ import { existsSync, statSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ const isWin = process.platform === 'win32';
7
+ /** 在 PATH 里找一个可执行文件(Windows 会补 .exe)。不依赖 shell,避免启动时开子进程。 */
8
+ function findOnPath(name) {
9
+ const paths = (process.env['PATH'] ?? '').split(isWin ? ';' : ':').filter(Boolean);
10
+ const exts = isWin ? ['', '.exe', '.cmd', '.bat'] : [''];
11
+ for (const dir of paths) {
12
+ for (const ext of exts) {
13
+ const candidate = join(dir, name.toLowerCase().endsWith(ext.toLowerCase()) ? name : name + ext);
14
+ try {
15
+ if (existsSync(candidate) && statSync(candidate).isFile())
16
+ return candidate;
17
+ }
18
+ catch {
19
+ /* 无权限的目录跳过 */
20
+ }
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ /** 探测机器上可用的 shell,顺序即默认优先级。 */
26
+ export function detectShells() {
27
+ const shells = [];
28
+ const seen = new Set();
29
+ const push = (item) => {
30
+ if (!item || seen.has(item.id))
31
+ return;
32
+ seen.add(item.id);
33
+ shells.push(item);
34
+ };
35
+ if (isWin) {
36
+ const pwsh = findOnPath('pwsh');
37
+ push(pwsh ? { id: 'pwsh', name: 'PowerShell 7', file: pwsh, args: ['-NoLogo'] } : null);
38
+ const powershell = findOnPath('powershell');
39
+ push(powershell ? { id: 'powershell', name: 'Windows PowerShell', file: powershell, args: ['-NoLogo'] } : null);
40
+ const comspec = process.env['ComSpec'] ?? findOnPath('cmd');
41
+ push(comspec ? { id: 'cmd', name: '命令提示符', file: comspec, args: [] } : null);
42
+ for (const bash of [
43
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
44
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
45
+ join(process.env['LOCALAPPDATA'] ?? '', 'Programs', 'Git', 'bin', 'bash.exe'),
46
+ ]) {
47
+ if (bash && existsSync(bash)) {
48
+ push({ id: 'bash', name: 'Git Bash', file: bash, args: ['--login', '-i'] });
49
+ break;
50
+ }
51
+ }
52
+ const wsl = findOnPath('wsl');
53
+ push(wsl ? { id: 'wsl', name: 'WSL', file: wsl, args: [] } : null);
54
+ }
55
+ else {
56
+ const candidates = [
57
+ { id: 'zsh', name: 'zsh', file: findOnPath('zsh') },
58
+ { id: 'bash', name: 'bash', file: findOnPath('bash') },
59
+ { id: 'sh', name: 'sh', file: findOnPath('sh') ?? '/bin/sh' },
60
+ ];
61
+ const preferred = process.env['SHELL'];
62
+ for (const item of candidates) {
63
+ if (!item.file)
64
+ continue;
65
+ push({ id: item.id, name: item.name, file: item.file, args: ['-l'] });
66
+ }
67
+ if (preferred) {
68
+ const found = shells.find((item) => item.file === preferred);
69
+ if (found) {
70
+ shells.splice(shells.indexOf(found), 1);
71
+ shells.unshift(found);
72
+ }
73
+ }
74
+ }
75
+ return shells;
76
+ }
77
+ export class TerminalManager {
78
+ sessions = new Map();
79
+ seq = 0;
80
+ shells;
81
+ constructor(shells) {
82
+ const detected = shells ?? detectShells();
83
+ this.shells = detected.length
84
+ ? detected
85
+ : [{ id: 'sh', name: 'sh', file: isWin ? (process.env['ComSpec'] ?? 'cmd.exe') : '/bin/sh', args: [] }];
86
+ }
87
+ defaultShell() {
88
+ return this.shells[0];
89
+ }
90
+ find(id) {
91
+ return this.sessions.get(id);
92
+ }
93
+ create(shellId, cwd, cols = 100, rows = 28) {
94
+ const shell = this.shells.find((item) => item.id === shellId) ?? this.defaultShell();
95
+ const id = `t${++this.seq}`;
96
+ const session = new TerminalSession(id, shell, cwd, cols, rows);
97
+ this.sessions.set(id, session);
98
+ return session;
99
+ }
100
+ async dispose() {
101
+ const sessions = [...this.sessions.values()];
102
+ this.sessions.clear();
103
+ await Promise.all(sessions.map((session) => session.dispose()));
104
+ }
105
+ }
106
+ export class TerminalSession {
107
+ id;
108
+ shell;
109
+ pty = null;
110
+ clients = new Set();
111
+ buffer = '';
112
+ startedAt = '';
113
+ cwd;
114
+ exitPromise = Promise.resolve();
115
+ exitResolve = null;
116
+ cols;
117
+ rows;
118
+ running = false;
119
+ constructor(id, shell, cwd, cols, rows) {
120
+ this.id = id;
121
+ this.shell = shell;
122
+ this.cwd = existsSync(cwd) ? cwd : process.cwd();
123
+ this.cols = Math.max(20, cols);
124
+ this.rows = Math.max(4, rows);
125
+ this.start();
126
+ }
127
+ info() {
128
+ return { id: this.id, shell: this.shell.id, name: this.shell.name, running: this.running, cwd: this.cwd };
129
+ }
130
+ start() {
131
+ this.startedAt = new Date().toISOString();
132
+ const env = {};
133
+ for (const [key, value] of Object.entries(process.env)) {
134
+ if (typeof value === 'string')
135
+ env[key] = value;
136
+ }
137
+ env['TERM'] = 'xterm-256color';
138
+ env['COLORTERM'] = 'truecolor';
139
+ // Windows 终端里让 Python / Node 也走 UTF-8,避免第三方程序自己输出 GBK
140
+ env['PYTHONIOENCODING'] = 'utf-8';
141
+ env['PYTHONUTF8'] = '1';
142
+ try {
143
+ // 延迟到运行时加载:没装到对应平台的预编译包时,服务本身仍然能启动
144
+ const spawnPty = globalThis.__bananaPtySpawn;
145
+ if (!spawnPty)
146
+ throw new Error('PTY 未加载');
147
+ this.pty = spawnPty(this.shell.file, this.shell.args, {
148
+ name: 'xterm-256color',
149
+ cols: this.cols,
150
+ rows: this.rows,
151
+ cwd: this.cwd,
152
+ env,
153
+ });
154
+ }
155
+ catch (error) {
156
+ this.running = false;
157
+ this.broadcast({
158
+ type: 'error',
159
+ message: `终端启动失败:${error instanceof Error ? error.message : String(error)}\n` +
160
+ 'PTY 原生模块可能没装上,试试重新安装:npm install -g @vincentbanana/bananacli',
161
+ });
162
+ return;
163
+ }
164
+ const pty = this.pty;
165
+ this.running = true;
166
+ this.exitPromise = new Promise((resolvePromise) => {
167
+ this.exitResolve = resolvePromise;
168
+ });
169
+ pty.onData((data) => {
170
+ this.buffer = (this.buffer + data).slice(-200_000);
171
+ this.broadcast({ type: 'data', data });
172
+ });
173
+ pty.onExit(({ exitCode, signal }) => {
174
+ // restart 时旧进程的退出事件可能晚到,不能把新进程的状态覆盖掉
175
+ if (this.pty !== pty)
176
+ return;
177
+ this.running = false;
178
+ this.pty = null;
179
+ this.exitResolve?.();
180
+ this.exitResolve = null;
181
+ const notice = `\r\n\x1b[90m[进程已退出,代码 ${exitCode ?? 0}]\x1b[0m\r\n`;
182
+ this.buffer += notice;
183
+ this.broadcast({ type: 'data', data: notice });
184
+ this.broadcast({ type: 'exit', code: exitCode ?? 0, signal: signal ?? null });
185
+ });
186
+ }
187
+ broadcast(payload) {
188
+ const line = JSON.stringify(payload);
189
+ for (const client of this.clients) {
190
+ if (client.ws.readyState === 1)
191
+ client.ws.send(line);
192
+ }
193
+ }
194
+ attach(ws) {
195
+ const client = { ws, alive: true };
196
+ this.clients.add(client);
197
+ ws.send(JSON.stringify({ type: 'ready', ...this.info(), startedAt: this.startedAt }));
198
+ // 重连时把最近的屏幕内容一次性补上,再让前端继续增量渲染
199
+ if (this.buffer)
200
+ ws.send(JSON.stringify({ type: 'data', data: this.buffer }));
201
+ ws.on('message', (raw) => {
202
+ let message;
203
+ try {
204
+ message = JSON.parse(String(raw));
205
+ }
206
+ catch {
207
+ return;
208
+ }
209
+ if (message.type === 'input' && typeof message.data === 'string')
210
+ this.write(message.data);
211
+ else if (message.type === 'resize')
212
+ this.resize(Number(message.cols) || 0, Number(message.rows) || 0);
213
+ else if (message.type === 'restart')
214
+ this.restart();
215
+ else if (message.type === 'close')
216
+ this.dispose();
217
+ });
218
+ ws.on('close', () => this.clients.delete(client));
219
+ ws.on('error', () => this.clients.delete(client));
220
+ }
221
+ write(data) {
222
+ if (this.pty && this.running)
223
+ this.pty.write(data);
224
+ }
225
+ resize(cols, rows) {
226
+ if (!cols || !rows)
227
+ return;
228
+ this.cols = Math.max(20, Math.floor(cols));
229
+ this.rows = Math.max(4, Math.floor(rows));
230
+ try {
231
+ this.pty?.resize(this.cols, this.rows);
232
+ }
233
+ catch {
234
+ /* 进程刚退出时 resize 会抛,忽略 */
235
+ }
236
+ }
237
+ restart() {
238
+ this.disposePty();
239
+ this.buffer = '';
240
+ this.start();
241
+ const pty = this.pty;
242
+ if (pty)
243
+ this.broadcast({ type: 'ready', ...this.info(), startedAt: this.startedAt });
244
+ }
245
+ async dispose() {
246
+ const pty = this.pty;
247
+ if (pty) {
248
+ try {
249
+ pty.kill();
250
+ }
251
+ catch {
252
+ /* 已经退出 */
253
+ }
254
+ // 等 PTY 真正退出,避免子进程的 stdout 句柄拖住父进程(npm publish 时会卡住)
255
+ await Promise.race([
256
+ this.exitPromise,
257
+ new Promise((resolvePromise) => setTimeout(resolvePromise, 1500)),
258
+ ]);
259
+ }
260
+ this.pty = null;
261
+ this.running = false;
262
+ for (const client of this.clients) {
263
+ try {
264
+ client.ws.close();
265
+ }
266
+ catch {
267
+ /* 已经断开 */
268
+ }
269
+ }
270
+ this.clients.clear();
271
+ }
272
+ disposePty() {
273
+ try {
274
+ this.pty?.kill();
275
+ }
276
+ catch {
277
+ /* 已经退出 */
278
+ }
279
+ this.pty = null;
280
+ this.running = false;
281
+ }
282
+ }
283
+ /** 启动时把 PTY 模块解析一次,失败不影响整个 GUI 服务。 */
284
+ export async function loadPty() {
285
+ try {
286
+ const mod = await import('@lydell/node-pty');
287
+ globalThis.__bananaPtySpawn = mod.spawn;
288
+ return true;
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ }
294
+ export function normalizeCwd(cwd, fallback) {
295
+ const raw = cwd?.trim();
296
+ if (!raw)
297
+ return fallback;
298
+ const next = resolve(raw);
299
+ try {
300
+ return existsSync(next) && statSync(next).isDirectory() ? next : fallback;
301
+ }
302
+ catch {
303
+ return fallback;
304
+ }
305
+ }