diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 088d7c565975..1e9cf5efe5b8 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -4,7 +4,12 @@ import { makeRuntime } from "@/effect/run-service" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, ServiceMap } from "effect" import z from "zod" +import path from "path" +import fs from "fs/promises" +import { existsSync } from "fs" import { Config } from "../config/config" +import { ConfigMarkdown } from "../config/markdown" +import { Glob } from "../util/glob" import { MCP } from "../mcp" import { Skill } from "../skill" import { Log } from "../util/log" @@ -50,12 +55,211 @@ export namespace Command { // 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 } + // Cache for command file mtimes when cache_command_markdown_files is false + 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): Promise<{ path: string } | null> { + const directories = await Config.directories() + 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 config = { name: cmdName, ...md.data, template: md.content.trim() } + const parsed = Config.Command.safeParse(config) + if (!parsed.success) return null + + return { + ...parsed.data, + name: cmdName, + hints: hints(parsed.data.template), + } + } + + async function loadFreshCommands(): Promise> { + const result: Record = {} + const cfg = await Config.get() + + // Load commands from config file (non-markdown) + for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { + ...command, + name, + hints: Command.hints(command.template), + } + } + + // Reload commands from markdown files in each config directory + const directories = await Config.directories() + for (const dir of directories) { + const commands = await Config.reloadCommands(dir) + for (const [name, command] of Object.entries(commands)) { + result[name] = { + ...command, + name, + hints: Command.hints(command.template), + } + } + } + + return result + } + + async function loadFreshCommandsWithMtime(): Promise> { + const result: Record = createBuiltInCommands() + const cfg = await Config.get() + + // Load commands from config file (non-markdown) + for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { ...command, name, hints: Command.hints(command.template) } + } + + // Load commands from markdown files with mtime checking + const directories = await Config.directories() + 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 + } + } + } + } + + // Add MCP prompts + for (const [name, prompt] of Object.entries(await MCP.prompts())) { + if (!result[name]) { + result[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return new Promise(async (resolve, reject) => { + const template = await MCP.getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ).catch(reject) + resolve( + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ) + }) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + } + + // Add skills + for (const skill of await Skill.all()) { + if (!result[skill.name]) { + result[skill.name] = { + name: skill.name, + description: skill.description, + source: "skill", + get template() { + return skill.content + }, + hints: [], + } + } + } + + 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 } @@ -168,11 +372,54 @@ export namespace Command { const state = yield* InstanceState.make((ctx) => init(ctx)) const get = Effect.fn("Command.get")(function* (name: string) { + const get = Effect.fn("Command.get")(function* (name: string) { + const cfg = yield* Effect.promise(() => Config.get()) + // When experimental.cache_command_markdown_files is explicitly false, + // reload commands from disk on each call + if (cfg.experimental?.cache_command_markdown_files === false) { + const builtIn = createBuiltInCommands() + if (builtIn[name]) return builtIn[name] + + const cached = commandCache.get(name) + const fileInfo = yield* Effect.promise(() => findCommandFile(name)) + + // Only use cfg.command as fallback if no markdown file exists on disk + if (!fileInfo && cfg.command?.[name]) { + return { ...cfg.command[name], name, hints: Command.hints(cfg.command[name].template) } + } + + if (!fileInfo) { + return undefined + } + + // Check if we have a cached version with valid mtime + if (cached && cached.filePath === fileInfo.path) { + const stat = yield* Effect.promise(() => fs.stat(fileInfo.path)) + if (stat.mtimeMs === cached.mtime) { + return cached.command + } + } + + // Load fresh from disk + 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* Effect.promise(() => Config.get()) + // When experimental.cache_command_markdown_files is explicitly false, + // reload commands from disk on each call + if (cfg.experimental?.cache_command_markdown_files === false) { + const fresh = yield* Effect.promise(() => loadFreshCommandsWithMtime()) + return Object.values(fresh) + } const s = yield* InstanceState.get(state) return Object.values(s.commands) }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 850bcc28bcd9..d9d15eae47fb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -505,7 +505,7 @@ export namespace Config { agent: z.string().optional(), model: ModelId.optional(), subtask: z.boolean().optional(), - }) + }).catchall(z.any()) export type Command = z.infer export const Skills = z.object({ @@ -1027,6 +1027,11 @@ export namespace Config { .array(z.string()) .optional() .describe("Tools that should only be available to primary agents."), + cache_command_markdown_files: z + .boolean() + .optional() + .default(true) + .describe("Cache command markdown files on first load. Set to false to reload command files on every execution."), continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), mcp_timeout: z .number() @@ -1572,4 +1577,7 @@ export namespace Config { export async function waitForDependencies() { return runPromise((svc) => svc.waitForDependencies()) } + + // Re-export loadCommand for use when cache_command_markdown_files is false + export const reloadCommands = loadCommand } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9c631360b620..f1601f6edb04 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -645,11 +645,13 @@ Nested command template`, const config = await Config.get() 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", }) @@ -690,11 +692,13 @@ Nested command template`, const config = await Config.get() 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/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2f8e99cfed8f..c6a921ab3fda 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1608,6 +1608,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 */