@gird/ccli 1.0.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/.env.example +23 -0
- package/CCLI.md +15 -0
- package/README.md +187 -0
- package/dist/agent/client-openai.js +166 -0
- package/dist/agent/client.js +63 -0
- package/dist/agent/factory.js +39 -0
- package/dist/agent/loop.js +163 -0
- package/dist/agent/pricing.js +28 -0
- package/dist/agent/prompt.js +43 -0
- package/dist/agent/provider.js +18 -0
- package/dist/cli/headless.js +50 -0
- package/dist/cli/mentions.js +60 -0
- package/dist/cli/session.js +432 -0
- package/dist/commands/custom.js +46 -0
- package/dist/commands/slash.js +148 -0
- package/dist/config/config.js +128 -0
- package/dist/config/dotenv.js +59 -0
- package/dist/context/project.js +155 -0
- package/dist/hooks/hooks.js +82 -0
- package/dist/index.js +237 -0
- package/dist/permissions/permissions.js +87 -0
- package/dist/session/store.js +81 -0
- package/dist/tools/bash.js +97 -0
- package/dist/tools/filesystem.js +215 -0
- package/dist/tools/index.js +45 -0
- package/dist/tools/search.js +0 -0
- package/dist/tools/task.js +35 -0
- package/dist/tools/todo.js +68 -0
- package/dist/tools/types.js +8 -0
- package/dist/tools/web.js +108 -0
- package/dist/ui/diff.js +105 -0
- package/dist/ui/markdown.js +137 -0
- package/dist/ui/spinner.js +64 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CCLI 入口 —— 命令行参数解析与启动。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* ccli 进入交互式会话(REPL)
|
|
7
|
+
* ccli -p "提示词" 一次性(非交互)模式,输出后退出
|
|
8
|
+
* ccli --model <id> 指定模型
|
|
9
|
+
* ccli config 配置 API Key
|
|
10
|
+
* ccli --version / --help
|
|
11
|
+
*/
|
|
12
|
+
import * as readline from 'node:readline/promises';
|
|
13
|
+
import { stdin, stdout } from 'node:process';
|
|
14
|
+
import { Config, DEFAULT_MODEL } from './config/config.js';
|
|
15
|
+
import { Session } from './cli/session.js';
|
|
16
|
+
import { c, brand } from './ui/theme.js';
|
|
17
|
+
import { box } from './ui/markdown.js';
|
|
18
|
+
import { runHeadless } from './cli/headless.js';
|
|
19
|
+
import { SessionStore } from './session/store.js';
|
|
20
|
+
import { loadDotenv } from './config/dotenv.js';
|
|
21
|
+
const VERSION = '1.0.0';
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = { cwd: process.cwd() };
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
switch (a) {
|
|
27
|
+
case '-p':
|
|
28
|
+
case '--print':
|
|
29
|
+
args.prompt = argv[++i];
|
|
30
|
+
break;
|
|
31
|
+
case '--model':
|
|
32
|
+
case '-m':
|
|
33
|
+
args.model = argv[++i];
|
|
34
|
+
break;
|
|
35
|
+
case '--cwd':
|
|
36
|
+
args.cwd = argv[++i];
|
|
37
|
+
break;
|
|
38
|
+
case '--dangerously-skip-permissions':
|
|
39
|
+
case '--yes':
|
|
40
|
+
case '-y':
|
|
41
|
+
args.autoApprove = true;
|
|
42
|
+
break;
|
|
43
|
+
case '--continue':
|
|
44
|
+
case '-c':
|
|
45
|
+
args.continueSession = true;
|
|
46
|
+
break;
|
|
47
|
+
case '--resume':
|
|
48
|
+
case '-r': {
|
|
49
|
+
args.resume = true;
|
|
50
|
+
const next = argv[i + 1];
|
|
51
|
+
if (next && !next.startsWith('-')) {
|
|
52
|
+
args.resumeId = next;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case '--version':
|
|
58
|
+
case '-v':
|
|
59
|
+
args.command = 'version';
|
|
60
|
+
break;
|
|
61
|
+
case '--help':
|
|
62
|
+
case '-h':
|
|
63
|
+
args.command = 'help';
|
|
64
|
+
break;
|
|
65
|
+
case 'config':
|
|
66
|
+
args.command = 'config';
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
// 把裸参数当作 prompt
|
|
70
|
+
if (!a.startsWith('-') && !args.prompt) {
|
|
71
|
+
args.prompt = argv.slice(i).join(' ');
|
|
72
|
+
i = argv.length;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return args;
|
|
77
|
+
}
|
|
78
|
+
function printHelp() {
|
|
79
|
+
const lines = [
|
|
80
|
+
brand.primary(c.bold('CCLI')) + c.dim(` v${VERSION} —— AI 软件工程师(Claude Code 复刻)`),
|
|
81
|
+
'',
|
|
82
|
+
c.bold('用法'),
|
|
83
|
+
` ${brand.accent('ccli')} 进入交互式会话`,
|
|
84
|
+
` ${brand.accent('ccli "重构 utils 模块"')} 直接给出目标并进入会话`,
|
|
85
|
+
` ${brand.accent('ccli -p "..."')} 一次性模式(输出后退出,适合脚本/管道)`,
|
|
86
|
+
` ${brand.accent('ccli config')} 配置 API Key`,
|
|
87
|
+
'',
|
|
88
|
+
c.bold('选项'),
|
|
89
|
+
` ${c.cyan('-p, --print <文本>')} 非交互模式`,
|
|
90
|
+
` ${c.cyan('-m, --model <id>')} 指定模型(默认 claude-opus-4-8)`,
|
|
91
|
+
` ${c.cyan('-y, --yes')} 自动批准所有工具调用`,
|
|
92
|
+
` ${c.cyan('-c, --continue')} 恢复本目录最近一次会话`,
|
|
93
|
+
` ${c.cyan('-r, --resume [id]')} 列出/恢复历史会话`,
|
|
94
|
+
` ${c.cyan('--cwd <目录>')} 指定工作目录`,
|
|
95
|
+
` ${c.cyan('-v, --version')} 版本号`,
|
|
96
|
+
` ${c.cyan('-h, --help')} 帮助`,
|
|
97
|
+
'',
|
|
98
|
+
c.bold('环境变量'),
|
|
99
|
+
` ${c.cyan('ANTHROPIC_API_KEY')} Anthropic API 密钥`,
|
|
100
|
+
` ${c.cyan('ANTHROPIC_BASE_URL')} 自定义 API 端点(可选)`,
|
|
101
|
+
];
|
|
102
|
+
stdout.write(box(lines) + '\n');
|
|
103
|
+
}
|
|
104
|
+
async function onboardApiKey(config) {
|
|
105
|
+
stdout.write(box([
|
|
106
|
+
brand.primary(c.bold('欢迎使用 CCLI')),
|
|
107
|
+
'',
|
|
108
|
+
'首次使用,先配置一个模型后端(将保存到 ~/.ccli/config.json)。',
|
|
109
|
+
'',
|
|
110
|
+
` ${brand.accent('1')} 通义千问 Qwen ${c.dim('(OpenAI 兼容,推荐)')}`,
|
|
111
|
+
` ${brand.accent('2')} Anthropic Claude`,
|
|
112
|
+
` ${brand.accent('3')} 其他 OpenAI 兼容后端 ${c.dim('(DeepSeek / GLM / 自建)')}`,
|
|
113
|
+
], { color: brand.warn }) + '\n');
|
|
114
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
115
|
+
try {
|
|
116
|
+
const choice = (await rl.question(brand.primary('选择后端 [1] ❯ '))).trim() || '1';
|
|
117
|
+
if (choice === '2') {
|
|
118
|
+
config.data.provider = 'anthropic';
|
|
119
|
+
const key = (await rl.question(brand.primary('Anthropic API Key ❯ '))).trim();
|
|
120
|
+
if (!key)
|
|
121
|
+
return false;
|
|
122
|
+
config.data.apiKey = key;
|
|
123
|
+
config.data.baseURL = undefined;
|
|
124
|
+
config.data.model = DEFAULT_MODEL;
|
|
125
|
+
}
|
|
126
|
+
else if (choice === '3') {
|
|
127
|
+
config.data.provider = 'openai';
|
|
128
|
+
const base = (await rl.question(brand.primary('Base URL ❯ '))).trim();
|
|
129
|
+
const model = (await rl.question(brand.primary('模型 ID ❯ '))).trim();
|
|
130
|
+
const key = (await rl.question(brand.primary('API Key ❯ '))).trim();
|
|
131
|
+
if (!key || !base || !model)
|
|
132
|
+
return false;
|
|
133
|
+
config.data.apiKey = key;
|
|
134
|
+
config.data.baseURL = base;
|
|
135
|
+
config.data.model = model;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// 默认:千问
|
|
139
|
+
config.data.provider = 'openai';
|
|
140
|
+
const key = (await rl.question(brand.primary('千问 API Key ') + c.dim('(sk-...) ❯ '))).trim();
|
|
141
|
+
if (!key)
|
|
142
|
+
return false;
|
|
143
|
+
config.data.apiKey = key;
|
|
144
|
+
config.data.baseURL =
|
|
145
|
+
'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
|
146
|
+
const model = (await rl.question(brand.primary('模型 ') + c.dim('[qwen-plus] ❯ '))).trim();
|
|
147
|
+
config.data.model = model || 'qwen-plus';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
rl.close();
|
|
152
|
+
}
|
|
153
|
+
config.saveGlobal();
|
|
154
|
+
stdout.write(brand.ok('✓ 已保存配置。') +
|
|
155
|
+
c.dim(` 后端 ${config.data.provider} · 模型 ${config.data.model}\n\n`));
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
async function main() {
|
|
159
|
+
const args = parseArgs(process.argv.slice(2));
|
|
160
|
+
// 本地开发:自动加载 .env(不覆盖真实环境变量)
|
|
161
|
+
loadDotenv(args.cwd);
|
|
162
|
+
if (args.command === 'version') {
|
|
163
|
+
stdout.write(`ccli v${VERSION}\n`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (args.command === 'help') {
|
|
167
|
+
printHelp();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const config = new Config(args.cwd);
|
|
171
|
+
if (args.model) {
|
|
172
|
+
config.model = args.model;
|
|
173
|
+
}
|
|
174
|
+
if (args.autoApprove) {
|
|
175
|
+
config.data.autoApprove = true;
|
|
176
|
+
}
|
|
177
|
+
if (args.command === 'config') {
|
|
178
|
+
await onboardApiKey(config);
|
|
179
|
+
stdout.write([
|
|
180
|
+
`当前模型:${brand.accent(config.model)}`,
|
|
181
|
+
`配置文件:~/.ccli/config.json`,
|
|
182
|
+
].join('\n') + '\n');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// 检查 API Key
|
|
186
|
+
if (!config.apiKey) {
|
|
187
|
+
if (args.prompt) {
|
|
188
|
+
stdout.write(brand.err('错误:') +
|
|
189
|
+
'未设置 API Key。请先运行 ccli(无参数,引导配置),或 export ANTHROPIC_API_KEY / QWEN_API_KEY。\n');
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
const ok = await onboardApiKey(config);
|
|
193
|
+
if (!ok || !config.apiKey) {
|
|
194
|
+
stdout.write(c.dim('已退出。设置 API Key 后再运行 ccli。\n'));
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 一次性模式
|
|
199
|
+
if (args.prompt) {
|
|
200
|
+
await runHeadless(config, args.cwd, args.prompt);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// 会话恢复
|
|
204
|
+
let resumeFrom;
|
|
205
|
+
if (args.resume && !args.resumeId) {
|
|
206
|
+
// 列出供选择
|
|
207
|
+
const all = SessionStore.list(args.cwd);
|
|
208
|
+
if (!all.length) {
|
|
209
|
+
stdout.write(c.dim('该目录暂无历史会话,将开启新会话。\n'));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
stdout.write(brand.primary('该目录的历史会话:\n'));
|
|
213
|
+
for (const s of all.slice(0, 10)) {
|
|
214
|
+
stdout.write(` ${brand.accent(s.id)} ${c.dim(s.updatedAt.replace('T', ' ').slice(0, 16))} ${c.dim(`${s.messages.length} 条`)}\n`);
|
|
215
|
+
}
|
|
216
|
+
stdout.write(c.dim('用 ccli --resume <id> 恢复指定会话。本次将开启新会话。\n\n'));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else if ((args.continueSession || args.resumeId) && config.apiKey) {
|
|
220
|
+
const data = SessionStore.load(args.cwd, args.resumeId);
|
|
221
|
+
if (data) {
|
|
222
|
+
resumeFrom = data;
|
|
223
|
+
config.model = data.model;
|
|
224
|
+
stdout.write(c.dim(`已恢复会话 ${data.id}(${data.messages.length} 条历史)。\n\n`));
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
stdout.write(c.dim('未找到可恢复的会话,将开启新会话。\n\n'));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// 交互式会话
|
|
231
|
+
const session = new Session(config, args.cwd, resumeFrom);
|
|
232
|
+
await session.start();
|
|
233
|
+
}
|
|
234
|
+
main().catch((e) => {
|
|
235
|
+
stdout.write(brand.err('\n致命错误:') + (e?.stack ?? e?.message ?? String(e)) + '\n');
|
|
236
|
+
process.exit(1);
|
|
237
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// 只读工具:默认无需确认
|
|
2
|
+
const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'grep', 'glob']);
|
|
3
|
+
// 危险命令模式
|
|
4
|
+
const DANGEROUS_PATTERNS = [
|
|
5
|
+
/\brm\s+-rf?\b/,
|
|
6
|
+
/\bgit\s+push\b/,
|
|
7
|
+
/\bgit\s+reset\s+--hard\b/,
|
|
8
|
+
/\bsudo\b/,
|
|
9
|
+
/\bchmod\s+-R\b/,
|
|
10
|
+
/\b(mkfs|dd)\b/,
|
|
11
|
+
/\b:\(\)\s*\{.*\};\s*:/, // fork bomb
|
|
12
|
+
/\bcurl\b.*\|\s*(sh|bash)\b/,
|
|
13
|
+
/\bnpm\s+publish\b/,
|
|
14
|
+
/\bgit\s+push\s+.*--force\b/,
|
|
15
|
+
/>\s*\/dev\/sd/,
|
|
16
|
+
];
|
|
17
|
+
export function isDangerousCommand(cmd) {
|
|
18
|
+
return DANGEROUS_PATTERNS.some((re) => re.test(cmd));
|
|
19
|
+
}
|
|
20
|
+
/** 提取 bash 命令的「命令前缀」用于白名单匹配,如 'npm test'。 */
|
|
21
|
+
export function commandPrefix(cmd) {
|
|
22
|
+
const trimmed = cmd.trim();
|
|
23
|
+
// 取第一个管道/分号前的片段
|
|
24
|
+
const first = trimmed.split(/[|;&]/)[0].trim();
|
|
25
|
+
const parts = first.split(/\s+/);
|
|
26
|
+
// 保留命令 + 第一个子命令(如 git status / npm test)
|
|
27
|
+
if (parts.length >= 2 && /^(git|npm|yarn|pnpm|cargo|go|docker|make)$/.test(parts[0])) {
|
|
28
|
+
return `${parts[0]} ${parts[1]}`;
|
|
29
|
+
}
|
|
30
|
+
return parts[0] || trimmed;
|
|
31
|
+
}
|
|
32
|
+
export class PermissionManager {
|
|
33
|
+
config;
|
|
34
|
+
asker;
|
|
35
|
+
constructor(config, asker) {
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.asker = asker;
|
|
38
|
+
}
|
|
39
|
+
/** 判断某次工具调用是否被允许,必要时询问用户。 */
|
|
40
|
+
async check(req) {
|
|
41
|
+
// 自动批准模式
|
|
42
|
+
if (this.config.data.autoApprove)
|
|
43
|
+
return 'allow';
|
|
44
|
+
// 只读工具放行
|
|
45
|
+
if (SAFE_TOOLS.has(req.tool))
|
|
46
|
+
return 'allow';
|
|
47
|
+
// 拒绝白名单优先
|
|
48
|
+
if (this.matchRule(this.config.data.deniedTools, req))
|
|
49
|
+
return 'deny';
|
|
50
|
+
// 批准白名单
|
|
51
|
+
if (this.matchRule(this.config.data.allowedTools, req))
|
|
52
|
+
return 'allow';
|
|
53
|
+
// 询问用户
|
|
54
|
+
const answer = await this.asker.ask(req);
|
|
55
|
+
if (answer === 'deny')
|
|
56
|
+
return 'deny';
|
|
57
|
+
if (answer === 'always') {
|
|
58
|
+
this.config.addAllowed(req.rule);
|
|
59
|
+
// 同时把工具级别也加入(如 'Edit'),便于后续同类放行
|
|
60
|
+
this.config.addAllowed(toolLevelRule(req.tool));
|
|
61
|
+
return 'allow';
|
|
62
|
+
}
|
|
63
|
+
return 'allow';
|
|
64
|
+
}
|
|
65
|
+
matchRule(rules, req) {
|
|
66
|
+
for (const r of rules) {
|
|
67
|
+
if (r === req.rule)
|
|
68
|
+
return true;
|
|
69
|
+
// 工具级通配,如 'Edit' 匹配所有 edit_file
|
|
70
|
+
if (r === toolLevelRule(req.tool))
|
|
71
|
+
return true;
|
|
72
|
+
// 前缀匹配,如 'Bash(npm)' 匹配 'Bash(npm test)'
|
|
73
|
+
if (req.rule.startsWith(r.replace(/\)$/, '')))
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function toolLevelRule(tool) {
|
|
80
|
+
const map = {
|
|
81
|
+
bash: 'Bash',
|
|
82
|
+
edit_file: 'Edit',
|
|
83
|
+
write_file: 'Write',
|
|
84
|
+
read_file: 'Read',
|
|
85
|
+
};
|
|
86
|
+
return map[tool] ?? tool;
|
|
87
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话持久化 —— 把对话历史落盘,支持 --continue / --resume。
|
|
3
|
+
* 存储路径:~/.ccli/sessions/<cwd 哈希>/<sessionId>.json
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
+
function sessionsRoot() {
|
|
10
|
+
return join(homedir(), '.ccli', 'sessions');
|
|
11
|
+
}
|
|
12
|
+
function cwdDir(cwd) {
|
|
13
|
+
const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
14
|
+
return join(sessionsRoot(), hash);
|
|
15
|
+
}
|
|
16
|
+
export class SessionStore {
|
|
17
|
+
id;
|
|
18
|
+
cwd;
|
|
19
|
+
dir;
|
|
20
|
+
createdAt;
|
|
21
|
+
constructor(cwd, id) {
|
|
22
|
+
this.cwd = cwd;
|
|
23
|
+
this.id = id ?? randomUUID().slice(0, 8);
|
|
24
|
+
this.dir = cwdDir(cwd);
|
|
25
|
+
this.createdAt = new Date().toISOString();
|
|
26
|
+
}
|
|
27
|
+
path() {
|
|
28
|
+
return join(this.dir, `${this.id}.json`);
|
|
29
|
+
}
|
|
30
|
+
save(model, messages) {
|
|
31
|
+
try {
|
|
32
|
+
if (!existsSync(this.dir))
|
|
33
|
+
mkdirSync(this.dir, { recursive: true });
|
|
34
|
+
const data = {
|
|
35
|
+
id: this.id,
|
|
36
|
+
cwd: this.cwd,
|
|
37
|
+
model,
|
|
38
|
+
createdAt: this.createdAt,
|
|
39
|
+
updatedAt: new Date().toISOString(),
|
|
40
|
+
messages,
|
|
41
|
+
};
|
|
42
|
+
writeFileSync(this.path(), JSON.stringify(data), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* 落盘失败不影响主流程 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 列出该 cwd 下的会话,按更新时间倒序。 */
|
|
49
|
+
static list(cwd) {
|
|
50
|
+
const dir = cwdDir(cwd);
|
|
51
|
+
if (!existsSync(dir))
|
|
52
|
+
return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const f of readdirSync(dir)) {
|
|
55
|
+
if (!f.endsWith('.json'))
|
|
56
|
+
continue;
|
|
57
|
+
try {
|
|
58
|
+
out.push(JSON.parse(readFileSync(join(dir, f), 'utf8')));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* skip */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
65
|
+
}
|
|
66
|
+
/** 加载指定会话(id 可省略 → 最新一条)。 */
|
|
67
|
+
static load(cwd, id) {
|
|
68
|
+
const all = SessionStore.list(cwd);
|
|
69
|
+
if (!all.length)
|
|
70
|
+
return null;
|
|
71
|
+
if (!id)
|
|
72
|
+
return all[0];
|
|
73
|
+
return all.find((s) => s.id === id) ?? null;
|
|
74
|
+
}
|
|
75
|
+
/** 用已有 SessionData 构造一个续写的 store(保留原 id 与创建时间)。 */
|
|
76
|
+
static resume(data) {
|
|
77
|
+
const s = new SessionStore(data.cwd, data.id);
|
|
78
|
+
s.createdAt = data.createdAt;
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash 工具 —— 通过 child_process 执行 shell 命令。
|
|
3
|
+
* 用于运行测试、构建、git 等命令。受权限机制约束,高危命令需二次确认。
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { c, brand } from '../ui/theme.js';
|
|
7
|
+
import { commandPrefix, isDangerousCommand, } from '../permissions/permissions.js';
|
|
8
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
9
|
+
const MAX_OUTPUT = 30_000;
|
|
10
|
+
export const bashTool = {
|
|
11
|
+
name: 'bash',
|
|
12
|
+
description: '在持久化的工作目录中执行 shell 命令(zsh/bash)。用于运行测试、构建、git、安装依赖等。不要用它来读写文件——请用 read_file/write_file/edit_file。避免交互式命令。',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
command: { type: 'string', description: '要执行的 shell 命令' },
|
|
17
|
+
timeout: {
|
|
18
|
+
type: 'number',
|
|
19
|
+
description: '超时毫秒数(默认 120000,最大 600000)',
|
|
20
|
+
},
|
|
21
|
+
description: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: '对该命令用途的一句话说明(5-10 字)',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
required: ['command'],
|
|
27
|
+
},
|
|
28
|
+
permission(input) {
|
|
29
|
+
const cmd = String(input.command || '');
|
|
30
|
+
const prefix = commandPrefix(cmd);
|
|
31
|
+
const dangerous = isDangerousCommand(cmd);
|
|
32
|
+
return {
|
|
33
|
+
tool: 'bash',
|
|
34
|
+
rule: `Bash(${prefix})`,
|
|
35
|
+
description: input.description
|
|
36
|
+
? `${input.description}:${cmd}`
|
|
37
|
+
: `执行:${cmd}`,
|
|
38
|
+
dangerous,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
preview(input) {
|
|
42
|
+
return `${brand.accent('$')} ${input.command}`;
|
|
43
|
+
},
|
|
44
|
+
async run(input, ctx) {
|
|
45
|
+
const cmd = String(input.command || '');
|
|
46
|
+
const timeout = Math.min(input.timeout ?? DEFAULT_TIMEOUT, 600_000);
|
|
47
|
+
return new Promise((resolveP) => {
|
|
48
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
49
|
+
const child = spawn(shell, ['-c', cmd], {
|
|
50
|
+
cwd: ctx.cwd,
|
|
51
|
+
env: process.env,
|
|
52
|
+
});
|
|
53
|
+
let stdout = '';
|
|
54
|
+
let stderr = '';
|
|
55
|
+
let killed = false;
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
killed = true;
|
|
58
|
+
child.kill('SIGKILL');
|
|
59
|
+
}, timeout);
|
|
60
|
+
child.stdout.on('data', (d) => {
|
|
61
|
+
stdout += d.toString();
|
|
62
|
+
if (stdout.length > MAX_OUTPUT)
|
|
63
|
+
stdout = stdout.slice(0, MAX_OUTPUT);
|
|
64
|
+
});
|
|
65
|
+
child.stderr.on('data', (d) => {
|
|
66
|
+
stderr += d.toString();
|
|
67
|
+
if (stderr.length > MAX_OUTPUT)
|
|
68
|
+
stderr = stderr.slice(0, MAX_OUTPUT);
|
|
69
|
+
});
|
|
70
|
+
child.on('error', (e) => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolveP({ content: `命令启动失败:${e.message}`, isError: true });
|
|
73
|
+
});
|
|
74
|
+
child.on('close', (code) => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
77
|
+
if (killed) {
|
|
78
|
+
resolveP({
|
|
79
|
+
content: `命令超时(${timeout}ms)被终止。\n部分输出:\n${combined.slice(0, 4000)}`,
|
|
80
|
+
isError: true,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const exitNote = code === 0 ? '' : `\n[退出码 ${code}]`;
|
|
85
|
+
const body = combined || '(无输出)';
|
|
86
|
+
const truncated = body.length >= MAX_OUTPUT ? '\n…(输出已截断)' : '';
|
|
87
|
+
resolveP({
|
|
88
|
+
content: `${body}${truncated}${exitNote}`,
|
|
89
|
+
isError: code !== 0,
|
|
90
|
+
display: code === 0
|
|
91
|
+
? `${brand.ok('✓')} ${c.dim(cmd)}`
|
|
92
|
+
: `${brand.err('✗')} ${c.dim(cmd)} ${c.dim(`(退出码 ${code})`)}`,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
};
|