| export type CodexToolResolution = { |
| name: string; |
| input: Record<string, unknown>; |
| }; |
|
|
| export type CodexTodoItem = { |
| content: string; |
| status: 'pending' | 'in_progress' | 'completed'; |
| activeForm?: string; |
| }; |
|
|
| const TOOL_NAME_BASH = 'Bash'; |
| const TOOL_NAME_READ = 'Read'; |
| const TOOL_NAME_EDIT = 'Edit'; |
| const TOOL_NAME_WRITE = 'Write'; |
| const TOOL_NAME_GREP = 'Grep'; |
| const TOOL_NAME_GLOB = 'Glob'; |
| const TOOL_NAME_TODO = 'TodoWrite'; |
| const TOOL_NAME_DELETE = 'Delete'; |
| const TOOL_NAME_LS = 'Ls'; |
|
|
| const INPUT_KEY_COMMAND = 'command'; |
| const INPUT_KEY_FILE_PATH = 'file_path'; |
| const INPUT_KEY_PATTERN = 'pattern'; |
|
|
| const SHELL_WRAPPER_PATTERNS = [ |
| /^\/bin\/bash\s+-lc\s+["']([\s\S]+)["']$/, |
| /^bash\s+-lc\s+["']([\s\S]+)["']$/, |
| /^\/bin\/sh\s+-lc\s+["']([\s\S]+)["']$/, |
| /^sh\s+-lc\s+["']([\s\S]+)["']$/, |
| /^cmd\.exe\s+\/c\s+["']?([\s\S]+)["']?$/i, |
| /^powershell(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i, |
| /^pwsh(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i, |
| ] as const; |
|
|
| const COMMAND_SEPARATOR_PATTERN = /\s*(?:&&|\|\||;)\s*/; |
| const SEGMENT_SKIP_PREFIXES = ['cd ', 'export ', 'set ', 'pushd '] as const; |
| const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']); |
| const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']); |
| const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']); |
| const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']); |
| const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']); |
| const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']); |
| const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']); |
| const APPLY_PATCH_COMMAND = 'apply_patch'; |
| const APPLY_PATCH_PATTERN = /\bapply_patch\b/; |
| const REDIRECTION_TARGET_PATTERN = /(?:>>|>)\s*([^\s]+)/; |
| const SED_IN_PLACE_FLAGS = new Set(['-i', '--in-place']); |
| const PERL_IN_PLACE_FLAG = /-.*i/; |
| const SEARCH_PATTERN_FLAGS = new Set(['-e', '--regexp']); |
| const SEARCH_VALUE_FLAGS = new Set([ |
| '-g', |
| '--glob', |
| '--iglob', |
| '--type', |
| '--type-add', |
| '--type-clear', |
| '--encoding', |
| ]); |
| const SEARCH_FILE_LIST_FLAGS = new Set(['--files']); |
| const TODO_LINE_PATTERN = /^[-*]\s*(?:\[(?<status>[ x~])\]\s*)?(?<content>.+)$/; |
| const TODO_STATUS_COMPLETED = 'completed'; |
| const TODO_STATUS_IN_PROGRESS = 'in_progress'; |
| const TODO_STATUS_PENDING = 'pending'; |
| const PATCH_FILE_MARKERS = [ |
| '*** Update File: ', |
| '*** Add File: ', |
| '*** Delete File: ', |
| '*** Move to: ', |
| ] as const; |
|
|
| function stripShellWrapper(command: string): string { |
| const trimmed = command.trim(); |
| for (const pattern of SHELL_WRAPPER_PATTERNS) { |
| const match = trimmed.match(pattern); |
| if (match && match[1]) { |
| return unescapeCommand(match[1].trim()); |
| } |
| } |
| return trimmed; |
| } |
|
|
| function unescapeCommand(command: string): string { |
| return command.replace(/\\(["'])/g, '$1'); |
| } |
|
|
| function extractPrimarySegment(command: string): string { |
| const segments = command |
| .split(COMMAND_SEPARATOR_PATTERN) |
| .map((segment) => segment.trim()) |
| .filter(Boolean); |
|
|
| for (const segment of segments) { |
| const shouldSkip = SEGMENT_SKIP_PREFIXES.some((prefix) => segment.startsWith(prefix)); |
| if (!shouldSkip) { |
| return segment; |
| } |
| } |
|
|
| return command.trim(); |
| } |
|
|
| function tokenizeCommand(command: string): string[] { |
| const tokens: string[] = []; |
| let current = ''; |
| let inSingleQuote = false; |
| let inDoubleQuote = false; |
| let isEscaped = false; |
|
|
| for (const char of command) { |
| if (isEscaped) { |
| current += char; |
| isEscaped = false; |
| continue; |
| } |
|
|
| if (char === '\\') { |
| isEscaped = true; |
| continue; |
| } |
|
|
| if (char === "'" && !inDoubleQuote) { |
| inSingleQuote = !inSingleQuote; |
| continue; |
| } |
|
|
| if (char === '"' && !inSingleQuote) { |
| inDoubleQuote = !inDoubleQuote; |
| continue; |
| } |
|
|
| if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) { |
| if (current) { |
| tokens.push(current); |
| current = ''; |
| } |
| continue; |
| } |
|
|
| current += char; |
| } |
|
|
| if (current) { |
| tokens.push(current); |
| } |
|
|
| return tokens; |
| } |
|
|
| function stripWrapperTokens(tokens: string[]): string[] { |
| let index = 0; |
| while (index < tokens.length && WRAPPER_COMMANDS.has(tokens[index].toLowerCase())) { |
| index += 1; |
| } |
| return tokens.slice(index); |
| } |
|
|
| function extractFilePathFromTokens(tokens: string[]): string | null { |
| const candidates = tokens.slice(1).filter((token) => token && !token.startsWith('-')); |
| if (candidates.length === 0) return null; |
| return candidates[candidates.length - 1]; |
| } |
|
|
| function extractSearchPattern(tokens: string[]): string | null { |
| const remaining = tokens.slice(1); |
|
|
| for (let index = 0; index < remaining.length; index += 1) { |
| const token = remaining[index]; |
| if (token === '--') { |
| return remaining[index + 1] ?? null; |
| } |
| if (SEARCH_PATTERN_FLAGS.has(token)) { |
| return remaining[index + 1] ?? null; |
| } |
| if (SEARCH_VALUE_FLAGS.has(token)) { |
| index += 1; |
| continue; |
| } |
| if (token.startsWith('-')) { |
| continue; |
| } |
| return token; |
| } |
|
|
| return null; |
| } |
|
|
| function extractTeeTarget(tokens: string[]): string | null { |
| const teeIndex = tokens.findIndex((token) => token === 'tee'); |
| if (teeIndex < 0) return null; |
| const candidate = tokens[teeIndex + 1]; |
| return candidate && !candidate.startsWith('-') ? candidate : null; |
| } |
|
|
| function extractRedirectionTarget(command: string): string | null { |
| const match = command.match(REDIRECTION_TARGET_PATTERN); |
| return match?.[1] ?? null; |
| } |
|
|
| function extractFilePathFromDeleteTokens(tokens: string[]): string | null { |
| |
| |
| for (let i = 1; i < tokens.length; i++) { |
| const token = tokens[i]; |
| if (token && !token.startsWith('-')) { |
| return token; |
| } |
| } |
| return null; |
| } |
|
|
| function hasSedInPlaceFlag(tokens: string[]): boolean { |
| return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i')); |
| } |
|
|
| function hasPerlInPlaceFlag(tokens: string[]): boolean { |
| return tokens.some((token) => PERL_IN_PLACE_FLAG.test(token)); |
| } |
|
|
| function extractPatchFilePath(command: string): string | null { |
| for (const marker of PATCH_FILE_MARKERS) { |
| const index = command.indexOf(marker); |
| if (index < 0) continue; |
| const start = index + marker.length; |
| const end = command.indexOf('\n', start); |
| const rawPath = (end === -1 ? command.slice(start) : command.slice(start, end)).trim(); |
| if (rawPath) return rawPath; |
| } |
| return null; |
| } |
|
|
| function buildInputWithFilePath(filePath: string | null): Record<string, unknown> { |
| return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {}; |
| } |
|
|
| function buildInputWithPattern(pattern: string | null): Record<string, unknown> { |
| return pattern ? { [INPUT_KEY_PATTERN]: pattern } : {}; |
| } |
|
|
| export function resolveCodexToolCall(command: string): CodexToolResolution { |
| const normalized = stripShellWrapper(command); |
| const primarySegment = extractPrimarySegment(normalized); |
| const tokens = stripWrapperTokens(tokenizeCommand(primarySegment)); |
| const commandToken = tokens[0]?.toLowerCase() ?? ''; |
|
|
| const redirectionTarget = extractRedirectionTarget(primarySegment); |
| if (redirectionTarget) { |
| return { |
| name: TOOL_NAME_WRITE, |
| input: buildInputWithFilePath(redirectionTarget), |
| }; |
| } |
|
|
| if (commandToken === APPLY_PATCH_COMMAND || APPLY_PATCH_PATTERN.test(primarySegment)) { |
| return { |
| name: TOOL_NAME_EDIT, |
| input: buildInputWithFilePath(extractPatchFilePath(primarySegment)), |
| }; |
| } |
|
|
| if (commandToken === 'sed' && hasSedInPlaceFlag(tokens)) { |
| return { |
| name: TOOL_NAME_EDIT, |
| input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), |
| }; |
| } |
|
|
| if (commandToken === 'perl' && hasPerlInPlaceFlag(tokens)) { |
| return { |
| name: TOOL_NAME_EDIT, |
| input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), |
| }; |
| } |
|
|
| if (WRITE_COMMANDS.has(commandToken)) { |
| const filePath = |
| commandToken === 'tee' ? extractTeeTarget(tokens) : extractFilePathFromTokens(tokens); |
| return { |
| name: TOOL_NAME_WRITE, |
| input: buildInputWithFilePath(filePath), |
| }; |
| } |
|
|
| if (SEARCH_COMMANDS.has(commandToken)) { |
| if (tokens.some((token) => SEARCH_FILE_LIST_FLAGS.has(token))) { |
| return { |
| name: TOOL_NAME_GLOB, |
| input: buildInputWithPattern(extractFilePathFromTokens(tokens)), |
| }; |
| } |
|
|
| return { |
| name: TOOL_NAME_GREP, |
| input: buildInputWithPattern(extractSearchPattern(tokens)), |
| }; |
| } |
|
|
| |
| if (DELETE_COMMANDS.has(commandToken)) { |
| |
| if ( |
| tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf') |
| ) { |
| return { |
| name: TOOL_NAME_BASH, |
| input: { [INPUT_KEY_COMMAND]: normalized }, |
| }; |
| } |
| |
| const filePath = extractFilePathFromDeleteTokens(tokens); |
| if (filePath) { |
| return { |
| name: TOOL_NAME_DELETE, |
| input: { path: filePath }, |
| }; |
| } |
| |
| return { |
| name: TOOL_NAME_BASH, |
| input: { [INPUT_KEY_COMMAND]: normalized }, |
| }; |
| } |
|
|
| |
| if (LIST_COMMANDS.has(commandToken)) { |
| const filePath = extractFilePathFromTokens(tokens); |
| return { |
| name: TOOL_NAME_LS, |
| input: { path: filePath || '.' }, |
| }; |
| } |
|
|
| if (GLOB_COMMANDS.has(commandToken)) { |
| return { |
| name: TOOL_NAME_GLOB, |
| input: buildInputWithPattern(extractFilePathFromTokens(tokens)), |
| }; |
| } |
|
|
| if (READ_COMMANDS.has(commandToken)) { |
| return { |
| name: TOOL_NAME_READ, |
| input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), |
| }; |
| } |
|
|
| return { |
| name: TOOL_NAME_BASH, |
| input: { [INPUT_KEY_COMMAND]: normalized }, |
| }; |
| } |
|
|
| function parseTodoLines(lines: string[]): CodexTodoItem[] { |
| const todos: CodexTodoItem[] = []; |
|
|
| for (const line of lines) { |
| const match = line.match(TODO_LINE_PATTERN); |
| if (!match?.groups?.content) continue; |
|
|
| const statusToken = match.groups.status; |
| const status = |
| statusToken === 'x' |
| ? TODO_STATUS_COMPLETED |
| : statusToken === '~' |
| ? TODO_STATUS_IN_PROGRESS |
| : TODO_STATUS_PENDING; |
|
|
| todos.push({ content: match.groups.content.trim(), status }); |
| } |
|
|
| return todos; |
| } |
|
|
| function extractTodoFromArray(value: unknown[]): CodexTodoItem[] { |
| return value |
| .map((entry) => { |
| if (typeof entry === 'string') { |
| return { content: entry, status: TODO_STATUS_PENDING }; |
| } |
| if (entry && typeof entry === 'object') { |
| const record = entry as Record<string, unknown>; |
| const content = |
| typeof record.content === 'string' |
| ? record.content |
| : typeof record.text === 'string' |
| ? record.text |
| : typeof record.title === 'string' |
| ? record.title |
| : null; |
| if (!content) return null; |
| const status = |
| record.status === TODO_STATUS_COMPLETED || |
| record.status === TODO_STATUS_IN_PROGRESS || |
| record.status === TODO_STATUS_PENDING |
| ? (record.status as CodexTodoItem['status']) |
| : TODO_STATUS_PENDING; |
| const activeForm = typeof record.activeForm === 'string' ? record.activeForm : undefined; |
| return { content, status, activeForm }; |
| } |
| return null; |
| }) |
| .filter((item): item is CodexTodoItem => Boolean(item)); |
| } |
|
|
| export function extractCodexTodoItems(item: Record<string, unknown>): CodexTodoItem[] | null { |
| const todosValue = item.todos; |
| if (Array.isArray(todosValue)) { |
| const todos = extractTodoFromArray(todosValue); |
| return todos.length > 0 ? todos : null; |
| } |
|
|
| const itemsValue = item.items; |
| if (Array.isArray(itemsValue)) { |
| const todos = extractTodoFromArray(itemsValue); |
| return todos.length > 0 ? todos : null; |
| } |
|
|
| const textValue = |
| typeof item.text === 'string' |
| ? item.text |
| : typeof item.content === 'string' |
| ? item.content |
| : null; |
| if (!textValue) return null; |
|
|
| const lines = textValue |
| .split('\n') |
| .map((line) => line.trim()) |
| .filter(Boolean); |
| const todos = parseTodoLines(lines); |
| return todos.length > 0 ? todos : null; |
| } |
|
|
| export function getCodexTodoToolName(): string { |
| return TOOL_NAME_TODO; |
| } |
|
|