Просмотр исходного кода

add auto formatting and experimental hooks feature

Dax Raad 7 месяцев назад
Родитель
Сommit
f8b78f08b4

+ 13 - 1
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"]
+        }
+      ]
+    }
+  }
 }

+ 70 - 0
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,

+ 0 - 1
packages/opencode/src/cli/cmd/run.ts

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

+ 26 - 0
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({

+ 143 - 0
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
+        }
+      },
+    },
+  ]
+}

+ 11 - 0
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,
+              })
+            }
+          }
+        })
       },
     }
   }

+ 4 - 1
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,

+ 2 - 0
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 = ""