claude-coder 1.7.0 → 1.8.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 (52) hide show
  1. package/README.md +177 -125
  2. package/bin/cli.js +159 -161
  3. package/package.json +52 -47
  4. package/src/commands/auth.js +294 -0
  5. package/src/commands/setup-modules/helpers.js +105 -0
  6. package/src/commands/setup-modules/index.js +26 -0
  7. package/src/commands/setup-modules/mcp.js +95 -0
  8. package/src/commands/setup-modules/provider.js +261 -0
  9. package/src/commands/setup-modules/safety.js +62 -0
  10. package/src/commands/setup-modules/simplify.js +53 -0
  11. package/src/commands/setup.js +172 -0
  12. package/src/common/assets.js +192 -0
  13. package/src/{config.js → common/config.js} +138 -201
  14. package/src/common/constants.js +57 -0
  15. package/src/{indicator.js → common/indicator.js} +222 -217
  16. package/src/common/interaction.js +170 -0
  17. package/src/common/logging.js +77 -0
  18. package/src/common/sdk.js +51 -0
  19. package/src/{tasks.js → common/tasks.js} +157 -172
  20. package/src/common/utils.js +147 -0
  21. package/src/core/base.js +54 -0
  22. package/src/core/coding.js +55 -0
  23. package/src/core/context.js +132 -0
  24. package/src/core/hooks.js +529 -0
  25. package/src/{init.js → core/init.js} +163 -144
  26. package/src/core/plan.js +318 -0
  27. package/src/core/prompts.js +253 -0
  28. package/src/core/query.js +48 -0
  29. package/src/core/repair.js +58 -0
  30. package/src/{runner.js → core/runner.js} +352 -420
  31. package/src/core/scan.js +89 -0
  32. package/src/core/simplify.js +59 -0
  33. package/src/core/validator.js +138 -0
  34. package/{prompts/ADD_GUIDE.md → templates/addGuide.md} +98 -98
  35. package/templates/addUser.md +26 -0
  36. package/{prompts/CLAUDE.md → templates/agentProtocol.md} +195 -199
  37. package/templates/bash-process.md +5 -0
  38. package/{prompts/coding_user.md → templates/codingUser.md} +31 -23
  39. package/templates/guidance.json +35 -0
  40. package/templates/playwright.md +17 -0
  41. package/templates/requirements.example.md +56 -56
  42. package/{prompts/SCAN_PROTOCOL.md → templates/scanProtocol.md} +118 -118
  43. package/{prompts/scan_user.md → templates/scanUser.md} +17 -17
  44. package/templates/test_rule.md +194 -194
  45. package/prompts/add_user.md +0 -24
  46. package/src/auth.js +0 -245
  47. package/src/hooks.js +0 -160
  48. package/src/prompts.js +0 -295
  49. package/src/scanner.js +0 -62
  50. package/src/session.js +0 -352
  51. package/src/setup.js +0 -579
  52. package/src/validator.js +0 -181
@@ -1,217 +1,222 @@
1
- 'use strict';
2
-
3
- const { COLOR } = require('./config');
4
-
5
- const SPINNERS = ['⠋', '⠙', '⠸', '⠴', '⠦', '⠇'];
6
-
7
- /**
8
- * 中间截断字符串,保留首尾
9
- */
10
- function truncateMiddle(str, maxLen) {
11
- if (str.length <= maxLen) return str;
12
- const startLen = Math.ceil((maxLen - 1) / 2);
13
- const endLen = Math.floor((maxLen - 1) / 2);
14
- return str.slice(0, startLen) + '…' + str.slice(-endLen);
15
- }
16
-
17
- /**
18
- * 路径感知截断:优先保留文件名,截断目录中间
19
- */
20
- function truncatePath(path, maxLen) {
21
- if (path.length <= maxLen) return path;
22
-
23
- const lastSlash = path.lastIndexOf('/');
24
- if (lastSlash === -1) {
25
- // 无路径分隔符,普通中间截断
26
- return truncateMiddle(path, maxLen);
27
- }
28
-
29
- const fileName = path.slice(lastSlash + 1);
30
- const dirPath = path.slice(0, lastSlash);
31
-
32
- // 文件名本身超长,截断文件名
33
- if (fileName.length >= maxLen - 2) {
34
- return truncateMiddle(path, maxLen);
35
- }
36
-
37
- // 保留文件名,截断目录
38
- const availableForDir = maxLen - fileName.length - 2; // -2 for '…/'
39
- if (availableForDir <= 0) {
40
- return '…/' + fileName.slice(0, maxLen - 2);
41
- }
42
-
43
- // 目录两端保留
44
- const dirStart = Math.ceil(availableForDir / 2);
45
- const dirEnd = Math.floor(availableForDir / 2);
46
- const truncatedDir = dirPath.slice(0, dirStart) + '…' + (dirEnd > 0 ? dirPath.slice(-dirEnd) : '');
47
-
48
- return truncatedDir + '/' + fileName;
49
- }
50
-
51
- class Indicator {
52
- constructor() {
53
- this.phase = 'thinking';
54
- this.step = '';
55
- this.toolTarget = '';
56
- this.spinnerIndex = 0;
57
- this.timer = null;
58
- this.lastActivity = '';
59
- this.lastToolTime = Date.now();
60
- this.sessionNum = 0;
61
- this.startTime = Date.now();
62
- this.stallTimeoutMin = 30;
63
- }
64
-
65
- start(sessionNum, stallTimeoutMin) {
66
- this.sessionNum = sessionNum;
67
- this.startTime = Date.now();
68
- if (stallTimeoutMin > 0) this.stallTimeoutMin = stallTimeoutMin;
69
- this.timer = setInterval(() => this._render(), 1000);
70
- }
71
-
72
- stop() {
73
- if (this.timer) {
74
- clearInterval(this.timer);
75
- this.timer = null;
76
- }
77
- process.stderr.write('\r\x1b[K');
78
- }
79
-
80
- updatePhase(phase) {
81
- this.phase = phase;
82
- }
83
-
84
- updateStep(step) {
85
- this.step = step;
86
- }
87
-
88
- appendActivity(toolName, summary) {
89
- this.lastActivity = `${toolName}: ${summary}`;
90
- }
91
-
92
- getStatusLine() {
93
- const now = new Date();
94
- const hh = String(now.getHours()).padStart(2, '0');
95
- const mi = String(now.getMinutes()).padStart(2, '0');
96
- const sc = String(now.getSeconds()).padStart(2, '0');
97
- const clock = `${hh}:${mi}:${sc}`;
98
-
99
- const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
100
- const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
101
- const ss = String(elapsed % 60).padStart(2, '0');
102
- const spinner = SPINNERS[this.spinnerIndex % SPINNERS.length];
103
-
104
- const phaseLabel = this.phase === 'thinking'
105
- ? `${COLOR.yellow}思考中${COLOR.reset}`
106
- : `${COLOR.green}编码中${COLOR.reset}`;
107
-
108
- const idleMs = Date.now() - this.lastToolTime;
109
- const idleMin = Math.floor(idleMs / 60000);
110
-
111
- let line = `${spinner} [Session ${this.sessionNum}] ${clock} ${phaseLabel} ${mm}:${ss}`;
112
- if (idleMin >= 2) {
113
- line += ` | ${COLOR.red}${idleMin}分无工具调用(等待模型响应, ${this.stallTimeoutMin}分钟超时自动中断)${COLOR.reset}`;
114
- }
115
- if (this.step) {
116
- line += ` | ${this.step}`;
117
- if (this.toolTarget) {
118
- // 动态获取终端宽度,默认 120 适配现代终端
119
- const cols = process.stderr.columns || 120;
120
- const usedWidth = line.replace(/\x1b\[[^m]*m/g, '').length;
121
- const availWidth = Math.max(20, cols - usedWidth - 4);
122
- const target = truncatePath(this.toolTarget, availWidth);
123
- line += `: ${target}`;
124
- }
125
- }
126
- return line;
127
- }
128
-
129
- _render() {
130
- this.spinnerIndex++;
131
- process.stderr.write(`\r\x1b[K${this.getStatusLine()}`);
132
- }
133
- }
134
-
135
- function extractFileTarget(toolInput) {
136
- const raw = typeof toolInput === 'object'
137
- ? (toolInput.file_path || toolInput.path || '')
138
- : '';
139
- if (!raw) return '';
140
- return raw.split('/').slice(-2).join('/');
141
- }
142
-
143
- function extractBashLabel(cmd) {
144
- if (cmd.includes('git ')) return 'Git 操作';
145
- if (cmd.includes('npm ') || cmd.includes('pip ') || cmd.includes('pnpm ') || cmd.includes('yarn ')) return '安装依赖';
146
- if (cmd.includes('curl') || cmd.includes('pytest') || cmd.includes('jest') || /\btest\b/.test(cmd)) return '测试验证';
147
- if (cmd.includes('python ') || cmd.includes('node ')) return '执行脚本';
148
- return '执行命令';
149
- }
150
-
151
- /**
152
- * 提取 Bash 命令的主体部分(移除管道、重定向等)
153
- * 正确处理引号内的内容,不会错误分割引号内的分隔符
154
- */
155
- function extractBashTarget(cmd) {
156
- // 移除开头的 cd xxx && 部分
157
- let clean = cmd.replace(/^(?:cd\s+\S+\s*&&\s*)+/g, '').trim();
158
-
159
- // 临时替换引号内的分隔符为占位符
160
- const unescape = (s) => s.replace(/\x00/g, ';');
161
- clean = clean.replace(/"[^"]*"/g, m => m.replace(/[;|&]/g, '\x00'));
162
- clean = clean.replace(/'[^']*'/g, m => m.replace(/[;|&]/g, '\x00'));
163
-
164
- // 分割并取第一部分
165
- clean = clean.split(/\s*(?:\|\|?|;|&&|2>&1|2>\/dev\/null|>\s*\/dev\/null)\s*/)[0];
166
-
167
- // 还原占位符
168
- return unescape(clean).trim();
169
- }
170
-
171
- function inferPhaseStep(indicator, toolName, toolInput) {
172
- const name = (toolName || '').toLowerCase();
173
-
174
- indicator.lastToolTime = Date.now();
175
-
176
- if (name === 'write' || name === 'edit' || name === 'multiedit' || name === 'str_replace_editor' || name === 'strreplace') {
177
- indicator.updatePhase('coding');
178
- indicator.updateStep('编辑文件');
179
- indicator.toolTarget = extractFileTarget(toolInput);
180
- } else if (name === 'bash' || name === 'shell') {
181
- const cmd = typeof toolInput === 'object' ? (toolInput.command || '') : String(toolInput || '');
182
- const label = extractBashLabel(cmd);
183
- indicator.updateStep(label);
184
- indicator.toolTarget = extractBashTarget(cmd);
185
- if (label === '测试验证' || label === '执行脚本' || label === '执行命令') {
186
- indicator.updatePhase('coding');
187
- }
188
- } else if (name === 'read' || name === 'glob' || name === 'grep' || name === 'ls') {
189
- indicator.updatePhase('thinking');
190
- indicator.updateStep('读取文件');
191
- indicator.toolTarget = extractFileTarget(toolInput);
192
- } else if (name === 'task') {
193
- indicator.updatePhase('thinking');
194
- indicator.updateStep('子 Agent 搜索');
195
- indicator.toolTarget = '';
196
- } else if (name === 'websearch' || name === 'webfetch') {
197
- indicator.updatePhase('thinking');
198
- indicator.updateStep('查阅文档');
199
- indicator.toolTarget = '';
200
- } else {
201
- indicator.updateStep('工具调用');
202
- indicator.toolTarget = '';
203
- }
204
-
205
- let summary;
206
- if (typeof toolInput === 'object') {
207
- const target = toolInput.file_path || toolInput.path || '';
208
- const cmd = toolInput.command || '';
209
- const pattern = toolInput.pattern || '';
210
- summary = target || (cmd ? cmd.slice(0, 200) : '') || (pattern ? `pattern: ${pattern}` : JSON.stringify(toolInput).slice(0, 200));
211
- } else {
212
- summary = String(toolInput || '').slice(0, 200);
213
- }
214
- indicator.appendActivity(toolName, summary);
215
- }
216
-
217
- module.exports = { Indicator, inferPhaseStep };
1
+ 'use strict';
2
+
3
+ const { COLOR } = require('./config');
4
+ const { localTimestamp, truncatePath } = require('./utils');
5
+
6
+ const SPINNERS = ['⠋', '⠙', '⠸', '⠴', '⠦', '⠇'];
7
+
8
+ class Indicator {
9
+ constructor() {
10
+ this.phase = 'thinking';
11
+ this.step = '';
12
+ this.toolTarget = '';
13
+ this.spinnerIndex = 0;
14
+ this.timer = null;
15
+ this.lastActivity = '';
16
+ this.lastToolTime = Date.now();
17
+ this.lastActivityTime = Date.now();
18
+ this.sessionNum = 0;
19
+ this.startTime = Date.now();
20
+ this.stallTimeoutMin = 30;
21
+ this.completionTimeoutMin = null;
22
+ this.toolRunning = false;
23
+ this.toolStartTime = 0;
24
+ this.currentToolName = '';
25
+ this._paused = false;
26
+ }
27
+
28
+ start(sessionNum, stallTimeoutMin) {
29
+ this.sessionNum = sessionNum;
30
+ this.startTime = Date.now();
31
+ this.lastActivityTime = Date.now();
32
+ if (stallTimeoutMin > 0) this.stallTimeoutMin = stallTimeoutMin;
33
+ this.timer = setInterval(() => this._render(), 1000);
34
+ }
35
+
36
+ stop() {
37
+ if (this.timer) {
38
+ clearInterval(this.timer);
39
+ this.timer = null;
40
+ }
41
+ process.stderr.write('\r\x1b[K');
42
+ }
43
+
44
+ updatePhase(phase) {
45
+ this.phase = phase;
46
+ }
47
+
48
+ updateStep(step) {
49
+ this.step = step;
50
+ }
51
+
52
+ appendActivity(toolName, summary) {
53
+ this.lastActivity = `${toolName}: ${summary}`;
54
+ }
55
+
56
+ setCompletionDetected(timeoutMin) {
57
+ this.completionTimeoutMin = timeoutMin;
58
+ }
59
+
60
+ updateActivity() {
61
+ this.lastActivityTime = Date.now();
62
+ }
63
+
64
+ startTool(name) {
65
+ this.toolRunning = true;
66
+ this.toolStartTime = Date.now();
67
+ this.currentToolName = name;
68
+ this.lastActivityTime = Date.now();
69
+ }
70
+
71
+ endTool() {
72
+ if (!this.toolRunning) return;
73
+ this.toolRunning = false;
74
+ this.lastActivityTime = Date.now();
75
+ }
76
+
77
+ pauseRendering() { this._paused = true; }
78
+ resumeRendering() { this._paused = false; }
79
+
80
+ getStatusLine() {
81
+ const clock = localTimestamp();
82
+ const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
83
+ const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
84
+ const ss = String(elapsed % 60).padStart(2, '0');
85
+ const spinner = SPINNERS[this.spinnerIndex % SPINNERS.length];
86
+
87
+ const phaseLabel = this.phase === 'thinking'
88
+ ? `${COLOR.yellow}思考中${COLOR.reset}`
89
+ : `${COLOR.green}编码中${COLOR.reset}`;
90
+
91
+ const idleMs = Date.now() - this.lastActivityTime;
92
+ const idleMin = Math.floor(idleMs / 60000);
93
+
94
+ let line = `${spinner} [Session ${this.sessionNum}] ${clock} ${phaseLabel} ${mm}:${ss}`;
95
+ if (idleMin >= 2) {
96
+ if (this.toolRunning) {
97
+ const toolSec = Math.floor((Date.now() - this.toolStartTime) / 1000);
98
+ const toolMm = Math.floor(toolSec / 60);
99
+ const toolSs = toolSec % 60;
100
+ line += ` | ${COLOR.yellow}工具执行中 ${toolMm}:${String(toolSs).padStart(2, '0')}${COLOR.reset}`;
101
+ } else if (this.completionTimeoutMin) {
102
+ line += ` | ${COLOR.red}${idleMin}分无响应(session_result 已写入, ${this.completionTimeoutMin}分钟超时自动中断)${COLOR.reset}`;
103
+ } else {
104
+ line += ` | ${COLOR.red}${idleMin}分无响应(等待模型响应, ${this.stallTimeoutMin}分钟超时自动中断)${COLOR.reset}`;
105
+ }
106
+ }
107
+ if (this.step) {
108
+ line += ` | ${this.step}`;
109
+ if (this.toolTarget) {
110
+ // 动态获取终端宽度,默认 120 适配现代终端
111
+ const cols = process.stderr.columns || 120;
112
+ const usedWidth = line.replace(/\x1b\[[^m]*m/g, '').length;
113
+ const availWidth = Math.max(20, cols - usedWidth - 4);
114
+ const target = truncatePath(this.toolTarget, availWidth);
115
+ line += `: ${target}`;
116
+ }
117
+ }
118
+ return line;
119
+ }
120
+
121
+ _render() {
122
+ if (this._paused) return;
123
+ this.spinnerIndex++;
124
+ process.stderr.write(`\r\x1b[K${this.getStatusLine()}`);
125
+ }
126
+ }
127
+
128
+ function extractFileTarget(toolInput) {
129
+ const raw = typeof toolInput === 'object'
130
+ ? (toolInput.file_path || toolInput.path || '')
131
+ : '';
132
+ if (!raw) return '';
133
+ return raw.split('/').slice(-2).join('/');
134
+ }
135
+
136
+ function extractBashLabel(cmd) {
137
+ if (cmd.includes('git ')) return 'Git 操作';
138
+ if (cmd.includes('npm ') || cmd.includes('pip ') || cmd.includes('pnpm ') || cmd.includes('yarn ')) return '安装依赖';
139
+ if (/\b(sleep|Start-Sleep|timeout\s+\/t)\b/i.test(cmd)) return '等待就绪';
140
+ if (cmd.includes('curl')) return '网络请求';
141
+ if (cmd.includes('pytest') || cmd.includes('jest') || /\btest\b/.test(cmd)) return '测试验证';
142
+ if (cmd.includes('python ') || cmd.includes('node ')) return '执行脚本';
143
+ return '执行命令';
144
+ }
145
+
146
+ function extractMcpTarget(toolInput) {
147
+ if (!toolInput || typeof toolInput !== 'object') return '';
148
+ return String(toolInput.url || toolInput.text || toolInput.element || '').slice(0, 60);
149
+ }
150
+
151
+ /**
152
+ * 提取 Bash 命令的主体部分(移除管道、重定向等)
153
+ * 正确处理引号内的内容,不会错误分割引号内的分隔符
154
+ */
155
+ function extractBashTarget(cmd) {
156
+ // 移除开头的 cd xxx && 部分
157
+ let clean = cmd.replace(/^(?:cd\s+\S+\s*&&\s*)+/g, '').trim();
158
+
159
+ // 临时替换引号内的分隔符为占位符
160
+ const unescape = (s) => s.replace(/\x00/g, ';');
161
+ clean = clean.replace(/"[^"]*"/g, m => m.replace(/[;|&]/g, '\x00'));
162
+ clean = clean.replace(/'[^']*'/g, m => m.replace(/[;|&]/g, '\x00'));
163
+
164
+ // 分割并取第一部分
165
+ clean = clean.split(/\s*(?:\|\|?|;|&&|2>&1|2>\/dev\/null|>\s*\/dev\/null)\s*/)[0];
166
+
167
+ // 还原占位符
168
+ return unescape(clean).trim();
169
+ }
170
+
171
+ function inferPhaseStep(indicator, toolName, toolInput) {
172
+ const name = (toolName || '').toLowerCase();
173
+
174
+ indicator.startTool(toolName);
175
+
176
+ if (name === 'write' || name === 'edit' || name === 'multiedit' || name === 'str_replace_editor' || name === 'strreplace') {
177
+ indicator.updatePhase('coding');
178
+ indicator.updateStep('编辑文件');
179
+ indicator.toolTarget = extractFileTarget(toolInput);
180
+ } else if (name === 'bash' || name === 'shell') {
181
+ const cmd = typeof toolInput === 'object' ? (toolInput.command || '') : String(toolInput || '');
182
+ const label = extractBashLabel(cmd);
183
+ indicator.updateStep(label);
184
+ indicator.toolTarget = extractBashTarget(cmd);
185
+ if (label === '测试验证' || label === '执行脚本' || label === '执行命令') {
186
+ indicator.updatePhase('coding');
187
+ }
188
+ } else if (name === 'read' || name === 'glob' || name === 'grep' || name === 'ls') {
189
+ indicator.updatePhase('thinking');
190
+ indicator.updateStep('读取文件');
191
+ indicator.toolTarget = extractFileTarget(toolInput);
192
+ } else if (name === 'task') {
193
+ indicator.updatePhase('thinking');
194
+ indicator.updateStep('子 Agent 搜索');
195
+ indicator.toolTarget = '';
196
+ } else if (name === 'websearch' || name === 'webfetch') {
197
+ indicator.updatePhase('thinking');
198
+ indicator.updateStep('查阅文档');
199
+ indicator.toolTarget = '';
200
+ } else if (name.startsWith('mcp__')) {
201
+ indicator.updatePhase('coding');
202
+ const action = name.split('__').pop() || name;
203
+ indicator.updateStep(`浏览器: ${action}`);
204
+ indicator.toolTarget = extractMcpTarget(toolInput);
205
+ } else {
206
+ indicator.updateStep('工具调用');
207
+ indicator.toolTarget = '';
208
+ }
209
+
210
+ let summary;
211
+ if (typeof toolInput === 'object') {
212
+ const target = toolInput.file_path || toolInput.path || '';
213
+ const cmd = toolInput.command || '';
214
+ const pattern = toolInput.pattern || '';
215
+ summary = target || (cmd ? cmd.slice(0, 200) : '') || (pattern ? `pattern: ${pattern}` : JSON.stringify(toolInput).slice(0, 200));
216
+ } else {
217
+ summary = String(toolInput || '').slice(0, 200);
218
+ }
219
+ indicator.appendActivity(toolName, summary);
220
+ }
221
+
222
+ module.exports = { Indicator, inferPhaseStep };
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ const readline = require('readline');
4
+ const { COLOR } = require('./config');
5
+
6
+ /**
7
+ * 在终端渲染一个结构化问题并收集用户选择
8
+ *
9
+ * @param {object} question - AskUserQuestion 格式的单个问题
10
+ * @param {string} question.question - 问题文本
11
+ * @param {string} question.header - 短标签
12
+ * @param {Array} question.options - 选项列表 [{ label, description }]
13
+ * @param {boolean} question.multiSelect - 是否多选
14
+ * @returns {Promise<string>} 用户选择的文本
15
+ */
16
+ async function renderQuestion(question) {
17
+ const rl = readline.createInterface({
18
+ input: process.stdin,
19
+ output: process.stderr,
20
+ terminal: process.stdin.isTTY || false,
21
+ });
22
+
23
+ return new Promise(resolve => {
24
+ const w = (s) => process.stderr.write(s);
25
+
26
+ w(`\n${COLOR.cyan}┌─ ${question.header || '问题'} ${'─'.repeat(Math.max(0, 40 - (question.header || '').length))}${COLOR.reset}\n`);
27
+ w(`${COLOR.cyan}│${COLOR.reset} ${COLOR.bold}${question.question}${COLOR.reset}\n`);
28
+ w(`${COLOR.cyan}│${COLOR.reset}\n`);
29
+
30
+ const options = question.options || [];
31
+ options.forEach((opt, i) => {
32
+ w(`${COLOR.cyan}│${COLOR.reset} ${COLOR.yellow}${i + 1}.${COLOR.reset} ${opt.label}\n`);
33
+ if (opt.description) {
34
+ w(`${COLOR.cyan}│${COLOR.reset} ${COLOR.dim}${opt.description}${COLOR.reset}\n`);
35
+ }
36
+ });
37
+
38
+ w(`${COLOR.cyan}│${COLOR.reset} ${COLOR.yellow}0.${COLOR.reset} ${COLOR.dim}其他 (自定义输入)${COLOR.reset}\n`);
39
+
40
+ if (question.multiSelect) {
41
+ w(`${COLOR.cyan}│${COLOR.reset}\n`);
42
+ w(`${COLOR.cyan}│${COLOR.reset} ${COLOR.dim}(多选: 用逗号分隔数字, 如 1,3)${COLOR.reset}\n`);
43
+ }
44
+
45
+ w(`${COLOR.cyan}└${'─'.repeat(44)}${COLOR.reset}\n`);
46
+
47
+ rl.question(` ${COLOR.green}>${COLOR.reset} `, answer => {
48
+ rl.close();
49
+
50
+ const trimmed = answer.trim();
51
+ if (!trimmed) {
52
+ resolve(options[0]?.label || '');
53
+ return;
54
+ }
55
+
56
+ if (trimmed === '0') {
57
+ const rl2 = readline.createInterface({
58
+ input: process.stdin,
59
+ output: process.stderr,
60
+ terminal: process.stdin.isTTY || false,
61
+ });
62
+ rl2.question(` ${COLOR.cyan}请输入你的想法:${COLOR.reset} `, customAnswer => {
63
+ rl2.close();
64
+ resolve(customAnswer.trim() || options[0]?.label || '');
65
+ });
66
+ return;
67
+ }
68
+
69
+ const nums = trimmed.split(/[,,\s]+/)
70
+ .map(n => parseInt(n, 10))
71
+ .filter(n => !isNaN(n) && n >= 1 && n <= options.length);
72
+
73
+ if (nums.length > 0) {
74
+ const selected = nums.map(n => options[n - 1].label);
75
+ resolve(question.multiSelect ? selected.join(', ') : selected[0]);
76
+ } else {
77
+ resolve(trimmed);
78
+ }
79
+ });
80
+ });
81
+ }
82
+
83
+ /**
84
+ * 处理完整的 AskUserQuestion 工具调用
85
+ * 逐个渲染问题,收集所有答案
86
+ *
87
+ * @param {object} toolInput - AskUserQuestion 的 tool_input
88
+ * @param {Array} toolInput.questions - 问题列表
89
+ * @returns {Promise<object>} { answers: { [question]: answer }, formatted: string }
90
+ */
91
+ async function handleUserQuestions(toolInput) {
92
+ const questions = toolInput.questions || [];
93
+ if (questions.length === 0) {
94
+ return { answers: {}, formatted: '(no questions)' };
95
+ }
96
+
97
+ process.stderr.write(`\n${COLOR.magenta}╔══ 模型需要你的输入 ══════════════════════╗${COLOR.reset}\n`);
98
+ process.stderr.write(`${COLOR.magenta}║${COLOR.reset} 以下是模型提出的问题,请逐一回答 ${COLOR.magenta}║${COLOR.reset}\n`);
99
+ process.stderr.write(`${COLOR.magenta}╚══════════════════════════════════════════╝${COLOR.reset}\n`);
100
+
101
+ const answers = {};
102
+ for (const q of questions) {
103
+ const answer = await renderQuestion(q);
104
+ answers[q.question] = answer;
105
+ }
106
+
107
+ const lines = Object.entries(answers)
108
+ .map(([q, a]) => `Q: ${q}\nA: ${a}`)
109
+ .join('\n\n');
110
+
111
+ process.stderr.write(`\n${COLOR.green}✓ 已收集回答:${COLOR.reset}\n`);
112
+ for (const [q, a] of Object.entries(answers)) {
113
+ const shortQ = q.length > 50 ? q.slice(0, 50) + '...' : q;
114
+ process.stderr.write(` ${COLOR.dim}${shortQ}${COLOR.reset} → ${COLOR.bold}${a}${COLOR.reset}\n`);
115
+ }
116
+ process.stderr.write('\n');
117
+
118
+ return { answers, formatted: lines };
119
+ }
120
+
121
+ /**
122
+ * 创建 AskUserQuestion 的 PreToolUse Hook 处理函数
123
+ *
124
+ * 工作原理:
125
+ * 1. 拦截模型的 AskUserQuestion 调用
126
+ * 2. 通过 readline 在终端展示问题
127
+ * 3. 收集用户答案
128
+ * 4. deny 工具调用,同时通过 systemMessage 将答案注入上下文
129
+ *
130
+ * @returns {Function} PreToolUse hook handler
131
+ */
132
+ function createAskUserQuestionHook(indicator) {
133
+ return async (input, _toolUseID, _context) => {
134
+ if (input.tool_name !== 'AskUserQuestion') return {};
135
+
136
+ if (indicator) {
137
+ indicator.pauseRendering();
138
+ process.stderr.write('\r\x1b[K');
139
+ }
140
+
141
+ let formatted;
142
+ try {
143
+ ({ formatted } = await handleUserQuestions(input.tool_input));
144
+ } finally {
145
+ if (indicator) indicator.resumeRendering();
146
+ }
147
+
148
+ return {
149
+ systemMessage: [
150
+ 'The user has answered your questions via the terminal interface.',
151
+ 'Here are their responses:',
152
+ '',
153
+ formatted,
154
+ '',
155
+ 'Proceed based on these answers. Do NOT ask the same questions again.',
156
+ ].join('\n'),
157
+ hookSpecificOutput: {
158
+ hookEventName: 'PreToolUse',
159
+ permissionDecision: 'deny',
160
+ permissionDecisionReason: `User answered via terminal. Answers:\n${formatted}`,
161
+ },
162
+ };
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ renderQuestion,
168
+ handleUserQuestions,
169
+ createAskUserQuestionHook,
170
+ };