takt/src/infra/cursor/client.ts
Junichi Kato b8b64f858b
feat: プロジェクト単位のCLIパス設定(Claude/Cursor/Codex) (#413)
* feat: プロジェクト単位のCLIパス設定を支援するconfig層を追加

validateCliPath汎用関数、Global/Project設定スキーマ拡張、
env override、3プロバイダ向けresolve関数(env→project→global→undefined)を追加。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: Claude/Cursor/CodexプロバイダにCLIパス解決を統合

各プロバイダのtoXxxOptions()でproject configを読み込み、
resolveXxxCliPath()経由でCLIパスを解決してSDKに渡す。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: per-project CLIパス機能のテストを追加

validateCliPath, resolveClaudeCliPath, resolveCursorCliPath,
resolveCodexCliPath(project config層)のユニットテスト、
および既存プロバイダテストのモック更新。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:44:16 +09:00

498 lines
13 KiB
TypeScript

/**
* Cursor Agent CLI integration for agent interactions
*/
import { spawn } from 'node:child_process';
import type { AgentResponse } from '../../core/models/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
import type { CursorCallOptions } from './types.js';
export type { CursorCallOptions } from './types.js';
const CURSOR_COMMAND = 'cursor-agent';
const CURSOR_ABORTED_MESSAGE = 'Cursor execution aborted';
const CURSOR_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
const CURSOR_FORCE_KILL_DELAY_MS_DEFAULT = 1_000;
const CURSOR_ERROR_DETAIL_MAX_LENGTH = 400;
function resolveForceKillDelayMs(): number {
const raw = process.env.TAKT_CURSOR_FORCE_KILL_DELAY_MS;
if (!raw) {
return CURSOR_FORCE_KILL_DELAY_MS_DEFAULT;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return CURSOR_FORCE_KILL_DELAY_MS_DEFAULT;
}
return parsed;
}
type CursorExecResult = {
stdout: string;
stderr: string;
};
type CursorExecError = Error & {
code?: string | number;
stdout?: string;
stderr?: string;
signal?: NodeJS.Signals | null;
};
function buildPrompt(prompt: string, systemPrompt?: string): string {
if (!systemPrompt) {
return prompt;
}
return `${systemPrompt}\n\n${prompt}`;
}
function buildArgs(prompt: string, options: CursorCallOptions): string[] {
const args = ['-p', '--output-format', 'json', '--workspace', options.cwd];
if (options.model) {
args.push('--model', options.model);
}
if (options.sessionId) {
args.push('--resume', options.sessionId);
}
if (options.permissionMode === 'full') {
args.push('--force');
}
args.push(buildPrompt(prompt, options.systemPrompt));
return args;
}
function buildEnv(cursorApiKey?: string): NodeJS.ProcessEnv {
if (!cursorApiKey) {
return process.env;
}
return {
...process.env,
CURSOR_API_KEY: cursorApiKey,
};
}
function createExecError(
message: string,
params: {
code?: string | number;
stdout?: string;
stderr?: string;
signal?: NodeJS.Signals | null;
name?: string;
} = {},
): CursorExecError {
const error = new Error(message) as CursorExecError;
if (params.name) {
error.name = params.name;
}
error.code = params.code;
error.stdout = params.stdout;
error.stderr = params.stderr;
error.signal = params.signal;
return error;
}
function execCursor(args: string[], options: CursorCallOptions): Promise<CursorExecResult> {
return new Promise<CursorExecResult>((resolve, reject) => {
const child = spawn(options.cursorCliPath ?? CURSOR_COMMAND, args, {
cwd: options.cwd,
env: buildEnv(options.cursorApiKey),
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
let abortTimer: ReturnType<typeof setTimeout> | undefined;
const abortHandler = (): void => {
if (settled) return;
child.kill('SIGTERM');
const forceKillDelayMs = resolveForceKillDelayMs();
abortTimer = setTimeout(() => {
if (!settled) {
child.kill('SIGKILL');
}
}, forceKillDelayMs);
abortTimer.unref?.();
};
const cleanup = (): void => {
if (abortTimer !== undefined) {
clearTimeout(abortTimer);
}
if (options.abortSignal) {
options.abortSignal.removeEventListener('abort', abortHandler);
}
};
const resolveOnce = (result: CursorExecResult): void => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
const rejectOnce = (error: CursorExecError): void => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const appendChunk = (target: 'stdout' | 'stderr', chunk: Buffer | string): void => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
const byteLength = Buffer.byteLength(text);
if (target === 'stdout') {
stdoutBytes += byteLength;
if (stdoutBytes > CURSOR_MAX_BUFFER_BYTES) {
child.kill('SIGTERM');
rejectOnce(createExecError('cursor-agent stdout exceeded buffer limit', {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout,
stderr,
}));
return;
}
stdout += text;
return;
}
stderrBytes += byteLength;
if (stderrBytes > CURSOR_MAX_BUFFER_BYTES) {
child.kill('SIGTERM');
rejectOnce(createExecError('cursor-agent stderr exceeded buffer limit', {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout,
stderr,
}));
return;
}
stderr += text;
};
child.stdout?.on('data', (chunk: Buffer | string) => appendChunk('stdout', chunk));
child.stderr?.on('data', (chunk: Buffer | string) => appendChunk('stderr', chunk));
child.on('error', (error: NodeJS.ErrnoException) => {
rejectOnce(createExecError(error.message, {
code: error.code,
stdout,
stderr,
}));
});
child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
if (settled) return;
if (options.abortSignal?.aborted) {
rejectOnce(createExecError(CURSOR_ABORTED_MESSAGE, {
name: 'AbortError',
stdout,
stderr,
signal,
}));
return;
}
if (code === 0) {
resolveOnce({ stdout, stderr });
return;
}
rejectOnce(createExecError(
signal
? `cursor-agent terminated by signal ${signal}`
: `cursor-agent exited with code ${code ?? 'unknown'}`,
{
code: code ?? undefined,
stdout,
stderr,
signal,
},
));
});
if (options.abortSignal) {
if (options.abortSignal.aborted) {
abortHandler();
} else {
options.abortSignal.addEventListener('abort', abortHandler, { once: true });
}
}
});
}
function toRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function firstNonEmptyString(values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
function extractContent(payload: unknown): string | undefined {
if (typeof payload === 'string') {
const trimmed = payload.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(payload)) {
const parts = payload
.map((entry) => extractContent(entry))
.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
return parts.length > 0 ? parts.join('\n') : undefined;
}
const record = toRecord(payload);
if (!record) {
return undefined;
}
const direct = firstNonEmptyString([
record.content,
record.text,
record.output,
record.result,
record.message,
]);
if (direct) {
return direct;
}
const nested = [record.data, record.response, record.payload]
.map((entry) => extractContent(entry))
.find((entry): entry is string => typeof entry === 'string' && entry.length > 0);
if (nested) {
return nested;
}
return undefined;
}
function extractSessionId(payload: unknown): string | undefined {
const record = toRecord(payload);
if (!record) {
return undefined;
}
const nestedData = toRecord(record.data);
const nestedPayload = toRecord(record.payload);
const nestedResponse = toRecord(record.response);
return firstNonEmptyString([
record.sessionId,
record.session_id,
record.chatId,
record.chat_id,
nestedData?.sessionId,
nestedData?.session_id,
nestedData?.chatId,
nestedData?.chat_id,
nestedPayload?.sessionId,
nestedPayload?.session_id,
nestedPayload?.chatId,
nestedPayload?.chat_id,
nestedResponse?.sessionId,
nestedResponse?.session_id,
nestedResponse?.chatId,
nestedResponse?.chat_id,
]);
}
function trimDetail(value: string | undefined, fallback = ''): string {
const normalized = (value ?? '').trim();
if (!normalized) {
return fallback;
}
return normalized.length > CURSOR_ERROR_DETAIL_MAX_LENGTH
? `${normalized.slice(0, CURSOR_ERROR_DETAIL_MAX_LENGTH)}...`
: normalized;
}
function isAuthenticationError(error: CursorExecError): boolean {
const message = [
trimDetail(error.message),
trimDetail(error.stderr),
trimDetail(error.stdout),
].join('\n').toLowerCase();
const patterns = [
'authentication',
'unauthorized',
'forbidden',
'api key',
'not logged in',
'login required',
'cursor_api_key',
];
return patterns.some((pattern) => message.includes(pattern));
}
function classifyExecutionError(error: CursorExecError, options: CursorCallOptions): string {
if (options.abortSignal?.aborted || error.name === 'AbortError') {
return CURSOR_ABORTED_MESSAGE;
}
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
return 'Cursor Agent CLI output exceeded buffer limit';
}
if (error.code === 'ENOENT') {
return 'cursor-agent binary not found. Install Cursor Agent CLI and ensure `cursor-agent` is in PATH.';
}
if (isAuthenticationError(error)) {
return 'Cursor authentication failed. Run `cursor-agent login` or set TAKT_CURSOR_API_KEY/cursor_api_key.';
}
if (typeof error.code === 'number') {
const detail = trimDetail(error.stderr, trimDetail(error.stdout, getErrorMessage(error)));
return `Cursor Agent CLI exited with code ${error.code}: ${detail}`;
}
return getErrorMessage(error);
}
function parseCursorOutput(stdout: string): { content: string; sessionId?: string } | { error: string } {
const trimmed = stdout.trim();
if (!trimmed) {
return { error: 'cursor-agent returned empty output' };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return {
error: `Failed to parse cursor-agent JSON output: ${trimDetail(trimmed, '<empty>')}`,
};
}
const content = extractContent(parsed);
if (!content) {
return {
error: `Failed to extract assistant content from cursor-agent JSON output: ${trimDetail(trimmed, '<empty>')}`,
};
}
const sessionId = extractSessionId(parsed);
return { content, sessionId };
}
/**
* Client for Cursor Agent CLI interactions.
*/
export class CursorClient {
async call(agentType: string, prompt: string, options: CursorCallOptions): Promise<AgentResponse> {
const args = buildArgs(prompt, options);
try {
const { stdout } = await execCursor(args, options);
const parsed = parseCursorOutput(stdout);
if ('error' in parsed) {
return {
persona: agentType,
status: 'error',
content: parsed.error,
timestamp: new Date(),
sessionId: options.sessionId,
};
}
const sessionId = parsed.sessionId ?? options.sessionId;
if (options.onStream) {
options.onStream({ type: 'text', data: { text: parsed.content } });
options.onStream({
type: 'result',
data: {
result: parsed.content,
success: true,
sessionId: sessionId ?? '',
},
});
}
return {
persona: agentType,
status: 'done',
content: parsed.content,
timestamp: new Date(),
sessionId,
};
} catch (rawError) {
const error = rawError as CursorExecError;
const message = classifyExecutionError(error, options);
if (options.onStream) {
options.onStream({
type: 'result',
data: {
result: '',
success: false,
error: message,
sessionId: options.sessionId ?? '',
},
});
}
return {
persona: agentType,
status: 'error',
content: message,
timestamp: new Date(),
sessionId: options.sessionId,
};
}
}
async callCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: CursorCallOptions,
): Promise<AgentResponse> {
return this.call(agentName, prompt, {
...options,
systemPrompt,
});
}
}
const defaultClient = new CursorClient();
export async function callCursor(
agentType: string,
prompt: string,
options: CursorCallOptions,
): Promise<AgentResponse> {
return defaultClient.call(agentType, prompt, options);
}
export async function callCursorCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: CursorCallOptions,
): Promise<AgentResponse> {
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
}