@ai-setting/roy-agent-cli 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/README.md +126 -0
- package/dist/bin/roy.js +127297 -0
- package/dist/roy-agent-darwin-arm64/bin/roy.js +127297 -0
- package/dist/roy-agent-darwin-x64/bin/roy.js +127297 -0
- package/dist/roy-agent-linux-arm64/bin/roy.js +127297 -0
- package/dist/roy-agent-linux-x64/bin/roy.js +127297 -0
- package/dist/roy-agent-windows-x64/bin/roy.js +127297 -0
- package/package.json +91 -0
- package/src/bin/roy.ts +12 -0
- package/src/cli.ts +101 -0
- package/src/commands/act.ts +480 -0
- package/src/commands/commands-add.ts +110 -0
- package/src/commands/commands-dirs.ts +70 -0
- package/src/commands/commands-info.ts +90 -0
- package/src/commands/commands-list.ts +161 -0
- package/src/commands/commands-remove.ts +147 -0
- package/src/commands/commands.ts +55 -0
- package/src/commands/config/config-service.test.ts +449 -0
- package/src/commands/config/config-service.ts +312 -0
- package/src/commands/config/deep-merge.test.ts +168 -0
- package/src/commands/config/deep-merge.ts +63 -0
- package/src/commands/config/export.ts +97 -0
- package/src/commands/config/filter-history-e2e.test.ts +141 -0
- package/src/commands/config/import-preserve-refs.test.ts +212 -0
- package/src/commands/config/import.ts +119 -0
- package/src/commands/config/index.ts +35 -0
- package/src/commands/config/list.ts +281 -0
- package/src/commands/config/roy-config-e2e.test.ts +297 -0
- package/src/commands/config/types.ts +54 -0
- package/src/commands/debug/index.ts +38 -0
- package/src/commands/debug/log.test.ts +233 -0
- package/src/commands/debug/log.ts +123 -0
- package/src/commands/debug/span.test.ts +297 -0
- package/src/commands/debug/span.ts +211 -0
- package/src/commands/debug/trace.test.ts +254 -0
- package/src/commands/debug/trace.ts +140 -0
- package/src/commands/eventsource/add.ts +133 -0
- package/src/commands/eventsource/index.ts +48 -0
- package/src/commands/eventsource/list.ts +194 -0
- package/src/commands/eventsource/remove.ts +95 -0
- package/src/commands/eventsource/start.ts +103 -0
- package/src/commands/eventsource/status.ts +185 -0
- package/src/commands/eventsource/stop.ts +89 -0
- package/src/commands/index.ts +22 -0
- package/src/commands/input-handler.test.ts +76 -0
- package/src/commands/input-handler.ts +43 -0
- package/src/commands/interactive-esc.test.ts +254 -0
- package/src/commands/interactive.shutdown.test.ts +122 -0
- package/src/commands/interactive.test.ts +221 -0
- package/src/commands/interactive.ts +1015 -0
- package/src/commands/lsp/check.ts +92 -0
- package/src/commands/lsp/index.ts +32 -0
- package/src/commands/lsp/install.ts +126 -0
- package/src/commands/lsp/list.ts +64 -0
- package/src/commands/mcp/index.ts +27 -0
- package/src/commands/mcp/list.ts +116 -0
- package/src/commands/mcp/reload.ts +70 -0
- package/src/commands/mcp/tools.ts +121 -0
- package/src/commands/memory/extract-e2e.test.ts +388 -0
- package/src/commands/memory/index.ts +11 -0
- package/src/commands/memory/memory-simplified.test.ts +58 -0
- package/src/commands/memory/memory.ts +25 -0
- package/src/commands/memory/organize.ts +300 -0
- package/src/commands/memory/recall.test.ts +120 -0
- package/src/commands/memory/recall.ts +88 -0
- package/src/commands/memory/record-extract-handle-query.test.ts +385 -0
- package/src/commands/memory/record-prompt-component.test.ts +343 -0
- package/src/commands/memory/record.test.ts +92 -0
- package/src/commands/memory/record.ts +332 -0
- package/src/commands/plugin.test.ts +292 -0
- package/src/commands/plugin.ts +267 -0
- package/src/commands/sessions/active.ts +96 -0
- package/src/commands/sessions/add-message.ts +96 -0
- package/src/commands/sessions/checkpoints.ts +154 -0
- package/src/commands/sessions/compact.test.ts +215 -0
- package/src/commands/sessions/compact.ts +269 -0
- package/src/commands/sessions/delete.ts +236 -0
- package/src/commands/sessions/get.ts +165 -0
- package/src/commands/sessions/grep.ts +233 -0
- package/src/commands/sessions/index.ts +95 -0
- package/src/commands/sessions/list.ts +210 -0
- package/src/commands/sessions/messages.test.ts +333 -0
- package/src/commands/sessions/messages.ts +248 -0
- package/src/commands/sessions/mock.ts +194 -0
- package/src/commands/sessions/new.ts +82 -0
- package/src/commands/sessions/rename.ts +98 -0
- package/src/commands/shared/event-handler.ts +213 -0
- package/src/commands/shared/event-message-formatter.ts +295 -0
- package/src/commands/shared/index.ts +11 -0
- package/src/commands/shared/query-executor.test.ts +434 -0
- package/src/commands/shared/query-executor.ts +324 -0
- package/src/commands/shared/repl-engine.test.ts +354 -0
- package/src/commands/shared/session-manager.test.ts +212 -0
- package/src/commands/shared/session-manager.ts +114 -0
- package/src/commands/skills/get.ts +90 -0
- package/src/commands/skills/index.ts +39 -0
- package/src/commands/skills/list.ts +129 -0
- package/src/commands/skills/reload.ts +59 -0
- package/src/commands/skills/search.ts +132 -0
- package/src/commands/skills/show-config.ts +93 -0
- package/src/commands/tasks/complete.ts +92 -0
- package/src/commands/tasks/create.ts +118 -0
- package/src/commands/tasks/delete.ts +86 -0
- package/src/commands/tasks/get.ts +116 -0
- package/src/commands/tasks/index.ts +53 -0
- package/src/commands/tasks/list.ts +140 -0
- package/src/commands/tasks/operations.ts +120 -0
- package/src/commands/tasks/update.ts +122 -0
- package/src/commands/tools/exec-tool.ts +128 -0
- package/src/commands/tools/get.ts +114 -0
- package/src/commands/tools/index.ts +35 -0
- package/src/commands/tools/list.ts +107 -0
- package/src/commands/tools/shared/index.ts +7 -0
- package/src/commands/tools/shared/schema-helper.ts +111 -0
- package/src/commands/workflow/commands/add.ts +315 -0
- package/src/commands/workflow/commands/get.ts +193 -0
- package/src/commands/workflow/commands/list.ts +137 -0
- package/src/commands/workflow/commands/nodes.ts +528 -0
- package/src/commands/workflow/commands/remove.ts +94 -0
- package/src/commands/workflow/commands/run.ts +398 -0
- package/src/commands/workflow/commands/status.ts +147 -0
- package/src/commands/workflow/commands/stop.ts +91 -0
- package/src/commands/workflow/commands/update.ts +130 -0
- package/src/commands/workflow/commands/validate.ts +139 -0
- package/src/commands/workflow/commands/workflow-cli.test.ts +196 -0
- package/src/commands/workflow/index.ts +65 -0
- package/src/commands/workflow/renderers.ts +358 -0
- package/src/commands/workflow/validators/index.ts +8 -0
- package/src/commands/workflow/validators/node-validator-factory.ts +40 -0
- package/src/commands/workflow/validators/node-validator.ts +125 -0
- package/src/commands/workflow/validators/nodes/agent-node-validator.ts +58 -0
- package/src/commands/workflow/validators/nodes/condition-node-validator.ts +34 -0
- package/src/commands/workflow/validators/nodes/decorator-node-validator.ts +45 -0
- package/src/commands/workflow/validators/nodes/merge-node-validator.ts +46 -0
- package/src/commands/workflow/validators/nodes/skill-node-validator.ts +33 -0
- package/src/commands/workflow/validators/nodes/tool-node-validator.ts +54 -0
- package/src/commands/workflow/validators/nodes/workflow-node-validator.ts +33 -0
- package/src/commands/workflow/validators/types.ts +78 -0
- package/src/commands/workflow/validators/workflow-validator.test.ts +273 -0
- package/src/commands/workflow/validators/workflow-validator.ts +320 -0
- package/src/index.ts +19 -0
- package/src/plugin/apply.ts +103 -0
- package/src/plugin/discover.ts +219 -0
- package/src/plugin/index.ts +45 -0
- package/src/plugin/registry.ts +272 -0
- package/src/plugin/types.ts +165 -0
- package/src/services/context-handler.service.test.ts +501 -0
- package/src/services/context-handler.service.ts +372 -0
- package/src/services/environment.service.commands-prompt.test.ts +167 -0
- package/src/services/environment.service.ts +656 -0
- package/src/services/output.service.test.ts +92 -0
- package/src/services/output.service.ts +122 -0
- package/src/services/quiet-mode.service.test.ts +114 -0
- package/src/services/quiet-mode.service.ts +81 -0
- package/src/services/stream-output.service.test.ts +214 -0
- package/src/services/stream-output.service.ts +323 -0
- package/src/util/which.test.ts +101 -0
- package/src/util/which.ts +55 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Stream Output Service
|
|
3
|
+
*
|
|
4
|
+
* 处理 LLM 流式事件并输出到控制台
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import type { EnvEvent } from "@ai-setting/roy-agent-core";
|
|
9
|
+
import { createLogger } from "@ai-setting/roy-agent-core";
|
|
10
|
+
|
|
11
|
+
// 创建 logger
|
|
12
|
+
const logger = createLogger("stream-output");
|
|
13
|
+
|
|
14
|
+
// ============ ANSI 颜色定义 ============
|
|
15
|
+
export const COLORS = {
|
|
16
|
+
// 用户输入颜色 (cyan)
|
|
17
|
+
userInput: chalk.cyan,
|
|
18
|
+
userInputPrefix: chalk.cyan("❯"),
|
|
19
|
+
|
|
20
|
+
// Assistant 输出颜色 (green)
|
|
21
|
+
assistant: chalk.green,
|
|
22
|
+
|
|
23
|
+
// 系统消息颜色 (yellow)
|
|
24
|
+
system: chalk.yellow,
|
|
25
|
+
|
|
26
|
+
// 思考过程颜色 (dim gray)
|
|
27
|
+
reasoning: chalk.dim.gray,
|
|
28
|
+
|
|
29
|
+
// 工具调用颜色 (magenta)
|
|
30
|
+
toolCall: chalk.magenta,
|
|
31
|
+
|
|
32
|
+
// 工具结果颜色 (blue)
|
|
33
|
+
toolResult: chalk.blue,
|
|
34
|
+
|
|
35
|
+
// 错误颜色 (red)
|
|
36
|
+
error: chalk.red,
|
|
37
|
+
|
|
38
|
+
// 进度/思考中颜色 (dim)
|
|
39
|
+
progress: chalk.dim,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
// Abort 信号类型
|
|
43
|
+
export type AbortSignal = {
|
|
44
|
+
aborted: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 全局 abort 信号(用于流式输出中断)
|
|
49
|
+
*/
|
|
50
|
+
export const streamAbortSignal: AbortSignal = {
|
|
51
|
+
aborted: false,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 重置 abort 信号
|
|
56
|
+
*/
|
|
57
|
+
export function resetStreamAbort(): void {
|
|
58
|
+
streamAbortSignal.aborted = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 请求 abort 流式输出
|
|
63
|
+
*/
|
|
64
|
+
export function abortStream(): void {
|
|
65
|
+
streamAbortSignal.aborted = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Usage 信息接口
|
|
70
|
+
*/
|
|
71
|
+
export interface UsageInfo {
|
|
72
|
+
/** 模型名称 */
|
|
73
|
+
model: string;
|
|
74
|
+
/** Provider ID */
|
|
75
|
+
providerId?: string;
|
|
76
|
+
/** Prompt tokens */
|
|
77
|
+
promptTokens?: number;
|
|
78
|
+
/** Completion tokens */
|
|
79
|
+
completionTokens?: number;
|
|
80
|
+
/** Total tokens */
|
|
81
|
+
totalTokens?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface StreamOutputOptions {
|
|
85
|
+
/** 显示推理/思考过程 */
|
|
86
|
+
showReasoning?: boolean;
|
|
87
|
+
/** 显示工具调用 */
|
|
88
|
+
showToolCalls?: boolean;
|
|
89
|
+
/** 显示工具结果 */
|
|
90
|
+
showToolResults?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 流式输出服务
|
|
95
|
+
*
|
|
96
|
+
* 订阅 Environment 的 LLM 事件,流式输出文本到 stdout
|
|
97
|
+
*/
|
|
98
|
+
export class StreamOutputService {
|
|
99
|
+
private fullText: string = "";
|
|
100
|
+
private options: StreamOutputOptions;
|
|
101
|
+
private usageInfo?: UsageInfo;
|
|
102
|
+
private contextWindow?: number;
|
|
103
|
+
private contextThreshold?: number;
|
|
104
|
+
private isFirstText = true; // 用于控制换行
|
|
105
|
+
private reasoningStarted = false; // 思考过程是否已开始
|
|
106
|
+
private thinkingPrinted = false; // "[thinking...]" 是否已打印
|
|
107
|
+
|
|
108
|
+
constructor(options: StreamOutputOptions = {}) {
|
|
109
|
+
this.options = options;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 设置上下文窗口信息
|
|
114
|
+
*/
|
|
115
|
+
setContextInfo(window: number, threshold: number): void {
|
|
116
|
+
this.contextWindow = window;
|
|
117
|
+
this.contextThreshold = threshold;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 检查是否已 abort
|
|
122
|
+
*/
|
|
123
|
+
private checkAbort(): boolean {
|
|
124
|
+
if (streamAbortSignal.aborted) {
|
|
125
|
+
// 输出 abort 提示
|
|
126
|
+
console.log(`\n${COLORS.system("[已中断]")}\n`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 处理环境事件
|
|
134
|
+
*/
|
|
135
|
+
handleEvent(event: EnvEvent): void {
|
|
136
|
+
// 检查 abort 信号
|
|
137
|
+
if (this.checkAbort()) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { type, payload } = event;
|
|
142
|
+
|
|
143
|
+
switch (type) {
|
|
144
|
+
case "llm.start":
|
|
145
|
+
this.handleStart(payload as { metadata?: { model?: string } });
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
case "llm.text":
|
|
149
|
+
this.handleText(payload as { delta?: string; content?: string });
|
|
150
|
+
break;
|
|
151
|
+
|
|
152
|
+
case "llm.reasoning":
|
|
153
|
+
this.handleReasoning(payload as { content?: string });
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case "llm.tool_call":
|
|
157
|
+
this.handleToolCall(payload as { toolCall?: { id?: string; name?: string; arguments?: string } });
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case "llm.completed":
|
|
161
|
+
this.handleCompleted(payload as { content?: string; metadata?: unknown });
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 处理开始事件
|
|
168
|
+
*/
|
|
169
|
+
private handleStart(payload: { metadata?: { model?: string } }): void {
|
|
170
|
+
if (payload.metadata?.model) {
|
|
171
|
+
// 可选:显示模型信息
|
|
172
|
+
// process.stderr.write(`\n[Using model: ${payload.metadata.model}]\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 处理文本事件
|
|
178
|
+
*/
|
|
179
|
+
private handleText(payload: { delta?: string; content?: string }): void {
|
|
180
|
+
// 清除 "[thinking...]" 如果已打印
|
|
181
|
+
if (!this.thinkingPrinted) {
|
|
182
|
+
this.thinkingPrinted = true;
|
|
183
|
+
// 移动光标到行首,清除 "[thinking...]" 并换行
|
|
184
|
+
process.stdout.write("\r" + " ".repeat(20) + "\r");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const text = payload.delta ?? payload.content ?? "";
|
|
188
|
+
|
|
189
|
+
if (!text) return;
|
|
190
|
+
|
|
191
|
+
// 如果是第一个文本块,先换行
|
|
192
|
+
if (this.isFirstText) {
|
|
193
|
+
process.stdout.write("\n");
|
|
194
|
+
this.isFirstText = false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 使用 assistant 颜色输出
|
|
198
|
+
process.stdout.write(COLORS.assistant(text));
|
|
199
|
+
this.fullText += text;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 处理思考过程事件
|
|
204
|
+
*/
|
|
205
|
+
private handleReasoning(payload: { content?: string }): void {
|
|
206
|
+
if (!this.options.showReasoning) return;
|
|
207
|
+
|
|
208
|
+
const content = payload.content ?? "";
|
|
209
|
+
if (!content) return;
|
|
210
|
+
|
|
211
|
+
// 如果是开始思考,先换行并显示标题
|
|
212
|
+
if (!this.reasoningStarted) {
|
|
213
|
+
this.reasoningStarted = true;
|
|
214
|
+
process.stdout.write("\n\n" + COLORS.reasoning("┌─ 思考过程 ─") + "\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
process.stdout.write(COLORS.reasoning(content));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 处理工具调用事件
|
|
222
|
+
*/
|
|
223
|
+
private handleToolCall(payload: { toolCall?: { id?: string; name?: string; arguments?: string } }): void {
|
|
224
|
+
if (!this.options.showToolCalls) return;
|
|
225
|
+
|
|
226
|
+
const toolCall = payload.toolCall;
|
|
227
|
+
if (toolCall?.name) {
|
|
228
|
+
process.stdout.write("\n" + COLORS.toolCall(`🔧 [Tool] ${toolCall.name}`) + "\n");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 处理完成事件
|
|
234
|
+
*/
|
|
235
|
+
private handleCompleted(payload: { content?: string; metadata?: unknown }): void {
|
|
236
|
+
// 完成时输出换行
|
|
237
|
+
process.stdout.write("\n");
|
|
238
|
+
|
|
239
|
+
// 如果有思考过程,关闭思考框
|
|
240
|
+
if (this.reasoningStarted) {
|
|
241
|
+
process.stdout.write(COLORS.reasoning("└" + "─".repeat(20) + "\n"));
|
|
242
|
+
this.reasoningStarted = false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 提取 usage 信息
|
|
246
|
+
const metadata = payload.metadata as {
|
|
247
|
+
model?: string;
|
|
248
|
+
usage?: {
|
|
249
|
+
promptTokens?: number;
|
|
250
|
+
completionTokens?: number;
|
|
251
|
+
totalTokens?: number;
|
|
252
|
+
};
|
|
253
|
+
} | undefined;
|
|
254
|
+
|
|
255
|
+
if (metadata?.model || metadata?.usage) {
|
|
256
|
+
let modelName = metadata.model || "";
|
|
257
|
+
let providerId: string | undefined;
|
|
258
|
+
|
|
259
|
+
// 解析 model 名称(格式:provider/model 或 model)
|
|
260
|
+
if (modelName.includes("/")) {
|
|
261
|
+
const parts = modelName.split("/");
|
|
262
|
+
providerId = parts[0];
|
|
263
|
+
modelName = parts[parts.length - 1];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.usageInfo = {
|
|
267
|
+
model: modelName,
|
|
268
|
+
providerId,
|
|
269
|
+
promptTokens: metadata.usage?.promptTokens,
|
|
270
|
+
completionTokens: metadata.usage?.completionTokens,
|
|
271
|
+
totalTokens: metadata.usage?.totalTokens,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// 打印 usage 日志
|
|
275
|
+
const logData: Record<string, unknown> = {
|
|
276
|
+
promptTokens: this.usageInfo.promptTokens,
|
|
277
|
+
completionTokens: this.usageInfo.completionTokens,
|
|
278
|
+
totalTokens: this.usageInfo.totalTokens,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// 添加 context 信息(如果已设置)
|
|
282
|
+
if (this.contextWindow !== undefined) {
|
|
283
|
+
logData.contextWindow = this.contextWindow;
|
|
284
|
+
if (this.contextThreshold !== undefined) {
|
|
285
|
+
logData.contextThreshold = this.contextThreshold;
|
|
286
|
+
// 计算使用率
|
|
287
|
+
if (this.usageInfo.totalTokens && this.contextWindow) {
|
|
288
|
+
const usageRate = (this.usageInfo.totalTokens / this.contextWindow * 100).toFixed(1);
|
|
289
|
+
logData.usageRate = `${usageRate}%`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
logger.info(`[LLM] ${modelName}`, logData);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 获取完整文本
|
|
300
|
+
*/
|
|
301
|
+
getFullText(): string {
|
|
302
|
+
return this.fullText;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 获取 usage 信息
|
|
307
|
+
*/
|
|
308
|
+
getUsageInfo(): UsageInfo | undefined {
|
|
309
|
+
return this.usageInfo;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 重置状态
|
|
314
|
+
*/
|
|
315
|
+
reset(): void {
|
|
316
|
+
this.fullText = "";
|
|
317
|
+
this.usageInfo = undefined;
|
|
318
|
+
this.isFirstText = true;
|
|
319
|
+
this.reasoningStarted = false;
|
|
320
|
+
this.thinkingPrinted = false;
|
|
321
|
+
// 保留 contextInfo,因为它是配置信息,不需要每次重置
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview which 命令测试
|
|
3
|
+
*
|
|
4
|
+
* TDD: 测试跨平台 which 命令功能
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "bun:test";
|
|
8
|
+
import { which } from "./which";
|
|
9
|
+
|
|
10
|
+
describe("which 命令 - 跨平台兼容性测试", () => {
|
|
11
|
+
describe("绝对路径处理", () => {
|
|
12
|
+
test("应识别绝对路径", () => {
|
|
13
|
+
// 使用系统中肯定存在的文件
|
|
14
|
+
const result = which("/bin/ls");
|
|
15
|
+
expect(result).toBe("/bin/ls");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("不存在的绝对路径应返回 null", () => {
|
|
19
|
+
const result = which("/non/existent/path/command");
|
|
20
|
+
expect(result).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("PATH 环境变量", () => {
|
|
25
|
+
test("Unix 平台应使用冒号分隔 PATH", () => {
|
|
26
|
+
// 测试逻辑:在 Unix 系统上应该能找到 ls
|
|
27
|
+
const result = which("ls");
|
|
28
|
+
expect(result).not.toBeNull();
|
|
29
|
+
expect(result).toContain("ls");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("Windows 平台应使用分号分隔 PATH", () => {
|
|
33
|
+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
34
|
+
Object.defineProperty(process, "platform", {
|
|
35
|
+
value: "win32",
|
|
36
|
+
writable: true,
|
|
37
|
+
configurable: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
|
42
|
+
expect(pathSeparator).toBe(";");
|
|
43
|
+
} finally {
|
|
44
|
+
if (originalPlatform) {
|
|
45
|
+
Object.defineProperty(process, "platform", originalPlatform);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("PATHEXT 扩展名 (Windows)", () => {
|
|
52
|
+
test("Windows 平台应检查常见扩展名", () => {
|
|
53
|
+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
54
|
+
Object.defineProperty(process, "platform", {
|
|
55
|
+
value: "win32",
|
|
56
|
+
writable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";");
|
|
62
|
+
expect(extensions).toContain(".EXE");
|
|
63
|
+
expect(extensions).toContain(".BAT");
|
|
64
|
+
expect(extensions).toContain(".CMD");
|
|
65
|
+
} finally {
|
|
66
|
+
if (originalPlatform) {
|
|
67
|
+
Object.defineProperty(process, "platform", originalPlatform);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("系统 which 命令 fallback", () => {
|
|
74
|
+
test("Unix 平台可以使用系统 which 命令", () => {
|
|
75
|
+
// 在 Linux 上测试 which 命令是否存在
|
|
76
|
+
const result = which("ls");
|
|
77
|
+
expect(result).not.toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("Windows 平台不应尝试执行系统 which 命令", () => {
|
|
81
|
+
// 这个测试验证 Windows 平台上逻辑正确
|
|
82
|
+
// 当 platform 是 win32 时,不应该执行 execSync which
|
|
83
|
+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
84
|
+
Object.defineProperty(process, "platform", {
|
|
85
|
+
value: "win32",
|
|
86
|
+
writable: true,
|
|
87
|
+
configurable: true,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// 验证 pathSeparator 在 Windows 上是分号
|
|
92
|
+
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
|
93
|
+
expect(pathSeparator).toBe(";");
|
|
94
|
+
} finally {
|
|
95
|
+
if (originalPlatform) {
|
|
96
|
+
Object.defineProperty(process, "platform", originalPlatform);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview which 命令实现
|
|
3
|
+
*
|
|
4
|
+
* 在 PATH 中查找可执行文件
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 在 PATH 中查找可执行文件
|
|
13
|
+
*
|
|
14
|
+
* @param command 命令名称
|
|
15
|
+
* @returns 命令的完整路径,如果找不到返回 null
|
|
16
|
+
*/
|
|
17
|
+
export function which(command: string): string | null {
|
|
18
|
+
// 如果是绝对路径,直接检查是否存在
|
|
19
|
+
if (path.isAbsolute(command)) {
|
|
20
|
+
return fs.existsSync(command) ? command : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 获取 PATH 环境变量
|
|
24
|
+
const PATH = process.env.PATH ?? "";
|
|
25
|
+
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
|
26
|
+
const paths = PATH.split(pathSeparator);
|
|
27
|
+
|
|
28
|
+
// Windows 下还要检查 PATHEXT
|
|
29
|
+
const extensions = process.platform === "win32"
|
|
30
|
+
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
|
|
31
|
+
: [""];
|
|
32
|
+
|
|
33
|
+
for (const dir of paths) {
|
|
34
|
+
for (const ext of extensions) {
|
|
35
|
+
const fullPath = path.join(dir, command + ext);
|
|
36
|
+
if (fs.existsSync(fullPath)) {
|
|
37
|
+
return fullPath;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 尝试使用系统 which 命令(仅在非 Windows 平台)
|
|
43
|
+
// Windows 没有原生的 which 命令,需要使用 PowerShell 的 Get-Command 或手动遍历
|
|
44
|
+
if (process.platform !== "win32") {
|
|
45
|
+
try {
|
|
46
|
+
const result = execSync(`which ${command}`, { encoding: "utf-8" });
|
|
47
|
+
const trimmed = result.trim();
|
|
48
|
+
return trimmed || null;
|
|
49
|
+
} catch {
|
|
50
|
+
// fall through to return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|