add auto formatting and experimental hooks feature

This commit is contained in:
Dax Raad 2025-06-26 22:12:23 -04:00
parent a4f32d602b
commit f8b78f08b4
8 changed files with 269 additions and 3 deletions

View file

@ -1,3 +1,15 @@
{
"$schema": "https://opencode.ai/config.json"
"$schema": "https://opencode.ai/config.json",
"experimental": {
"hook": {
"file_edited": {
".json": []
},
"session_completed": [
{
"command": ["touch", "./node_modules/foo"]
}
]
}
}
}

View file

@ -183,6 +183,9 @@
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
@ -223,6 +226,10 @@
},
"id": {
"type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"additionalProperties": false
@ -295,6 +302,69 @@
]
},
"description": "MCP (Model Context Protocol) server configurations"
},
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"command"
],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false,

View file

@ -171,4 +171,3 @@ export const RunCommand = cmd({
)
},
})

View file

@ -167,6 +167,32 @@ export namespace Config {
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
experimental: z
.object({
hook: z
.object({
file_edited: z
.record(
z.string(),
z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array(),
)
.optional(),
session_completed: z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array()
.optional(),
})
.optional(),
})
.optional(),
})
.strict()
.openapi({

View file

@ -0,0 +1,143 @@
import { App } from '../app/app'
import { BunProc } from '../bun'
import { Config } from '../config/config'
import { Log } from '../util/log'
import path from 'path'
export namespace Format {
const log = Log.create({ service: 'format' })
const state = App.state('format', async () => {
const hooks: Record<string, Hook[]> = {}
for (const item of FORMATTERS) {
if (await item.enabled()) {
for (const ext of item.extensions) {
const list = hooks[ext] ?? []
list.push({
command: item.command,
environment: item.environment,
})
hooks[ext] = list
}
}
}
const cfg = await Config.get()
for (const [file, items] of Object.entries(
cfg.experimental?.hook?.file_edited ?? {},
)) {
for (const item of items) {
const list = hooks[file] ?? []
list.push({
command: item.command,
environment: item.environment,
})
hooks[file] = list
}
}
return {
hooks,
}
})
export async function run(file: string) {
log.info('formatting', { file })
const { hooks } = await state()
const ext = path.extname(file)
const match = hooks[ext]
if (!match) return
for (const item of match) {
log.info('running', { command: item.command })
const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace('$FILE', file)),
cwd: App.info().path.cwd,
env: item.environment,
})
const exit = await proc.exited
if (exit !== 0)
log.error('failed', {
command: item.command,
...item.environment,
})
}
}
interface Hook {
command: string[]
environment?: Record<string, string>
}
interface Native {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
}
const FORMATTERS: Native[] = [
{
name: 'prettier',
extensions: [
'.js',
'.jsx',
'.mjs',
'.cjs',
'.ts',
'.tsx',
'.mts',
'.cts',
'.html',
'.htm',
'.css',
'.scss',
'.sass',
'.less',
'.vue',
'.svelte',
'.json',
'.jsonc',
'.yaml',
'.yml',
'.toml',
'.xml',
'.md',
'.mdx',
'.php',
'.rb',
'.java',
'.go',
'.rs',
'.swift',
'.kt',
'.kts',
'.sol',
'.graphql',
'.gql',
],
command: [BunProc.which(), 'run', 'prettier', '--write', '$FILE'],
environment: {
BUN_BE_BUN: '1',
},
async enabled() {
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), 'run', 'prettier', '--version'],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: '1',
},
stdout: 'ignore',
stderr: 'ignore',
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
},
]
}

View file

@ -853,6 +853,17 @@ export namespace Session {
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
state().pending.delete(sessionID)
Config.get().then((cfg) => {
if (cfg.experimental?.hook?.session_completed) {
for (const item of cfg.experimental.hook.session_completed) {
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
})
}
}
})
},
}
}

View file

@ -11,6 +11,7 @@ import { createTwoFilesPatch } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
import { Format } from "../format"
export const EditTool = Tool.define({
id: "edit",
@ -59,6 +60,7 @@ export const EditTool = Tool.define({
if (params.oldString === "") {
contentNew = params.newString
await Bun.write(filepath, params.newString)
await Format.run(filepath)
return
}
@ -77,6 +79,7 @@ export const EditTool = Tool.define({
params.replaceAll,
)
await file.write(contentNew)
await Format.run(filepath)
})()
const diff = trimDiff(
@ -473,7 +476,7 @@ export function replace(
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,

View file

@ -6,6 +6,7 @@ import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
import { Format } from "../format"
export const WriteTool = Tool.define({
id: "write",
@ -42,6 +43,7 @@ export const WriteTool = Tool.define({
})
await Bun.write(filepath, params.content)
await Format.run(filepath)
FileTimes.read(ctx.sessionID, filepath)
let output = ""