summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-06-26 22:12:23 -0400
committerJay V <[email protected]>2025-06-27 19:10:41 -0400
commitb61a841aa8f6f92803df8873ad63efd10397fc0f (patch)
treecbc7f38f4a9f8251b0fdbc5445d3384b9d9f4034
parentebcf11e574d0ebb056248e84f495789e1b211437 (diff)
downloadopencode-b61a841aa8f6f92803df8873ad63efd10397fc0f.tar.gz
opencode-b61a841aa8f6f92803df8873ad63efd10397fc0f.zip
add auto formatting and experimental hooks feature
-rw-r--r--opencode.json14
-rw-r--r--packages/opencode/config.schema.json70
-rw-r--r--packages/opencode/src/cli/cmd/run.ts1
-rw-r--r--packages/opencode/src/config/config.ts26
-rw-r--r--packages/opencode/src/format/index.ts143
-rw-r--r--packages/opencode/src/session/index.ts11
-rw-r--r--packages/opencode/src/tool/edit.ts5
-rw-r--r--packages/opencode/src/tool/write.ts2
8 files changed, 269 insertions, 3 deletions
diff --git a/opencode.json b/opencode.json
index 720ece5c1..59748f927 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 99845c53d..98fadb65b 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 d811a51ef..f8f886868 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 d53f4dda3..bf3c0ecdf 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 000000000..7aef384c1
--- /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<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
+ }
+ },
+ },
+ ]
+}
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index ba8787d8b..9abcb6985 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 b451ef556..f1e6535dc 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 3cc0a0816..264633ff0 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 = ""