From f95d3b1ef5dbe0379f9f78dcd1ca4dbbcb3fe5f0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 23 Mar 2026 18:22:49 +0900 Subject: [PATCH] fix: emit formatter events from hashline-edit tool (fixes #2117) --- src/plugin/tool-registry.ts | 2 +- src/tools/hashline-edit/formatter-trigger.ts | 122 ++++++++++++++++++ .../hashline-edit/hashline-edit-executor.ts | 32 ++++- src/tools/hashline-edit/tools.ts | 5 +- 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 src/tools/hashline-edit/formatter-trigger.ts diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index f58053801..82a9a2ceb 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -126,7 +126,7 @@ export function createToolRegistry(args: { const hashlineEnabled = pluginConfig.hashline_edit ?? false const hashlineToolsRecord: Record = hashlineEnabled - ? { edit: createHashlineEditTool() } + ? { edit: createHashlineEditTool(ctx) } : {} const allTools: Record = { diff --git a/src/tools/hashline-edit/formatter-trigger.ts b/src/tools/hashline-edit/formatter-trigger.ts new file mode 100644 index 000000000..1f9a6d6bc --- /dev/null +++ b/src/tools/hashline-edit/formatter-trigger.ts @@ -0,0 +1,122 @@ +import path from "path" +import { log } from "../../shared" + +interface FormatterConfig { + disabled?: boolean + command?: string[] + environment?: Record + extensions?: string[] +} + +interface OpencodeConfig { + formatter?: + | false + | Record + experimental?: { + hook?: { + file_edited?: Record }>> + } + } +} + +interface OpencodeClient { + config: { + get: (options?: { query?: { directory?: string } }) => Promise<{ data?: OpencodeConfig }> + } +} + +let cachedFormatters: Map }>> | null = null + +async function resolveFormatters( + client: OpencodeClient, + directory: string, +): Promise }>>> { + if (cachedFormatters) return cachedFormatters + + const result = new Map }>>() + + try { + const response = await client.config.get({ query: { directory } }) + const config = response.data + if (!config) return result + + if (config.formatter && typeof config.formatter === "object") { + for (const [, formatter] of Object.entries(config.formatter)) { + if (formatter.disabled || !formatter.command?.length || !formatter.extensions?.length) continue + for (const ext of formatter.extensions) { + const normalizedExt = ext.startsWith(".") ? ext : `.${ext}` + const existing = result.get(normalizedExt) ?? [] + existing.push({ + command: formatter.command, + environment: formatter.environment ?? {}, + }) + result.set(normalizedExt, existing) + } + } + } + + if (config.experimental?.hook?.file_edited) { + for (const [ext, commands] of Object.entries(config.experimental.hook.file_edited)) { + const normalizedExt = ext.startsWith(".") ? ext : `.${ext}` + const existing = result.get(normalizedExt) ?? [] + for (const cmd of commands) { + existing.push({ + command: cmd.command, + environment: cmd.environment ?? {}, + }) + } + result.set(normalizedExt, existing) + } + } + } catch (error) { + log("[formatter-trigger] Failed to fetch formatter config", { error }) + } + + cachedFormatters = result + return result +} + +function buildFormatterCommand(command: string[], filePath: string): string[] { + return command.map((arg) => arg.replace(/\$FILE/g, filePath)) +} + +export async function runFormattersForFile( + client: OpencodeClient, + directory: string, + filePath: string, +): Promise { + const ext = path.extname(filePath) + if (!ext) return + + const formatters = await resolveFormatters(client, directory) + const matching = formatters.get(ext) + if (!matching?.length) return + + for (const formatter of matching) { + const cmd = buildFormatterCommand(formatter.command, filePath) + try { + log("[formatter-trigger] Running formatter", { command: cmd, file: filePath }) + const proc = Bun.spawn(cmd, { + cwd: directory, + env: { ...process.env, ...formatter.environment }, + stdout: "ignore", + stderr: "pipe", + }) + await proc.exited + if (proc.exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + log("[formatter-trigger] Formatter failed", { + command: cmd, + exitCode: proc.exitCode, + stderr: stderr.slice(0, 500), + }) + } + } catch (error) { + log("[formatter-trigger] Formatter execution error", { command: cmd, error }) + } + } +} + +export function clearFormatterCache(): void { + cachedFormatters = null +} diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index d316307db..f3ea00aa6 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -6,6 +6,8 @@ import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalizat import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits" import type { HashlineEdit } from "./types" import { HashlineMismatchError } from "./validation" +import { runFormattersForFile } from "./formatter-trigger" +import type { PluginContext } from "../../plugin/types" interface HashlineEditArgs { filePath: string @@ -80,7 +82,7 @@ function buildSuccessMeta( } } -export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext): Promise { +export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext, pluginCtx?: PluginContext): Promise { try { const metadataContext = context as ToolContextWithMetadata const filePath = args.filePath @@ -129,6 +131,34 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T await Bun.write(filePath, writeContent) + if (pluginCtx?.client) { + await runFormattersForFile(pluginCtx.client as any, context.directory, filePath) + const formattedContent = Buffer.from(await Bun.file(filePath).arrayBuffer()).toString("utf8") + if (formattedContent !== writeContent) { + const formattedEnvelope = canonicalizeFileText(formattedContent) + const formattedMeta = buildSuccessMeta( + filePath, + oldEnvelope.content, + formattedEnvelope.content, + applyResult.noopEdits, + applyResult.deduplicatedEdits + ) + if (typeof metadataContext.metadata === "function") { + metadataContext.metadata(formattedMeta) + } + const callID = resolveToolCallID(metadataContext) + if (callID) { + storeToolMetadata(context.sessionID, callID, formattedMeta) + } + if (rename && rename !== filePath) { + await Bun.write(rename, formattedContent) + await Bun.file(filePath).delete() + return `Moved ${filePath} to ${rename}` + } + return `Updated ${filePath}` + } + } + if (rename && rename !== filePath) { await Bun.write(rename, writeContent) await Bun.file(filePath).delete() diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index 6d9cb9424..a3ee5d840 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -2,6 +2,7 @@ import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin import { executeHashlineEditTool } from "./hashline-edit-executor" import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description" import type { RawHashlineEdit } from "./normalize-edits" +import type { PluginContext } from "../../plugin/types" interface HashlineEditArgs { filePath: string @@ -10,7 +11,7 @@ interface HashlineEditArgs { rename?: string } -export function createHashlineEditTool(): ToolDefinition { +export function createHashlineEditTool(ctx?: PluginContext): ToolDefinition { return tool({ description: HASHLINE_EDIT_DESCRIPTION, args: { @@ -36,6 +37,6 @@ export function createHashlineEditTool(): ToolDefinition { ) .describe("Array of edit operations to apply (empty when delete=true)"), }, - execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context), + execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context, ctx), }) }