diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 27ba357ecc9a..137fe50a7d14 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -2,10 +2,16 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect" import { EffectBridge } from "@/effect" import type { InstanceContext } from "@/project/instance" +import { Instance } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context } from "effect" import z from "zod" +import path from "path" +import fs from "fs/promises" +import { existsSync } from "fs" import { Config } from "../config" +import * as ConfigMarkdown from "../config/markdown" +import { Glob } from "@opencode-ai/shared/util/glob" import { MCP } from "../mcp" import { Skill } from "../skill" import PROMPT_INITIALIZE from "./template/initialize.txt" @@ -47,12 +53,131 @@ export const Info = z // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } +const commandCache = new Map() + +function rel(item: string, patterns: string[]) { + const normalizedItem = item.replaceAll("\\", "/") + for (const pattern of patterns) { + const index = normalizedItem.indexOf(pattern) + if (index === -1) continue + return normalizedItem.slice(index + pattern.length) + } +} + +function trim(file: string) { + const ext = path.extname(file) + return ext.length ? file.slice(0, -ext.length) : file +} + +async function findCommandFile(name: string, directories: string[]): Promise<{ path: string } | null> { + for (const dir of directories) { + const subdirs = ["command", "commands", ".opencode/command", ".opencode/commands"] + for (const subdir of subdirs) { + const filePath = path.join(dir, subdir, name + ".md") + if (existsSync(filePath)) { + return { path: filePath } + } + const nested = path.join(dir, subdir, name + "/index.md") + if (existsSync(nested)) { + return { path: nested } + } + } + } + return null +} + +async function loadSingleCommand(filePath: string): Promise { + const md = await ConfigMarkdown.parse(filePath).catch(() => null) + if (!md) return null + + const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] + const file = rel(filePath, patterns) ?? path.basename(filePath) + const cmdName = trim(file) + const template = md.content.trim() + + return { + name: cmdName, + description: md.data?.description, + agent: md.data?.agent, + model: md.data?.model, + source: "command" as const, + template, + subtask: md.data?.subtask, + hints: hints(template), + } +} + +async function loadFreshCommandsWithMtime(directories: string[]): Promise> { + const result: Record = createBuiltInCommands() + + for (const dir of directories) { + const subdirs = ["command", "commands", ".opencode/command", ".opencode/commands"] + for (const subdir of subdirs) { + const cmdDir = path.join(dir, subdir) + if (!existsSync(cmdDir)) continue + + const files = await Glob.scan("**/*.md", { cwd: cmdDir, absolute: true, dot: true, symlink: true }) + for (const filePath of files) { + const stat = await fs.stat(filePath) + const mtime = stat.mtimeMs + const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] + const file = rel(filePath, patterns) ?? path.basename(filePath) + const cmdName = trim(file) + + const cached = commandCache.get(cmdName) + if (cached && cached.filePath === filePath && cached.mtime === mtime) { + result[cmdName] = cached.command + continue + } + + const command = await loadSingleCommand(filePath) + if (command) { + commandCache.set(cmdName, { command, mtime, filePath }) + result[cmdName] = command + } + } + } + } + + return result +} + +function createBuiltInCommands(): Record { + return { + [Default.INIT]: { + name: Default.INIT, + description: "create/update AGENTS.md", + source: "command", + get template() { + return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) + }, + hints: hints(PROMPT_INITIALIZE), + }, + [Default.REVIEW]: { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", + get template() { + return PROMPT_REVIEW.replace("${path}", Instance.worktree) + }, + subtask: true, + hints: hints(PROMPT_REVIEW), + }, + } +} + export function hints(template: string) { const result: string[] = [] const numbered = template.match(/\$\d+/g) if (numbered) { for (const match of [...new Set(numbered)].sort()) result.push(match) } + const extended = template.match(/\$\{(\d+|\d*\.\.\d*)\}/g) + if (extended) { + for (const match of [...new Set(extended)].sort()) { + if (!result.includes(match)) result.push(match) + } + } if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") return result } @@ -166,11 +291,95 @@ export const layer = Layer.effect( const state = yield* InstanceState.make((ctx) => init(ctx)) const get = Effect.fn("Command.get")(function* (name: string) { + const cfg = yield* config.get() + if (cfg.experimental?.cache_command_markdown_files === false) { + const builtIn = createBuiltInCommands() + if (builtIn[name]) return builtIn[name] + + const dirs = yield* config.directories() + const cached = commandCache.get(name) + const fileInfo = yield* Effect.promise(() => findCommandFile(name, dirs)) + + if (!fileInfo && cfg.command?.[name]) { + return { ...cfg.command[name], name, hints: hints(cfg.command[name].template) } + } + + if (!fileInfo) { + return undefined + } + + if (cached && cached.filePath === fileInfo.path) { + const stat = yield* Effect.promise(() => fs.stat(fileInfo.path)) + if (stat.mtimeMs === cached.mtime) { + return cached.command + } + } + + const command = yield* Effect.promise(() => loadSingleCommand(fileInfo.path)) + if (command) { + const stat = yield* Effect.promise(() => fs.stat(fileInfo.path)) + commandCache.set(name, { command, mtime: stat.mtimeMs, filePath: fileInfo.path }) + } + return command ?? undefined + } const s = yield* InstanceState.get(state) return s.commands[name] }) const list = Effect.fn("Command.list")(function* () { + const cfg = yield* config.get() + if (cfg.experimental?.cache_command_markdown_files === false) { + const dirs = yield* config.directories() + const fresh = yield* Effect.promise(() => loadFreshCommandsWithMtime(dirs)) + + for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { + if (!fresh[name]) { + const bridge = yield* EffectBridge.make() + fresh[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return bridge.promise( + mcp + .getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ) + .pipe( + Effect.map( + (template) => + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ), + ), + ) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + } + + for (const item of yield* skill.all()) { + if (!fresh[item.name]) { + fresh[item.name] = { + name: item.name, + description: item.description, + source: "skill", + get template() { + return item.content + }, + hints: [], + } + } + } + + return Object.values(fresh) + } const s = yield* InstanceState.get(state) return Object.values(s.commands) }) diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 3e0adccc303b..e7bb585dc4bb 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -15,6 +15,7 @@ import { ConfigModelID } from "./model-id" const log = Log.create({ service: "config" }) export const Info = Schema.Struct({ + name: Schema.optional(Schema.String), template: Schema.String, description: Schema.optional(Schema.String), agent: Schema.optional(Schema.String), diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5423ba3baf5f..b08537bdd80a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -234,6 +234,9 @@ export const Info = Schema.Struct({ primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents.", }), + cache_command_markdown_files: Schema.optional(Schema.Boolean).annotate({ + description: "Cache command markdown files on first load. Set to false to reload command files on every execution.", + }), continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({ description: "Continue the agent loop when a tool call is denied", }), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 73dd46e31994..2c1b5965a3c4 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -729,11 +729,13 @@ Nested command template`, const config = await load() expect(config.command?.["hello"]).toEqual({ + name: "hello", description: "Test command", template: "Hello from singular command", }) expect(config.command?.["nested/child"]).toEqual({ + name: "nested/child", description: "Nested command", template: "Nested command template", }) @@ -774,11 +776,13 @@ Nested command template`, const config = await load() expect(config.command?.["hello"]).toEqual({ + name: "hello", description: "Test command", template: "Hello from plural commands", }) expect(config.command?.["nested/child"]).toEqual({ + name: "nested/child", description: "Nested command", template: "Nested command template", }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index b94dd5208655..23aa54d33e26 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -379,6 +379,7 @@ describe("tool.task", () => { }, experimental: { primary_tools: ["bash", "read"], + cache_command_markdown_files: true, }, }, }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1fcab2eda6d6..9c45c519419b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1669,6 +1669,10 @@ export type Config = { * Tools that should only be available to primary agents. */ primary_tools?: Array + /** + * Cache command markdown files on first load. Set to false to reload command files on every execution. + */ + cache_command_markdown_files?: boolean /** * Continue the agent loop when a tool call is denied */