From 69bca167faffe3429e591d41ee69574ea7a6b103 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 13:14:34 +0900 Subject: [PATCH] feat(omo-claude): vendor comment-checker component with CC patches Vendored from omo-codex via sync-components: model/turn_id optional in the PostToolUse validator (permission_mode kept), CLAUDE_PLUGIN_ROOT hook path. Builds clean; runs without crash on a CC-shaped PostToolUse payload (no turn_id). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../comment-checker/hooks/hooks.json | 17 + .../components/comment-checker/package.json | 57 +++ .../skills/comment-checker/SKILL.md | 16 + .../components/comment-checker/src/cli.ts | 12 + .../comment-checker/src/codex-hook.ts | 159 ++++++++ .../components/comment-checker/src/core.ts | 361 ++++++++++++++++++ .../components/comment-checker/src/runner.ts | 195 ++++++++++ .../comment-checker/tsconfig.build.json | 12 + .../components/comment-checker/tsconfig.json | 27 ++ 9 files changed, 856 insertions(+) create mode 100644 packages/omo-claude/plugin/components/comment-checker/hooks/hooks.json create mode 100644 packages/omo-claude/plugin/components/comment-checker/package.json create mode 100644 packages/omo-claude/plugin/components/comment-checker/skills/comment-checker/SKILL.md create mode 100644 packages/omo-claude/plugin/components/comment-checker/src/cli.ts create mode 100644 packages/omo-claude/plugin/components/comment-checker/src/codex-hook.ts create mode 100644 packages/omo-claude/plugin/components/comment-checker/src/core.ts create mode 100644 packages/omo-claude/plugin/components/comment-checker/src/runner.ts create mode 100644 packages/omo-claude/plugin/components/comment-checker/tsconfig.build.json create mode 100644 packages/omo-claude/plugin/components/comment-checker/tsconfig.json diff --git a/packages/omo-claude/plugin/components/comment-checker/hooks/hooks.json b/packages/omo-claude/plugin/components/comment-checker/hooks/hooks.json new file mode 100644 index 000000000..003323421 --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use", + "timeout": 30, + "statusMessage": "checking comments" + } + ] + } + ] + } +} diff --git a/packages/omo-claude/plugin/components/comment-checker/package.json b/packages/omo-claude/plugin/components/comment-checker/package.json new file mode 100644 index 000000000..44f855e59 --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/package.json @@ -0,0 +1,57 @@ +{ + "name": "@code-yeongyu/codex-comment-checker", + "version": "0.1.1", + "description": "Codex plugin that runs comment-checker after edit-like PostToolUse hooks.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-comment-checker", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-comment-checker.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-comment-checker/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "comment-checker", + "hooks", + "typescript" + ], + "bin": { + "codex-comment-checker": "./dist/cli.js" + }, + "files": [ + "dist", + "hooks", + "skills", + ".codex-plugin", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest --run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "check": "tsc --noEmit && biome check . && npm run build" + }, + "optionalDependencies": { + "@code-yeongyu/comment-checker": "^0.8.0" + }, + "devDependencies": { + "@biomejs/biome": "2.4.15", + "@types/node": "^25.7.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/omo-claude/plugin/components/comment-checker/skills/comment-checker/SKILL.md b/packages/omo-claude/plugin/components/comment-checker/skills/comment-checker/SKILL.md new file mode 100644 index 000000000..7ce771015 --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/skills/comment-checker/SKILL.md @@ -0,0 +1,16 @@ +--- +name: comment-checker +description: Use when Codex needs to understand or respond to automatic comment-checker feedback emitted after an edit-like PostToolUse hook. +--- + +# Codex Comment Checker + +The plugin registers a `PostToolUse` hook for successful `apply_patch`, `write`, `edit`, `multi_edit`, and `multiedit` calls. + +When comment-checker reports a warning after a patch, Codex receives blocking feedback and should fix or explain the flagged comment before moving on. + +## Scope + +- No MCP tool is exposed. +- Non-edit tools are ignored by this plugin. +- Missing checker binaries emit no hook output so normal Codex work can continue. diff --git a/packages/omo-claude/plugin/components/comment-checker/src/cli.ts b/packages/omo-claude/plugin/components/comment-checker/src/cli.ts new file mode 100644 index 000000000..ca8991828 --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/src/cli.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +import { runCodexHookCli } from "./codex-hook.js"; + +const [command, subcommand] = process.argv.slice(2); + +if (command === "hook" && subcommand === "post-tool-use") { + await runCodexHookCli(); +} else { + process.stderr.write("Usage: codex-comment-checker hook post-tool-use\n"); + process.exitCode = 2; +} diff --git a/packages/omo-claude/plugin/components/comment-checker/src/codex-hook.ts b/packages/omo-claude/plugin/components/comment-checker/src/codex-hook.ts new file mode 100644 index 000000000..cd9ff28a2 --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/src/codex-hook.ts @@ -0,0 +1,159 @@ +import { stdin as processStdin, stdout as processStdout } from "node:process"; + +import { + type CommentCheckRequest, + extractCommentCheckRequests, + isRecord, + type ToolResultContent, + type ToolResultLike, + toHookInput, +} from "./core.js"; +import { type CommentCheckerRunner, runCommentChecker } from "./runner.js"; + +export type CodexPostToolUseInput = { + session_id: string; + turn_id?: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostToolUse"; + model?: string; + permission_mode: string; + tool_name: string; + tool_input: Record; + tool_response: unknown; + tool_use_id: string; +}; + +export type CodexHookOptions = { + run?: CommentCheckerRunner; +}; + +export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] { + return extractCommentCheckRequests(toToolResultLike(input)); +} + +export async function runCommentCheckerPostToolUse( + input: CodexPostToolUseInput, + options: CodexHookOptions = {}, +): Promise { + const requests = extractCodexCommentCheckRequests(input); + if (requests.length === 0) return ""; + + const runner = options.run ?? runCommentChecker; + const warnings: Array<{ filePath: string; message: string }> = []; + + for (const request of requests) { + const context = { + sessionId: input.session_id, + cwd: input.cwd, + ...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }), + }; + const result = await runner(toHookInput(request, context)); + if (result.status === "missing" || result.status === "pass") continue; + if (result.status === "error") continue; + const message = result.message.trim(); + if (message.length > 0) { + warnings.push({ filePath: request.filePath, message }); + } + } + + if (warnings.length === 0) return ""; + + return JSON.stringify({ + decision: "block", + reason: formatWarnings(warnings), + }); +} + +export async function runCodexHookCli(): Promise { + const input = await readStdin(); + if (input.trim().length === 0) return; + const parsed = parseCodexPostToolUseInput(input); + if (!parsed) return; + const output = await runCommentCheckerPostToolUse(parsed); + if (output.length > 0) { + processStdout.write(output); + processStdout.write("\n"); + } +} + +export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return undefined; + } + return isCodexPostToolUseInput(parsed) ? parsed : undefined; +} + +function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike { + return { + toolName: input.tool_name, + input: normalizeToolInput(input.tool_name, input.tool_input), + content: normalizeToolResponse(input.tool_response), + isError: isErrorResponse(input.tool_response), + details: isRecord(input.tool_response) ? input.tool_response : undefined, + }; +} + +function normalizeToolInput(toolName: string, toolInput: Record): Record { + if (toolName === "apply_patch" && typeof toolInput["command"] === "string") { + return { + ...toolInput, + input: toolInput["command"], + patch: toolInput["command"], + }; + } + return toolInput; +} + +function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] { + if (typeof toolResponse === "string") { + return [{ type: "text", text: toolResponse }]; + } + if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") { + return [{ type: "text", text: toolResponse["text"] }]; + } + return []; +} + +function isErrorResponse(toolResponse: unknown): boolean { + return isRecord(toolResponse) && toolResponse["is_error"] === true; +} + +function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string { + return warnings + .map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`) + .join("\n\n"); +} + +function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput { + return ( + isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + (typeof value["turn_id"] === "string" || value["turn_id"] === undefined) && + (typeof value["transcript_path"] === "string" || value["transcript_path"] === null) && + typeof value["cwd"] === "string" && + (typeof value["model"] === "string" || value["model"] === undefined) && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + isRecord(value["tool_input"]) && + typeof value["tool_use_id"] === "string" + ); +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf-8"); + processStdin.on("data", (chunk: string) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/packages/omo-claude/plugin/components/comment-checker/src/core.ts b/packages/omo-claude/plugin/components/comment-checker/src/core.ts new file mode 100644 index 000000000..d8506d69d --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/src/core.ts @@ -0,0 +1,361 @@ +export type TextContent = { + type: "text"; + text: string; +}; + +export type ImageContent = { + type: "image"; + data: string; + mimeType: string; +}; + +export type CheckerToolName = "Write" | "Edit" | "MultiEdit"; + +export type CheckerEdit = { + old_string: string; + new_string: string; +}; + +export type CheckerToolInput = { + file_path: string; + content?: string; + old_string?: string; + new_string?: string; + edits?: CheckerEdit[]; +}; + +export type CommentCheckRequest = { + sourceToolName: string; + toolName: CheckerToolName; + filePath: string; + toolInput: CheckerToolInput; +}; + +export type CommentCheckerHookInput = { + session_id: string; + tool_name: CheckerToolName; + transcript_path: string; + cwd: string; + hook_event_name: "PostToolUse"; + tool_input: CheckerToolInput; +}; + +export type ToolResultContent = TextContent | ImageContent; + +export type ToolResultLike = { + toolName: string; + input: Record; + content?: ToolResultContent[]; + isError?: boolean; + details?: unknown; +}; + +type ApplyPatchAccumulator = { + operation: "add" | "delete" | "update"; + filePath: string; + movePath?: string; + oldLines: string[]; + newLines: string[]; +}; + +type ApplyPatchFileMetadata = { + filePath: string; + movePath?: string; + before: string; + after: string; + type?: string; +}; + +export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] { + if (event.isError) return []; + if (isToolFailureOutput(getContentText(event.content))) return []; + + const toolName = event.toolName.toLowerCase(); + if (toolName === "write") return extractWriteRequest(event); + if (toolName === "edit") return extractEditRequest(event); + if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event); + if (toolName === "apply_patch") return extractApplyPatchRequests(event); + return []; +} + +export function toHookInput( + request: CommentCheckRequest, + context: { + sessionId: string; + cwd: string; + transcriptPath?: string; + }, +): CommentCheckerHookInput { + return { + session_id: context.sessionId, + tool_name: request.toolName, + transcript_path: context.transcriptPath ?? "", + cwd: context.cwd, + hook_event_name: "PostToolUse", + tool_input: request.toolInput, + }; +} + +export function isToolFailureOutput(text: string): boolean { + const lower = text.trim().toLowerCase(); + return ( + lower.startsWith("error") || + lower.includes("error:") || + lower.includes("failed to") || + lower.includes("could not") + ); +} + +function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const content = getString(event.input, ["content"]); + if (!filePath || content === undefined) return []; + return [ + { + sourceToolName: event.toolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content, + }, + }, + ]; +} + +function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const oldString = getString(event.input, ["oldString", "old_string"]); + const newString = getString(event.input, ["newString", "new_string"]); + if (!filePath || oldString === undefined || newString === undefined) return []; + const toolInput: CheckerToolInput = { file_path: filePath }; + toolInput.old_string = oldString; + toolInput.new_string = newString; + return [ + { + sourceToolName: event.toolName, + toolName: "Edit", + filePath, + toolInput, + }, + ]; +} + +function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const edits = getEdits(event.input["edits"]); + if (!filePath || edits.length === 0) return []; + return [ + { + sourceToolName: event.toolName, + toolName: "MultiEdit", + filePath, + toolInput: { + file_path: filePath, + edits, + }, + }, + ]; +} + +function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] { + const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName); + if (metadataRequests.length > 0) return metadataRequests; + + const patch = getString(event.input, ["input", "patch", "command"]); + if (!patch) return []; + return parseApplyPatchRequests(patch, event.toolName); +} + +function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] { + const metadataFiles = getApplyPatchMetadataFiles(details); + if (metadataFiles.length === 0) return []; + + const requests: CommentCheckRequest[] = []; + for (const file of metadataFiles) { + if (file.type === "delete") continue; + const filePath = file.movePath ?? file.filePath; + if (file.before.length === 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content: file.after, + }, + }); + continue; + } + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: file.before, + new_string: file.after, + }, + }); + } + return requests; +} + +function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] { + if (!isRecord(details)) return []; + const direct = readApplyPatchMetadataFiles(details["files"]); + if (direct.length > 0) return direct; + const resultDetails = details["result"]; + const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : []; + if (result.length > 0) return result; + const metadataDetails = details["metadata"]; + const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : []; + return metadata; +} + +function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] { + if (!Array.isArray(value)) return []; + const files: ApplyPatchFileMetadata[] = []; + for (const item of value) { + if (!isRecord(item)) continue; + const filePath = getString(item, ["filePath", "file_path", "path"]); + const movePath = getString(item, ["movePath", "move_path"]); + const before = getString(item, ["before", "old", "oldString", "old_string"]); + const after = getString(item, ["after", "new", "newString", "new_string"]); + const type = getString(item, ["type", "operation"]); + if (!filePath || before === undefined || after === undefined) continue; + files.push({ + filePath, + before, + after, + ...(movePath === undefined ? {} : { movePath }), + ...(type === undefined ? {} : { type }), + }); + } + return files; +} + +export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] { + const requests: CommentCheckRequest[] = []; + let current: ApplyPatchAccumulator | undefined; + + const flush = (): void => { + if (!current) return; + if (current.operation === "add") { + const content = joinPatchLines(current.newLines); + if (content.length > 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath: current.filePath, + toolInput: { + file_path: current.filePath, + content, + }, + }); + } + } + if (current.operation === "update") { + const newString = joinPatchLines(current.newLines); + if (newString.length > 0) { + const filePath = current.movePath ?? current.filePath; + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: joinPatchLines(current.oldLines), + new_string: newString, + }, + }); + } + } + current = undefined; + }; + + for (const line of patch.split(/\r?\n/)) { + if (line === "*** Begin Patch" || line === "*** End Patch") continue; + if (line.startsWith("*** Add File: ")) { + flush(); + current = makeAccumulator("add", line.slice("*** Add File: ".length).trim()); + continue; + } + if (line.startsWith("*** Update File: ")) { + flush(); + current = makeAccumulator("update", line.slice("*** Update File: ".length).trim()); + continue; + } + if (line.startsWith("*** Delete File: ")) { + flush(); + current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim()); + continue; + } + if (line.startsWith("*** Move to: ")) { + if (current?.operation === "update") current.movePath = line.slice("*** Move to: ".length).trim(); + continue; + } + if (!current) continue; + if (line.startsWith("@@")) continue; + if (current.operation === "add") { + if (line.startsWith("+")) current.newLines.push(line.slice(1)); + continue; + } + if (current.operation === "update") { + if (line.startsWith("+")) current.newLines.push(line.slice(1)); + if (line.startsWith("-")) current.oldLines.push(line.slice(1)); + } + } + + flush(); + return requests; +} + +function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator { + return { + operation, + filePath, + oldLines: [], + newLines: [], + }; +} + +function getEdits(value: unknown): CheckerEdit[] { + if (!Array.isArray(value)) return []; + const edits: CheckerEdit[] = []; + for (const item of value) { + if (!isRecord(item)) continue; + const oldString = getString(item, ["oldString", "old_string"]); + const newString = getString(item, ["newString", "new_string"]); + if (oldString === undefined || newString === undefined) continue; + edits.push({ + old_string: oldString, + new_string: newString, + }); + } + return edits; +} + +function getContentText(content: ToolResultContent[] | undefined): string { + if (!content) return ""; + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +function getString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function joinPatchLines(lines: string[]): string { + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/omo-claude/plugin/components/comment-checker/src/runner.ts b/packages/omo-claude/plugin/components/comment-checker/src/runner.ts new file mode 100644 index 000000000..7d6ce285b --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/src/runner.ts @@ -0,0 +1,195 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +import type { CommentCheckerHookInput } from "./core.js"; + +export type ProcessResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; + +export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024; + +export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise; + +export type RunCommentCheckerOptions = { + binaryPath?: string; + customPrompt?: string; + resolveBinary?: () => string | undefined; + executor?: ProcessExecutor; +}; + +export type CommentCheckerRunResult = { + status: "pass" | "warning" | "error" | "missing"; + message: string; + binaryPath?: string; + exitCode?: number | null; + stdout?: string; + stderr?: string; +}; + +export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise; + +export async function runCommentChecker( + input: CommentCheckerHookInput, + options: RunCommentCheckerOptions = {}, +): Promise { + const binaryPath = + options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary()); + if (!binaryPath) { + return { + status: "missing", + message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.", + }; + } + + const args = ["check"]; + if (options.customPrompt) { + args.push("--prompt", options.customPrompt); + } + + const executor = options.executor ?? spawnProcess; + const result = await executor(binaryPath, args, JSON.stringify(input)); + const message = result.stderr || result.stdout; + if (result.exitCode === 0) { + return { + status: "pass", + message: "", + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + if (result.exitCode === 2) { + return { + status: "warning", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + return { + status: "error", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export function resolveCommentCheckerBinary(): string | undefined { + const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker"; + const fromPackageApi = resolvePackageApiBinary(); + if (fromPackageApi) return fromPackageApi; + const fromPackage = resolvePackageBinary(binaryName); + if (fromPackage) return fromPackage; + return undefined; +} + +function resolvePackageApiBinary(): string | undefined { + try { + const require = createRequire(import.meta.url); + const packageExports: unknown = require("@code-yeongyu/comment-checker"); + if (!isCommentCheckerPackage(packageExports)) return undefined; + const binaryPath = packageExports.getBinaryPath(); + return existsSync(binaryPath) ? binaryPath : undefined; + } catch { + return undefined; + } +} + +function resolvePackageBinary(binaryName: string): string | undefined { + try { + const require = createRequire(import.meta.url); + const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json"); + const binaryPath = join(dirname(packagePath), "bin", binaryName); + return existsSync(binaryPath) ? binaryPath : undefined; + } catch { + return undefined; + } +} + +function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } { + return isRecord(value) && typeof value["getBinaryPath"] === "function"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +interface OutputAccumulator { + text: string; + bytes: number; + truncated: boolean; +} + +function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void { + if (output.truncated) return; + + const remainingBytes = maxOutputBytes - output.bytes; + const chunkBytes = Buffer.byteLength(chunk, "utf8"); + if (chunkBytes <= remainingBytes) { + output.text += chunk; + output.bytes += chunkBytes; + return; + } + + if (remainingBytes > 0) { + output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8"); + output.bytes += remainingBytes; + } + output.truncated = true; +} + +function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string { + if (!output.truncated) return output.text; + return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`; +} + +export function spawnProcess( + command: string, + args: string[], + stdin: string, + maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES, +): Promise { + return new Promise((resolve) => { + const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0; + const proc = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false }; + const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false }; + + proc.stdout.setEncoding("utf-8"); + proc.stderr.setEncoding("utf-8"); + proc.stdout.on("data", (chunk: string) => { + appendOutput(stdout, chunk, outputByteLimit); + }); + proc.stderr.on("data", (chunk: string) => { + appendOutput(stderr, chunk, outputByteLimit); + }); + proc.once("error", (error) => { + appendOutput(stderr, error.message, outputByteLimit); + resolve({ + exitCode: null, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.once("close", (exitCode) => { + resolve({ + exitCode, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.stdin.end(stdin); + }); +} diff --git a/packages/omo-claude/plugin/components/comment-checker/tsconfig.build.json b/packages/omo-claude/plugin/components/comment-checker/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["test/**/*"] +} diff --git a/packages/omo-claude/plugin/components/comment-checker/tsconfig.json b/packages/omo-claude/plugin/components/comment-checker/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-claude/plugin/components/comment-checker/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "verbatimModuleSyntax": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "useDefineForClassFields": false, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +}