diff --git a/opencode.json b/opencode.json index 720ece5c..59748f92 100644 --- a/opencode.json +++ b/opencode.json @@ -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"] + } + ] + } + } } diff --git a/packages/opencode/config.schema.json b/packages/opencode/config.schema.json index 99845c53..98fadb65 100644 --- a/packages/opencode/config.schema.json +++ b/packages/opencode/config.schema.json @@ -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, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index d811a51e..f8f88686 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -171,4 +171,3 @@ export const RunCommand = cmd({ ) }, }) - diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d53f4dda..bf3c0ecd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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({ diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts new file mode 100644 index 00000000..7aef384c --- /dev/null +++ b/packages/opencode/src/format/index.ts @@ -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 = {} + 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 + } + + interface Native { + name: string + command: string[] + environment?: Record + extensions: string[] + enabled(): Promise + } + + 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 + } + }, + }, + ] +} diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index ba8787d8..9abcb698 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -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, + }) + } + } + }) }, } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index b451ef55..f1e6535d 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -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, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 3cc0a081..264633ff 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -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 = ""