전체적인 리팩토링
This commit is contained in:
649
lib/autotrade/cli-provider.ts
Normal file
649
lib/autotrade/cli-provider.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 구독형 CLI(Codex/Gemini)로 전략/신호 JSON을 생성하는 Provider 계층입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - vendor 선택(auto/codex/gemini) 및 실행
|
||||
* - vendor별 model 옵션 적용(--model)
|
||||
* - 실행 결과를 파싱 가능한 형태로 반환(vendor/model/attempts)
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
AutotradeCompiledStrategy,
|
||||
AutotradeMarketSnapshot,
|
||||
AutotradeTechniqueId,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
|
||||
type CliVendor = "gemini" | "codex";
|
||||
type CliVendorPreference = "auto" | CliVendor;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
export interface SubscriptionCliAttemptInfo {
|
||||
vendor: CliVendor;
|
||||
model: string | null;
|
||||
status: "ok" | "empty_output" | "process_error" | "timeout";
|
||||
command?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionCliParsedResult {
|
||||
parsed: unknown | null;
|
||||
vendor: CliVendor | null;
|
||||
model: string | null;
|
||||
attempts: SubscriptionCliAttemptInfo[];
|
||||
}
|
||||
|
||||
const ATTEMPT_STATUS_LABEL: Record<SubscriptionCliAttemptInfo["status"], string> = {
|
||||
ok: "ok",
|
||||
empty_output: "empty",
|
||||
process_error: "error",
|
||||
timeout: "timeout",
|
||||
};
|
||||
|
||||
// [목적] 구독형 CLI(codex/gemini)로 전략 JSON을 생성합니다.
|
||||
export async function compileStrategyWithSubscriptionCli(params: {
|
||||
prompt: string;
|
||||
selectedTechniques: AutotradeTechniqueId[];
|
||||
confidenceThreshold: number;
|
||||
}) {
|
||||
const result = await compileStrategyWithSubscriptionCliDetailed(params);
|
||||
return result.parsed;
|
||||
}
|
||||
|
||||
// [목적] 구독형 CLI(codex/gemini) 전략 생성 + vendor/시도 정보 추적
|
||||
export async function compileStrategyWithSubscriptionCliDetailed(params: {
|
||||
prompt: string;
|
||||
selectedTechniques: AutotradeTechniqueId[];
|
||||
confidenceThreshold: number;
|
||||
preferredVendor?: CliVendorPreference;
|
||||
preferredModel?: string;
|
||||
}): Promise<SubscriptionCliParsedResult> {
|
||||
const prompt = [
|
||||
"너는 자동매매 전략 컴파일러다.",
|
||||
"반드시 JSON 객체만 출력한다. 코드블록/설명문 금지.",
|
||||
"summary는 반드시 한국어 1문장으로 작성하고, 사용자 프롬프트의 핵심 단어를 1개 이상 포함한다.",
|
||||
"summary에 'provided prompt', '테스트 목적', 'sample/example' 같은 추상 문구만 쓰지 않는다.",
|
||||
'필수 키: summary, confidenceThreshold, maxDailyOrders, cooldownSec, maxOrderAmountRatio',
|
||||
"제약: confidenceThreshold(0.45~0.95), maxDailyOrders(1~200), cooldownSec(10~600), maxOrderAmountRatio(0.05~1)",
|
||||
`입력: ${JSON.stringify(params)}`,
|
||||
].join("\n");
|
||||
|
||||
const execution = await executeSubscriptionCliPrompt(prompt, {
|
||||
preferredVendor: params.preferredVendor,
|
||||
preferredModel: params.preferredModel,
|
||||
});
|
||||
if (!execution.output) {
|
||||
return {
|
||||
parsed: null,
|
||||
vendor: execution.vendor,
|
||||
model: execution.model,
|
||||
attempts: execution.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
parsed: extractJsonObject(execution.output),
|
||||
vendor: execution.vendor,
|
||||
model: execution.model,
|
||||
attempts: execution.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
// [목적] 구독형 CLI(codex/gemini)로 신호 JSON을 생성합니다.
|
||||
export async function generateSignalWithSubscriptionCli(params: {
|
||||
prompt: string;
|
||||
strategy: AutotradeCompiledStrategy;
|
||||
snapshot: AutotradeMarketSnapshot;
|
||||
}) {
|
||||
const result = await generateSignalWithSubscriptionCliDetailed(params);
|
||||
return result.parsed;
|
||||
}
|
||||
|
||||
// [목적] 구독형 CLI(codex/gemini) 신호 생성 + vendor/시도 정보 추적
|
||||
export async function generateSignalWithSubscriptionCliDetailed(params: {
|
||||
prompt: string;
|
||||
strategy: AutotradeCompiledStrategy;
|
||||
snapshot: AutotradeMarketSnapshot;
|
||||
preferredVendor?: CliVendorPreference;
|
||||
preferredModel?: string;
|
||||
}): Promise<SubscriptionCliParsedResult> {
|
||||
const prompt = [
|
||||
"너는 자동매매 신호 생성기다.",
|
||||
"반드시 JSON 객체만 출력한다. 코드블록/설명문 금지.",
|
||||
"필수 키: signal, confidence, reason, ttlSec, riskFlags",
|
||||
"signal은 buy/sell/hold, confidence는 0~1",
|
||||
"reason은 반드시 한국어 한 문장으로 작성한다.",
|
||||
"operatorPrompt가 비어있지 않으면 strategy.summary보다 우선 참고하되, 무리한 진입은 금지한다.",
|
||||
"tradeVolume은 단일 체결 수량일 수 있으니 accumulatedVolume/recentTradeCount/recentTradeVolumeSum/liquidityDepth/orderBookImbalance를 함께 보고 유동성을 판단한다.",
|
||||
"tickTime/requestAtKst/marketDataLatencySec으로 데이터 시차를 확인하고, 시차가 크면 공격적 진입을 피한다.",
|
||||
"recentTradeVolumes/recentNetBuyTrail/recentTickAgesSec으로 체결 흐름의 연속성을 확인한다.",
|
||||
"topLevelOrderBookImbalance와 buySellExecutionRatio를 함께 보고 단기 수급 왜곡을 판단한다.",
|
||||
"recentMinuteCandles와 minutePatternContext가 있으면 직전 3~10봉 추세와 최근 2~8봉 압축 구간을 먼저 판별한다.",
|
||||
"minutePatternContext.impulseDirection/impulseChangeRate/consolidationRangePercent/consolidationVolumeRatio/breakoutUpper/breakoutLower를 함께 보고 박스권 돌파/이탈 가능성을 판단한다.",
|
||||
"budgetContext가 있으면 estimatedBuyableQuantity(예산 기준 최대 매수 가능 주수)를 넘는 과도한 매수는 피한다.",
|
||||
"portfolioContext가 있으면 sellableQuantity(실매도 가능 수량)가 0일 때는 sell을 피한다.",
|
||||
"executionCostProfile이 있으면 매도 수수료/세금까지 포함한 순손익 관점으로 판단한다.",
|
||||
"압축 구간이 불명확하거나 박스 내부 중간값이면 hold를 우선한다.",
|
||||
"확신이 낮거나 위험하면 hold를 반환한다.",
|
||||
`입력: ${JSON.stringify({
|
||||
operatorPrompt: params.prompt,
|
||||
strategy: params.strategy,
|
||||
snapshot: params.snapshot,
|
||||
})}`,
|
||||
].join("\n");
|
||||
|
||||
const execution = await executeSubscriptionCliPrompt(prompt, {
|
||||
preferredVendor: params.preferredVendor,
|
||||
preferredModel: params.preferredModel,
|
||||
});
|
||||
if (!execution.output) {
|
||||
return {
|
||||
parsed: null,
|
||||
vendor: execution.vendor,
|
||||
model: execution.model,
|
||||
attempts: execution.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
parsed: extractJsonObject(execution.output),
|
||||
vendor: execution.vendor,
|
||||
model: execution.model,
|
||||
attempts: execution.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
// [목적] API 응답/로그에서 codex/gemini 실제 실행 흐름을 사람이 읽기 쉬운 문자열로 제공합니다.
|
||||
export function summarizeSubscriptionCliExecution(execution: {
|
||||
vendor: CliVendor | null;
|
||||
model: string | null;
|
||||
attempts: SubscriptionCliAttemptInfo[];
|
||||
}) {
|
||||
const selectedVendor = execution.vendor ? execution.vendor : "none";
|
||||
const selectedModel = execution.model ?? "default";
|
||||
const attemptsText =
|
||||
execution.attempts.length > 0
|
||||
? execution.attempts
|
||||
.map((attempt) => {
|
||||
const model = attempt.model ?? "default";
|
||||
const detail = attempt.detail ? `(${sanitizeAttemptDetail(attempt.detail)})` : "";
|
||||
return `${attempt.vendor}:${model}:${ATTEMPT_STATUS_LABEL[attempt.status]}${detail}`;
|
||||
})
|
||||
.join(", ")
|
||||
: "none";
|
||||
return `selected=${selectedVendor}:${selectedModel}; attempts=${attemptsText}`;
|
||||
}
|
||||
|
||||
async function executeSubscriptionCliPrompt(
|
||||
prompt: string,
|
||||
options?: {
|
||||
preferredVendor?: CliVendorPreference;
|
||||
preferredModel?: string;
|
||||
},
|
||||
) {
|
||||
// auto 모드: codex 우선 시도 후 gemini로 폴백
|
||||
const preferredVendor = normalizeCliVendorPreference(options?.preferredVendor);
|
||||
const mode = preferredVendor ?? normalizeCliVendorPreference(process.env.AUTOTRADE_SUBSCRIPTION_CLI) ?? "auto";
|
||||
const vendors: CliVendor[] =
|
||||
mode === "gemini" ? ["gemini"] : mode === "codex" ? ["codex"] : ["codex", "gemini"];
|
||||
const attempts: SubscriptionCliAttemptInfo[] = [];
|
||||
const preferredModel = normalizeCliModel(options?.preferredModel);
|
||||
const debugEnabled = isSubscriptionCliDebugEnabled();
|
||||
|
||||
if (debugEnabled) {
|
||||
console.info(
|
||||
`[autotrade-cli] mode=${mode} preferredVendor=${preferredVendor ?? "none"} preferredModel=${preferredModel ?? "default"} prompt="${toPromptPreview(prompt)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const vendor of vendors) {
|
||||
// [Step 1] vendor별 모델 선택: vendor 전용 변수 -> 공통 변수 순서로 해석
|
||||
const model = preferredModel ?? resolveSubscriptionCliModel(vendor);
|
||||
// [Step 2] CLI 실행 후 시도 결과를 누적합니다.
|
||||
const result = await runCliVendor({ vendor, prompt, model });
|
||||
attempts.push(result.attempt);
|
||||
|
||||
if (debugEnabled) {
|
||||
console.info(
|
||||
`[autotrade-cli] vendor=${vendor} model=${model ?? "default"} status=${result.attempt.status} command=${result.attempt.command ?? vendor} detail=${result.attempt.detail ?? "-"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.output) {
|
||||
return {
|
||||
output: result.output,
|
||||
vendor,
|
||||
model,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output: null,
|
||||
vendor: null,
|
||||
model: null,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
async function runCliVendor(params: {
|
||||
vendor: CliVendor;
|
||||
prompt: string;
|
||||
model: string | null;
|
||||
}) {
|
||||
const { vendor, prompt, model } = params;
|
||||
const commands = resolveCliCommandCandidates(vendor);
|
||||
|
||||
// gemini는 headless prompt 옵션으로 직접 결과를 stdout으로 받습니다.
|
||||
if (vendor === "gemini") {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "autotrade-gemini-"));
|
||||
try {
|
||||
// [안전 설정]
|
||||
// - approvalMode=plan으로 전역 설정된 환경에서도 비대화형 호출이 깨지지 않도록 default를 명시합니다.
|
||||
// - 실행 CWD는 임시 디렉터리(tempDir)로 고정해, 도구 호출이 발생해도 프로젝트 소스 수정 위험을 줄입니다.
|
||||
const args = ["-p", prompt, "--output-format", "text", "--approval-mode", "default"];
|
||||
if (model) {
|
||||
// 공식 문서 기준: Gemini CLI는 --model(-m)로 모델 지정 지원
|
||||
args.push("--model", model);
|
||||
}
|
||||
|
||||
const commandResult = await runProcessWithCommandCandidates(commands, args, {
|
||||
cwd: tempDir,
|
||||
// Windows에서 gemini는 npm shim(.cmd) 실행을 고려해 shell 모드를 사용합니다.
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
const result = commandResult.result;
|
||||
const output = result.stdout.trim();
|
||||
if (output) {
|
||||
// 출력이 있으면 우선 파싱 단계로 넘깁니다.
|
||||
return {
|
||||
output,
|
||||
attempt: {
|
||||
vendor,
|
||||
model,
|
||||
status: "ok",
|
||||
command: commandResult.command,
|
||||
} satisfies SubscriptionCliAttemptInfo,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const status = result.stderr.includes("[timeout]") ? "timeout" : "process_error";
|
||||
return {
|
||||
output: null,
|
||||
attempt: {
|
||||
vendor,
|
||||
model,
|
||||
status,
|
||||
command: commandResult.command,
|
||||
detail: resolveAttemptDetail(result),
|
||||
} satisfies SubscriptionCliAttemptInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
output: null,
|
||||
attempt: {
|
||||
vendor,
|
||||
model,
|
||||
status: "empty_output",
|
||||
command: commandResult.command,
|
||||
detail: resolveAttemptDetail(result),
|
||||
} satisfies SubscriptionCliAttemptInfo,
|
||||
};
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "autotrade-codex-"));
|
||||
const outputPath = join(tempDir, "last-message.txt");
|
||||
|
||||
try {
|
||||
// codex는 마지막 메시지를 파일로 받아 안정적으로 파싱합니다.
|
||||
const args = ["exec", "--skip-git-repo-check", "--sandbox", "read-only"];
|
||||
if (model) {
|
||||
// 공식 문서 기준: Codex CLI는 exec에서 --model(-m) 옵션으로 모델 지정 지원
|
||||
args.push("--model", model);
|
||||
}
|
||||
const codexEffortOverride = resolveCodexReasoningEffortOverride(model);
|
||||
if (codexEffortOverride) {
|
||||
// gpt-5-codex는 xhigh를 지원하지 않아 high로 강제합니다.
|
||||
args.push("-c", `model_reasoning_effort=\"${codexEffortOverride}\"`);
|
||||
}
|
||||
args.push("-o", outputPath, prompt);
|
||||
|
||||
const commandResult = await runProcessWithCommandCandidates(commands, args, {
|
||||
cwd: tempDir,
|
||||
// codex는 prompt 문자열을 인자로 안정 전달해야 하므로 shell 모드를 사용하지 않습니다.
|
||||
shell: false,
|
||||
});
|
||||
const result = commandResult.result;
|
||||
|
||||
const output = await readFile(outputPath, "utf-8")
|
||||
.then((value) => value.trim())
|
||||
.catch(() => result.stdout.trim());
|
||||
if (!output) {
|
||||
if (result.exitCode !== 0) {
|
||||
const status = result.stderr.includes("[timeout]") ? "timeout" : "process_error";
|
||||
return {
|
||||
output: null,
|
||||
attempt: {
|
||||
vendor,
|
||||
model,
|
||||
status,
|
||||
command: commandResult.command,
|
||||
detail: resolveAttemptDetail(result),
|
||||
} satisfies SubscriptionCliAttemptInfo,
|
||||
};
|
||||
}
|
||||
return {
|
||||
output: null,
|
||||
attempt: {
|
||||
vendor,
|
||||
model,
|
||||
status: "empty_output",
|
||||
command: commandResult.command,
|
||||
detail: resolveAttemptDetail(result),
|
||||
} satisfies SubscriptionCliAttemptInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
output,
|
||||
attempt: {
|
||||
vendor,
|
||||
model,
|
||||
status: "ok",
|
||||
command: commandResult.command,
|
||||
} satisfies SubscriptionCliAttemptInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "unexpected_error";
|
||||
return {
|
||||
output: null,
|
||||
attempt: {
|
||||
vendor,
|
||||
model,
|
||||
status: "process_error",
|
||||
detail,
|
||||
} satisfies SubscriptionCliAttemptInfo,
|
||||
};
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function runProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string;
|
||||
shell?: boolean;
|
||||
},
|
||||
) {
|
||||
return new Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
spawnErrorCode: string | null;
|
||||
spawnErrorMessage: string | null;
|
||||
}>((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options?.cwd,
|
||||
shell: options?.shell ?? false,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killedByTimeout = false;
|
||||
let spawnErrorCode: string | null = null;
|
||||
let spawnErrorMessage: string | null = null;
|
||||
let completed = false;
|
||||
const timeoutMs = resolveCliTimeoutMs();
|
||||
|
||||
const resolveOnce = (payload: {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
spawnErrorCode: string | null;
|
||||
spawnErrorMessage: string | null;
|
||||
}) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
resolve(payload);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
killedByTimeout = true;
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
spawnErrorCode =
|
||||
typeof (error as NodeJS.ErrnoException).code === "string"
|
||||
? (error as NodeJS.ErrnoException).code!
|
||||
: "SPAWN_ERROR";
|
||||
spawnErrorMessage = error.message;
|
||||
resolveOnce({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: 1,
|
||||
spawnErrorCode,
|
||||
spawnErrorMessage,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolveOnce({
|
||||
stdout,
|
||||
stderr: killedByTimeout ? `${stderr}\n[timeout]` : stderr,
|
||||
exitCode: typeof code === "number" ? code : 1,
|
||||
spawnErrorCode,
|
||||
spawnErrorMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCliTimeoutMs() {
|
||||
const raw = Number.parseInt(process.env.AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS ?? "", 10);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_TIMEOUT_MS;
|
||||
return Math.max(5_000, Math.min(120_000, raw));
|
||||
}
|
||||
|
||||
function resolveSubscriptionCliModel(vendor: CliVendor) {
|
||||
// [Step 1] vendor별 전용 모델 환경변수를 우선 적용합니다.
|
||||
const vendorSpecific =
|
||||
vendor === "codex" ? process.env.AUTOTRADE_CODEX_MODEL : process.env.AUTOTRADE_GEMINI_MODEL;
|
||||
const normalizedVendorSpecific = normalizeCliModel(vendorSpecific);
|
||||
if (normalizedVendorSpecific) {
|
||||
return normalizedVendorSpecific;
|
||||
}
|
||||
|
||||
// [Step 2] 공통 모델 환경변수(AUTOTRADE_SUBSCRIPTION_CLI_MODEL)를 fallback으로 사용합니다.
|
||||
return normalizeCliModel(process.env.AUTOTRADE_SUBSCRIPTION_CLI_MODEL);
|
||||
}
|
||||
|
||||
function resolveCodexReasoningEffortOverride(model: string | null) {
|
||||
const normalized = model?.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
if (normalized === "gpt-5-codex") return "high";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCliVendorPreference(raw: string | undefined): CliVendorPreference | null {
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (normalized === "codex") return "codex";
|
||||
if (normalized === "gemini") return "gemini";
|
||||
if (normalized === "auto") return "auto";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCliModel(raw: string | undefined) {
|
||||
const normalized = raw?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function sanitizeAttemptDetail(detail: string) {
|
||||
return detail.replace(/\s+/g, " ").trim().slice(0, 180);
|
||||
}
|
||||
|
||||
function resolveAttemptDetail(result: {
|
||||
stderr: string;
|
||||
spawnErrorCode: string | null;
|
||||
spawnErrorMessage: string | null;
|
||||
}) {
|
||||
if (result.spawnErrorCode) {
|
||||
const message = result.spawnErrorMessage
|
||||
? `spawn:${result.spawnErrorCode} ${result.spawnErrorMessage}`
|
||||
: `spawn:${result.spawnErrorCode}`;
|
||||
return sanitizeAttemptDetail(message);
|
||||
}
|
||||
|
||||
const stderr = result.stderr.trim();
|
||||
if (!stderr) return undefined;
|
||||
return sanitizeAttemptDetail(stderr);
|
||||
}
|
||||
|
||||
function resolveCliCommandCandidates(vendor: CliVendor) {
|
||||
const envCommand =
|
||||
vendor === "codex"
|
||||
? normalizeCliCommand(process.env.AUTOTRADE_CODEX_COMMAND)
|
||||
: normalizeCliCommand(process.env.AUTOTRADE_GEMINI_COMMAND);
|
||||
const windowsNpmCommand = vendor === "gemini" ? resolveWindowsNpmCommand(vendor) : null;
|
||||
const windowsCodexExecutable = vendor === "codex" ? resolveWindowsCodexExecutable() : null;
|
||||
const baseCommand = vendor;
|
||||
|
||||
const candidates = [envCommand, baseCommand, windowsCodexExecutable, windowsNpmCommand].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
async function runProcessWithCommandCandidates(
|
||||
commands: string[],
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string;
|
||||
shell?: boolean;
|
||||
},
|
||||
) {
|
||||
let lastResult: Awaited<ReturnType<typeof runProcess>> | null = null;
|
||||
let lastCommand: string | null = null;
|
||||
|
||||
for (const command of commands) {
|
||||
const result = await runProcess(command, args, options);
|
||||
lastResult = result;
|
||||
lastCommand = command;
|
||||
if (result.spawnErrorCode !== "ENOENT") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastResult || !lastCommand) {
|
||||
throw new Error("CLI command resolution failed");
|
||||
}
|
||||
|
||||
return { command: lastCommand, result: lastResult };
|
||||
}
|
||||
|
||||
function normalizeCliCommand(raw: string | undefined) {
|
||||
const normalized = raw?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function resolveWindowsNpmCommand(vendor: CliVendor) {
|
||||
if (process.platform !== "win32") return null;
|
||||
const appData = process.env.APPDATA;
|
||||
if (!appData) return null;
|
||||
const fileName = vendor === "codex" ? "codex.cmd" : "gemini.cmd";
|
||||
const candidate = join(appData, "npm", fileName);
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
function resolveWindowsCodexExecutable() {
|
||||
if (process.platform !== "win32") return null;
|
||||
const userProfile = process.env.USERPROFILE;
|
||||
if (!userProfile) return null;
|
||||
|
||||
const extensionRoot = join(userProfile, ".vscode", "extensions");
|
||||
if (!existsSync(extensionRoot)) return null;
|
||||
|
||||
const candidates = readdirSync(extensionRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openai.chatgpt-"))
|
||||
.map((entry) => join(extensionRoot, entry.name, "bin", "windows-x86_64", "codex.exe"))
|
||||
.filter((candidate) => existsSync(candidate));
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function isSubscriptionCliDebugEnabled() {
|
||||
const value = process.env.AUTOTRADE_SUBSCRIPTION_CLI_DEBUG?.trim().toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes" || value === "on";
|
||||
}
|
||||
|
||||
function toPromptPreview(prompt: string) {
|
||||
return prompt.replace(/\s+/g, " ").trim().slice(0, 180);
|
||||
}
|
||||
|
||||
function extractJsonObject(raw: string) {
|
||||
// CLI 출력이 코드블록/설명문을 섞어도 첫 JSON 객체를 최대한 추출합니다.
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const fenced = trimmed.match(/```json\s*([\s\S]*?)```/i)?.[1];
|
||||
if (fenced) {
|
||||
try {
|
||||
return JSON.parse(fenced) as unknown;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
const firstBrace = trimmed.indexOf("{");
|
||||
if (firstBrace < 0) return null;
|
||||
|
||||
let depth = 0;
|
||||
for (let index = firstBrace; index < trimmed.length; index += 1) {
|
||||
const char = trimmed[index];
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") depth -= 1;
|
||||
if (depth !== 0) continue;
|
||||
|
||||
const candidate = trimmed.slice(firstBrace, index + 1);
|
||||
try {
|
||||
return JSON.parse(candidate) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user