glab-agent 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/glab-agent.mjs +18 -0
- package/package.json +59 -0
- package/src/local-agent/agent-config.ts +315 -0
- package/src/local-agent/agent-provider.ts +59 -0
- package/src/local-agent/agent-runner.ts +244 -0
- package/src/local-agent/claude-runner.ts +136 -0
- package/src/local-agent/cli.ts +1497 -0
- package/src/local-agent/codex-runner.ts +153 -0
- package/src/local-agent/gitlab-glab-client.ts +722 -0
- package/src/local-agent/health-server.ts +56 -0
- package/src/local-agent/heartbeat.ts +33 -0
- package/src/local-agent/log-rotate.ts +56 -0
- package/src/local-agent/logger.ts +92 -0
- package/src/local-agent/metrics.ts +51 -0
- package/src/local-agent/mr-actions.ts +121 -0
- package/src/local-agent/notifier.ts +190 -0
- package/src/local-agent/process-manager.ts +193 -0
- package/src/local-agent/reply-runner.ts +111 -0
- package/src/local-agent/repo-cache.ts +144 -0
- package/src/local-agent/report.ts +183 -0
- package/src/local-agent/skill-import.ts +344 -0
- package/src/local-agent/skill-inject.ts +109 -0
- package/src/local-agent/skill-parse.ts +47 -0
- package/src/local-agent/smoke-test.ts +443 -0
- package/src/local-agent/state-store.ts +186 -0
- package/src/local-agent/token-check.ts +37 -0
- package/src/local-agent/watcher.ts +1226 -0
- package/src/local-agent/wiki-sync.ts +290 -0
- package/src/local-agent/worktree-manager.ts +141 -0
- package/src/text.ts +16 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { compactText, truncate } from "../text.js";
|
|
8
|
+
import type { GitlabIssue } from "./gitlab-glab-client.js";
|
|
9
|
+
import type { AgentProvider } from "./agent-provider.js";
|
|
10
|
+
import type { WorktreeInfo } from "./worktree-manager.js";
|
|
11
|
+
import type { Logger } from "./logger.js";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
export interface AgentRunResult {
|
|
16
|
+
summary: string;
|
|
17
|
+
outputPath: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentRunContext {
|
|
21
|
+
todoId?: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AgentRunner {
|
|
26
|
+
run(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<AgentRunResult>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AgentRunnerOptions {
|
|
30
|
+
execFileImpl?: typeof execFileAsync;
|
|
31
|
+
env?: NodeJS.ProcessEnv;
|
|
32
|
+
repoPath?: string;
|
|
33
|
+
gitlabHost?: string;
|
|
34
|
+
gitlabProjectId?: number;
|
|
35
|
+
agentDefinition?: import("./agent-config.js").AgentDefinition;
|
|
36
|
+
logger?: Logger;
|
|
37
|
+
/** Override timeout in seconds; falls back to agentDefinition.timeout_seconds if not set. */
|
|
38
|
+
timeoutSeconds?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AgentRunnerError extends Error {
|
|
42
|
+
readonly summary: string;
|
|
43
|
+
|
|
44
|
+
readonly outputPath: string;
|
|
45
|
+
|
|
46
|
+
constructor(message: string, summary: string, outputPath: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "AgentRunnerError";
|
|
49
|
+
this.summary = summary;
|
|
50
|
+
this.outputPath = outputPath;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isSmokeIssue(issue: Pick<GitlabIssue, "title" | "description">): boolean {
|
|
55
|
+
return issue.title.startsWith("[smoke]") || issue.description.includes("Smoke test run:");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AgentPromptContext {
|
|
59
|
+
issueIid: number;
|
|
60
|
+
issueTitle: string;
|
|
61
|
+
gitlabHost: string;
|
|
62
|
+
gitlabProjectId: number;
|
|
63
|
+
worktreePath: string;
|
|
64
|
+
branch: string;
|
|
65
|
+
provider: AgentProvider;
|
|
66
|
+
todoId?: number;
|
|
67
|
+
preamble?: string;
|
|
68
|
+
append?: string;
|
|
69
|
+
templatePath?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildBasePrompt(ctx: AgentPromptContext): string {
|
|
73
|
+
const p = ctx.gitlabProjectId;
|
|
74
|
+
const iid = ctx.issueIid;
|
|
75
|
+
const todoId = ctx.todoId;
|
|
76
|
+
return [
|
|
77
|
+
"你是这个项目的团队成员(AI agent),被分配处理一个 GitLab 任务。",
|
|
78
|
+
"你在项目的独立工作分支中,可以读代码、读文档、运行命令、操作 GitLab。",
|
|
79
|
+
"",
|
|
80
|
+
"## 任务",
|
|
81
|
+
`Issue #${iid}: ${truncate(ctx.issueTitle, 200)}`,
|
|
82
|
+
"",
|
|
83
|
+
"## GitLab 环境",
|
|
84
|
+
`- Host: ${ctx.gitlabHost} | Project ID: ${p}`,
|
|
85
|
+
"- GITLAB_TOKEN 和 GITLAB_HOST 已配置,可直接使用 glab 命令。",
|
|
86
|
+
"",
|
|
87
|
+
"## 基本原则",
|
|
88
|
+
"- **永远不直接 push 到默认分支**。所有代码改动通过 MR。",
|
|
89
|
+
"- **每次 @mention 都要回复**。即使不做修改,也要在 issue 上说明原因。",
|
|
90
|
+
"- **先理解再行动**。读完 issue 详情、评论、项目文档后再决定做什么。",
|
|
91
|
+
"- **参考项目设计原则**。阅读 docs/design-principles.md 和 README.md 了解项目目标。",
|
|
92
|
+
"",
|
|
93
|
+
"## 工作流程",
|
|
94
|
+
"",
|
|
95
|
+
"### 1. 理解任务",
|
|
96
|
+
`- 读 issue 详情: glab api projects/${p}/issues/${iid}`,
|
|
97
|
+
`- 读评论: glab api projects/${p}/issues/${iid}/notes`,
|
|
98
|
+
`- 读关联 issue: glab api projects/${p}/issues/${iid}/links`,
|
|
99
|
+
"- 阅读项目代码和文档,理解架构和设计原则。",
|
|
100
|
+
"- 如果存在 .glab-agent/wiki/index.md,先查阅项目知识库了解已有经验和踩坑记录。",
|
|
101
|
+
"",
|
|
102
|
+
"### 2. 判断并行动",
|
|
103
|
+
"- **明确的技术任务**(bug fix、功能实现、重构、文档)→ 实现它",
|
|
104
|
+
"- **产品方向/架构大改/引入新外部依赖** → 在 issue 上回复评估意见,不要直接实现",
|
|
105
|
+
"- **信息不足** → 在 issue 上提问,不要猜测需求",
|
|
106
|
+
"",
|
|
107
|
+
"### 3. 管理看板状态",
|
|
108
|
+
`- 开始工作时: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=In Progress"`,
|
|
109
|
+
`- 创建 MR 后: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=In Review"`,
|
|
110
|
+
`- 遇到无法解决的问题: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=Error"`,
|
|
111
|
+
todoId ? `- 处理完成后: glab api --method POST todos/${todoId}/mark_as_done` : "",
|
|
112
|
+
"",
|
|
113
|
+
"### 4. 代码交付(仅当决定实现时)",
|
|
114
|
+
"1. 在当前工作目录中编码和修改。",
|
|
115
|
+
"2. 运行测试验证。",
|
|
116
|
+
"3. git add、git commit。",
|
|
117
|
+
"4. git push 并创建 MR(target_branch 用默认分支,通过 git symbolic-ref refs/remotes/origin/HEAD 查询)。",
|
|
118
|
+
` glab api --method POST projects/${p}/merge_requests --raw-field "source_branch=${ctx.branch}" --raw-field "target_branch=<默认分支>" --raw-field "title=<MR标题>" --raw-field "description=<简要说明>"`,
|
|
119
|
+
"5. 不要关闭 issue。",
|
|
120
|
+
"",
|
|
121
|
+
"### 5. 沟通",
|
|
122
|
+
`- 发评论: glab api --method POST projects/${p}/issues/${iid}/notes --raw-field "body=<内容>"`,
|
|
123
|
+
"- 用 Markdown 格式,结构清晰。",
|
|
124
|
+
"- 像团队成员一样沟通,自然、简洁、有判断力。",
|
|
125
|
+
"- **不要**暴露内部实现细节(worktree 路径、执行器名称、PID 等)。",
|
|
126
|
+
"- **不要**输出内部思考过程。",
|
|
127
|
+
"",
|
|
128
|
+
"## 当前环境",
|
|
129
|
+
`- 分支: ${ctx.branch}`,
|
|
130
|
+
"- 工作目录: 项目的独立工作副本",
|
|
131
|
+
"",
|
|
132
|
+
"## 最终输出",
|
|
133
|
+
"标准输出不超过 5 行的简短总结(不是 GitLab 评论)。"
|
|
134
|
+
].filter(Boolean).join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildAgentPrompt(ctx: AgentPromptContext): string {
|
|
138
|
+
let prompt = buildBasePrompt(ctx);
|
|
139
|
+
|
|
140
|
+
if (ctx.preamble) {
|
|
141
|
+
prompt = ctx.preamble.trimEnd() + "\n\n" + prompt;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Skills are injected via .claude/skills/*.md files (injectSkillFiles),
|
|
145
|
+
// Claude Code loads them automatically. No need to duplicate in prompt.
|
|
146
|
+
|
|
147
|
+
if (ctx.append) {
|
|
148
|
+
prompt = prompt + "\n\n" + ctx.append.trimEnd();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return prompt;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function createOutputPath(prefix: string, issueIid: number): Promise<string> {
|
|
155
|
+
const outputDir = await mkdtemp(path.join(tmpdir(), `${prefix}-issue-${issueIid}-`));
|
|
156
|
+
return path.join(outputDir, "last-message.txt");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function writeSummary(outputPath: string, summary: string): Promise<void> {
|
|
160
|
+
await writeFile(outputPath, truncate(compactText(summary), 600), "utf8");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function readSummary(outputPath: string, fallback: string): Promise<string> {
|
|
164
|
+
try {
|
|
165
|
+
const content = await readFile(outputPath, "utf8");
|
|
166
|
+
return truncate(compactText(content || fallback), 600);
|
|
167
|
+
} catch {
|
|
168
|
+
return truncate(compactText(fallback), 600);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function runSmokeTestChecks(
|
|
173
|
+
issue: GitlabIssue,
|
|
174
|
+
worktree: WorktreeInfo,
|
|
175
|
+
outputPath: string,
|
|
176
|
+
execFileImpl: typeof execFileAsync,
|
|
177
|
+
env: NodeJS.ProcessEnv,
|
|
178
|
+
repoPath?: string
|
|
179
|
+
): Promise<AgentRunResult> {
|
|
180
|
+
const lines = [
|
|
181
|
+
`Smoke test issue: ${truncate(issue.title, 200)}`,
|
|
182
|
+
`Worktree: ${worktree.worktreePath}`
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const gitStatus = await execInWorktree(execFileImpl, "git", ["status", "--short"], worktree.worktreePath, env);
|
|
187
|
+
lines.push(`git status --short: ${gitStatus.stdout ? compactText(gitStatus.stdout) : "(clean)"}`);
|
|
188
|
+
|
|
189
|
+
const testCwd = repoPath ?? worktree.worktreePath;
|
|
190
|
+
const testEnv = cleanEnvForSubprocess(env);
|
|
191
|
+
const testResult = await execInWorktree(execFileImpl, "pnpm", ["test"], testCwd, testEnv);
|
|
192
|
+
lines.push(`pnpm test (${testCwd}): ok`);
|
|
193
|
+
if (testResult.stdout) {
|
|
194
|
+
lines.push(truncate(compactText(testResult.stdout), 220));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const summary = truncate(compactText(lines.join("\n")), 600);
|
|
198
|
+
await writeFile(outputPath, summary, "utf8");
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
summary,
|
|
202
|
+
outputPath
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const summary = truncate(compactText(`Smoke test failed: ${String(error)}`), 600);
|
|
206
|
+
await writeFile(outputPath, summary, "utf8");
|
|
207
|
+
throw new AgentRunnerError("Smoke test execution failed.", summary, outputPath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Remove env vars that tsx / pnpm run-script inject into the watcher process
|
|
213
|
+
* and that can break a fresh `pnpm test` subprocess (wrong NODE_PATH, stale
|
|
214
|
+
* npm lifecycle metadata, etc.).
|
|
215
|
+
*/
|
|
216
|
+
function cleanEnvForSubprocess(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
217
|
+
const cleaned: NodeJS.ProcessEnv = { ...env };
|
|
218
|
+
// tsx injects its own module dirs into NODE_PATH; strip it so vitest resolves
|
|
219
|
+
// modules from the project's own node_modules instead.
|
|
220
|
+
delete cleaned.NODE_PATH;
|
|
221
|
+
// pnpm run-script sets these; a nested `pnpm test` invocation should start
|
|
222
|
+
// fresh rather than inheriting the parent script's lifecycle context.
|
|
223
|
+
for (const key of Object.keys(cleaned)) {
|
|
224
|
+
if (key.startsWith("npm_") || key === "PNPM_SCRIPT_SRC_DIR") {
|
|
225
|
+
delete cleaned[key];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return cleaned;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function execInWorktree(
|
|
232
|
+
execFileImpl: typeof execFileAsync,
|
|
233
|
+
file: string,
|
|
234
|
+
args: string[],
|
|
235
|
+
cwd: string,
|
|
236
|
+
env: NodeJS.ProcessEnv
|
|
237
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
238
|
+
return execFileImpl(file, args, {
|
|
239
|
+
cwd,
|
|
240
|
+
env,
|
|
241
|
+
encoding: "utf8",
|
|
242
|
+
maxBuffer: 10 * 1024 * 1024
|
|
243
|
+
});
|
|
244
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { compactText, truncate } from "../text.js";
|
|
6
|
+
import type { GitlabIssue } from "./gitlab-glab-client.js";
|
|
7
|
+
import type { WorktreeInfo } from "./worktree-manager.js";
|
|
8
|
+
import {
|
|
9
|
+
AgentRunnerError,
|
|
10
|
+
type AgentRunContext,
|
|
11
|
+
type AgentRunResult,
|
|
12
|
+
type AgentRunner,
|
|
13
|
+
type AgentRunnerOptions,
|
|
14
|
+
buildAgentPrompt,
|
|
15
|
+
createOutputPath,
|
|
16
|
+
isSmokeIssue,
|
|
17
|
+
runSmokeTestChecks
|
|
18
|
+
} from "./agent-runner.js";
|
|
19
|
+
import { createLogger, type Logger } from "./logger.js";
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
export class ClaudeRunner implements AgentRunner {
|
|
24
|
+
private readonly execFileImpl: typeof execFileAsync;
|
|
25
|
+
|
|
26
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
27
|
+
|
|
28
|
+
private readonly repoPath?: string;
|
|
29
|
+
|
|
30
|
+
private readonly gitlabHost: string;
|
|
31
|
+
|
|
32
|
+
private readonly gitlabProjectId: number;
|
|
33
|
+
|
|
34
|
+
private readonly agentDefinition?: import("./agent-config.js").AgentDefinition;
|
|
35
|
+
|
|
36
|
+
private readonly logger: Logger;
|
|
37
|
+
|
|
38
|
+
private readonly timeoutSeconds?: number;
|
|
39
|
+
|
|
40
|
+
constructor(options: AgentRunnerOptions = {}) {
|
|
41
|
+
this.execFileImpl = options.execFileImpl ?? execFileAsync;
|
|
42
|
+
this.env = options.env ?? process.env;
|
|
43
|
+
this.repoPath = options.repoPath;
|
|
44
|
+
this.gitlabHost = options.gitlabHost ?? "";
|
|
45
|
+
this.gitlabProjectId = options.gitlabProjectId ?? 0;
|
|
46
|
+
this.agentDefinition = options.agentDefinition;
|
|
47
|
+
this.logger = options.logger ?? createLogger("runner", options.agentDefinition?.name);
|
|
48
|
+
this.timeoutSeconds = options.timeoutSeconds ?? options.agentDefinition?.timeout_seconds;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async run(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<AgentRunResult> {
|
|
52
|
+
const signal = context?.signal;
|
|
53
|
+
const outputPath = await createOutputPath("claude", issue.iid);
|
|
54
|
+
|
|
55
|
+
if (isSmokeIssue(issue)) {
|
|
56
|
+
return runSmokeTestChecks(issue, worktree, outputPath, this.execFileImpl, this.env, this.repoPath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const prompt = buildAgentPrompt({
|
|
60
|
+
issueIid: issue.iid,
|
|
61
|
+
issueTitle: issue.title,
|
|
62
|
+
gitlabHost: this.gitlabHost,
|
|
63
|
+
gitlabProjectId: this.gitlabProjectId,
|
|
64
|
+
worktreePath: worktree.worktreePath,
|
|
65
|
+
branch: worktree.branch,
|
|
66
|
+
provider: "claude",
|
|
67
|
+
todoId: context?.todoId,
|
|
68
|
+
preamble: this.agentDefinition?.prompt.preamble,
|
|
69
|
+
append: this.agentDefinition?.prompt.append,
|
|
70
|
+
// skills injected via .claude/skills/*.md files, not in prompt
|
|
71
|
+
});
|
|
72
|
+
const claudeCommand = resolveClaudeCommand(this.env);
|
|
73
|
+
|
|
74
|
+
this.logger.info(`Executing: ${claudeCommand} -p ... cwd=${worktree.worktreePath}`);
|
|
75
|
+
this.logger.info(`Prompt (${prompt.length} chars): issue #${issue.iid} "${issue.title}"`);
|
|
76
|
+
|
|
77
|
+
const timeoutMs = this.timeoutSeconds ? this.timeoutSeconds * 1000 : 0;
|
|
78
|
+
if (this.timeoutSeconds) {
|
|
79
|
+
this.logger.info(`Timeout: ${this.timeoutSeconds}s`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const args = ["-p", "--output-format", "text", "--permission-mode", "bypassPermissions"];
|
|
84
|
+
if (this.agentDefinition?.model) {
|
|
85
|
+
args.push("--model", this.agentDefinition.model);
|
|
86
|
+
}
|
|
87
|
+
args.push(prompt);
|
|
88
|
+
|
|
89
|
+
const { stdout } = await this.execFileImpl(
|
|
90
|
+
claudeCommand,
|
|
91
|
+
args,
|
|
92
|
+
{
|
|
93
|
+
cwd: worktree.worktreePath,
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
96
|
+
env: this.env,
|
|
97
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs, killSignal: "SIGTERM" } : {}),
|
|
98
|
+
...(signal ? { signal } : {})
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const summary = truncate(compactText(stdout || "Claude Code finished without a final summary."), 600);
|
|
103
|
+
this.logger.info(`Finished. Output: ${outputPath}`);
|
|
104
|
+
await writeFile(outputPath, summary, "utf8");
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
summary,
|
|
108
|
+
outputPath
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const execError = error as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
|
|
112
|
+
const isTimeout = execError.killed === true && timeoutMs > 0;
|
|
113
|
+
const summary = isTimeout
|
|
114
|
+
? `Timed out after ${this.timeoutSeconds}s`
|
|
115
|
+
: truncate(
|
|
116
|
+
compactText(execError.stdout || execError.stderr || execError.message || "Claude Code execution failed."),
|
|
117
|
+
600
|
|
118
|
+
);
|
|
119
|
+
if (isTimeout) {
|
|
120
|
+
this.logger.error(`Timed out after ${this.timeoutSeconds}s.`);
|
|
121
|
+
} else {
|
|
122
|
+
this.logger.error(`Failed. stderr: ${execError.stderr?.slice(0, 300) ?? "(none)"}`);
|
|
123
|
+
}
|
|
124
|
+
await writeFile(outputPath, summary, "utf8");
|
|
125
|
+
throw new AgentRunnerError(
|
|
126
|
+
isTimeout ? `Claude Code timed out after ${this.timeoutSeconds}s.` : "Claude Code execution failed.",
|
|
127
|
+
summary,
|
|
128
|
+
outputPath
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resolveClaudeCommand(env: NodeJS.ProcessEnv = process.env): string {
|
|
135
|
+
return env.CLAUDE_BIN?.trim() || "claude";
|
|
136
|
+
}
|