@gird/ccli 1.0.2 → 2.0.1

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 (46) hide show
  1. package/README.md +22 -61
  2. package/dist/args.js +35 -0
  3. package/dist/args.js.map +1 -0
  4. package/dist/config.js +120 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/env.js +39 -0
  7. package/dist/env.js.map +1 -0
  8. package/dist/index.js +36 -226
  9. package/dist/index.js.map +1 -0
  10. package/dist/repl.js +59 -0
  11. package/dist/repl.js.map +1 -0
  12. package/dist/version.js +13 -0
  13. package/dist/version.js.map +1 -0
  14. package/package.json +4 -14
  15. package/.env.example +0 -23
  16. package/CCLI.md +0 -15
  17. package/dist/agent/client-openai.js +0 -166
  18. package/dist/agent/client.js +0 -63
  19. package/dist/agent/factory.js +0 -39
  20. package/dist/agent/loop.js +0 -163
  21. package/dist/agent/pricing.js +0 -28
  22. package/dist/agent/prompt.js +0 -43
  23. package/dist/agent/provider.js +0 -18
  24. package/dist/cli/headless.js +0 -50
  25. package/dist/cli/mentions.js +0 -60
  26. package/dist/cli/session.js +0 -432
  27. package/dist/commands/custom.js +0 -46
  28. package/dist/commands/slash.js +0 -148
  29. package/dist/config/config.js +0 -128
  30. package/dist/config/dotenv.js +0 -59
  31. package/dist/context/project.js +0 -155
  32. package/dist/hooks/hooks.js +0 -82
  33. package/dist/permissions/permissions.js +0 -87
  34. package/dist/session/store.js +0 -81
  35. package/dist/tools/bash.js +0 -97
  36. package/dist/tools/filesystem.js +0 -215
  37. package/dist/tools/index.js +0 -45
  38. package/dist/tools/search.js +0 -0
  39. package/dist/tools/task.js +0 -35
  40. package/dist/tools/todo.js +0 -68
  41. package/dist/tools/types.js +0 -8
  42. package/dist/tools/web.js +0 -108
  43. package/dist/ui/diff.js +0 -105
  44. package/dist/ui/markdown.js +0 -137
  45. package/dist/ui/spinner.js +0 -64
  46. package/dist/ui/theme.js +0 -106
@@ -1,148 +0,0 @@
1
- /**
2
- * 斜杠命令(Slash Commands)—— 复刻 Claude Code 的 /help、/clear、/model 等。
3
- */
4
- import { c, brand } from '../ui/theme.js';
5
- import { box } from '../ui/markdown.js';
6
- import { estimateCost, formatUsd } from '../agent/pricing.js';
7
- export const COMMANDS = [
8
- {
9
- name: 'help',
10
- description: '显示所有可用命令',
11
- run: (_args, ctx) => {
12
- const lines = COMMANDS.map((cmd) => ` ${brand.accent('/' + cmd.name.padEnd(12))} ${c.dim(cmd.description)}`);
13
- ctx.print([
14
- brand.primary(c.bold('CCLI 命令')),
15
- ...lines,
16
- '',
17
- c.dim(' 直接输入文字即可与 AI 对话。'),
18
- c.dim(' Ctrl+C 中断当前操作,再次 Ctrl+C 或 /exit 退出。'),
19
- c.dim(' shell 直通:以 ! 开头的输入会作为 shell 命令执行。'),
20
- c.dim(' 文件引用:在消息中用 @路径 自动并入文件内容。'),
21
- c.dim(' 写记忆:以 # 开头的输入会追加到 CCLI.md。'),
22
- c.dim(' 自定义命令:在 .ccli/commands/<名>.md 定义 /<名>。'),
23
- ].join('\n'));
24
- },
25
- },
26
- {
27
- name: 'clear',
28
- description: '清空对话历史,重置上下文',
29
- run: (_args, ctx) => {
30
- ctx.clearHistory();
31
- ctx.print(brand.ok('✓ 已清空对话历史。'));
32
- },
33
- },
34
- {
35
- name: 'compact',
36
- description: '压缩对话历史以节省上下文',
37
- run: async (_args, ctx) => {
38
- await ctx.requestCompact();
39
- },
40
- },
41
- {
42
- name: 'model',
43
- description: '查看或切换模型,如 /model claude-sonnet-4-6',
44
- run: (args, ctx) => {
45
- const m = args.trim();
46
- if (!m) {
47
- ctx.print([
48
- `当前模型:${brand.accent(ctx.getModel())}`,
49
- c.dim('可选:claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5-20251001'),
50
- c.dim('用法:/model <模型ID>'),
51
- ].join('\n'));
52
- return;
53
- }
54
- ctx.setModel(m);
55
- ctx.print(brand.ok(`✓ 已切换模型为 ${m}`));
56
- },
57
- },
58
- {
59
- name: 'cost',
60
- description: '显示本次会话的 token 用量与估算成本',
61
- run: (_args, ctx) => {
62
- const u = ctx.getUsage();
63
- const model = ctx.getModel();
64
- const cost = estimateCost(model, u);
65
- ctx.print([
66
- brand.primary('本次会话用量'),
67
- ` 模型:${brand.accent(model)}`,
68
- ` 输入 tokens:${c.bold(u.input.toLocaleString())} (${formatUsd(cost.input)})`,
69
- ` 输出 tokens:${c.bold(u.output.toLocaleString())} (${formatUsd(cost.output)})`,
70
- ` 合计:${c.bold((u.input + u.output).toLocaleString())} tokens ${brand.ok(formatUsd(cost.total))}`,
71
- c.dim(' (按公开近似单价估算,仅供参考)'),
72
- ].join('\n'));
73
- },
74
- },
75
- {
76
- name: 'auto',
77
- description: '切换自动批准模式(危险:跳过所有确认)',
78
- run: (_args, ctx) => {
79
- const on = ctx.toggleAutoApprove();
80
- ctx.print(on
81
- ? c.yellow('⚠ 已开启自动批准模式 —— 所有工具调用将不再询问。')
82
- : brand.ok('✓ 已关闭自动批准模式。'));
83
- },
84
- },
85
- {
86
- name: 'permissions',
87
- description: '查看已批准的权限白名单',
88
- run: (_args, ctx) => {
89
- const allowed = ctx.listAllowed();
90
- if (!allowed.length) {
91
- ctx.print(c.dim('(暂无永久批准的权限)'));
92
- return;
93
- }
94
- ctx.print([brand.primary('已批准的权限'), ...allowed.map((r) => ` ${c.green('✓')} ${r}`)].join('\n'));
95
- },
96
- },
97
- {
98
- name: 'init',
99
- description: '分析项目并生成 CCLI.md',
100
- run: () => {
101
- /* 由 Session.runSlash 拦截处理 */
102
- },
103
- },
104
- {
105
- name: 'commit',
106
- description: '生成提交信息并 git 提交',
107
- run: () => {
108
- /* 由 Session.runSlash 拦截处理 */
109
- },
110
- },
111
- {
112
- name: 'resume',
113
- description: '列出本目录的历史会话',
114
- run: () => {
115
- /* 由 Session.runSlash 拦截处理 */
116
- },
117
- },
118
- {
119
- name: 'status',
120
- description: '显示当前会话状态',
121
- run: (_args, ctx) => {
122
- const u = ctx.getUsage();
123
- ctx.print(box([
124
- `${c.dim('工作目录')} ${ctx.cwd}`,
125
- `${c.dim('模型')} ${brand.accent(ctx.getModel())}`,
126
- `${c.dim('历史消息')} ${ctx.getHistoryLength()} 条`,
127
- `${c.dim('Token')} ${(u.input + u.output).toLocaleString()}`,
128
- ], { color: brand.accent }));
129
- },
130
- },
131
- {
132
- name: 'exit',
133
- description: '退出 CCLI',
134
- run: (_args, ctx) => {
135
- ctx.requestExit();
136
- },
137
- },
138
- {
139
- name: 'quit',
140
- description: '退出 CCLI(同 /exit)',
141
- run: (_args, ctx) => {
142
- ctx.requestExit();
143
- },
144
- },
145
- ];
146
- export function findCommand(name) {
147
- return COMMANDS.find((c) => c.name === name);
148
- }
@@ -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
- }