@vincentbanana/bananacli 0.1.0 → 0.3.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.
- package/README.md +13 -0
- package/dist/cli/repl.js +2 -1
- package/dist/cli/ui.js +24 -2
- package/dist/index.js +43 -7
- package/dist/server/ui-server.js +618 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -31,6 +31,19 @@
|
|
|
31
31
|
- **改前先看 diff、改后能回滚**:写文件前展示具体增删行,`/undo` 还原上一轮所有文件改动。
|
|
32
32
|
- **多模态与扩展**:`/image` 附加截图,`.banana/tools/` 放脚本即可注册自定义工具,成本按 token 估算。
|
|
33
33
|
|
|
34
|
+
## 图形界面
|
|
35
|
+
|
|
36
|
+
同一个内核,两种界面。CLI 加 `--ui` 就能开图形界面:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
banana --ui # 打开图形界面
|
|
40
|
+
npm install -g @vincentbanana/banana-gui && banana-gui # 或装独立的 GUI 包
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
图形界面与终端**共用同一套内核**(agent 循环、工具、权限、`commands.ts` 里的斜杠命令),行为完全一致,配置也共用 `~/.banana/config.json`。
|
|
44
|
+
|
|
45
|
+
界面提供:会话管理与导出(Markdown / JSON)、`/` 命令面板、`@` 文件补全、项目文件面板、工具输出详情、三态主题、API Key 设置。
|
|
46
|
+
|
|
34
47
|
## 平台支持
|
|
35
48
|
|
|
36
49
|
| 平台 | 状态 | 说明 |
|
package/dist/cli/repl.js
CHANGED
|
@@ -487,6 +487,7 @@ export async function startRepl(options) {
|
|
|
487
487
|
if ((session.usage.costUsd ?? 0) > 0)
|
|
488
488
|
parts.push(`累计 ${formatCost(session.usage.costUsd ?? 0)}`);
|
|
489
489
|
ui.hint(parts.join(' · '));
|
|
490
|
+
ui.line(ui.divider());
|
|
490
491
|
const todosAfter = JSON.stringify(session.todos ?? []);
|
|
491
492
|
if (todosAfter !== todosBefore && session.todos?.length) {
|
|
492
493
|
ui.line(ui.c.bold('任务清单'));
|
|
@@ -611,7 +612,7 @@ export async function startRepl(options) {
|
|
|
611
612
|
await handleInput(answer);
|
|
612
613
|
}
|
|
613
614
|
if (!exiting)
|
|
614
|
-
ui.line();
|
|
615
|
+
ui.line(ui.divider());
|
|
615
616
|
}
|
|
616
617
|
}
|
|
617
618
|
finally {
|
package/dist/cli/ui.js
CHANGED
|
@@ -35,11 +35,24 @@ export const c = {
|
|
|
35
35
|
magenta: paint(35, 39),
|
|
36
36
|
gray: paint(90, 39),
|
|
37
37
|
};
|
|
38
|
+
let sink = null;
|
|
39
|
+
/** 把输出转到别处(GUI 用);返回还原函数。 */
|
|
40
|
+
export function captureOutput(target) {
|
|
41
|
+
const previous = sink;
|
|
42
|
+
sink = target;
|
|
43
|
+
return () => {
|
|
44
|
+
sink = previous;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
38
47
|
export function write(text) {
|
|
48
|
+
if (sink) {
|
|
49
|
+
sink(text);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
39
52
|
process.stdout.write(text);
|
|
40
53
|
}
|
|
41
54
|
export function line(text = '') {
|
|
42
|
-
|
|
55
|
+
write(`${text}\n`);
|
|
43
56
|
}
|
|
44
57
|
export function info(text) {
|
|
45
58
|
line(c.cyan(text));
|
|
@@ -118,7 +131,12 @@ export function truncateDisplay(text, maxWidth, ellipsis = '…') {
|
|
|
118
131
|
return out + ellipsis;
|
|
119
132
|
}
|
|
120
133
|
export function toolLine(label, detail) {
|
|
121
|
-
return `${c.magenta('⏺')} ${c.bold(label)} ${c.dim(detail)}`;
|
|
134
|
+
return `${c.dim('│')} ${c.magenta('⏺')} ${c.bold(label)} ${c.dim(detail)}`;
|
|
135
|
+
}
|
|
136
|
+
/** 回合之间的细分隔线,让每轮对话在视觉上分成独立区块。 */
|
|
137
|
+
export function divider() {
|
|
138
|
+
const width = Math.max(24, Math.min(72, (process.stdout.columns ?? 80) - 14));
|
|
139
|
+
return c.dim('─'.repeat(width));
|
|
122
140
|
}
|
|
123
141
|
export function bullet(text) {
|
|
124
142
|
return `${c.gray('•')} ${text}`;
|
|
@@ -169,6 +187,10 @@ export function relativeTime(iso) {
|
|
|
169
187
|
return `${Math.round(hours / 24)} 天前`;
|
|
170
188
|
}
|
|
171
189
|
export function clearScreen() {
|
|
190
|
+
if (sink) {
|
|
191
|
+
sink('\u001b[clear]\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
172
194
|
if (process.stdout.isTTY)
|
|
173
195
|
process.stdout.write('\u001b[2J\u001b[H');
|
|
174
196
|
}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createProvider } from "./providers/index.js";
|
|
|
6
6
|
import { SessionStore } from "./session/store.js";
|
|
7
7
|
import { allTools } from "./tools/index.js";
|
|
8
8
|
import { loadPluginTools } from "./tools/plugins.js";
|
|
9
|
+
import { startUiServer, openBrowser } from "./server/ui-server.js";
|
|
9
10
|
import { join } from 'node:path';
|
|
10
11
|
import { startRepl } from "./cli/repl.js";
|
|
11
12
|
import { runSetupWizard } from "./cli/setup.js";
|
|
@@ -32,6 +33,9 @@ ${'🍌'} BananaCLI v${ui.version} - 终端里的多 provider AI agent
|
|
|
32
33
|
--no-tools 不给 agent 任何工具(纯对话)
|
|
33
34
|
--no-reasoning 不显示推理模型的思考过程
|
|
34
35
|
--setup 运行/重新运行配置向导(配置 API key、模型、权限)
|
|
36
|
+
--ui 启动图形界面(本地服务 + 浏览器),不开终端对话
|
|
37
|
+
--port <端口> 指定 GUI 端口(默认随机端口)
|
|
38
|
+
--no-open 启动 GUI 时不自动打开浏览器
|
|
35
39
|
-h, --help 显示帮助
|
|
36
40
|
-v, --version 显示版本
|
|
37
41
|
|
|
@@ -71,6 +75,9 @@ async function resolve(argv) {
|
|
|
71
75
|
'no-tools': { type: 'boolean' },
|
|
72
76
|
'no-reasoning': { type: 'boolean' },
|
|
73
77
|
setup: { type: 'boolean' },
|
|
78
|
+
ui: { type: 'boolean' },
|
|
79
|
+
port: { type: 'string' },
|
|
80
|
+
'no-open': { type: 'boolean' },
|
|
74
81
|
help: { type: 'boolean', short: 'h' },
|
|
75
82
|
version: { type: 'boolean', short: 'v' },
|
|
76
83
|
},
|
|
@@ -156,6 +163,9 @@ async function resolve(argv) {
|
|
|
156
163
|
noTools: Boolean(values['no-tools']),
|
|
157
164
|
yolo: Boolean(values.yolo),
|
|
158
165
|
setup: Boolean(values.setup),
|
|
166
|
+
ui: Boolean(values.ui),
|
|
167
|
+
port: values.port ? Number(values.port) : undefined,
|
|
168
|
+
launchBrowser: values['no-open'] !== true,
|
|
159
169
|
firstRun: !existsSync(configPath()),
|
|
160
170
|
missingKey,
|
|
161
171
|
};
|
|
@@ -210,13 +220,23 @@ async function main() {
|
|
|
210
220
|
provider = createProvider(config.current, config);
|
|
211
221
|
}
|
|
212
222
|
catch (error) {
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
ui.
|
|
216
|
-
|
|
223
|
+
// GUI 模式下不因缺 key 而退出:界面本身可以用来填 key
|
|
224
|
+
if (resolved.ui) {
|
|
225
|
+
ui.warn(error instanceof Error ? error.message : String(error));
|
|
226
|
+
ui.hint(' 图形界面会照常打开,在设置里填入 API Key 即可开始使用。');
|
|
227
|
+
provider = null;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
231
|
+
if (interactive)
|
|
232
|
+
ui.hint(' 运行 `banana --setup` 运行配置向导,或设置对应环境变量后重试。');
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (provider) {
|
|
237
|
+
session.provider = provider.name;
|
|
238
|
+
session.model = provider.model;
|
|
217
239
|
}
|
|
218
|
-
session.provider = provider.name;
|
|
219
|
-
session.model = provider.model;
|
|
220
240
|
const tools = resolved.noTools ? [] : [...allTools];
|
|
221
241
|
if (resolved.noTools)
|
|
222
242
|
ui.hint('已禁用全部工具(--no-tools)。');
|
|
@@ -231,10 +251,26 @@ async function main() {
|
|
|
231
251
|
ui.hint(`已加载 ${pluginResult.tools.length} 个插件工具: ${pluginResult.tools.map((tool) => tool.name).join(', ')}`);
|
|
232
252
|
}
|
|
233
253
|
}
|
|
254
|
+
// GUI 模式:本地服务 + 浏览器,不进入终端对话(不需要先有可用 provider)
|
|
255
|
+
if (resolved.ui) {
|
|
256
|
+
const handle = await startUiServer({ config, store, tools, port: resolved.port });
|
|
257
|
+
ui.line();
|
|
258
|
+
ui.ok(`GUI 已启动:${handle.url}`);
|
|
259
|
+
ui.hint('只监听 127.0.0.1,URL 里的 token 是访问凭证,不要分享。按 Ctrl+C 退出。');
|
|
260
|
+
if (resolved.launchBrowser)
|
|
261
|
+
openBrowser(handle.url);
|
|
262
|
+
await new Promise((resolvePromise) => {
|
|
263
|
+
process.on('SIGINT', () => {
|
|
264
|
+
void handle.close().finally(() => resolvePromise());
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// 走到这里说明 provider 可用(GUI 分支已在上方返回)
|
|
234
270
|
const outcome = await startRepl({
|
|
235
271
|
config,
|
|
236
272
|
store,
|
|
237
|
-
provider,
|
|
273
|
+
provider: provider,
|
|
238
274
|
tools,
|
|
239
275
|
session,
|
|
240
276
|
oneShot: resolved.oneShot,
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { extname, join, resolve, dirname } from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { bananaHome } from "../config/config.js";
|
|
8
|
+
import { runTurn } from "../agent/loop.js";
|
|
9
|
+
import { compactMessages } from "../agent/context.js";
|
|
10
|
+
import { createProvider } from "../providers/index.js";
|
|
11
|
+
import { estimateCost, formatCost, resolvePricing } from "../providers/pricing.js";
|
|
12
|
+
import { SessionStore } from "../session/store.js";
|
|
13
|
+
import { BackupStore, newTurnId } from "../session/backups.js";
|
|
14
|
+
import { allTools, permissionFor, summarizeInput } from "../tools/index.js";
|
|
15
|
+
import { loadPluginTools } from "../tools/plugins.js";
|
|
16
|
+
import { resolvePath } from "../tools/fs.js";
|
|
17
|
+
import { commands, findCommand, suggestCommand } from "../cli/commands.js";
|
|
18
|
+
import { completeCommand, completeFileReference } from "../cli/completion.js";
|
|
19
|
+
import { captureOutput } from "../cli/ui.js";
|
|
20
|
+
const MIME = {
|
|
21
|
+
'.html': 'text/html; charset=utf-8',
|
|
22
|
+
'.css': 'text/css; charset=utf-8',
|
|
23
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
};
|
|
26
|
+
const IMAGE_TYPES = {
|
|
27
|
+
'.png': 'image/png',
|
|
28
|
+
'.jpg': 'image/jpeg',
|
|
29
|
+
'.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif',
|
|
31
|
+
'.webp': 'image/webp',
|
|
32
|
+
};
|
|
33
|
+
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
34
|
+
const MAX_FILE_BYTES = 400 * 1024;
|
|
35
|
+
const TEXT_EXT = new Set([
|
|
36
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.txt', '.yml', '.yaml',
|
|
37
|
+
'.toml', '.css', '.scss', '.html', '.sh', '.bash', '.zsh', '.py', '.go', '.rs', '.java',
|
|
38
|
+
'.c', '.h', '.cpp', '.hpp', '.sql', '.xml', '.ini', '.conf', '.env', '.gitignore', '.lock',
|
|
39
|
+
]);
|
|
40
|
+
const SKIP_DIRS = new Set([
|
|
41
|
+
'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', 'coverage', '__pycache__',
|
|
42
|
+
'.venv', 'venv', 'target', '.cache', '.idea', '.gradle',
|
|
43
|
+
]);
|
|
44
|
+
/** 静态资源目录:默认是 CLI 包自带的 ui/,GUI 包启动时可以传自己的目录进来。 */
|
|
45
|
+
function defaultAssetsDir() {
|
|
46
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
|
|
47
|
+
}
|
|
48
|
+
let activeAssetsDir = null;
|
|
49
|
+
function assetRoot() {
|
|
50
|
+
return activeAssetsDir ?? defaultAssetsDir();
|
|
51
|
+
}
|
|
52
|
+
/** 启动只监听回环地址的本地 GUI 服务;token 保护所有接口。 */
|
|
53
|
+
export async function startUiServer(options) {
|
|
54
|
+
const { config, store } = options;
|
|
55
|
+
const token = randomBytes(24).toString('hex');
|
|
56
|
+
const host = options.host ?? '127.0.0.1';
|
|
57
|
+
const prefix = `/t/${token}`;
|
|
58
|
+
const assets = options.assetsDir ?? defaultAssetsDir();
|
|
59
|
+
activeAssetsDir = assets;
|
|
60
|
+
let turn = null;
|
|
61
|
+
const provider = () => createProvider(config.current, config);
|
|
62
|
+
const backups = (sessionId) => new BackupStore(join(bananaHome(), 'backups'), sessionId);
|
|
63
|
+
const info = async (session) => ({
|
|
64
|
+
version: '0.1.0',
|
|
65
|
+
provider: { name: config.current, model: config.providers[config.current]?.model ?? '', protocol: config.providers[config.current]?.protocol ?? 'openai' },
|
|
66
|
+
providers: Object.entries(config.providers).map(([name, item]) => ({
|
|
67
|
+
name,
|
|
68
|
+
model: item.model,
|
|
69
|
+
protocol: item.protocol,
|
|
70
|
+
current: name === config.current,
|
|
71
|
+
keySaved: Boolean(item.apiKey),
|
|
72
|
+
keyEnv: item.apiKeyEnv ?? null,
|
|
73
|
+
// 环境变量名只是"配置里写了",不代表真的设了值,所以把可用性也算出来
|
|
74
|
+
keyReady: Boolean(item.apiKey || (item.apiKeyEnv && process.env[item.apiKeyEnv])),
|
|
75
|
+
})),
|
|
76
|
+
workspace: config.workspace,
|
|
77
|
+
platform: process.platform,
|
|
78
|
+
permissions: config.permissions,
|
|
79
|
+
tools: options.tools.map((tool) => ({ name: tool.name, approval: tool.approval, permission: permissionFor(config.permissions, tool) })),
|
|
80
|
+
session: {
|
|
81
|
+
id: session.id,
|
|
82
|
+
title: session.title,
|
|
83
|
+
messages: session.messages,
|
|
84
|
+
todos: session.todos ?? [],
|
|
85
|
+
usage: session.usage ?? { inputTokens: 0, outputTokens: 0 },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const listSessions = async () => (await store.list()).map((item) => ({
|
|
89
|
+
id: item.id,
|
|
90
|
+
title: item.title,
|
|
91
|
+
updatedAt: item.updatedAt,
|
|
92
|
+
turns: item.turns,
|
|
93
|
+
}));
|
|
94
|
+
const server = createServer((req, res) => {
|
|
95
|
+
handle(req, res).catch((error) => {
|
|
96
|
+
if (!res.headersSent)
|
|
97
|
+
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
98
|
+
else
|
|
99
|
+
res.end();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
async function handle(req, res) {
|
|
103
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
104
|
+
if (!url.pathname.startsWith(prefix))
|
|
105
|
+
return notFound(res);
|
|
106
|
+
const path = url.pathname.slice(prefix.length) || '/';
|
|
107
|
+
if (path === '/' || path === '/index.html')
|
|
108
|
+
return sendFile(res, 'index.html');
|
|
109
|
+
if (path === '/style.css')
|
|
110
|
+
return sendFile(res, 'style.css');
|
|
111
|
+
if (path === '/app.js')
|
|
112
|
+
return sendFile(res, 'app.js');
|
|
113
|
+
let session = turn?.session ?? (await store.lastActive().then(async (id) => (id ? await store.load(id) : null))) ?? store.create();
|
|
114
|
+
if (path === '/api/info' && req.method === 'GET')
|
|
115
|
+
return json(res, 200, await info(session));
|
|
116
|
+
if (path === '/api/sessions' && req.method === 'GET')
|
|
117
|
+
return json(res, 200, { sessions: await listSessions(), current: session.id });
|
|
118
|
+
if (path === '/api/files' && req.method === 'GET') {
|
|
119
|
+
const target = url.searchParams.get('path') ?? '.';
|
|
120
|
+
try {
|
|
121
|
+
return json(res, 200, { entries: await listTree(config, target) });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (path === '/api/file' && req.method === 'GET') {
|
|
128
|
+
const requested = url.searchParams.get('path') ?? '';
|
|
129
|
+
try {
|
|
130
|
+
const context = sandboxContext(config);
|
|
131
|
+
const absolute = resolvePath(requested, context);
|
|
132
|
+
const info = await stat(absolute);
|
|
133
|
+
if (!info.isFile())
|
|
134
|
+
return json(res, 400, { error: '不是文件' });
|
|
135
|
+
if (info.size > MAX_FILE_BYTES)
|
|
136
|
+
return json(res, 200, { path: requested, tooLarge: true, bytes: info.size });
|
|
137
|
+
const ext = extname(absolute).toLowerCase();
|
|
138
|
+
if (!TEXT_EXT.has(ext) && ext !== '') {
|
|
139
|
+
return json(res, 200, { path: requested, binary: true, bytes: info.size });
|
|
140
|
+
}
|
|
141
|
+
const content = await readFile(absolute, 'utf8');
|
|
142
|
+
return json(res, 200, { path: requested, content, bytes: info.size, truncated: info.size >= MAX_FILE_BYTES });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (path === '/api/session' && req.method === 'POST') {
|
|
149
|
+
const body = await readJson(req);
|
|
150
|
+
if (body.action === 'new') {
|
|
151
|
+
session = store.create(body.title?.trim() || '新对话');
|
|
152
|
+
await store.save(session);
|
|
153
|
+
}
|
|
154
|
+
else if (body.action === 'rename') {
|
|
155
|
+
session.title = body.title?.trim() || session.title;
|
|
156
|
+
await store.save(session);
|
|
157
|
+
}
|
|
158
|
+
else if (body.action === 'switch') {
|
|
159
|
+
const found = body.ref ? await store.find(body.ref) : null;
|
|
160
|
+
if (!found)
|
|
161
|
+
return json(res, 404, { error: '没找到这个对话' });
|
|
162
|
+
session = found;
|
|
163
|
+
}
|
|
164
|
+
else if (body.action === 'delete') {
|
|
165
|
+
const found = body.ref ? await store.find(body.ref) : null;
|
|
166
|
+
if (found) {
|
|
167
|
+
await store.remove(found.id);
|
|
168
|
+
await backups(found.id).clear();
|
|
169
|
+
if (found.id === session.id) {
|
|
170
|
+
session = store.create();
|
|
171
|
+
await store.save(session);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (body.action === 'clear') {
|
|
176
|
+
session.messages = [];
|
|
177
|
+
session.todos = [];
|
|
178
|
+
session.usage = { inputTokens: 0, outputTokens: 0 };
|
|
179
|
+
await store.save(session);
|
|
180
|
+
}
|
|
181
|
+
if (turn)
|
|
182
|
+
turn.session = session;
|
|
183
|
+
return json(res, 200, { session: session.id, sessions: await listSessions(), info: await info(session) });
|
|
184
|
+
}
|
|
185
|
+
if (path === '/api/complete' && req.method === 'GET') {
|
|
186
|
+
const line = url.searchParams.get('line') ?? '';
|
|
187
|
+
// 与 CLI 共用同一份补全实现
|
|
188
|
+
const files = completeFileReference(line, config.workspace, { limit: 12 });
|
|
189
|
+
if (files)
|
|
190
|
+
return json(res, 200, { kind: 'file', replace: files[1], candidates: files[0] });
|
|
191
|
+
const commandHits = completeCommand(line, commands.map((command) => command.name));
|
|
192
|
+
if (commandHits)
|
|
193
|
+
return json(res, 200, { kind: 'command', replace: commandHits[1], candidates: commandHits[0] });
|
|
194
|
+
return json(res, 200, { kind: 'none', replace: '', candidates: [] });
|
|
195
|
+
}
|
|
196
|
+
if (path === '/api/command' && req.method === 'POST') {
|
|
197
|
+
const body = await readJson(req);
|
|
198
|
+
return runCliCommand(body.input ?? '', res, session);
|
|
199
|
+
}
|
|
200
|
+
if (path === '/api/commands' && req.method === 'GET') {
|
|
201
|
+
return json(res, 200, {
|
|
202
|
+
commands: commands.map((command) => ({
|
|
203
|
+
name: command.name,
|
|
204
|
+
aliases: command.aliases ?? [],
|
|
205
|
+
usage: command.usage,
|
|
206
|
+
summary: command.summary,
|
|
207
|
+
})),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (path === '/api/action' && req.method === 'POST') {
|
|
211
|
+
const body = await readJson(req);
|
|
212
|
+
if (body.action === 'provider' && body.name && config.providers[body.name]) {
|
|
213
|
+
config.current = body.name;
|
|
214
|
+
session.provider = body.name;
|
|
215
|
+
}
|
|
216
|
+
else if (body.action === 'model' && body.value) {
|
|
217
|
+
const current = config.providers[config.current];
|
|
218
|
+
if (current)
|
|
219
|
+
current.model = body.value;
|
|
220
|
+
}
|
|
221
|
+
else if (body.action === 'permission' && body.name && body.value) {
|
|
222
|
+
config.permissions = { ...config.permissions, [body.name]: body.value };
|
|
223
|
+
}
|
|
224
|
+
else if (body.action === 'apiKey') {
|
|
225
|
+
const current = config.providers[config.current];
|
|
226
|
+
if (current) {
|
|
227
|
+
const value = String(body.value ?? '').trim();
|
|
228
|
+
if (value)
|
|
229
|
+
current.apiKey = value;
|
|
230
|
+
else
|
|
231
|
+
delete current.apiKey;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (body.action === 'system' && body.value) {
|
|
235
|
+
config.systemPrompt = body.value;
|
|
236
|
+
}
|
|
237
|
+
else if (body.action === 'workspace' && body.value) {
|
|
238
|
+
config.workspace = resolve(body.value);
|
|
239
|
+
}
|
|
240
|
+
const { saveConfig } = await import("../config/config.js");
|
|
241
|
+
await saveConfig(config);
|
|
242
|
+
// 没配 key 也要能把设置存下去,这里不能因为构造 provider 失败而 500
|
|
243
|
+
try {
|
|
244
|
+
session.model = provider().model;
|
|
245
|
+
session.provider = config.current;
|
|
246
|
+
await store.save(session);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* provider 暂时不可用,不影响设置已保存 */
|
|
250
|
+
}
|
|
251
|
+
return json(res, 200, { info: await info(session) });
|
|
252
|
+
}
|
|
253
|
+
if (path === '/api/undo' && req.method === 'POST') {
|
|
254
|
+
const result = await backups(session.id).undo();
|
|
255
|
+
return json(res, 200, { restored: result.restored, deleted: result.deleted, had: Boolean(result.turn) });
|
|
256
|
+
}
|
|
257
|
+
if (path === '/api/attach' && req.method === 'POST') {
|
|
258
|
+
const name = decodeURIComponent(new URL(req.url ?? '/', 'http://x').searchParams.get('name') ?? 'image.png');
|
|
259
|
+
const body = await readBody(req);
|
|
260
|
+
const buffer = Buffer.from(body, 'base64');
|
|
261
|
+
const dir = join(bananaHome(), 'attachments');
|
|
262
|
+
await mkdir(dir, { recursive: true });
|
|
263
|
+
const file = join(dir, `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}${extname(name).toLowerCase()}`);
|
|
264
|
+
await writeFile(file, buffer);
|
|
265
|
+
return json(res, 200, { path: file, bytes: buffer.byteLength });
|
|
266
|
+
}
|
|
267
|
+
if (path === '/api/approve' && req.method === 'POST') {
|
|
268
|
+
const body = await readJson(req);
|
|
269
|
+
const active = turn;
|
|
270
|
+
const resolvePending = active?.pending.get(body.id);
|
|
271
|
+
if (resolvePending && active) {
|
|
272
|
+
active.pending.delete(body.id);
|
|
273
|
+
resolvePending(Boolean(body.allowed));
|
|
274
|
+
}
|
|
275
|
+
return json(res, 200, { ok: Boolean(resolvePending) });
|
|
276
|
+
}
|
|
277
|
+
if (path === '/api/abort' && req.method === 'POST') {
|
|
278
|
+
if (turn) {
|
|
279
|
+
for (const resolvePending of turn.pending.values())
|
|
280
|
+
resolvePending(false);
|
|
281
|
+
turn.pending.clear();
|
|
282
|
+
turn.controller.abort();
|
|
283
|
+
}
|
|
284
|
+
return json(res, 200, { ok: Boolean(turn) });
|
|
285
|
+
}
|
|
286
|
+
if (path === '/api/chat' && req.method === 'POST') {
|
|
287
|
+
const body = await readJson(req);
|
|
288
|
+
if (turn)
|
|
289
|
+
return json(res, 409, { error: '当前正在跑一轮对话' });
|
|
290
|
+
return chat(body, res, session);
|
|
291
|
+
}
|
|
292
|
+
return notFound(res);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
|
|
296
|
+
* 输出逐行推给前端,行为和终端里一致。
|
|
297
|
+
*/
|
|
298
|
+
async function runCliCommand(input, res, session) {
|
|
299
|
+
const trimmed = input.trim();
|
|
300
|
+
const [token = '', ...rest] = trimmed.replace(/^\//, '').split(/\s+/);
|
|
301
|
+
const command = findCommand(token);
|
|
302
|
+
if (!command) {
|
|
303
|
+
const suggestion = suggestCommand(token);
|
|
304
|
+
json(res, 200, {
|
|
305
|
+
ok: false,
|
|
306
|
+
output: suggestion
|
|
307
|
+
? `未知命令 /${token},你是不是想用 /${suggestion}?`
|
|
308
|
+
: `未知命令 /${token}。输入 /help 看可用命令。`,
|
|
309
|
+
info: await info(session),
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const chunks = [];
|
|
314
|
+
const restore = captureOutput((text) => chunks.push(text));
|
|
315
|
+
let next = session;
|
|
316
|
+
let failure = '';
|
|
317
|
+
// provider 惰性创建:像 /help、/cwd 这类命令不需要 key 也能跑
|
|
318
|
+
let lazyProvider = null;
|
|
319
|
+
const needProvider = () => {
|
|
320
|
+
if (!lazyProvider) {
|
|
321
|
+
try {
|
|
322
|
+
lazyProvider = provider();
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\n请先在设置里填入 API Key。`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return lazyProvider;
|
|
329
|
+
};
|
|
330
|
+
try {
|
|
331
|
+
await command.run(rest.join(' '), {
|
|
332
|
+
config,
|
|
333
|
+
store,
|
|
334
|
+
session,
|
|
335
|
+
get provider() {
|
|
336
|
+
return needProvider();
|
|
337
|
+
},
|
|
338
|
+
tools: options.tools,
|
|
339
|
+
ask: async (question) => {
|
|
340
|
+
throw new Error(`${question}(这个命令需要交互输入,请用界面上的对应功能)`);
|
|
341
|
+
},
|
|
342
|
+
confirm: async () => false,
|
|
343
|
+
setSession: (value) => {
|
|
344
|
+
next = value;
|
|
345
|
+
},
|
|
346
|
+
setSessionAndSave: async (value) => {
|
|
347
|
+
next = value;
|
|
348
|
+
await store.save(value);
|
|
349
|
+
},
|
|
350
|
+
setProvider: () => { },
|
|
351
|
+
setConfig: (value) => Object.assign(config, value),
|
|
352
|
+
setTools: () => { },
|
|
353
|
+
saveSession: async () => {
|
|
354
|
+
await store.save(next);
|
|
355
|
+
},
|
|
356
|
+
reloadProvider: () => { },
|
|
357
|
+
compactNow: async () => { },
|
|
358
|
+
runSetup: async () => {
|
|
359
|
+
throw new Error('配置向导请在终端运行 banana --setup');
|
|
360
|
+
},
|
|
361
|
+
undo: async () => backups(next.id).undo(),
|
|
362
|
+
clearBackups: async (sessionId) => {
|
|
363
|
+
await backups(sessionId).clear();
|
|
364
|
+
},
|
|
365
|
+
attachImage: async () => ({ ok: false, message: '请直接在界面里粘贴图片' }),
|
|
366
|
+
listImages: () => [],
|
|
367
|
+
clearImages: () => { },
|
|
368
|
+
clearScreen: () => { },
|
|
369
|
+
exit: () => { },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
restore();
|
|
377
|
+
}
|
|
378
|
+
return json(res, 200, {
|
|
379
|
+
ok: !failure,
|
|
380
|
+
output: chunks.join(''),
|
|
381
|
+
error: failure,
|
|
382
|
+
info: await info(next),
|
|
383
|
+
sessions: await listSessions(),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
async function chat(body, res, session) {
|
|
387
|
+
const controller = new AbortController();
|
|
388
|
+
const active = { controller, pending: new Map(), session };
|
|
389
|
+
turn = active;
|
|
390
|
+
res.writeHead(200, {
|
|
391
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
392
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
393
|
+
Connection: 'keep-alive',
|
|
394
|
+
'X-Accel-Buffering': 'no',
|
|
395
|
+
});
|
|
396
|
+
const send = (type, payload = {}) => {
|
|
397
|
+
if (!res.writableEnded)
|
|
398
|
+
res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
|
|
399
|
+
};
|
|
400
|
+
const finish = () => {
|
|
401
|
+
turn = null;
|
|
402
|
+
if (!res.writableEnded)
|
|
403
|
+
res.end();
|
|
404
|
+
};
|
|
405
|
+
send('start', { session: session.id });
|
|
406
|
+
res.on('close', () => {
|
|
407
|
+
if (!res.writableEnded)
|
|
408
|
+
controller.abort();
|
|
409
|
+
});
|
|
410
|
+
// 没配 key 之类的问题要在流里说清楚,否则前端会一直转圈
|
|
411
|
+
const current = config.providers[config.current];
|
|
412
|
+
const hasKey = Boolean(current?.apiKey || (current?.apiKeyEnv && process.env[current.apiKeyEnv]));
|
|
413
|
+
if (!hasKey) {
|
|
414
|
+
send('error', {
|
|
415
|
+
message: `还没有配置 ${config.current} 的 API key。点右上角的模型按钮,在设置里填入 API Key 就能开始对话。`,
|
|
416
|
+
hint: '也可以在终端运行 banana --setup 走一遍配置向导。',
|
|
417
|
+
code: 'no_api_key',
|
|
418
|
+
});
|
|
419
|
+
finish();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const dataUrls = await Promise.all((body.images ?? []).map(async (file) => {
|
|
423
|
+
const absolute = resolve(file);
|
|
424
|
+
const type = IMAGE_TYPES[extname(absolute).toLowerCase()];
|
|
425
|
+
if (!type)
|
|
426
|
+
return null;
|
|
427
|
+
const { readFile } = await import('node:fs/promises');
|
|
428
|
+
const buffer = await readFile(absolute);
|
|
429
|
+
if (buffer.byteLength > MAX_IMAGE_BYTES)
|
|
430
|
+
return null;
|
|
431
|
+
return `data:${type};base64,${buffer.toString('base64')}`;
|
|
432
|
+
}));
|
|
433
|
+
const todosBefore = JSON.stringify(session.todos ?? []);
|
|
434
|
+
session.todos ??= [];
|
|
435
|
+
const recorder = await backups(session.id).beginTurn(newTurnId(), config.workspace);
|
|
436
|
+
const approve = async (kind, summary, preview) => {
|
|
437
|
+
const id = randomBytes(6).toString('hex');
|
|
438
|
+
send('approval', { id, kind, summary, preview });
|
|
439
|
+
return new Promise((resolvePromise) => {
|
|
440
|
+
active.pending.set(id, resolvePromise);
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
const toolContext = {
|
|
444
|
+
workspace: config.workspace,
|
|
445
|
+
signal: controller.signal,
|
|
446
|
+
approve,
|
|
447
|
+
log: (message) => send('status', { message }),
|
|
448
|
+
allowOutsideWorkspace: config.allowOutsideWorkspace,
|
|
449
|
+
permissions: config.permissions,
|
|
450
|
+
todos: session.todos,
|
|
451
|
+
};
|
|
452
|
+
const started = Date.now();
|
|
453
|
+
let result;
|
|
454
|
+
try {
|
|
455
|
+
result = await runTurn({
|
|
456
|
+
provider: provider(),
|
|
457
|
+
tools: options.tools,
|
|
458
|
+
permissions: config.permissions,
|
|
459
|
+
system: config.systemPrompt,
|
|
460
|
+
maxSteps: config.maxSteps,
|
|
461
|
+
maxContextChars: config.maxContextChars,
|
|
462
|
+
toolsWorkspace: config.workspace,
|
|
463
|
+
allowOutsideWorkspace: config.allowOutsideWorkspace,
|
|
464
|
+
signal: controller.signal,
|
|
465
|
+
todos: session.todos,
|
|
466
|
+
recordChange: (path, beforeContent) => recorder.record(path, beforeContent),
|
|
467
|
+
onText: (chunk) => send('text', { chunk }),
|
|
468
|
+
onReasoning: (chunk) => send('reasoning', { chunk }),
|
|
469
|
+
onToolStart: (name, input) => send('tool_start', { name, detail: summarizeInput(input) }),
|
|
470
|
+
onToolEnd: (name, toolResult) => send('tool_end', {
|
|
471
|
+
name,
|
|
472
|
+
ok: toolResult.ok,
|
|
473
|
+
display: toolResult.display ?? '',
|
|
474
|
+
error: toolResult.ok ? '' : toolResult.content.slice(0, 400),
|
|
475
|
+
// 详情面板用:命令输出 / 搜索结果等,最多 8KB
|
|
476
|
+
detail: toolResult.content.slice(0, 8000),
|
|
477
|
+
}),
|
|
478
|
+
onNotice: (message) => send('status', { message }),
|
|
479
|
+
onCompact: () => send('status', { message: '已压缩历史对话' }),
|
|
480
|
+
onRetry: (info) => send('status', { message: `网络重试 ${info.attempt}:${info.reason}` }),
|
|
481
|
+
approve,
|
|
482
|
+
}, session.messages, body.message, dataUrls.filter((item) => Boolean(item)));
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
// 任何异常都要以 error 事件收尾,否则前端会一直等下去
|
|
486
|
+
send('error', {
|
|
487
|
+
message: error instanceof Error ? error.message : String(error),
|
|
488
|
+
code: 'turn_failed',
|
|
489
|
+
});
|
|
490
|
+
finish();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
session.messages = result.messages;
|
|
494
|
+
session.provider = config.current;
|
|
495
|
+
session.model = session.model || provider().model;
|
|
496
|
+
const pricing = resolvePricing(session.model, config.providers[config.current]?.pricing);
|
|
497
|
+
const cost = estimateCost(result.usage, pricing);
|
|
498
|
+
session.usage = {
|
|
499
|
+
inputTokens: (session.usage?.inputTokens ?? 0) + (result.usage.inputTokens ?? 0),
|
|
500
|
+
outputTokens: (session.usage?.outputTokens ?? 0) + (result.usage.outputTokens ?? 0),
|
|
501
|
+
costUsd: (session.usage?.costUsd ?? 0) + cost,
|
|
502
|
+
};
|
|
503
|
+
await store.save(session);
|
|
504
|
+
await recorder.commit().catch(() => { });
|
|
505
|
+
send('done', {
|
|
506
|
+
stopReason: result.stopReason,
|
|
507
|
+
error: result.error ?? '',
|
|
508
|
+
steps: result.steps,
|
|
509
|
+
ms: Date.now() - started,
|
|
510
|
+
usage: result.usage,
|
|
511
|
+
cost: formatCost(cost),
|
|
512
|
+
todos: JSON.stringify(session.todos ?? []) !== todosBefore ? session.todos : undefined,
|
|
513
|
+
info: await info(session),
|
|
514
|
+
});
|
|
515
|
+
finish();
|
|
516
|
+
}
|
|
517
|
+
const port = await new Promise((resolvePromise, rejectPromise) => {
|
|
518
|
+
server.once('error', rejectPromise);
|
|
519
|
+
server.listen(options.port ?? 0, host, () => resolvePromise(server.address().port));
|
|
520
|
+
});
|
|
521
|
+
return {
|
|
522
|
+
url: `http://${host}:${port}${prefix}/`,
|
|
523
|
+
token,
|
|
524
|
+
port,
|
|
525
|
+
close: async () => {
|
|
526
|
+
// 浏览器可能留着 keep-alive 连接,先断开再关,否则 Windows 上会触发 libuv 断言
|
|
527
|
+
server.closeAllConnections?.();
|
|
528
|
+
await new Promise((resolvePromise) => server.close(() => resolvePromise()));
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function json(res, status, payload) {
|
|
533
|
+
const body = JSON.stringify(payload);
|
|
534
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
|
|
535
|
+
res.end(body);
|
|
536
|
+
}
|
|
537
|
+
function sandboxContext(config) {
|
|
538
|
+
return {
|
|
539
|
+
workspace: config.workspace,
|
|
540
|
+
signal: new AbortController().signal,
|
|
541
|
+
approve: async () => false,
|
|
542
|
+
log: () => { },
|
|
543
|
+
allowOutsideWorkspace: false,
|
|
544
|
+
permissions: config.permissions,
|
|
545
|
+
todos: [],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/** 目录树:只返回一层子项,按需展开,避免大仓库一次拉全量。 */
|
|
549
|
+
async function listTree(config, requested) {
|
|
550
|
+
const { readdir, stat: statFile } = await import('node:fs/promises');
|
|
551
|
+
const root = resolvePath(requested || '.', sandboxContext(config));
|
|
552
|
+
const info = await statFile(root);
|
|
553
|
+
if (!info.isDirectory())
|
|
554
|
+
throw new Error(`${root} 不是目录`);
|
|
555
|
+
const items = await readdir(root, { withFileTypes: true });
|
|
556
|
+
const entries = [];
|
|
557
|
+
for (const item of items) {
|
|
558
|
+
if (item.name.startsWith('.'))
|
|
559
|
+
continue;
|
|
560
|
+
if (item.isDirectory() && SKIP_DIRS.has(item.name))
|
|
561
|
+
continue;
|
|
562
|
+
const rel = join(requested === '.' ? '' : requested, item.name).split('\\').join('/');
|
|
563
|
+
if (item.isDirectory()) {
|
|
564
|
+
entries.push({ name: item.name, path: rel, type: 'dir' });
|
|
565
|
+
}
|
|
566
|
+
else if (item.isFile()) {
|
|
567
|
+
entries.push({ name: item.name, path: rel, type: 'file' });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
571
|
+
return entries.slice(0, 500);
|
|
572
|
+
}
|
|
573
|
+
function notFound(res) {
|
|
574
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
575
|
+
res.end('not found');
|
|
576
|
+
}
|
|
577
|
+
async function sendFile(res, name) {
|
|
578
|
+
const { readFile } = await import('node:fs/promises');
|
|
579
|
+
try {
|
|
580
|
+
const file = await readFile(join(assetRoot(), name));
|
|
581
|
+
res.writeHead(200, {
|
|
582
|
+
'Content-Type': MIME[extname(name)] ?? 'application/octet-stream',
|
|
583
|
+
'Content-Length': file.byteLength,
|
|
584
|
+
'Cache-Control': 'no-store',
|
|
585
|
+
});
|
|
586
|
+
res.end(file);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
notFound(res);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async function readBody(req) {
|
|
593
|
+
const chunks = [];
|
|
594
|
+
for await (const chunk of req)
|
|
595
|
+
chunks.push(Buffer.from(chunk));
|
|
596
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
597
|
+
}
|
|
598
|
+
async function readJson(req) {
|
|
599
|
+
try {
|
|
600
|
+
return JSON.parse(await readBody(req));
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return {};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/** 打开系统默认浏览器。 */
|
|
607
|
+
export function openBrowser(url) {
|
|
608
|
+
const command = process.platform === 'darwin' ? { file: 'open', args: [url] }
|
|
609
|
+
: process.platform === 'win32' ? { file: 'cmd', args: ['/c', 'start', '""', url] }
|
|
610
|
+
: { file: 'xdg-open', args: [url] };
|
|
611
|
+
try {
|
|
612
|
+
spawn(command.file, command.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
// 打不开就让用户手动复制链接
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
export const __testing = { MIME, IMAGE_TYPES, resolvePath, assetRoot };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentbanana/bananacli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A multi-provider AI agent for your terminal: file tools, shell, web search, persistent sessions, diff preview and undo",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
},
|
|
12
12
|
"main": "dist/index.js",
|
|
13
13
|
"exports": {
|
|
14
|
-
".": "./dist/index.js"
|
|
14
|
+
".": "./dist/index.js",
|
|
15
|
+
"./server": "./dist/server/ui-server.js"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"dist",
|
|
19
|
+
"ui",
|
|
18
20
|
"README.md",
|
|
19
21
|
"LICENSE"
|
|
20
22
|
],
|
|
@@ -23,7 +25,8 @@
|
|
|
23
25
|
"build": "tsc -p tsconfig.build.json",
|
|
24
26
|
"typecheck": "tsc --noEmit",
|
|
25
27
|
"dev": "node --watch src/index.ts",
|
|
26
|
-
"test": "node test/prompt.test.mjs && node test/ui.test.mjs && node test/p0.test.mjs && node test/features.test.mjs && node test/smoke.mjs",
|
|
28
|
+
"test": "node test/prompt.test.mjs && node test/ui.test.mjs && node test/p0.test.mjs && node test/features.test.mjs && node test/gui.test.mjs && node test/smoke.mjs",
|
|
29
|
+
"gui:sync": "node scripts/sync-gui.mjs",
|
|
27
30
|
"prepare": "npm run build",
|
|
28
31
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
29
32
|
},
|
|
@@ -46,4 +49,4 @@
|
|
|
46
49
|
"@types/node": "^22.20.5",
|
|
47
50
|
"typescript": "^5.7.3"
|
|
48
51
|
}
|
|
49
|
-
}
|
|
52
|
+
}
|