@gird/ccli 1.0.2 → 2.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.
Files changed (45) hide show
  1. package/dist/args.js +35 -0
  2. package/dist/args.js.map +1 -0
  3. package/dist/config.js +120 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/env.js +39 -0
  6. package/dist/env.js.map +1 -0
  7. package/dist/index.js +36 -226
  8. package/dist/index.js.map +1 -0
  9. package/dist/repl.js +59 -0
  10. package/dist/repl.js.map +1 -0
  11. package/dist/version.js +13 -0
  12. package/dist/version.js.map +1 -0
  13. package/package.json +4 -14
  14. package/.env.example +0 -23
  15. package/CCLI.md +0 -15
  16. package/dist/agent/client-openai.js +0 -166
  17. package/dist/agent/client.js +0 -63
  18. package/dist/agent/factory.js +0 -39
  19. package/dist/agent/loop.js +0 -163
  20. package/dist/agent/pricing.js +0 -28
  21. package/dist/agent/prompt.js +0 -43
  22. package/dist/agent/provider.js +0 -18
  23. package/dist/cli/headless.js +0 -50
  24. package/dist/cli/mentions.js +0 -60
  25. package/dist/cli/session.js +0 -432
  26. package/dist/commands/custom.js +0 -46
  27. package/dist/commands/slash.js +0 -148
  28. package/dist/config/config.js +0 -128
  29. package/dist/config/dotenv.js +0 -59
  30. package/dist/context/project.js +0 -155
  31. package/dist/hooks/hooks.js +0 -82
  32. package/dist/permissions/permissions.js +0 -87
  33. package/dist/session/store.js +0 -81
  34. package/dist/tools/bash.js +0 -97
  35. package/dist/tools/filesystem.js +0 -215
  36. package/dist/tools/index.js +0 -45
  37. package/dist/tools/search.js +0 -0
  38. package/dist/tools/task.js +0 -35
  39. package/dist/tools/todo.js +0 -68
  40. package/dist/tools/types.js +0 -8
  41. package/dist/tools/web.js +0 -108
  42. package/dist/ui/diff.js +0 -105
  43. package/dist/ui/markdown.js +0 -137
  44. package/dist/ui/spinner.js +0 -64
  45. package/dist/ui/theme.js +0 -106
@@ -1,128 +0,0 @@
1
- /**
2
- * 配置管理:API Key、模型、权限白名单、用户偏好。
3
- * 全局配置存于 ~/.ccli/config.json,项目级配置存于 <cwd>/.ccli/config.json。
4
- */
5
- import { homedir } from 'node:os';
6
- import { join } from 'node:path';
7
- import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
8
- export const DEFAULT_MODEL = 'claude-opus-4-8';
9
- export const DEFAULT_SMALL_MODEL = 'claude-haiku-4-5-20251001';
10
- const DEFAULTS = {
11
- provider: 'anthropic',
12
- model: DEFAULT_MODEL,
13
- smallModel: DEFAULT_SMALL_MODEL,
14
- maxTokens: 16384,
15
- allowedTools: [],
16
- deniedTools: [],
17
- autoApprove: false,
18
- temperature: 1,
19
- theme: 'dark',
20
- };
21
- export function globalConfigDir() {
22
- return join(homedir(), '.ccli');
23
- }
24
- export function globalConfigPath() {
25
- return join(globalConfigDir(), 'config.json');
26
- }
27
- export function projectConfigDir(cwd) {
28
- return join(cwd, '.ccli');
29
- }
30
- function readJson(path) {
31
- try {
32
- if (existsSync(path)) {
33
- return JSON.parse(readFileSync(path, 'utf8'));
34
- }
35
- }
36
- catch {
37
- // 忽略坏文件
38
- }
39
- return {};
40
- }
41
- export class Config {
42
- data;
43
- cwd;
44
- constructor(cwd) {
45
- this.cwd = cwd;
46
- const global = readJson(globalConfigPath());
47
- const project = readJson(join(projectConfigDir(cwd), 'config.json'));
48
- this.data = { ...DEFAULTS, ...global, ...project };
49
- // 环境变量优先级最高
50
- const envKey = process.env.ANTHROPIC_API_KEY ||
51
- process.env.CLAUDE_API_KEY ||
52
- process.env.CCLI_API_KEY;
53
- if (envKey)
54
- this.data.apiKey = envKey;
55
- if (process.env.ANTHROPIC_BASE_URL)
56
- this.data.baseURL = process.env.ANTHROPIC_BASE_URL;
57
- if (process.env.CCLI_MODEL)
58
- this.data.model = process.env.CCLI_MODEL;
59
- // OpenAI 兼容后端(通义千问 / 通用 OpenAI 兼容)。
60
- // 当未配置 Anthropic key 而存在 QWEN/OpenAI 兼容配置时,自动切换 provider。
61
- const qwenKey = process.env.QWEN_API_KEY;
62
- const oaiKey = process.env.OPENAI_API_KEY;
63
- const explicitProvider = process.env.CCLI_PROVIDER;
64
- if (!envKey && (qwenKey || oaiKey)) {
65
- this.data.provider = 'openai';
66
- this.data.apiKey = qwenKey || oaiKey;
67
- this.data.baseURL =
68
- process.env.QWEN_BASE_URL ||
69
- process.env.OPENAI_BASE_URL ||
70
- 'https://dashscope.aliyuncs.com/compatible-mode/v1';
71
- this.data.model =
72
- process.env.CCLI_MODEL ||
73
- process.env.QWEN_MODEL ||
74
- process.env.OPENAI_MODEL ||
75
- 'qwen-plus';
76
- }
77
- if (explicitProvider)
78
- this.data.provider = explicitProvider;
79
- // 兜底:base URL 指向兼容端点时,识别为 openai 协议
80
- if (this.data.baseURL &&
81
- /compatible-mode|dashscope|\/v1\/?$|openai/i.test(this.data.baseURL) &&
82
- !process.env.ANTHROPIC_BASE_URL) {
83
- this.data.provider = 'openai';
84
- }
85
- }
86
- get provider() {
87
- return this.data.provider;
88
- }
89
- get apiKey() {
90
- return this.data.apiKey;
91
- }
92
- get model() {
93
- return this.data.model;
94
- }
95
- set model(m) {
96
- this.data.model = m;
97
- }
98
- /** 保存到全局配置文件(不含敏感环境变量覆盖的字段以外的处理从简)。 */
99
- saveGlobal() {
100
- const dir = globalConfigDir();
101
- if (!existsSync(dir))
102
- mkdirSync(dir, { recursive: true });
103
- writeFileSync(globalConfigPath(), JSON.stringify(this.data, null, 2), 'utf8');
104
- }
105
- saveProject() {
106
- const dir = projectConfigDir(this.cwd);
107
- if (!existsSync(dir))
108
- mkdirSync(dir, { recursive: true });
109
- const path = join(dir, 'config.json');
110
- writeFileSync(path, JSON.stringify({
111
- model: this.data.model,
112
- allowedTools: this.data.allowedTools,
113
- deniedTools: this.data.deniedTools,
114
- }, null, 2), 'utf8');
115
- }
116
- addAllowed(rule) {
117
- if (!this.data.allowedTools.includes(rule)) {
118
- this.data.allowedTools.push(rule);
119
- this.saveProject();
120
- }
121
- }
122
- addDenied(rule) {
123
- if (!this.data.deniedTools.includes(rule)) {
124
- this.data.deniedTools.push(rule);
125
- this.saveProject();
126
- }
127
- }
128
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * 极简 .env 加载器(零依赖)。
3
- * 启动时从工作目录(及其上溯的项目根)读取 .env,注入 process.env。
4
- * 已存在的环境变量不会被覆盖(真实 shell 环境优先)。
5
- */
6
- import { existsSync, readFileSync } from 'node:fs';
7
- import { join, dirname } from 'node:path';
8
- function parseEnv(text) {
9
- const out = {};
10
- for (const rawLine of text.split('\n')) {
11
- const line = rawLine.trim();
12
- if (!line || line.startsWith('#'))
13
- continue;
14
- const eq = line.indexOf('=');
15
- if (eq === -1)
16
- continue;
17
- const key = line.slice(0, eq).trim();
18
- let val = line.slice(eq + 1).trim();
19
- // 去掉首尾引号
20
- if ((val.startsWith('"') && val.endsWith('"')) ||
21
- (val.startsWith("'") && val.endsWith("'"))) {
22
- val = val.slice(1, -1);
23
- }
24
- if (key)
25
- out[key] = val;
26
- }
27
- return out;
28
- }
29
- /** 从指定目录起向上查找最近的 .env(最多上溯 5 层)。 */
30
- function findEnvFile(startDir) {
31
- let dir = startDir;
32
- for (let i = 0; i < 5; i++) {
33
- const p = join(dir, '.env');
34
- if (existsSync(p))
35
- return p;
36
- const parent = dirname(dir);
37
- if (parent === dir)
38
- break;
39
- dir = parent;
40
- }
41
- return null;
42
- }
43
- /** 加载 .env 到 process.env(不覆盖已有值)。返回是否加载成功。 */
44
- export function loadDotenv(cwd) {
45
- const path = findEnvFile(cwd);
46
- if (!path)
47
- return false;
48
- try {
49
- const vars = parseEnv(readFileSync(path, 'utf8'));
50
- for (const [k, v] of Object.entries(vars)) {
51
- if (process.env[k] === undefined)
52
- process.env[k] = v;
53
- }
54
- return true;
55
- }
56
- catch {
57
- return false;
58
- }
59
- }
@@ -1,155 +0,0 @@
1
- /**
2
- * 项目上下文:扫描目录结构、读取 CCLI.md(兼容 CLAUDE.md)、收集环境信息。
3
- * 对应文档中「智能文件索引 + 分层上下文压缩」的能力。
4
- */
5
- import { existsSync, readFileSync, readdirSync, statSync, } from 'node:fs';
6
- import { join } from 'node:path';
7
- import { execSync } from 'node:child_process';
8
- const IGNORE_DIRS = new Set([
9
- 'node_modules',
10
- '.git',
11
- 'dist',
12
- 'build',
13
- 'out',
14
- '.next',
15
- '.cache',
16
- 'coverage',
17
- '__pycache__',
18
- '.venv',
19
- 'venv',
20
- 'target',
21
- '.ccli',
22
- ]);
23
- /** 生成精简的目录树(限制深度与条目数,避免上下文爆炸)。 */
24
- export function buildTree(root, maxDepth = 3, maxEntries = 200) {
25
- const out = [];
26
- let count = 0;
27
- function walk(dir, depth, prefix) {
28
- if (depth > maxDepth || count > maxEntries)
29
- return;
30
- let entries;
31
- try {
32
- entries = readdirSync(dir);
33
- }
34
- catch {
35
- return;
36
- }
37
- entries = entries
38
- .filter((e) => !e.startsWith('.') || e === '.gitignore' || e === '.env.example')
39
- .filter((e) => !IGNORE_DIRS.has(e))
40
- .sort((a, b) => {
41
- const aDir = isDir(join(dir, a));
42
- const bDir = isDir(join(dir, b));
43
- if (aDir !== bDir)
44
- return aDir ? -1 : 1;
45
- return a.localeCompare(b);
46
- });
47
- for (let i = 0; i < entries.length; i++) {
48
- if (count > maxEntries) {
49
- out.push(prefix + '└── …(已截断)');
50
- return;
51
- }
52
- const e = entries[i];
53
- const full = join(dir, e);
54
- const last = i === entries.length - 1;
55
- const branch = last ? '└── ' : '├── ';
56
- const dirFlag = isDir(full);
57
- out.push(prefix + branch + e + (dirFlag ? '/' : ''));
58
- count++;
59
- if (dirFlag) {
60
- walk(full, depth + 1, prefix + (last ? ' ' : '│ '));
61
- }
62
- }
63
- }
64
- function isDir(p) {
65
- try {
66
- return statSync(p).isDirectory();
67
- }
68
- catch {
69
- return false;
70
- }
71
- }
72
- out.push('.');
73
- walk(root, 1, '');
74
- return out.join('\n');
75
- }
76
- function safeExec(cmd, cwd) {
77
- try {
78
- return execSync(cmd, {
79
- cwd,
80
- encoding: 'utf8',
81
- stdio: ['ignore', 'pipe', 'ignore'],
82
- timeout: 5000,
83
- }).trim();
84
- }
85
- catch {
86
- return undefined;
87
- }
88
- }
89
- /**
90
- * 查找并合并项目记忆文件。
91
- * 主文件为 CCLI.md(本工具读写的约定);同时兼容读取 CLAUDE.md(Claude Code 生态)。
92
- */
93
- function loadMemory(cwd) {
94
- const candidates = [
95
- join(cwd, 'CCLI.md'),
96
- join(cwd, '.ccli', 'CCLI.md'),
97
- join(cwd, 'CLAUDE.md'), // 兼容 Claude Code
98
- ];
99
- const parts = [];
100
- for (const p of candidates) {
101
- if (existsSync(p)) {
102
- try {
103
- parts.push(readFileSync(p, 'utf8'));
104
- }
105
- catch {
106
- /* ignore */
107
- }
108
- }
109
- }
110
- return parts.length ? parts.join('\n\n') : undefined;
111
- }
112
- export function gatherContext(cwd) {
113
- const isGitRepo = existsSync(join(cwd, '.git'));
114
- const ctx = {
115
- cwd,
116
- isGitRepo,
117
- platform: `${process.platform} ${process.arch}`,
118
- tree: buildTree(cwd),
119
- memory: loadMemory(cwd),
120
- };
121
- if (isGitRepo) {
122
- ctx.gitBranch = safeExec('git rev-parse --abbrev-ref HEAD', cwd);
123
- ctx.gitStatus = safeExec('git status --short', cwd);
124
- }
125
- return ctx;
126
- }
127
- /** 将上下文渲染成注入系统提示的环境信息块。 */
128
- export function renderEnvironment(ctx) {
129
- const lines = [];
130
- lines.push('<env>');
131
- lines.push(`工作目录: ${ctx.cwd}`);
132
- lines.push(`是否 Git 仓库: ${ctx.isGitRepo ? '是' : '否'}`);
133
- if (ctx.gitBranch)
134
- lines.push(`当前分支: ${ctx.gitBranch}`);
135
- lines.push(`平台: ${ctx.platform}`);
136
- lines.push(`今天日期: ${new Date().toISOString().slice(0, 10)}`);
137
- lines.push('</env>');
138
- lines.push('');
139
- lines.push('<project_tree>');
140
- lines.push(ctx.tree);
141
- lines.push('</project_tree>');
142
- if (ctx.gitStatus) {
143
- lines.push('');
144
- lines.push('<git_status>');
145
- lines.push(ctx.gitStatus);
146
- lines.push('</git_status>');
147
- }
148
- if (ctx.memory) {
149
- lines.push('');
150
- lines.push('<project_instructions source="CCLI.md">');
151
- lines.push(ctx.memory);
152
- lines.push('</project_instructions>');
153
- }
154
- return lines.join('\n');
155
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * 生命周期钩子(Hooks)—— 复刻 Claude Code 的 PreToolUse / PostToolUse。
3
- * 在工具执行前后运行用户配置的 shell 命令;PreToolUse 非零退出码可阻止工具。
4
- */
5
- import { spawnSync } from 'node:child_process';
6
- import { existsSync, readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { homedir } from 'node:os';
9
- function loadSettings(cwd) {
10
- const merged = {};
11
- for (const p of [
12
- join(homedir(), '.ccli', 'settings.json'),
13
- join(cwd, '.ccli', 'settings.json'),
14
- ]) {
15
- if (!existsSync(p))
16
- continue;
17
- try {
18
- const data = JSON.parse(readFileSync(p, 'utf8'));
19
- const h = data.hooks;
20
- if (h?.PreToolUse)
21
- merged.PreToolUse = [...(merged.PreToolUse ?? []), ...h.PreToolUse];
22
- if (h?.PostToolUse)
23
- merged.PostToolUse = [...(merged.PostToolUse ?? []), ...h.PostToolUse];
24
- }
25
- catch {
26
- /* 忽略坏配置 */
27
- }
28
- }
29
- return merged;
30
- }
31
- export class HookRunner {
32
- cwd;
33
- config;
34
- constructor(cwd, config) {
35
- this.cwd = cwd;
36
- this.config = config ?? loadSettings(cwd);
37
- }
38
- hasAny() {
39
- return ((this.config.PreToolUse?.length ?? 0) +
40
- (this.config.PostToolUse?.length ?? 0) >
41
- 0);
42
- }
43
- /** 工具执行前:任一匹配钩子返回非零 → 阻止。 */
44
- runPre(tool, input) {
45
- for (const h of this.config.PreToolUse ?? []) {
46
- if (!this.match(h.matcher, tool))
47
- continue;
48
- const res = this.exec(h.command, { tool, input });
49
- if (res.status !== 0) {
50
- return {
51
- blocked: true,
52
- reason: (res.stderr || res.stdout || `钩子拒绝(退出码 ${res.status})`).trim(),
53
- };
54
- }
55
- }
56
- return { blocked: false };
57
- }
58
- /** 工具执行后:仅观测,忽略退出码。 */
59
- runPost(tool, input, output) {
60
- for (const h of this.config.PostToolUse ?? []) {
61
- if (!this.match(h.matcher, tool))
62
- continue;
63
- this.exec(h.command, { tool, input, output });
64
- }
65
- }
66
- match(matcher, tool) {
67
- try {
68
- return new RegExp(matcher).test(tool);
69
- }
70
- catch {
71
- return matcher === tool;
72
- }
73
- }
74
- exec(command, payload) {
75
- return spawnSync(process.env.SHELL || '/bin/bash', ['-c', command], {
76
- cwd: this.cwd,
77
- input: JSON.stringify(payload),
78
- encoding: 'utf8',
79
- timeout: 30_000,
80
- });
81
- }
82
- }
@@ -1,87 +0,0 @@
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
- }
@@ -1,81 +0,0 @@
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
- }
@@ -1,97 +0,0 @@
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
- };