@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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统提示词 —— 定义 Agent 的人格、工作流与约束。
|
|
3
|
+
* 复刻 Claude Code 「AI 软件工程师」的代理型行为。
|
|
4
|
+
*/
|
|
5
|
+
import { renderEnvironment } from '../context/project.js';
|
|
6
|
+
const BASE_PROMPT = `你是 CCLI,一个运行在用户终端中的 AI 软件工程师,是 Claude Code 的开源复刻。
|
|
7
|
+
你不仅能编写代码,更能理解整个代码库的架构与逻辑,并自主完成多步骤的开发任务。
|
|
8
|
+
|
|
9
|
+
# 工作方式(代理循环 Agentic Loop)
|
|
10
|
+
你的每一次响应都遵循「感知 → 推理 → 行动 → 验证」的循环:
|
|
11
|
+
1. 感知:通过工具读取文件、运行命令、检索代码来收集事实,而不是猜测。
|
|
12
|
+
2. 推理:基于事实规划要做的步骤。
|
|
13
|
+
3. 行动:调用工具执行——读写文件、运行命令、修改代码。
|
|
14
|
+
4. 验证:运行测试 / 类型检查 / 构建命令来确认改动正确,必要时修复。
|
|
15
|
+
循环持续进行,直到用户的目标真正完成。
|
|
16
|
+
|
|
17
|
+
# 行为准则
|
|
18
|
+
- 务实、直接、简洁。这是 CLI 终端环境,避免冗长的寒暄和不必要的总结。
|
|
19
|
+
- 默认用中文与用户交流(除非用户使用其他语言)。
|
|
20
|
+
- 动手之前先用工具了解现状:阅读相关文件、搜索相关代码,理解现有约定(命名、风格、依赖)后再修改。
|
|
21
|
+
- 修改代码时严格匹配周边代码的风格与缩进。不要主动添加未被要求的注释。
|
|
22
|
+
- 不要假设某个库存在或可用——先检查(package.json、import 语句、已有用法)。
|
|
23
|
+
- 一次只做用户要求的事,不擅自扩大范围。完成后简要说明做了什么。
|
|
24
|
+
- 编辑文件后,主动运行可用的验证手段(测试、lint、typecheck、build),并修复发现的问题。
|
|
25
|
+
- 涉及破坏性操作(删除文件、git push、覆盖重要文件)时保持谨慎,遵循权限机制。
|
|
26
|
+
|
|
27
|
+
# 工具使用
|
|
28
|
+
- 优先使用专用工具(read_file / edit_file / write_file / grep / glob / list_dir)而非 shell 等价命令。
|
|
29
|
+
- 需要并行且互相独立的工具调用时,可在一次响应中同时发起。
|
|
30
|
+
- bash 工具用于运行测试、构建、git 等命令;不要用它来读写文件(用专用工具更安全)。
|
|
31
|
+
- 不要在没有读取文件内容的情况下编辑它。edit_file 要求 old_text 精确匹配(含缩进)。
|
|
32
|
+
|
|
33
|
+
# 输出格式
|
|
34
|
+
- 输出会以 GitHub 风格 Markdown 在终端渲染。可使用代码块、列表、粗体。
|
|
35
|
+
- 引用代码位置时使用 \`文件路径:行号\` 格式,便于用户跳转。
|
|
36
|
+
- 不要用表情包堆砌,保持专业。
|
|
37
|
+
|
|
38
|
+
# 安全
|
|
39
|
+
- 协助授权范围内的安全测试、防御性安全与教学场景。
|
|
40
|
+
- 拒绝明显恶意的破坏性请求。`;
|
|
41
|
+
export function buildSystemPrompt(ctx) {
|
|
42
|
+
return `${BASE_PROMPT}\n\n# 当前环境\n${renderEnvironment(ctx)}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AnthropicClient } from './client.js';
|
|
2
|
+
import { OpenAICompatClient } from './client-openai.js';
|
|
3
|
+
export function createClient(config) {
|
|
4
|
+
const common = {
|
|
5
|
+
apiKey: config.apiKey,
|
|
6
|
+
model: config.model,
|
|
7
|
+
maxTokens: config.data.maxTokens,
|
|
8
|
+
temperature: config.data.temperature,
|
|
9
|
+
};
|
|
10
|
+
if (config.provider === 'openai') {
|
|
11
|
+
return new OpenAICompatClient({
|
|
12
|
+
...common,
|
|
13
|
+
baseURL: config.data.baseURL ||
|
|
14
|
+
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return new AnthropicClient({ ...common, baseURL: config.data.baseURL });
|
|
18
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性(非交互)模式 —— 对应 `ccli -p "..."`。
|
|
3
|
+
* 适合脚本与管道:自动批准只读工具,写操作默认按配置(可 -y 全自动)。
|
|
4
|
+
*/
|
|
5
|
+
import { stdout } from 'node:process';
|
|
6
|
+
import { createClient } from '../agent/provider.js';
|
|
7
|
+
import { buildAgent } from '../agent/factory.js';
|
|
8
|
+
import { PermissionManager, } from '../permissions/permissions.js';
|
|
9
|
+
import { c, brand } from '../ui/theme.js';
|
|
10
|
+
import { renderTodos } from '../tools/todo.js';
|
|
11
|
+
import { expandMentions } from './mentions.js';
|
|
12
|
+
export async function runHeadless(config, cwd, prompt) {
|
|
13
|
+
const client = createClient(config);
|
|
14
|
+
// 非交互:无法询问用户。未开启 autoApprove 时,对写操作拒绝并提示。
|
|
15
|
+
const asker = {
|
|
16
|
+
async ask(req) {
|
|
17
|
+
if (config.data.autoApprove)
|
|
18
|
+
return 'allow';
|
|
19
|
+
stdout.write(c.dim(` [跳过未授权操作:${req.description}(非交互模式,加 -y 可自动批准)]\n`));
|
|
20
|
+
return 'deny';
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const permissions = new PermissionManager(config, asker);
|
|
24
|
+
const { agent, todos } = buildAgent({ client, cwd, permissions });
|
|
25
|
+
const isTTY = stdout.isTTY ?? false;
|
|
26
|
+
if (isTTY) {
|
|
27
|
+
todos.onChange((items) => stdout.write('\n' + renderTodos(items) + '\n'));
|
|
28
|
+
}
|
|
29
|
+
const events = {
|
|
30
|
+
onTurnStart: () => { },
|
|
31
|
+
onTurnEnd: () => { },
|
|
32
|
+
onAssistantText: (d) => stdout.write(d),
|
|
33
|
+
onToolStart: (_t, _i, preview) => {
|
|
34
|
+
if (isTTY)
|
|
35
|
+
stdout.write('\n' + brand.accent('⚒ ') + preview + '\n');
|
|
36
|
+
},
|
|
37
|
+
onToolResult: (_t, display, isError) => {
|
|
38
|
+
if (isTTY)
|
|
39
|
+
stdout.write(' ' + display + '\n');
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
await agent.run(expandMentions(prompt, cwd), events);
|
|
44
|
+
stdout.write('\n');
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
stdout.write(brand.err('错误:') + (e?.message ?? String(e)) + '\n');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @文件引用展开 —— 把用户输入中的 @path 自动并入文件内容。
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
6
|
+
const MAX_PER_FILE = 4000;
|
|
7
|
+
/**
|
|
8
|
+
* 扫描输入中的 @path,对存在的文件/目录追加内容块。
|
|
9
|
+
* 返回展开后的消息文本(原文 + 附加块)。
|
|
10
|
+
*/
|
|
11
|
+
export function expandMentions(input, cwd) {
|
|
12
|
+
const re = /@([\w./_-]+)/g;
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const blocks = [];
|
|
15
|
+
let match;
|
|
16
|
+
while ((match = re.exec(input))) {
|
|
17
|
+
const p = match[1];
|
|
18
|
+
if (seen.has(p))
|
|
19
|
+
continue;
|
|
20
|
+
seen.add(p);
|
|
21
|
+
const full = isAbsolute(p) ? p : resolve(cwd, p);
|
|
22
|
+
if (!existsSync(full))
|
|
23
|
+
continue; // 不存在则当普通文字
|
|
24
|
+
try {
|
|
25
|
+
const st = statSync(full);
|
|
26
|
+
if (st.isDirectory()) {
|
|
27
|
+
const list = readdirSync(full)
|
|
28
|
+
.filter((e) => e !== 'node_modules' && e !== '.git')
|
|
29
|
+
.slice(0, 100)
|
|
30
|
+
.join('\n');
|
|
31
|
+
blocks.push(`<directory path="${p}">\n${list}\n</directory>`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
let text = readFileSync(full, 'utf8');
|
|
35
|
+
if (text.length > MAX_PER_FILE) {
|
|
36
|
+
text = text.slice(0, MAX_PER_FILE) + '\n…(已截断)';
|
|
37
|
+
}
|
|
38
|
+
blocks.push(`<file path="${p}">\n${text}\n</file>`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* 读失败则忽略 */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!blocks.length)
|
|
46
|
+
return input;
|
|
47
|
+
return `${input}\n\n${blocks.join('\n\n')}`;
|
|
48
|
+
}
|
|
49
|
+
/** 返回输入中实际命中的引用路径(用于 UI 提示)。 */
|
|
50
|
+
export function foundMentions(input, cwd) {
|
|
51
|
+
const re = /@([\w./_-]+)/g;
|
|
52
|
+
const out = [];
|
|
53
|
+
let m;
|
|
54
|
+
while ((m = re.exec(input))) {
|
|
55
|
+
const full = isAbsolute(m[1]) ? m[1] : resolve(cwd, m[1]);
|
|
56
|
+
if (existsSync(full))
|
|
57
|
+
out.push(m[1]);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交互式会话(REPL)—— 自定义终端 UI,串联输入、流式输出、权限确认与斜杠命令。
|
|
3
|
+
*/
|
|
4
|
+
import * as readline from 'node:readline';
|
|
5
|
+
import { stdin, stdout } from 'node:process';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createClient } from '../agent/provider.js';
|
|
10
|
+
import { Agent } from '../agent/loop.js';
|
|
11
|
+
import { buildAgent } from '../agent/factory.js';
|
|
12
|
+
import { PermissionManager, } from '../permissions/permissions.js';
|
|
13
|
+
import { c, brand } from '../ui/theme.js';
|
|
14
|
+
import { renderMarkdown, box, MarkdownStream } from '../ui/markdown.js';
|
|
15
|
+
import { renderTodos } from '../tools/todo.js';
|
|
16
|
+
import { Spinner } from '../ui/spinner.js';
|
|
17
|
+
import { findCommand } from '../commands/slash.js';
|
|
18
|
+
import { loadCustomCommands, expandTemplate, } from '../commands/custom.js';
|
|
19
|
+
import { expandMentions, foundMentions } from './mentions.js';
|
|
20
|
+
import { SessionStore } from '../session/store.js';
|
|
21
|
+
import { createReadOnlyRegistry } from '../tools/index.js';
|
|
22
|
+
const INIT_PROMPT = `请分析当前项目并生成一份 CCLI.md(供 AI 助手阅读的项目说明)。
|
|
23
|
+
步骤:
|
|
24
|
+
1. 用 list_dir / glob / read_file 了解项目结构、技术栈、关键约定(构建/测试/lint 命令、代码风格、目录职责)。
|
|
25
|
+
2. 阅读 package.json / README / 配置文件等以提取真实信息,不要臆测。
|
|
26
|
+
3. 用 write_file 写入 CCLI.md,内容简洁实用:项目概述、常用命令、关键约定、目录结构要点。
|
|
27
|
+
如果已存在 CCLI.md 或 CLAUDE.md,请先读取并在其基础上补充完善,而非覆盖有用内容。`;
|
|
28
|
+
function commitPrompt(extra) {
|
|
29
|
+
return `请帮我创建一个 git 提交:
|
|
30
|
+
1. 用 bash 运行 \`git status\` 和 \`git diff --staged\`(以及必要时 \`git diff\`)了解改动。
|
|
31
|
+
2. 若没有已暂存的改动,先 \`git add -A\` 暂存相关文件。
|
|
32
|
+
3. 依据改动总结一个清晰的提交信息(中文,首行简明,必要时附要点)。
|
|
33
|
+
4. 用 \`git commit\` 提交。不要包含 Co-Authored-By 行。
|
|
34
|
+
${extra ? `\n补充要求:${extra}` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
export class Session {
|
|
37
|
+
config;
|
|
38
|
+
client;
|
|
39
|
+
agent;
|
|
40
|
+
permissions;
|
|
41
|
+
rl;
|
|
42
|
+
spinner = new Spinner();
|
|
43
|
+
cwd;
|
|
44
|
+
exiting = false;
|
|
45
|
+
currentAbort = null;
|
|
46
|
+
streaming = false;
|
|
47
|
+
streamedAny = false;
|
|
48
|
+
todos;
|
|
49
|
+
customCommands;
|
|
50
|
+
store;
|
|
51
|
+
constructor(config, cwd, resumeFrom) {
|
|
52
|
+
this.config = config;
|
|
53
|
+
this.cwd = cwd;
|
|
54
|
+
this.client = createClient(config);
|
|
55
|
+
const asker = {
|
|
56
|
+
ask: (req) => this.askPermission(req),
|
|
57
|
+
};
|
|
58
|
+
this.permissions = new PermissionManager(config, asker);
|
|
59
|
+
const built = buildAgent({
|
|
60
|
+
client: this.client,
|
|
61
|
+
cwd,
|
|
62
|
+
permissions: this.permissions,
|
|
63
|
+
});
|
|
64
|
+
this.agent = built.agent;
|
|
65
|
+
this.todos = built.todos;
|
|
66
|
+
// 渲染 todo 变更
|
|
67
|
+
this.todos.onChange((items) => {
|
|
68
|
+
stdout.write('\n' + this.indent(renderTodos(items)) + '\n');
|
|
69
|
+
});
|
|
70
|
+
// 自定义命令
|
|
71
|
+
this.customCommands = loadCustomCommands(cwd);
|
|
72
|
+
// 会话持久化
|
|
73
|
+
if (resumeFrom) {
|
|
74
|
+
this.agent.history = resumeFrom.messages;
|
|
75
|
+
this.store = SessionStore.resume(resumeFrom);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
this.store = new SessionStore(cwd);
|
|
79
|
+
}
|
|
80
|
+
this.rl = readline.createInterface({
|
|
81
|
+
input: stdin,
|
|
82
|
+
output: stdout,
|
|
83
|
+
prompt: this.promptStr(),
|
|
84
|
+
historySize: 200,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
promptStr() {
|
|
88
|
+
return brand.primary('❯ ');
|
|
89
|
+
}
|
|
90
|
+
print(s) {
|
|
91
|
+
stdout.write(s + '\n');
|
|
92
|
+
}
|
|
93
|
+
// ---------- 启动 ----------
|
|
94
|
+
async start() {
|
|
95
|
+
this.printBanner();
|
|
96
|
+
this.setupSignals();
|
|
97
|
+
this.rl.prompt();
|
|
98
|
+
this.rl.on('line', async (line) => {
|
|
99
|
+
const input = line.trim();
|
|
100
|
+
if (this.streaming)
|
|
101
|
+
return; // 流式中忽略
|
|
102
|
+
await this.handleLine(input);
|
|
103
|
+
if (!this.exiting) {
|
|
104
|
+
this.rl.setPrompt(this.promptStr());
|
|
105
|
+
this.rl.prompt();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
this.rl.on('close', () => {
|
|
109
|
+
if (!this.exiting) {
|
|
110
|
+
this.print('\n' + c.dim('再见 👋'));
|
|
111
|
+
}
|
|
112
|
+
process.exit(0);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
printBanner() {
|
|
116
|
+
const lines = [
|
|
117
|
+
brand.primary(c.bold('✶ CCLI')) + c.dim(' —— AI 软件工程师(Claude Code 复刻)'),
|
|
118
|
+
'',
|
|
119
|
+
`${c.dim('模型')} ${brand.accent(this.config.model)}`,
|
|
120
|
+
`${c.dim('目录')} ${this.cwd}`,
|
|
121
|
+
'',
|
|
122
|
+
c.dim('输入你的目标,我会自主规划并完成。/help 查看命令。'),
|
|
123
|
+
];
|
|
124
|
+
this.print(box(lines));
|
|
125
|
+
this.print('');
|
|
126
|
+
}
|
|
127
|
+
setupSignals() {
|
|
128
|
+
let lastSigint = 0;
|
|
129
|
+
process.on('SIGINT', () => {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (this.streaming && this.currentAbort) {
|
|
132
|
+
// 中断当前生成
|
|
133
|
+
this.currentAbort.abort();
|
|
134
|
+
this.spinner.stop();
|
|
135
|
+
this.streaming = false;
|
|
136
|
+
this.print('\n' + c.yellow('⏹ 已中断。'));
|
|
137
|
+
this.rl.prompt();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (now - lastSigint < 1000) {
|
|
141
|
+
this.exiting = true;
|
|
142
|
+
this.print('\n' + c.dim('再见 👋'));
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
lastSigint = now;
|
|
146
|
+
this.print('\n' + c.dim('(再次按 Ctrl+C 退出)'));
|
|
147
|
+
this.rl.prompt();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// ---------- 输入分发 ----------
|
|
151
|
+
async handleLine(input) {
|
|
152
|
+
if (!input)
|
|
153
|
+
return;
|
|
154
|
+
// shell 直通
|
|
155
|
+
if (input.startsWith('!')) {
|
|
156
|
+
this.runShell(input.slice(1).trim());
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// # 写记忆:追加到 CCLI.md
|
|
160
|
+
if (input.startsWith('#')) {
|
|
161
|
+
this.appendMemory(input.slice(1).trim());
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// 斜杠命令
|
|
165
|
+
if (input.startsWith('/')) {
|
|
166
|
+
await this.runSlash(input.slice(1));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// 普通对话 —— 进入代理循环
|
|
170
|
+
await this.converse(input);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 把一行规则追加到项目记忆文件(# 写记忆)。
|
|
174
|
+
* 默认写入 CCLI.md;若项目已有 CLAUDE.md 而无 CCLI.md,则尊重现状写入 CLAUDE.md。
|
|
175
|
+
*/
|
|
176
|
+
appendMemory(text) {
|
|
177
|
+
if (!text) {
|
|
178
|
+
this.print(c.dim('用法:# 你想让我长期记住的规则'));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const ccli = join(this.cwd, 'CCLI.md');
|
|
182
|
+
const claude = join(this.cwd, 'CLAUDE.md');
|
|
183
|
+
const path = !existsSync(ccli) && existsSync(claude) ? claude : ccli;
|
|
184
|
+
const name = path === claude ? 'CLAUDE.md' : 'CCLI.md';
|
|
185
|
+
try {
|
|
186
|
+
let content = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
187
|
+
if (content && !content.endsWith('\n'))
|
|
188
|
+
content += '\n';
|
|
189
|
+
if (!content.includes('## 用户记忆')) {
|
|
190
|
+
content += (content ? '\n' : '') + '## 用户记忆\n';
|
|
191
|
+
}
|
|
192
|
+
content += `- ${text}\n`;
|
|
193
|
+
writeFileSync(path, content, 'utf8');
|
|
194
|
+
this.print(brand.ok(`✓ 已记入 ${name}:`) + ' ' + c.dim(text));
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
this.print(brand.err('写入失败:' + e.message));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
runShell(cmd) {
|
|
201
|
+
if (!cmd)
|
|
202
|
+
return;
|
|
203
|
+
this.print(c.dim(`$ ${cmd}`));
|
|
204
|
+
const res = spawnSync(process.env.SHELL || '/bin/bash', ['-c', cmd], {
|
|
205
|
+
cwd: this.cwd,
|
|
206
|
+
stdio: 'inherit',
|
|
207
|
+
});
|
|
208
|
+
if (res.status !== 0) {
|
|
209
|
+
this.print(c.dim(`[退出码 ${res.status}]`));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async runSlash(raw) {
|
|
213
|
+
const [name, ...rest] = raw.split(/\s+/);
|
|
214
|
+
// /resume:列出会话
|
|
215
|
+
if (name === 'resume') {
|
|
216
|
+
this.listSessions();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// /init:让 agent 分析项目并生成 CCLI.md
|
|
220
|
+
if (name === 'init') {
|
|
221
|
+
await this.converse(INIT_PROMPT);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// /commit:让 agent 生成提交信息并提交
|
|
225
|
+
if (name === 'commit') {
|
|
226
|
+
await this.converse(commitPrompt(rest.join(' ')));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const cmd = findCommand(name);
|
|
230
|
+
if (!cmd) {
|
|
231
|
+
// 自定义命令兜底
|
|
232
|
+
const custom = this.customCommands.get(name);
|
|
233
|
+
if (custom) {
|
|
234
|
+
const msg = expandTemplate(custom.template, rest.join(' '));
|
|
235
|
+
this.print(c.dim(`(自定义命令 /${name})`));
|
|
236
|
+
await this.converse(msg);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.print(c.yellow(`未知命令 /${name},输入 /help 查看可用命令。`));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const sctx = {
|
|
243
|
+
print: (s) => this.print(s),
|
|
244
|
+
clearHistory: () => this.agent.clear(),
|
|
245
|
+
setModel: (m) => {
|
|
246
|
+
this.config.model = m;
|
|
247
|
+
this.client.setModel(m);
|
|
248
|
+
this.config.saveProject();
|
|
249
|
+
},
|
|
250
|
+
getModel: () => this.client.getModel(),
|
|
251
|
+
getUsage: () => this.agent.totalUsage,
|
|
252
|
+
toggleAutoApprove: () => {
|
|
253
|
+
this.config.data.autoApprove = !this.config.data.autoApprove;
|
|
254
|
+
return this.config.data.autoApprove;
|
|
255
|
+
},
|
|
256
|
+
listAllowed: () => this.config.data.allowedTools,
|
|
257
|
+
cwd: this.cwd,
|
|
258
|
+
requestExit: () => {
|
|
259
|
+
this.exiting = true;
|
|
260
|
+
this.print(c.dim('再见 👋'));
|
|
261
|
+
process.exit(0);
|
|
262
|
+
},
|
|
263
|
+
requestCompact: () => this.compact(),
|
|
264
|
+
getHistoryLength: () => this.agent.history.length,
|
|
265
|
+
};
|
|
266
|
+
await cmd.run(rest.join(' '), sctx);
|
|
267
|
+
}
|
|
268
|
+
listSessions() {
|
|
269
|
+
const all = SessionStore.list(this.cwd);
|
|
270
|
+
if (!all.length) {
|
|
271
|
+
this.print(c.dim('(该目录暂无历史会话)'));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const lines = all.slice(0, 10).map((s, i) => {
|
|
275
|
+
const when = s.updatedAt.replace('T', ' ').slice(0, 16);
|
|
276
|
+
const turns = s.messages.length;
|
|
277
|
+
return ` ${brand.accent(s.id)} ${c.dim(when)} ${c.dim(`${turns} 条`)}`;
|
|
278
|
+
});
|
|
279
|
+
this.print([
|
|
280
|
+
brand.primary('该目录的历史会话'),
|
|
281
|
+
...lines,
|
|
282
|
+
'',
|
|
283
|
+
c.dim('用 ccli --resume <id> 恢复某次会话。'),
|
|
284
|
+
].join('\n'));
|
|
285
|
+
}
|
|
286
|
+
// ---------- 对话(代理循环)----------
|
|
287
|
+
async converse(rawInput) {
|
|
288
|
+
// @文件引用展开
|
|
289
|
+
const mentioned = foundMentions(rawInput, this.cwd);
|
|
290
|
+
if (mentioned.length) {
|
|
291
|
+
this.print(c.dim(` ↳ 已引用:${mentioned.join('、')}`));
|
|
292
|
+
}
|
|
293
|
+
const input = expandMentions(rawInput, this.cwd);
|
|
294
|
+
this.streaming = true;
|
|
295
|
+
this.streamedAny = false;
|
|
296
|
+
this.currentAbort = new AbortController();
|
|
297
|
+
const signal = this.currentAbort.signal;
|
|
298
|
+
// 流式 Markdown 渲染器(每个 assistant 文本段一个)
|
|
299
|
+
let md = null;
|
|
300
|
+
const endTextSegment = () => {
|
|
301
|
+
if (md) {
|
|
302
|
+
md.flush();
|
|
303
|
+
md = null;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const events = {
|
|
307
|
+
onTurnStart: () => this.spinner.start(),
|
|
308
|
+
onTurnEnd: () => this.spinner.stop(),
|
|
309
|
+
onAssistantText: (delta) => {
|
|
310
|
+
if (!md) {
|
|
311
|
+
this.spinner.stop();
|
|
312
|
+
stdout.write('\n' + brand.primary('●') + '\n');
|
|
313
|
+
md = new MarkdownStream((s) => stdout.write(s));
|
|
314
|
+
this.streamedAny = true;
|
|
315
|
+
}
|
|
316
|
+
md.feed(delta);
|
|
317
|
+
},
|
|
318
|
+
onThinking: (delta) => {
|
|
319
|
+
// 思考内容以暗色显示
|
|
320
|
+
this.spinner.stop();
|
|
321
|
+
stdout.write(c.dim(delta));
|
|
322
|
+
},
|
|
323
|
+
onToolStart: (tool, _input, preview) => {
|
|
324
|
+
endTextSegment();
|
|
325
|
+
this.spinner.stop();
|
|
326
|
+
stdout.write('\n' + brand.accent(' ⚒ ') + preview + '\n');
|
|
327
|
+
},
|
|
328
|
+
onToolResult: (_tool, display, isError) => {
|
|
329
|
+
stdout.write(' ' + this.indent(display) + '\n');
|
|
330
|
+
},
|
|
331
|
+
onToolDenied: (_tool, reason) => {
|
|
332
|
+
stdout.write(' ' + c.yellow('✗ ' + reason) + '\n');
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
try {
|
|
336
|
+
await this.agent.run(input, events, signal);
|
|
337
|
+
endTextSegment();
|
|
338
|
+
this.store.save(this.client.getModel(), this.agent.history);
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
endTextSegment();
|
|
342
|
+
this.spinner.stop();
|
|
343
|
+
if (!signal.aborted) {
|
|
344
|
+
this.print('\n' + brand.err('✗ 出错:') + (e?.message ?? String(e)));
|
|
345
|
+
if (e?.status === 401) {
|
|
346
|
+
this.print(c.dim(' API Key 无效,请检查 ANTHROPIC_API_KEY。'));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
this.streaming = false;
|
|
352
|
+
this.currentAbort = null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
indent(s) {
|
|
356
|
+
return s.split('\n').join('\n ');
|
|
357
|
+
}
|
|
358
|
+
// ---------- 权限确认 UI ----------
|
|
359
|
+
askPermission(req) {
|
|
360
|
+
this.spinner.stop();
|
|
361
|
+
return new Promise((resolvePromise) => {
|
|
362
|
+
const title = req.dangerous
|
|
363
|
+
? brand.err('⚠ 高危操作需要确认')
|
|
364
|
+
: brand.warn('需要你的授权');
|
|
365
|
+
const lines = [
|
|
366
|
+
'',
|
|
367
|
+
box([
|
|
368
|
+
title,
|
|
369
|
+
'',
|
|
370
|
+
`${c.dim('操作')} ${req.description}`,
|
|
371
|
+
req.dangerous ? c.red(' 这是一个可能造成破坏的命令,请谨慎。') : '',
|
|
372
|
+
].filter(Boolean), { color: req.dangerous ? c.red : brand.warn }),
|
|
373
|
+
'',
|
|
374
|
+
` ${brand.ok('[y]')} 允许一次 ${brand.accent('[a]')} 始终允许(加入白名单) ${c.red('[n]')} 拒绝`,
|
|
375
|
+
'',
|
|
376
|
+
];
|
|
377
|
+
this.print(lines.join('\n'));
|
|
378
|
+
// 复用主 readline 的 question —— Node 会在此期间抑制 'line' 事件,
|
|
379
|
+
// 避免双重消费按键。递归直到得到合法答案。
|
|
380
|
+
const askOnce = () => {
|
|
381
|
+
this.rl.question(brand.primary(' > '), (raw) => {
|
|
382
|
+
const key = raw.trim().toLowerCase();
|
|
383
|
+
let answer = null;
|
|
384
|
+
if (key === 'y' || key === '' || key === 'yes')
|
|
385
|
+
answer = 'allow';
|
|
386
|
+
else if (key === 'a' || key === 'always')
|
|
387
|
+
answer = 'always';
|
|
388
|
+
else if (key === 'n' || key === 'no')
|
|
389
|
+
answer = 'deny';
|
|
390
|
+
if (!answer) {
|
|
391
|
+
stdout.write(c.dim(' 请输入 y / a / n。\n'));
|
|
392
|
+
askOnce();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
resolvePromise(answer);
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
askOnce();
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// ---------- compact ----------
|
|
402
|
+
async compact() {
|
|
403
|
+
if (this.agent.history.length === 0) {
|
|
404
|
+
this.print(c.dim('(无可压缩的历史)'));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
this.print(c.dim('正在压缩对话历史…'));
|
|
408
|
+
this.spinner.start('压缩中');
|
|
409
|
+
try {
|
|
410
|
+
const summaryAgent = this.agent;
|
|
411
|
+
// 用一个轻量请求生成摘要
|
|
412
|
+
const tmp = new Agent(this.client, createReadOnlyRegistry(), this.permissions, '你是一个对话摘要助手。请用简洁中文总结此前对话的关键事实、已完成的改动和待办,供后续继续工作使用。', { cwd: this.cwd });
|
|
413
|
+
tmp.history = [...summaryAgent.history];
|
|
414
|
+
let summary = '';
|
|
415
|
+
await tmp.run('请总结以上对话,输出一段供继续工作的上下文摘要。', {
|
|
416
|
+
onAssistantText: (d) => {
|
|
417
|
+
summary += d;
|
|
418
|
+
},
|
|
419
|
+
onTurnStart: () => { },
|
|
420
|
+
onTurnEnd: () => { },
|
|
421
|
+
});
|
|
422
|
+
this.spinner.stop();
|
|
423
|
+
this.agent.compactSummary(summary.trim());
|
|
424
|
+
this.print(brand.ok('✓ 已压缩历史。'));
|
|
425
|
+
this.print(c.dim(renderMarkdown(summary.trim()).split('\n').slice(0, 8).join('\n')));
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
this.spinner.stop();
|
|
429
|
+
this.print(brand.err('压缩失败:' + e.message));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自定义斜杠命令 —— 从 .ccli/commands/*.md 加载用户定义的提示词模板。
|
|
3
|
+
* 文件名即命令名,$ARGUMENTS 会被替换为调用参数。
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join, basename } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
function scanDir(dir) {
|
|
9
|
+
if (!existsSync(dir))
|
|
10
|
+
return [];
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const f of readdirSync(dir)) {
|
|
13
|
+
if (!f.endsWith('.md'))
|
|
14
|
+
continue;
|
|
15
|
+
try {
|
|
16
|
+
const template = readFileSync(join(dir, f), 'utf8');
|
|
17
|
+
out.push({
|
|
18
|
+
name: basename(f, '.md'),
|
|
19
|
+
template,
|
|
20
|
+
source: join(dir, f),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* skip */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** 加载项目级 + 全局自定义命令(项目级优先)。 */
|
|
30
|
+
export function loadCustomCommands(cwd) {
|
|
31
|
+
const map = new Map();
|
|
32
|
+
for (const cmd of scanDir(join(homedir(), '.ccli', 'commands'))) {
|
|
33
|
+
map.set(cmd.name, cmd);
|
|
34
|
+
}
|
|
35
|
+
for (const cmd of scanDir(join(cwd, '.ccli', 'commands'))) {
|
|
36
|
+
map.set(cmd.name, cmd); // 项目级覆盖全局
|
|
37
|
+
}
|
|
38
|
+
return map;
|
|
39
|
+
}
|
|
40
|
+
/** 把模板里的 $ARGUMENTS 展开为参数文本。 */
|
|
41
|
+
export function expandTemplate(template, args) {
|
|
42
|
+
if (template.includes('$ARGUMENTS')) {
|
|
43
|
+
return template.split('$ARGUMENTS').join(args);
|
|
44
|
+
}
|
|
45
|
+
return args ? `${template}\n\n${args}` : template;
|
|
46
|
+
}
|