Browse Source

slash commands (#2157)

Co-authored-by: adamdotdevin <[email protected]>
Dax 7 months ago
parent
commit
133fe41cd5

+ 9 - 0
.opencode/command/commit.md

@@ -0,0 +1,9 @@
+commit and push
+
+make sure it includes a prefix like
+docs:
+tui:
+core:
+ci:
+ignore:
+wip:

+ 8 - 0
.opencode/command/hello.md

@@ -0,0 +1,8 @@
+---
+description: hello world
+---
+
+hey there $ARGUMENTS
+
+!`ls`
+check out @README.md

+ 11 - 20
bun.lock

@@ -26,7 +26,7 @@
     },
     "cloud/core": {
       "name": "@opencode/cloud-core",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "drizzle-orm": "0.41.0",
@@ -40,7 +40,7 @@
     },
     "cloud/function": {
       "name": "@opencode/cloud-function",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "dependencies": {
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/openai": "2.0.2",
@@ -60,7 +60,7 @@
     },
     "cloud/web": {
       "name": "@opencode/cloud-web",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "dependencies": {
         "@kobalte/core": "0.13.9",
         "@openauthjs/solid": "0.0.0-20250322224806",
@@ -79,7 +79,7 @@
     },
     "packages/function": {
       "name": "@opencode/function",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "22.0.0",
@@ -94,7 +94,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -144,7 +144,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
       },
@@ -156,7 +156,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "devDependencies": {
         "@hey-api/openapi-ts": "0.80.1",
         "@tsconfig/node22": "catalog:",
@@ -165,7 +165,7 @@
     },
     "packages/web": {
       "name": "@opencode/web",
-      "version": "0.5.12",
+      "version": "0.5.13",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -204,6 +204,9 @@
     "web-tree-sitter",
     "tree-sitter-bash",
   ],
+  "overrides": {
+    "zod": "3.25.76",
+  },
   "catalog": {
     "@hono/zod-validator": "0.4.2",
     "@tsconfig/node22": "22.0.2",
@@ -3063,8 +3066,6 @@
 
     "@astrojs/mdx/source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
 
-    "@astrojs/sitemap/zod": ["[email protected]", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
-
     "@astrojs/solid-js/vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
 
     "@astrojs/solid-js/vite-plugin-solid": ["[email protected]", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-hFrCxBfv3B1BmFqnJF4JOCYpjrmi/zwyeKjcomQ0khh8HFyQ8SbuBWQ7zGojfrz6HUOBFrJBNySDi/JgAHytWg=="],
@@ -3161,8 +3162,6 @@
 
     "@mdx-js/mdx/source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
 
-    "@modelcontextprotocol/sdk/zod": ["[email protected]", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
-
     "@netlify/dev-utils/find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^7.2.0", "path-exists": "^5.0.0", "unicorn-magic": "^0.1.0" } }, "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g=="],
 
     "@netlify/dev-utils/uuid": ["[email protected]", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
@@ -3339,8 +3338,6 @@
 
     "astro/vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
 
-    "astro/zod": ["[email protected]", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
-
     "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
 
     "bl/buffer": ["[email protected]", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
@@ -3417,8 +3414,6 @@
 
     "miniflare/youch": ["[email protected]", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
 
-    "miniflare/zod": ["[email protected]", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
-
     "minipass-flush/minipass": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
 
     "minipass-pipeline/minipass": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -3463,8 +3458,6 @@
 
     "opencontrol/hono": ["[email protected]", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
 
-    "opencontrol/zod": ["[email protected]", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
-
     "opencontrol/zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="],
 
     "openid-client/jose": ["[email protected]", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
@@ -3883,8 +3876,6 @@
 
     "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["[email protected]", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
 
-    "opencontrol/@modelcontextprotocol/sdk/zod": ["[email protected]", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
-
     "opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
 
     "prebuild-install/tar-fs/tar-stream": ["[email protected]", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],

+ 15 - 0
logs/.496fc674ed58d31f8b883da41cc2adb4564aad58-audit.json

@@ -0,0 +1,15 @@
+{
+    "keep": {
+        "days": true,
+        "amount": 14
+    },
+    "auditLog": "/Users/adam/code/opencode/dev/logs/.496fc674ed58d31f8b883da41cc2adb4564aad58-audit.json",
+    "files": [
+        {
+            "date": 1755891797740,
+            "name": "/Users/adam/code/opencode/dev/logs/mcp-puppeteer-2025-08-22.log",
+            "hash": "dd9b1f2e98b661ba2f56b91dd9afbdb25e50adbdd52ed1b0eef1d2045235d17c"
+        }
+    ],
+    "hashType": "sha256"
+}

+ 6 - 0
logs/mcp-puppeteer-2025-08-22.log

@@ -0,0 +1,6 @@
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-08-22 14:43:17.765"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-08-22 14:43:17.766"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-08-22 14:46:45.539"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-08-22 14:46:45.540"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-08-22 14:53:08.159"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-08-22 14:53:08.160"}

+ 1 - 4
opencode.json

@@ -1,10 +1,7 @@
 {
   "$schema": "https://opencode.ai/config.json",
+
   "mcp": {
-    "context7": {
-      "type": "remote",
-      "url": "https://mcp.context7.com/sse"
-    },
     "weather": {
       "type": "local",
       "command": ["opencode", "x", "@h1deya/mcp-server-weather"]

+ 3 - 0
package.json

@@ -51,5 +51,8 @@
     "tree-sitter-bash",
     "web-tree-sitter"
   ],
+  "overrides": {
+    "zod": "3.25.76"
+  },
   "patchedDependencies": {}
 }

+ 1 - 0
packages/opencode/script/schema.ts

@@ -5,6 +5,7 @@ import { Config } from "../src/config/config"
 import { zodToJsonSchema } from "zod-to-json-schema"
 
 const file = process.argv[2]
+console.log(file)
 
 const result = zodToJsonSchema(Config.Info, {
   /**

+ 44 - 0
packages/opencode/src/command/index.ts

@@ -0,0 +1,44 @@
+import z from "zod"
+import { App } from "../app/app"
+import { Config } from "../config/config"
+
+export namespace Command {
+  export const Info = z
+    .object({
+      name: z.string(),
+      description: z.string().optional(),
+      agent: z.string().optional(),
+      model: z.string().optional(),
+      template: z.string(),
+    })
+    .openapi({
+      ref: "Command",
+    })
+  export type Info = z.infer<typeof Info>
+
+  const state = App.state("command", async () => {
+    const cfg = await Config.get()
+
+    const result: Record<string, Info> = {}
+
+    for (const [name, command] of Object.entries(cfg.command ?? {})) {
+      result[name] = {
+        name,
+        agent: command.agent,
+        model: command.model,
+        description: command.description,
+        template: command.template,
+      }
+    }
+
+    return result
+  })
+
+  export async function get(name: string) {
+    return state().then((x) => x[name])
+  }
+
+  export async function list() {
+    return state().then((x) => Object.values(x))
+  }
+}

+ 35 - 0
packages/opencode/src/config/config.ts

@@ -107,6 +107,32 @@ export namespace Config {
       }
       throw new InvalidError({ path: item }, { cause: parsed.error })
     }
+
+    // Load command markdown files
+    result.command = result.command || {}
+    const markdownCommands = [
+      ...(await Filesystem.globUp("command/*.md", Global.Path.config, Global.Path.config)),
+      ...(await Filesystem.globUp(".opencode/command/*.md", app.path.cwd, app.path.root)),
+    ]
+    for (const item of markdownCommands) {
+      const content = await Bun.file(item).text()
+      const md = matter(content)
+      if (!md.data) continue
+
+      const config = {
+        name: path.basename(item, ".md"),
+        ...md.data,
+        template: md.content.trim(),
+      }
+      const parsed = Command.safeParse(config)
+      if (parsed.success) {
+        result.command = mergeDeep(result.command, {
+          [config.name]: parsed.data,
+        })
+        continue
+      }
+      throw new InvalidError({ path: item }, { cause: parsed.error })
+    }
     // Migrate deprecated mode field to agent field
     for (const [name, mode] of Object.entries(result.mode)) {
       result.agent = mergeDeep(result.agent ?? {}, {
@@ -192,6 +218,14 @@ export namespace Config {
   export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
   export type Permission = z.infer<typeof Permission>
 
+  export const Command = z.object({
+    template: z.string(),
+    description: z.string().optional(),
+    agent: z.string().optional(),
+    model: z.string().optional(),
+  })
+  export type Command = z.infer<typeof Command>
+
   export const Agent = z
     .object({
       model: z.string().optional(),
@@ -305,6 +339,7 @@ export namespace Config {
       theme: z.string().optional().describe("Theme name to use for the interface"),
       keybinds: Keybinds.optional().describe("Custom keybind configurations"),
       tui: TUI.optional().describe("TUI specific settings"),
+      command: z.record(z.string(), Command).optional(),
       plugin: z.string().array().optional(),
       snapshot: z.boolean().optional(),
       share: z

+ 2 - 2
packages/opencode/src/provider/provider.ts

@@ -36,9 +36,9 @@ export namespace Provider {
         },
       }
     },
-    async opencode() {
+    async opencode(input) {
       return {
-        autoload: true,
+        autoload: Object.keys(input.models).length > 0,
         options: {},
       }
     },

+ 62 - 3
packages/opencode/src/server/server.ts

@@ -21,6 +21,7 @@ import { Permission } from "../permission"
 import { lazy } from "../util/lazy"
 import { Agent } from "../agent/agent"
 import { Auth } from "../auth"
+import { Command } from "../command"
 
 const ERRORS = {
   400: {
@@ -611,10 +612,12 @@ export namespace Server {
               description: "Created message",
               content: {
                 "application/json": {
-                  schema: resolver(z.object({
+                  schema: resolver(
+                    z.object({
                       info: MessageV2.Assistant,
                       parts: MessageV2.Part.array(),
-                    })),
+                    }),
+                  ),
                 },
               },
             },
@@ -634,6 +637,41 @@ export namespace Server {
           return c.json(msg)
         },
       )
+      .post(
+        "/session/:id/command",
+        describeRoute({
+          description: "Send a new command to a session",
+          operationId: "session.command",
+          responses: {
+            200: {
+              description: "Created message",
+              content: {
+                "application/json": {
+                  schema: resolver(
+                    z.object({
+                      info: MessageV2.Assistant,
+                      parts: MessageV2.Part.array(),
+                    }),
+                  ),
+                },
+              },
+            },
+          },
+        }),
+        zValidator(
+          "param",
+          z.object({
+            id: z.string().openapi({ description: "Session ID" }),
+          }),
+        ),
+        zValidator("json", Session.CommandInput.omit({ sessionID: true })),
+        async (c) => {
+          const sessionID = c.req.valid("param").id
+          const body = c.req.valid("json")
+          const msg = await Session.command({ ...body, sessionID })
+          return c.json(msg)
+        },
+      )
       .post(
         "/session/:id/shell",
         describeRoute({
@@ -656,7 +694,7 @@ export namespace Server {
             id: z.string().openapi({ description: "Session ID" }),
           }),
         ),
-        zValidator("json", Session.CommandInput.omit({ sessionID: true })),
+        zValidator("json", Session.ShellInput.omit({ sessionID: true })),
         async (c) => {
           const sessionID = c.req.valid("param").id
           const body = c.req.valid("json")
@@ -753,6 +791,27 @@ export namespace Server {
           return c.json(true)
         },
       )
+      .get(
+        "/command",
+        describeRoute({
+          description: "List all commands",
+          operationId: "command.list",
+          responses: {
+            200: {
+              description: "List of commands",
+              content: {
+                "application/json": {
+                  schema: resolver(Command.Info.array()),
+                },
+              },
+            },
+          },
+        }),
+        async (c) => {
+          const commands = await Command.list()
+          return c.json(commands)
+        },
+      )
       .get(
         "/config/providers",
         describeRoute({

+ 71 - 3
packages/opencode/src/session/index.ts

@@ -47,6 +47,8 @@ import { Permission } from "../permission"
 import { Wildcard } from "../util/wildcard"
 import { ulid } from "ulid"
 import { defer } from "../util/defer"
+import { Command } from "../command"
+import { $ } from "bun"
 
 export namespace Session {
   const log = Log.create({ service: "session" })
@@ -1025,13 +1027,13 @@ export namespace Session {
     return result
   }
 
-  export const CommandInput = z.object({
+  export const ShellInput = z.object({
     sessionID: Identifier.schema("session"),
     agent: z.string(),
     command: z.string(),
   })
-  export type CommandInput = z.infer<typeof CommandInput>
-  export async function shell(input: CommandInput) {
+  export type ShellInput = z.infer<typeof ShellInput>
+  export async function shell(input: ShellInput) {
     using abort = lock(input.sessionID)
     const msg: MessageV2.Assistant = {
       id: Identifier.ascending("message"),
@@ -1155,6 +1157,72 @@ export namespace Session {
     return { info: msg, parts: [part] }
   }
 
+  export const CommandInput = z.object({
+    messageID: Identifier.schema("message").optional(),
+    sessionID: Identifier.schema("session"),
+    agent: z.string().optional(),
+    model: z.string().optional(),
+    arguments: z.string(),
+    command: z.string(),
+  })
+  export type CommandInput = z.infer<typeof CommandInput>
+  const bashRegex = /!`([^`]+)`/g
+  const fileRegex = /@([^\s]+)/g
+
+  export async function command(input: CommandInput) {
+    const command = await Command.get(input.command)
+    const agent = input.agent ?? command.agent ?? "build"
+    const model =
+      input.model ??
+      command.model ??
+      (await Agent.get(agent).then((x) => (x.model ? `${x.model.providerID}/${x.model.modelID}` : undefined))) ??
+      (await Provider.defaultModel().then((x) => `${x.providerID}/${x.modelID}`))
+    let template = command.template.replace("$ARGUMENTS", input.arguments)
+
+    const bash = Array.from(template.matchAll(bashRegex))
+    if (bash.length > 0) {
+      const results = await Promise.all(
+        bash.map(async ([, cmd]) => {
+          try {
+            return await $`${{ raw: cmd }}`.nothrow().text()
+          } catch (error) {
+            return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
+          }
+        }),
+      )
+      let index = 0
+      template = template.replace(bashRegex, () => results[index++])
+    }
+
+    const parts = [
+      {
+        type: "text",
+        text: template,
+      },
+    ] as ChatInput["parts"]
+
+    const matches = template.matchAll(fileRegex)
+    const app = App.info()
+
+    for (const match of matches) {
+      const file = path.join(app.path.cwd, match[1])
+      parts.push({
+        type: "file",
+        url: `file://${file}`,
+        filename: match[1],
+        mime: "text/plain",
+      })
+    }
+
+    return chat({
+      sessionID: input.sessionID,
+      messageID: input.messageID,
+      ...Provider.parseModel(model!),
+      agent,
+      parts,
+    })
+  }
+
   function createProcessor(assistantMsg: MessageV2.Assistant, model: ModelsDev.Model) {
     const toolcalls: Record<string, MessageV2.ToolPart> = {}
     let snapshot: string | undefined

+ 4 - 4
packages/sdk/go/.stats.yml

@@ -1,4 +1,4 @@
-configured_endpoints: 39
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-be3e40e0bf7dde2bb15ff82d5d104418fb47fe335808a1aa6468b0be2210a88f.yml
-openapi_spec_hash: c1bbb3ebd807656bd9f31a618077e76b
-config_hash: eab3723c4c2232a6ba1821151259d6da
+configured_endpoints: 41
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-d5200eaa145f567a58daa78941ab1141dd63f5f0cfe1596d5c9ecf12d34fea35.yml
+openapi_spec_hash: abeb66291dc158f2cdc90bf9945e283e
+config_hash: fb625e876313a9f8f31532348fa91f59

+ 12 - 0
packages/sdk/go/api.md

@@ -70,6 +70,16 @@ Methods:
 
 - <code title="get /config">client.Config.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ConfigService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Config">Config</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 
+# Command
+
+Response Types:
+
+- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Command">Command</a>
+
+Methods:
+
+- <code title="get /command">client.Command.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#CommandService.List">List</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Command">Command</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
+
 # Session
 
 Params Types:
@@ -106,6 +116,7 @@ Response Types:
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateRunning">ToolStateRunning</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#UserMessage">UserMessage</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionChatResponse">SessionChatResponse</a>
+- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionCommandResponse">SessionCommandResponse</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessageResponse">SessionMessageResponse</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessagesResponse">SessionMessagesResponse</a>
 
@@ -118,6 +129,7 @@ Methods:
 - <code title="post /session/{id}/abort">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Abort">Abort</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Chat">Chat</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionChatParams">SessionChatParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionChatResponse">SessionChatResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="get /session/{id}/children">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Children">Children</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
+- <code title="post /session/{id}/command">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Command">Command</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionCommandParams">SessionCommandParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionCommandResponse">SessionCommandResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="get /session/{id}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="post /session/{id}/init">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionInitParams">SessionInitParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
 - <code title="get /session/{id}/message/{messageID}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Message">Message</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, messageID <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessageResponse">SessionMessageResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>

+ 2 - 0
packages/sdk/go/client.go

@@ -21,6 +21,7 @@ type Client struct {
 	Find    *FindService
 	File    *FileService
 	Config  *ConfigService
+	Command *CommandService
 	Session *SessionService
 	Tui     *TuiService
 }
@@ -49,6 +50,7 @@ func NewClient(opts ...option.RequestOption) (r *Client) {
 	r.Find = NewFindService(opts...)
 	r.File = NewFileService(opts...)
 	r.Config = NewConfigService(opts...)
+	r.Command = NewCommandService(opts...)
 	r.Session = NewSessionService(opts...)
 	r.Tui = NewTuiService(opts...)
 

+ 67 - 0
packages/sdk/go/command.go

@@ -0,0 +1,67 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package opencode
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/sst/opencode-sdk-go/internal/apijson"
+	"github.com/sst/opencode-sdk-go/internal/requestconfig"
+	"github.com/sst/opencode-sdk-go/option"
+)
+
+// CommandService contains methods and other services that help with interacting
+// with the opencode API.
+//
+// Note, unlike clients, this service does not read variables from the environment
+// automatically. You should not instantiate this service directly, and instead use
+// the [NewCommandService] method instead.
+type CommandService struct {
+	Options []option.RequestOption
+}
+
+// NewCommandService generates a new service that applies the given options to each
+// request. These options are applied after the parent client's options (if there
+// is one), and before any request-specific options.
+func NewCommandService(opts ...option.RequestOption) (r *CommandService) {
+	r = &CommandService{}
+	r.Options = opts
+	return
+}
+
+// List all commands
+func (r *CommandService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Command, err error) {
+	opts = append(r.Options[:], opts...)
+	path := "command"
+	err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
+	return
+}
+
+type Command struct {
+	Name        string      `json:"name,required"`
+	Template    string      `json:"template,required"`
+	Agent       string      `json:"agent"`
+	Description string      `json:"description"`
+	Model       string      `json:"model"`
+	JSON        commandJSON `json:"-"`
+}
+
+// commandJSON contains the JSON metadata for the struct [Command]
+type commandJSON struct {
+	Name        apijson.Field
+	Template    apijson.Field
+	Agent       apijson.Field
+	Description apijson.Field
+	Model       apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *Command) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r commandJSON) RawJSON() string {
+	return r.raw
+}

+ 36 - 0
packages/sdk/go/command_test.go

@@ -0,0 +1,36 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package opencode_test
+
+import (
+	"context"
+	"errors"
+	"os"
+	"testing"
+
+	"github.com/sst/opencode-sdk-go"
+	"github.com/sst/opencode-sdk-go/internal/testutil"
+	"github.com/sst/opencode-sdk-go/option"
+)
+
+func TestCommandList(t *testing.T) {
+	t.Skip("skipped: tests are disabled for the time being")
+	baseURL := "http://localhost:4010"
+	if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+		baseURL = envURL
+	}
+	if !testutil.CheckTestServer(t, baseURL) {
+		return
+	}
+	client := opencode.NewClient(
+		option.WithBaseURL(baseURL),
+	)
+	_, err := client.Command.List(context.TODO())
+	if err != nil {
+		var apierr *opencode.Error
+		if errors.As(err, &apierr) {
+			t.Log(string(apierr.DumpRequest(true)))
+		}
+		t.Fatalf("err should be nil: %s", err.Error())
+	}
+}

+ 29 - 1
packages/sdk/go/config.go

@@ -49,7 +49,8 @@ type Config struct {
 	// automatically
 	Autoshare bool `json:"autoshare"`
 	// Automatically update to the latest version
-	Autoupdate bool `json:"autoupdate"`
+	Autoupdate bool                     `json:"autoupdate"`
+	Command    map[string]ConfigCommand `json:"command"`
 	// Disable providers that are loaded automatically
 	DisabledProviders []string                   `json:"disabled_providers"`
 	Experimental      ConfigExperimental         `json:"experimental"`
@@ -94,6 +95,7 @@ type configJSON struct {
 	Agent             apijson.Field
 	Autoshare         apijson.Field
 	Autoupdate        apijson.Field
+	Command           apijson.Field
 	DisabledProviders apijson.Field
 	Experimental      apijson.Field
 	Formatter         apijson.Field
@@ -664,6 +666,32 @@ func (r ConfigAgentPlanPermissionWebfetch) IsKnown() bool {
 	return false
 }
 
+type ConfigCommand struct {
+	Template    string            `json:"template,required"`
+	Agent       string            `json:"agent"`
+	Description string            `json:"description"`
+	Model       string            `json:"model"`
+	JSON        configCommandJSON `json:"-"`
+}
+
+// configCommandJSON contains the JSON metadata for the struct [ConfigCommand]
+type configCommandJSON struct {
+	Template    apijson.Field
+	Agent       apijson.Field
+	Description apijson.Field
+	Model       apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *ConfigCommand) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r configCommandJSON) RawJSON() string {
+	return r.raw
+}
+
 type ConfigExperimental struct {
 	Hook ConfigExperimentalHook `json:"hook"`
 	JSON configExperimentalJSON `json:"-"`

+ 47 - 0
packages/sdk/go/session.go

@@ -114,6 +114,18 @@ func (r *SessionService) Children(ctx context.Context, id string, opts ...option
 	return
 }
 
+// Send a new command to a session
+func (r *SessionService) Command(ctx context.Context, id string, body SessionCommandParams, opts ...option.RequestOption) (res *SessionCommandResponse, err error) {
+	opts = append(r.Options[:], opts...)
+	if id == "" {
+		err = errors.New("missing required id parameter")
+		return
+	}
+	path := fmt.Sprintf("session/%s/command", id)
+	err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
+	return
+}
+
 // Get session
 func (r *SessionService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) {
 	opts = append(r.Options[:], opts...)
@@ -2301,6 +2313,29 @@ func (r sessionChatResponseJSON) RawJSON() string {
 	return r.raw
 }
 
+type SessionCommandResponse struct {
+	Info  AssistantMessage           `json:"info,required"`
+	Parts []Part                     `json:"parts,required"`
+	JSON  sessionCommandResponseJSON `json:"-"`
+}
+
+// sessionCommandResponseJSON contains the JSON metadata for the struct
+// [SessionCommandResponse]
+type sessionCommandResponseJSON struct {
+	Info        apijson.Field
+	Parts       apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *SessionCommandResponse) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r sessionCommandResponseJSON) RawJSON() string {
+	return r.raw
+}
+
 type SessionMessageResponse struct {
 	Info  Message                    `json:"info,required"`
 	Parts []Part                     `json:"parts,required"`
@@ -2419,6 +2454,18 @@ func (r SessionChatParamsPartsType) IsKnown() bool {
 	return false
 }
 
+type SessionCommandParams struct {
+	Arguments param.Field[string] `json:"arguments,required"`
+	Command   param.Field[string] `json:"command,required"`
+	Agent     param.Field[string] `json:"agent"`
+	MessageID param.Field[string] `json:"messageID"`
+	Model     param.Field[string] `json:"model"`
+}
+
+func (r SessionCommandParams) MarshalJSON() (data []byte, err error) {
+	return apijson.MarshalRoot(r)
+}
+
 type SessionInitParams struct {
 	MessageID  param.Field[string] `json:"messageID,required"`
 	ModelID    param.Field[string] `json:"modelID,required"`

+ 32 - 0
packages/sdk/go/session_test.go

@@ -199,6 +199,38 @@ func TestSessionChildren(t *testing.T) {
 	}
 }
 
+func TestSessionCommandWithOptionalParams(t *testing.T) {
+	t.Skip("skipped: tests are disabled for the time being")
+	baseURL := "http://localhost:4010"
+	if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+		baseURL = envURL
+	}
+	if !testutil.CheckTestServer(t, baseURL) {
+		return
+	}
+	client := opencode.NewClient(
+		option.WithBaseURL(baseURL),
+	)
+	_, err := client.Session.Command(
+		context.TODO(),
+		"id",
+		opencode.SessionCommandParams{
+			Arguments: opencode.F("arguments"),
+			Command:   opencode.F("command"),
+			Agent:     opencode.F("agent"),
+			MessageID: opencode.F("msg"),
+			Model:     opencode.F("model"),
+		},
+	)
+	if err != nil {
+		var apierr *opencode.Error
+		if errors.As(err, &apierr) {
+			t.Log(string(apierr.DumpRequest(true)))
+		}
+		t.Fatalf("err should be nil: %s", err.Error())
+	}
+}
+
 func TestSessionGet(t *testing.T) {
 	t.Skip("skipped: tests are disabled for the time being")
 	baseURL := "http://localhost:4010"

+ 31 - 0
packages/sdk/js/src/gen/sdk.gen.ts

@@ -39,6 +39,8 @@ import type {
   SessionChatResponses,
   SessionMessageData,
   SessionMessageResponses,
+  SessionCommandData,
+  SessionCommandResponses,
   SessionShellData,
   SessionShellResponses,
   SessionRevertData,
@@ -47,6 +49,8 @@ import type {
   SessionUnrevertResponses,
   PostSessionByIdPermissionsByPermissionIdData,
   PostSessionByIdPermissionsByPermissionIdResponses,
+  CommandListData,
+  CommandListResponses,
   ConfigProvidersData,
   ConfigProvidersResponses,
   FindTextData,
@@ -355,6 +359,20 @@ class Session extends _HeyApiClient {
     })
   }
 
+  /**
+   * Send a new command to a session
+   */
+  public command<ThrowOnError extends boolean = false>(options: Options<SessionCommandData, ThrowOnError>) {
+    return (options.client ?? this._client).post<SessionCommandResponses, unknown, ThrowOnError>({
+      url: "/session/{id}/command",
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        ...options.headers,
+      },
+    })
+  }
+
   /**
    * Run a shell command
    */
@@ -394,6 +412,18 @@ class Session extends _HeyApiClient {
   }
 }
 
+class Command extends _HeyApiClient {
+  /**
+   * List all commands
+   */
+  public list<ThrowOnError extends boolean = false>(options?: Options<CommandListData, ThrowOnError>) {
+    return (options?.client ?? this._client).get<CommandListResponses, unknown, ThrowOnError>({
+      url: "/command",
+      ...options,
+    })
+  }
+}
+
 class Find extends _HeyApiClient {
   /**
    * Find text in files
@@ -592,6 +622,7 @@ export class OpencodeClient extends _HeyApiClient {
   app = new App({ client: this._client })
   config = new Config({ client: this._client })
   session = new Session({ client: this._client })
+  command = new Command({ client: this._client })
   find = new Find({ client: this._client })
   file = new File({ client: this._client })
   tui = new Tui({ client: this._client })

+ 62 - 0
packages/sdk/js/src/gen/types.gen.ts

@@ -585,6 +585,14 @@ export type Config = {
      */
     scroll_speed: number
   }
+  command?: {
+    [key: string]: {
+      template: string
+      description?: string
+      agent?: string
+      model?: string
+    }
+  }
   plugin?: Array<string>
   snapshot?: boolean
   /**
@@ -1110,6 +1118,14 @@ export type AgentPartInput = {
   }
 }
 
+export type Command = {
+  name: string
+  description?: string
+  agent?: string
+  model?: string
+  template: string
+}
+
 export type Symbol = {
   name: string
   kind: number
@@ -1563,6 +1579,36 @@ export type SessionMessageResponses = {
 
 export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses]
 
+export type SessionCommandData = {
+  body?: {
+    messageID?: string
+    agent?: string
+    model?: string
+    arguments: string
+    command: string
+  }
+  path: {
+    /**
+     * Session ID
+     */
+    id: string
+  }
+  query?: never
+  url: "/session/{id}/command"
+}
+
+export type SessionCommandResponses = {
+  /**
+   * Created message
+   */
+  200: {
+    info: AssistantMessage
+    parts: Array<Part>
+  }
+}
+
+export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses]
+
 export type SessionShellData = {
   body?: {
     agent: string
@@ -1648,6 +1694,22 @@ export type PostSessionByIdPermissionsByPermissionIdResponses = {
 export type PostSessionByIdPermissionsByPermissionIdResponse =
   PostSessionByIdPermissionsByPermissionIdResponses[keyof PostSessionByIdPermissionsByPermissionIdResponses]
 
+export type CommandListData = {
+  body?: never
+  path?: never
+  query?: never
+  url: "/command"
+}
+
+export type CommandListResponses = {
+  /**
+   * List of commands
+   */
+  200: Array<Command>
+}
+
+export type CommandListResponse = CommandListResponses[keyof CommandListResponses]
+
 export type ConfigProvidersData = {
   body?: never
   path?: never

+ 7 - 0
packages/sdk/stainless/stainless.yml

@@ -85,6 +85,12 @@ resources:
     methods:
       get: get /config
 
+  command:
+    models:
+      command: Command
+    methods:
+      list: get /command
+
   session:
     models:
       session: Session
@@ -126,6 +132,7 @@ resources:
       message: get /session/{id}/message/{messageID}
       messages: get /session/{id}/message
       chat: post /session/{id}/message
+      command: post /session/{id}/command
       shell: post /session/{id}/shell
       update: patch /session/{id}
       revert: post /session/{id}/revert

+ 42 - 1
packages/tui/internal/app/app.go

@@ -84,6 +84,10 @@ type SendPrompt = Prompt
 type SendShell = struct {
 	Command string
 }
+type SendCommand = struct {
+	Command string
+	Args    string
+}
 type SetEditorContentMsg struct {
 	Text string
 }
@@ -183,6 +187,11 @@ func New(
 
 	slog.Debug("Loaded config", "config", configInfo)
 
+	customCommands, err := httpClient.Command.List(ctx)
+	if err != nil {
+		return nil, err
+	}
+
 	app := &App{
 		Info:           appInfo,
 		Agents:         agents,
@@ -194,7 +203,7 @@ func New(
 		AgentIndex:     agentIndex,
 		Session:        &opencode.Session{},
 		Messages:       []Message{},
-		Commands:       commands.LoadFromConfig(configInfo),
+		Commands:       commands.LoadFromConfig(configInfo, *customCommands),
 		InitialModel:   initialModel,
 		InitialPrompt:  initialPrompt,
 		InitialAgent:   initialAgent,
@@ -793,6 +802,38 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
 	return a, tea.Batch(cmds...)
 }
 
+func (a *App) SendCommand(ctx context.Context, command string, args string) (*App, tea.Cmd) {
+	var cmds []tea.Cmd
+	if a.Session.ID == "" {
+		session, err := a.CreateSession(ctx)
+		if err != nil {
+			return a, toast.NewErrorToast(err.Error())
+		}
+		a.Session = session
+		cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
+	}
+
+	cmds = append(cmds, func() tea.Msg {
+		_, err := a.Client.Session.Command(
+			context.Background(),
+			a.Session.ID,
+			opencode.SessionCommandParams{
+				Command:   opencode.F(command),
+				Arguments: opencode.F(args),
+			},
+		)
+		if err != nil {
+			slog.Error("Failed to execute command", "error", err)
+			return toast.NewErrorToast("Failed to execute command")
+		}
+		return nil
+	})
+
+	// The actual response will come through SSE
+	// For now, just return success
+	return a, tea.Batch(cmds...)
+}
+
 func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) {
 	var cmds []tea.Cmd
 	if a.Session.ID == "" {

+ 13 - 1
packages/tui/internal/commands/command.go

@@ -31,6 +31,7 @@ type Command struct {
 	Description string
 	Keybindings []Keybinding
 	Trigger     []string
+	Custom      bool
 }
 
 func (c Command) Keys() []string {
@@ -96,6 +97,7 @@ func (r CommandRegistry) Sorted() []Command {
 	})
 	return commands
 }
+
 func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
 	var matched []Command
 	for _, command := range r.Sorted() {
@@ -182,7 +184,7 @@ func parseBindings(bindings ...string) []Keybinding {
 	return parsedBindings
 }
 
-func LoadFromConfig(config *opencode.Config) CommandRegistry {
+func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) CommandRegistry {
 	defaults := []Command{
 		{
 			Name:        AppHelpCommand,
@@ -400,6 +402,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 		}
 		registry[command.Name] = command
 	}
+	for _, command := range customCommands {
+		registry[CommandName(command.Name)] = Command{
+			Name:        CommandName(command.Name),
+			Description: command.Description,
+			Trigger:     []string{command.Name},
+			Keybindings: []Keybinding{},
+			Custom:      true,
+		}
+	}
+
 	slog.Info("Loaded commands", "commands", registry)
 	return registry
 }

+ 27 - 1
packages/tui/internal/components/chat/editor.go

@@ -224,10 +224,17 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case dialog.CompletionSelectedMsg:
 		switch msg.Item.ProviderID {
 		case "commands":
-			commandName := strings.TrimPrefix(msg.Item.Value, "/")
+			command := msg.Item.RawData.(commands.Command)
+			if command.Custom {
+				m.SetValue("/" + command.PrimaryTrigger() + " ")
+				return m, nil
+			}
+
 			updated, cmd := m.Clear()
 			m = updated.(*editorComponent)
 			cmds = append(cmds, cmd)
+
+			commandName := strings.TrimPrefix(msg.Item.Value, "/")
 			cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
 			return m, tea.Batch(cmds...)
 		case "files":
@@ -481,6 +488,25 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
 	}
 
 	var cmds []tea.Cmd
+	if strings.HasPrefix(value, "/") {
+		value = value[1:]
+		commandName := strings.Split(value, " ")[0]
+		command := m.app.Commands[commands.CommandName(commandName)]
+		if command.Custom {
+			args := strings.TrimPrefix(value, command.PrimaryTrigger()+" ")
+			cmds = append(
+				cmds,
+				util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}),
+			)
+
+			updated, cmd := m.Clear()
+			m = updated.(*editorComponent)
+			cmds = append(cmds, cmd)
+
+			return m, tea.Batch(cmds...)
+		}
+	}
+
 	attachments := m.textarea.GetAttachments()
 
 	prompt := app.Prompt{Text: value, Attachments: attachments}

+ 4 - 0
packages/tui/internal/components/chat/messages.go

@@ -174,6 +174,10 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.viewport.GotoBottom()
 		m.tail = true
 		return m, nil
+	case app.SendCommand:
+		m.viewport.GotoBottom()
+		m.tail = true
+		return m, nil
 	case dialog.ThemeSelectedMsg:
 		m.cache.Clear()
 		m.loading = true

+ 18 - 0
packages/tui/internal/tui/tui.go

@@ -408,6 +408,24 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.app, cmd = a.app.SendPrompt(context.Background(), msg)
 			cmds = append(cmds, cmd)
 		}
+	case app.SendCommand:
+		// If we're in a child session, switch back to parent before sending prompt
+		if a.app.Session.ParentID != "" {
+			parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID)
+			if err != nil {
+				slog.Error("Failed to get parent session", "error", err)
+				return a, toast.NewErrorToast("Failed to get parent session")
+			}
+			a.app.Session = parentSession
+			a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
+			cmds = append(cmds, tea.Sequence(
+				util.CmdHandler(app.SessionSelectedMsg(parentSession)),
+				cmd,
+			))
+		} else {
+			a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
+			cmds = append(cmds, cmd)
+		}
 	case app.SendShell:
 		// If we're in a child session, switch back to parent before sending prompt
 		if a.app.Session.ParentID != "" {

+ 1 - 8
packages/web/astro.config.mjs

@@ -67,14 +67,7 @@ export default defineConfig({
 
         {
           label: "Usage",
-          items: [
-            "docs/tui",
-            "docs/cli",
-            "docs/ide",
-            "docs/share",
-            "docs/github",
-            "docs/gitlab"
-          ],
+          items: ["docs/tui", "docs/cli", "docs/ide", "docs/share", "docs/github", "docs/gitlab"],
         },
 
         {

+ 167 - 0
packages/web/src/content/docs/docs/commands.mdx

@@ -0,0 +1,167 @@
+---
+title: Commands
+description: Create custom commands for repetitive tasks.
+---
+
+Define custom commands to automate repetitive coding tasks.
+
+---
+
+## Create command files
+
+Create markdown files in the `command/` directory to define custom commands.
+
+Create `.opencode/command/test.md`:
+
+```md
+---
+description: Run tests with coverage
+agent: build
+model: anthropic/claude-3-5-sonnet-20241022
+---
+
+Run the full test suite with coverage report and show any failures.
+Focus on the failing tests and suggest fixes.
+```
+
+The frontmatter defines command properties. The content becomes the template.
+
+Use the command by typing `/` followed by the command name.
+
+```bash frame="none"
+"/test"
+```
+
+---
+
+## Create command files
+
+For complex commands, create markdown files in the `command/` directory.
+
+Create `.opencode/command/test.md`:
+
+```md
+---
+description: Run tests with coverage
+agent: build
+model: anthropic/claude-3-5-sonnet-20241022
+---
+
+Run the full test suite with coverage report and show any failures.
+Focus on the failing tests and suggest fixes.
+```
+
+The frontmatter defines command properties. The content becomes the template.
+
+---
+
+## Use arguments
+
+Pass arguments to commands using the `$ARGUMENTS` placeholder.
+
+```md
+---
+description: Create a new component
+---
+
+Create a new React component named $ARGUMENTS with TypeScript support.
+Include proper typing and basic structure.
+```
+
+Run the command with arguments:
+
+```bash frame="none"
+"/component Button"
+```
+
+---
+
+## Inject shell output
+
+Use `!`command`` to inject shell command output into your prompt.
+
+```md
+---
+description: Analyze test coverage
+---
+
+Here are the current test results:
+`!npm test`
+
+Based on these results, suggest improvements to increase coverage.
+```
+
+```md
+---
+description: Review recent changes
+---
+
+Recent git commits:
+`!git log --oneline -10`
+
+Review these changes and suggest any improvements.
+```
+
+Commands run in your project's root directory and their output becomes part of the prompt.
+
+---
+
+## Reference files
+
+Include files in your command using `@` followed by the filename.
+
+```md
+---
+description: Review component
+---
+
+Review the component in @src/components/Button.tsx.
+Check for performance issues and suggest improvements.
+```
+
+The file content gets included in the prompt automatically.
+
+---
+
+## Command properties
+
+Configure commands with these optional frontmatter properties:
+
+- **description**: Brief explanation of what the command does
+- **agent**: Agent to use (defaults to "build")
+- **model**: Specific model to use for this command
+
+```md
+---
+description: Code review assistant
+agent: build
+model: anthropic/claude-3-5-sonnet-20241022
+---
+
+Review the code for best practices and suggest improvements.
+```
+
+---
+
+## Command directory
+
+Store command files in these locations:
+
+- `.opencode/command/` - Project-specific commands
+- `command/` - Global commands in config directory
+
+Project commands take precedence over global ones.
+
+---
+
+## Built-in commands
+
+opencode includes several built-in commands:
+
+- `/init` - Initialize project and create AGENTS.md
+- `/undo` - Revert the last changes
+- `/redo` - Restore reverted changes
+- `/share` - Share the current conversation
+- `/help` - Show available commands and keybinds
+
+Use `/help` to see all available commands in your setup.

+ 5 - 21
packages/web/src/content/docs/docs/index.mdx

@@ -41,26 +41,10 @@ You can also install it with the following:
 - **Using Node.js**
 
   <Tabs>
-    <TabItem label="npm">
-      ```bash
-      npm install -g opencode-ai
-      ```
-    </TabItem>
-    <TabItem label="Bun">
-      ```bash
-      bun install -g opencode-ai
-      ```
-    </TabItem>
-    <TabItem label="pnpm">
-      ```bash
-      pnpm install -g opencode-ai
-      ```
-    </TabItem>
-    <TabItem label="Yarn">
-      ```bash
-      yarn global add opencode-ai
-      ```
-    </TabItem>
+    <TabItem label="npm">```bash npm install -g opencode-ai ```</TabItem>
+    <TabItem label="Bun">```bash bun install -g opencode-ai ```</TabItem>
+    <TabItem label="pnpm">```bash pnpm install -g opencode-ai ```</TabItem>
+    <TabItem label="Yarn">```bash yarn global add opencode-ai ```</TabItem>
   </Tabs>
 
 - **Using Homebrew on macOS and Linux**
@@ -308,4 +292,4 @@ Here's an [example conversation](https://opencode.ai/s/4XP1fce5) with opencode.
 
 And that's it! You are now a pro at using opencode.
 
-To make it your own, we recommend [picking a theme](/docs/themes), [customizing the keybinds](/docs/keybinds), [configuring code formatters](/docs/formatters), or playing around with the [opencode config](/docs/config).
+To make it your own, we recommend [picking a theme](/docs/themes), [customizing the keybinds](/docs/keybinds), [configuring code formatters](/docs/formatters), [creating custom commands](/docs/commands), or playing around with the [opencode config](/docs/config).