fix: emit formatter events from hashline-edit tool (fixes #2117)

This commit is contained in:
YeonGyu-Kim
2026-03-23 18:22:49 +09:00
parent d5d7c7dd26
commit f95d3b1ef5
4 changed files with 157 additions and 4 deletions

View File

@@ -126,7 +126,7 @@ export function createToolRegistry(args: {
const hashlineEnabled = pluginConfig.hashline_edit ?? false
const hashlineToolsRecord: Record<string, ToolDefinition> = hashlineEnabled
? { edit: createHashlineEditTool() }
? { edit: createHashlineEditTool(ctx) }
: {}
const allTools: Record<string, ToolDefinition> = {

View File

@@ -0,0 +1,122 @@
import path from "path"
import { log } from "../../shared"
interface FormatterConfig {
disabled?: boolean
command?: string[]
environment?: Record<string, string>
extensions?: string[]
}
interface OpencodeConfig {
formatter?:
| false
| Record<string, FormatterConfig>
experimental?: {
hook?: {
file_edited?: Record<string, Array<{ command: string[]; environment?: Record<string, string> }>>
}
}
}
interface OpencodeClient {
config: {
get: (options?: { query?: { directory?: string } }) => Promise<{ data?: OpencodeConfig }>
}
}
let cachedFormatters: Map<string, Array<{ command: string[]; environment: Record<string, string> }>> | null = null
async function resolveFormatters(
client: OpencodeClient,
directory: string,
): Promise<Map<string, Array<{ command: string[]; environment: Record<string, string> }>>> {
if (cachedFormatters) return cachedFormatters
const result = new Map<string, Array<{ command: string[]; environment: Record<string, string> }>>()
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<void> {
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
}

View File

@@ -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<string> {
export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext, pluginCtx?: PluginContext): Promise<string> {
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()

View File

@@ -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),
})
}