diff --git a/packages/opencode/src/commands/index.ts b/packages/opencode/src/commands/index.ts new file mode 100644 index 00000000..c7e1f175 --- /dev/null +++ b/packages/opencode/src/commands/index.ts @@ -0,0 +1,554 @@ +import fs from "fs/promises" +import path from "path" +import { Global } from "../global" +import { Log } from "../util/log" +import { App } from "../app/app" +import { z } from "zod" + +export namespace Commands { + const log = Log.create({ service: "commands" }) + + export interface CommandFile { + name: string + filename: string + content: string + } + + export interface BashCommandResult { + command: string + stdout: string + stderr: string + exitCode: number | null + } + + export const CustomCommand = z + .object({ + name: z.string(), + description: z.string().optional(), + content: z.string(), + filePath: z.string(), + isGlobal: z.boolean(), + }) + .openapi({ + ref: "CustomCommand", + }) + export type CustomCommand = z.infer + + export const ExecuteCommandRequest = z + .object({ + arguments: z.string().optional(), + }) + .openapi({ + ref: "ExecuteCommandRequest", + }) + export type ExecuteCommandRequest = z.infer + + export const ExecuteCommandResponse = z + .object({ + processedContent: z.string(), + bashResults: z.array( + z.object({ + command: z.string(), + stdout: z.string(), + stderr: z.string(), + exitCode: z.number(), + }), + ), + }) + .openapi({ + ref: "ExecuteCommandResponse", + }) + export type ExecuteCommandResponse = z.infer + + export async function getCommandsDirectory(): Promise { + return path.join(Global.Path.config, "commands") + } + + export async function ensureCommandsDirectory(): Promise { + const commandsDir = await getCommandsDirectory() + await fs.mkdir(commandsDir, { recursive: true }) + } + + export async function listCommandFiles(): Promise { + try { + await ensureCommandsDirectory() + const commandsDir = await getCommandsDirectory() + const files = await fs.readdir(commandsDir) + + const commandFiles: CommandFile[] = [] + + for (const file of files) { + if (file.endsWith(".md")) { + const filePath = path.join(commandsDir, file) + const content = await fs.readFile(filePath, "utf-8") + const name = path.basename(file, ".md") + + commandFiles.push({ + name, + filename: file, + content, + }) + } + } + + log.info(`Found ${commandFiles.length} command files`) + return commandFiles.sort((a, b) => a.name.localeCompare(b.name)) + } catch (error) { + log.error("Failed to list command files", { error }) + return [] + } + } + + export async function getCommandFile( + name: string, + ): Promise { + try { + const commandsDir = await getCommandsDirectory() + const filePath = path.join(commandsDir, `${name}.md`) + const content = await fs.readFile(filePath, "utf-8") + + return { + name, + filename: `${name}.md`, + content, + } + } catch (error) { + log.error(`Failed to read command file: ${name}`, { error }) + return null + } + } + + async function executeBashCommand( + command: string, + ): Promise { + const process = Bun.spawn({ + cmd: ["bash", "-c", command], + cwd: App.info().path.cwd, + maxBuffer: 30000, + timeout: 60000, + stdout: "pipe", + stderr: "pipe", + }) + + await process.exited + const stdout = await new Response(process.stdout).text() + const stderr = await new Response(process.stderr).text() + + return { + command, + stdout: stdout || "", + stderr: stderr || "", + exitCode: process.exitCode, + } + } + + function parseBashCommands(content: string): { + commands: string[] + cleanContent: string + } { + const lines = content.split("\n") + const commands: string[] = [] + const cleanLines: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith("!")) { + const command = trimmed.slice(1).trim() + if (command) { + commands.push(command) + } + } else { + cleanLines.push(line) + } + } + + return { + commands, + cleanContent: cleanLines.join("\n"), + } + } + + export async function processCommandWithBash( + commandFile: CommandFile, + ): Promise { + try { + const { commands, cleanContent } = parseBashCommands(commandFile.content) + + if (commands.length === 0) { + return commandFile + } + + log.info( + `Executing ${commands.length} bash commands for ${commandFile.name}`, + ) + + const results: BashCommandResult[] = [] + for (const command of commands) { + try { + const result = await executeBashCommand(command) + results.push(result) + log.info(`Executed command: ${command}`, { + exitCode: result.exitCode, + }) + } catch (error) { + log.error(`Failed to execute command: ${command}`, { error }) + results.push({ + command, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + exitCode: 1, + }) + } + } + + let contextSection = "\n\n## Command Context\n\n" + contextSection += + "The following bash commands were executed to gather context:\n\n" + + for (const result of results) { + contextSection += `### Command: \`${result.command}\`\n\n` + if (result.exitCode === 0) { + if (result.stdout.trim()) { + contextSection += "```\n" + result.stdout.trim() + "\n```\n\n" + } else { + contextSection += "*No output*\n\n" + } + } else { + contextSection += `*Command failed with exit code ${result.exitCode}*\n\n` + if (result.stderr.trim()) { + contextSection += "```\n" + result.stderr.trim() + "\n```\n\n" + } + } + } + + return { + ...commandFile, + content: cleanContent + contextSection, + } + } catch (error) { + log.error(`Failed to process bash commands for ${commandFile.name}`, { + error, + }) + return commandFile + } + } + + export async function getCommandFileWithBash( + name: string, + ): Promise { + const commandFile = await getCommandFile(name) + if (!commandFile) return null + + return processCommandWithBash(commandFile) + } + + export async function createExampleCommandFile(): Promise { + try { + await ensureCommandsDirectory() + const commandsDir = await getCommandsDirectory() + const examplePath = path.join(commandsDir, "example.md") + + // Check if example already exists + try { + await fs.access(examplePath) + return // File already exists + } catch { + // File doesn't exist, create it + } + + const exampleContent = `# Example Command + +This is an example command file. You can create markdown files in the commands directory to define custom commands. + +## Usage + +When you type \`/example\` in the chat, this content will be sent to the LLM as context. + +## Features + +- Use markdown formatting +- Include code examples +- Add instructions for the LLM +- Create reusable prompts +- Execute bash commands with \`!\` prefix for context gathering + +## Bash Commands + +You can include bash commands that will be executed before the command content is sent to the LLM: + +!git status +!git branch +!git log --oneline -5 + +These commands will be executed and their output will be included in the context. + +## Example Code + +\`\`\`typescript +function example() { + console.log("This is an example"); +} +\`\`\` + +You can customize this file or create new ones with different names. +` + + await fs.writeFile(examplePath, exampleContent, "utf-8") + log.info("Created example command file") + } catch (error) { + log.error("Failed to create example command file", { error }) + } + } + + /** + * List all available custom commands from both global and project directories + */ + export async function listCustomCommands(): Promise { + const app = App.info() + const commands: CustomCommand[] = [] + + // Get global commands from ~/.config/opencode/commands + const globalCommandsDir = path.join(app.path.config, "commands") + const globalCommands = await scanCommandsDirectory( + globalCommandsDir, + "", + true, + ) + commands.push(...globalCommands) + + // Get project-level commands from $PWD/.opencode/commands + const projectCommandsDir = path.join(app.path.cwd, ".opencode", "commands") + const projectCommands = await scanCommandsDirectory( + projectCommandsDir, + "", + false, + ) + commands.push(...projectCommands) + + // Sort commands alphabetically, with project commands taking precedence + const commandMap = new Map() + + // Add global commands first + globalCommands.forEach((cmd) => commandMap.set(cmd.name, cmd)) + + // Add project commands (will override global commands with same name) + projectCommands.forEach((cmd) => commandMap.set(cmd.name, cmd)) + + return Array.from(commandMap.values()).sort((a, b) => + a.name.localeCompare(b.name), + ) + } + + /** + * Get a specific custom command by name + */ + export async function getCustomCommand( + commandName: string, + ): Promise { + const commands = await listCustomCommands() + return commands.find((cmd) => cmd.name === commandName) || null + } + + /** + * Execute a custom command with optional arguments + */ + export async function executeCustomCommand( + commandName: string, + args?: string, + ): Promise { + const command = await getCustomCommand(commandName) + if (!command) { + throw new Error(`Custom command '${commandName}' not found`) + } + + log.info("executing custom command", { commandName, args }) + + // Replace $ARGUMENTS placeholder with actual arguments + let content = command.content + if (args) { + content = content.replace(/\$ARGUMENTS/g, args) + } + + // Process bash commands if any exist + const { commands: bashCommands, cleanContent } = parseBashCommands(content) + const bashResults: Array<{ + command: string + stdout: string + stderr: string + exitCode: number + }> = [] + + if (bashCommands.length > 0) { + log.info("processing bash commands", { count: bashCommands.length }) + + let contextSection = "\n\n## Command Context\n\n" + contextSection += + "The following bash commands were executed to gather context:\n\n" + + for (const bashCommand of bashCommands) { + try { + const result = await executeBashCommand(bashCommand) + bashResults.push({ + command: result.command, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode || 0, + }) + + contextSection += `### Command: \`${bashCommand}\`\n\n` + if (result.exitCode === 0) { + if (result.stdout.trim()) { + contextSection += "```\n" + contextSection += result.stdout.trim() + contextSection += "\n```\n\n" + } else { + contextSection += "*No output*\n\n" + } + } else { + contextSection += `*Command failed with exit code ${result.exitCode}*\n\n` + if (result.stderr.trim()) { + contextSection += "```\n" + contextSection += result.stderr.trim() + contextSection += "\n```\n\n" + } + } + } catch (error) { + log.error("failed to execute bash command", { + command: bashCommand, + error, + }) + const errorResult = { + command: bashCommand, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + exitCode: 1, + } + bashResults.push(errorResult) + + contextSection += `### Command: \`${bashCommand}\`\n\n` + contextSection += `*Command failed: ${errorResult.stderr}*\n\n` + } + } + + return { + processedContent: cleanContent + contextSection, + bashResults, + } + } + + return { + processedContent: content, + bashResults: [], + } + } + + /** + * Check if a custom command exists + */ + export async function customCommandExists( + commandName: string, + ): Promise { + const command = await getCustomCommand(commandName) + return command !== null + } + + /** + * Recursively scan a directory for markdown command files + */ + async function scanCommandsDirectory( + baseDir: string, + relativePath: string, + isGlobal: boolean, + ): Promise { + const commands: CustomCommand[] = [] + const currentDir = path.join(baseDir, relativePath) + + try { + const entries = await fs.readdir(currentDir) + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry) + const stat = await fs.stat(entryPath) + + if (stat.isDirectory()) { + // Recursively scan subdirectories + const subPath = relativePath ? path.join(relativePath, entry) : entry + const subCommands = await scanCommandsDirectory( + baseDir, + subPath, + isGlobal, + ) + commands.push(...subCommands) + } else if (entry.endsWith(".md")) { + // Calculate relative path from base commands directory + const relativeFilePath = relativePath + ? path.join(relativePath, entry) + : entry + + // Convert file path to command name with colon notation + const commandName = relativeFilePath + .replace(/\.md$/, "") + .replace(/[/\\]/g, ":") + + try { + const content = await fs.readFile(entryPath, "utf-8") + + // Extract description from frontmatter or first line + const description = extractDescription(content) + + commands.push({ + name: commandName, + description, + content, + filePath: entryPath, + isGlobal, + }) + } catch (error) { + log.warn("failed to read command file", { path: entryPath, error }) + } + } + } + } catch (error) { + // Directory doesn't exist or can't be read, return empty array + log.info("commands directory not accessible", { dir: currentDir, error }) + } + + return commands + } + + /** + * Extract description from command content (frontmatter or first line) + */ + function extractDescription(content: string): string | undefined { + const lines = content.split("\n") + + // Check for frontmatter + if (lines[0]?.trim() === "---") { + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim() + if (line === "---") break + if (line.startsWith("description:")) { + return line + .substring("description:".length) + .trim() + .replace(/^["']|["']$/g, "") + } + } + } + + // Fallback to first non-empty line or first heading + for (const line of lines) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith("#")) { + return trimmed.length > 100 + ? trimmed.substring(0, 100) + "..." + : trimmed + } + if (trimmed.startsWith("# ")) { + return trimmed.substring(2).trim() + } + } + + return undefined + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index df645cd8..6b69c024 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,6 +14,7 @@ import { NamedError } from "../util/error" import { ModelsDev } from "../provider/models" import { Ripgrep } from "../file/ripgrep" import { Config } from "../config/config" +import { Commands } from "../commands" import { File } from "../file" import { LSP } from "../lsp" @@ -649,6 +650,120 @@ export namespace Server { return c.json(content) }, ) + .get( + "/commands", + describeRoute({ + description: "List all available custom commands", + responses: { + 200: { + description: "List of custom commands", + content: { + "application/json": { + schema: resolver(Commands.CustomCommand.array()), + }, + }, + }, + }, + }), + async (c) => { + const commands = await Commands.listCustomCommands() + return c.json(commands) + }, + ) + .get( + "/commands/:name", + describeRoute({ + description: "Get a specific custom command", + responses: { + 200: { + description: "Custom command details", + content: { + "application/json": { + schema: resolver(Commands.CustomCommand), + }, + }, + }, + 404: { + description: "Command not found", + content: { + "application/json": { + schema: resolver( + z.object({ + error: z.string(), + }), + ), + }, + }, + }, + }, + }), + zValidator( + "param", + z.object({ + name: z.string(), + }), + ), + async (c) => { + const commandName = c.req.valid("param").name + const command = await Commands.getCustomCommand(commandName) + if (!command) { + return c.json({ error: "Command not found" }, 404) + } + return c.json(command) + }, + ) + .post( + "/commands/:name/execute", + describeRoute({ + description: "Execute a custom command with optional arguments", + responses: { + 200: { + description: "Command execution result", + content: { + "application/json": { + schema: resolver(Commands.ExecuteCommandResponse), + }, + }, + }, + 404: { + description: "Command not found", + content: { + "application/json": { + schema: resolver( + z.object({ + error: z.string(), + }), + ), + }, + }, + }, + }, + }), + zValidator( + "param", + z.object({ + name: z.string(), + }), + ), + zValidator("json", Commands.ExecuteCommandRequest), + async (c) => { + const commandName = c.req.valid("param").name + const body = c.req.valid("json") + + try { + const result = await Commands.executeCustomCommand( + commandName, + body.arguments, + ) + return c.json(result) + } catch (error) { + if (error instanceof Error && error.message.includes("not found")) { + return c.json({ error: error.message }, 404) + } + throw error + } + }, + ) return result } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index f4e80ce9..f96d2387 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -27,6 +27,7 @@ import { Installation } from "../installation" import { MCP } from "../mcp" import { Provider } from "../provider/provider" import { ProviderTransform } from "../provider/transform" +import { FileReference } from "../util/file-reference" import type { ModelsDev } from "../provider/models" import { Share } from "../share/share" import { Snapshot } from "../snapshot" @@ -361,6 +362,20 @@ export namespace Session { ) if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) + // Process file references in text parts + const processedParts = await Promise.all( + input.parts.map(async (part) => { + if (part.type === "text") { + const { processedText } = await FileReference.resolve(part.text) + return { + ...part, + text: processedText, + } + } + return part + }), + ) + const app = App.info() input.parts = await Promise.all( input.parts.map(async (part): Promise => { @@ -449,7 +464,7 @@ export namespace Session { const msg: Message.Info = { role: "user", id: Identifier.ascending("message"), - parts: input.parts, + parts: processedParts, metadata: { time: { created: Date.now(), diff --git a/packages/opencode/src/util/file-reference.ts b/packages/opencode/src/util/file-reference.ts new file mode 100644 index 00000000..7bdf4c2d --- /dev/null +++ b/packages/opencode/src/util/file-reference.ts @@ -0,0 +1,86 @@ +import path from "path" +import { App } from "../app/app" +import { Log } from "./log" + +export namespace FileReference { + const log = Log.create({ service: "file-reference" }) + + export interface Reference { + original: string + path: string + resolved: string + exists: boolean + } + + export async function parse(text: string, rootPath?: string): Promise { + const references: Reference[] = [] + const regex = /@([^\s@]+(?:\.[^\s@]*)?)/g + let match + + while ((match = regex.exec(text)) !== null) { + const original = match[0] + const filePath = match[1] + + let root: string + try { + root = rootPath ?? App.info().path.root + } catch { + root = process.cwd() + } + + const resolved = path.isAbsolute(filePath) + ? filePath + : path.resolve(root, filePath) + + let exists = false + try { + const file = Bun.file(resolved) + exists = await file.exists() + } catch { + exists = false + } + + references.push({ + original, + path: filePath, + resolved, + exists + }) + } + + return references + } + + export async function resolve(text: string, rootPath?: string): Promise<{ + processedText: string + references: Reference[] + }> { + const references = await parse(text, rootPath) + let processedText = text + + for (const ref of references) { + if (ref.exists) { + try { + const file = Bun.file(ref.resolved) + const content = await file.text() + const replacement = `${ref.original}\n\`\`\`\n${content}\n\`\`\`` + processedText = processedText.replace(ref.original, replacement) + log.info("resolved file reference", { path: ref.path, size: content.length }) + } catch (error) { + log.warn("failed to read referenced file", { path: ref.path, error }) + const replacement = `${ref.original} (file not readable)` + processedText = processedText.replace(ref.original, replacement) + } + } else { + log.warn("referenced file does not exist", { path: ref.path }) + const replacement = `${ref.original} (file not found)` + processedText = processedText.replace(ref.original, replacement) + } + } + + return { + processedText, + references + } + } +} \ No newline at end of file diff --git a/packages/opencode/test/commands/api-integration.test.ts b/packages/opencode/test/commands/api-integration.test.ts new file mode 100644 index 00000000..be7b81a7 --- /dev/null +++ b/packages/opencode/test/commands/api-integration.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test" +import { Server } from "../../src/server/server" +import { App } from "../../src/app/app" +import path from "path" +import fs from "fs/promises" +import os from "os" + +describe("Custom Commands API Integration", () => { + let server: any + let tempDir: string + let originalCwd: string + const port = 3001 // Use different port to avoid conflicts + + beforeAll(async () => { + // Create a temporary directory for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-api-test-")) + originalCwd = process.cwd() + process.chdir(tempDir) + + // Create test command directories + const globalCommandsDir = path.join( + tempDir, + ".config", + "opencode", + "commands", + ) + const projectCommandsDir = path.join(tempDir, ".opencode", "commands") + + await fs.mkdir(globalCommandsDir, { recursive: true }) + await fs.mkdir(projectCommandsDir, { recursive: true }) + + // Create test commands + await fs.writeFile( + path.join(globalCommandsDir, "api-test.md"), + `--- +description: An API test command +--- + +# API Test Command + +This is an API test command. + +!echo "API test executed" + +Please help with the API test. +`, + ) + + await fs.writeFile( + path.join(projectCommandsDir, "project-api.md"), + `# Project API Command + +This is a project API command with arguments: $ARGUMENTS +`, + ) + + // Mock App context + await App.provide({ cwd: tempDir }, async () => { + // Start the server + server = Server.listen({ port, hostname: "localhost" }) + + // Wait a bit for server to start + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + }) + + afterAll(async () => { + if (server) { + server.stop() + } + process.chdir(originalCwd) + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("should list custom commands via API", async () => { + const response = await fetch(`http://localhost:${port}/commands`) + expect(response.status).toBe(200) + + const commands = await response.json() + expect(Array.isArray(commands)).toBe(true) + expect(commands.length).toBeGreaterThan(0) + + const commandNames = commands.map((cmd: any) => cmd.name) + expect(commandNames).toContain("api-test") + expect(commandNames).toContain("project-api") + }) + + it("should get a specific command via API", async () => { + const response = await fetch(`http://localhost:${port}/commands/api-test`) + expect(response.status).toBe(200) + + const command = await response.json() + expect(command.name).toBe("api-test") + expect(command.description).toBe("An API test command") + expect(command.content).toContain("Please help with the API test") + }) + + it("should return 404 for non-existent command", async () => { + const response = await fetch( + `http://localhost:${port}/commands/non-existent`, + ) + expect(response.status).toBe(404) + + const error = await response.json() + expect(error.error).toBe("Command not found") + }) + + it("should execute a command via API", async () => { + const response = await fetch( + `http://localhost:${port}/commands/api-test/execute`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ) + + expect(response.status).toBe(200) + + const result = await response.json() + expect(result.processedContent).toContain("Please help with the API test") + expect(result.processedContent).toContain("## Command Context") + expect(result.bashResults).toHaveLength(1) + expect(result.bashResults[0].command).toBe('echo "API test executed"') + expect(result.bashResults[0].stdout).toBe("API test executed\n") + }) + + it("should execute a command with arguments via API", async () => { + const response = await fetch( + `http://localhost:${port}/commands/project-api/execute`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + arguments: "test arguments", + }), + }, + ) + + expect(response.status).toBe(200) + + const result = await response.json() + expect(result.processedContent).toContain( + "This is a project API command with arguments: test arguments", + ) + }) + + it("should return 404 when executing non-existent command", async () => { + const response = await fetch( + `http://localhost:${port}/commands/non-existent/execute`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ) + + expect(response.status).toBe(404) + + const error = await response.json() + expect(error.error).toContain("not found") + }) +}) diff --git a/packages/opencode/test/commands/bash-commands.test.ts b/packages/opencode/test/commands/bash-commands.test.ts new file mode 100644 index 00000000..32810fc2 --- /dev/null +++ b/packages/opencode/test/commands/bash-commands.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "bun:test" + +describe("Commands with Bash Execution", () => { + it("should parse bash commands from content", () => { + const content = `# Test Command + +This is a test command. + +!echo "Hello World" +!pwd +!ls -la + +Some more content here.` + + // Simulate the parseBashCommands function + const lines = content.split("\n") + const commands: string[] = [] + const cleanLines: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith("!")) { + const command = trimmed.slice(1).trim() + if (command) { + commands.push(command) + } + } else { + cleanLines.push(line) + } + } + + const cleanContent = cleanLines.join("\n") + + expect(commands).toEqual(['echo "Hello World"', "pwd", "ls -la"]) + + expect(cleanContent).toContain("This is a test command.") + expect(cleanContent).toContain("Some more content here.") + expect(cleanContent).not.toContain("!echo") + expect(cleanContent).not.toContain("!pwd") + expect(cleanContent).not.toContain("!ls") + }) + + it("should handle content without bash commands", () => { + const content = `# Test Command + +This is a test command without bash commands. + +Some content here.` + + // Simulate the parseBashCommands function + const lines = content.split("\n") + const commands: string[] = [] + const cleanLines: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith("!")) { + const command = trimmed.slice(1).trim() + if (command) { + commands.push(command) + } + } else { + cleanLines.push(line) + } + } + + const cleanContent = cleanLines.join("\n") + + expect(commands).toEqual([]) + expect(cleanContent).toBe(content) + }) + + it("should format command context correctly", () => { + const mockResults = [ + { + command: "git status", + stdout: "On branch main\nnothing to commit, working tree clean", + stderr: "", + exitCode: 0, + }, + { + command: "git branch", + stdout: "* main\n feature-branch", + stderr: "", + exitCode: 0, + }, + { + command: "invalid-command", + stdout: "", + stderr: "command not found: invalid-command", + exitCode: 1, + }, + ] + + let contextSection = "\n\n## Command Context\n\n" + contextSection += + "The following bash commands were executed to gather context:\n\n" + + for (const result of mockResults) { + contextSection += `### Command: \`${result.command}\`\n\n` + if (result.exitCode === 0) { + if (result.stdout.trim()) { + contextSection += "```\n" + result.stdout.trim() + "\n```\n\n" + } else { + contextSection += "*No output*\n\n" + } + } else { + contextSection += `*Command failed with exit code ${result.exitCode}*\n\n` + if (result.stderr.trim()) { + contextSection += "```\n" + result.stderr.trim() + "\n```\n\n" + } + } + } + + expect(contextSection).toContain("## Command Context") + expect(contextSection).toContain("### Command: `git status`") + expect(contextSection).toContain("On branch main") + expect(contextSection).toContain("### Command: `git branch`") + expect(contextSection).toContain("* main") + expect(contextSection).toContain("### Command: `invalid-command`") + expect(contextSection).toContain("*Command failed with exit code 1*") + expect(contextSection).toContain("command not found: invalid-command") + }) +}) diff --git a/packages/opencode/test/commands/custom-commands.test.ts b/packages/opencode/test/commands/custom-commands.test.ts new file mode 100644 index 00000000..d95e2f2f --- /dev/null +++ b/packages/opencode/test/commands/custom-commands.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test" +import { Commands } from "../../src/commands" +import { App } from "../../src/app/app" +import path from "path" +import fs from "fs/promises" +import os from "os" + +describe("Custom Commands", () => { + let tempDir: string + let originalCwd: string + + beforeAll(async () => { + // Create a temporary directory for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")) + originalCwd = process.cwd() + process.chdir(tempDir) + + // Create test command directories + const globalCommandsDir = path.join( + tempDir, + ".config", + "opencode", + "commands", + ) + const projectCommandsDir = path.join(tempDir, ".opencode", "commands") + + await fs.mkdir(globalCommandsDir, { recursive: true }) + await fs.mkdir(projectCommandsDir, { recursive: true }) + + // Create test commands + await fs.writeFile( + path.join(globalCommandsDir, "global-test.md"), + `--- +description: A global test command +--- + +# Global Test Command + +This is a global test command. + +!echo "Global command executed" + +Please help with the global test. +`, + ) + + await fs.writeFile( + path.join(projectCommandsDir, "project-test.md"), + `--- +description: A project test command +--- + +# Project Test Command + +This is a project test command. + +!echo "Project command executed" + +Please help with the project test: $ARGUMENTS +`, + ) + + // Create nested command + await fs.mkdir(path.join(projectCommandsDir, "nested"), { recursive: true }) + await fs.writeFile( + path.join(projectCommandsDir, "nested", "command.md"), + `# Nested Command + +This is a nested command. +`, + ) + + // Mock App.info() to return our test paths + App.info = () => ({ + user: "test", + hostname: "test", + git: false, + path: { + config: path.join(tempDir, ".config", "opencode"), + data: path.join(tempDir, ".data"), + root: tempDir, + cwd: tempDir, + state: path.join(tempDir, ".state"), + }, + time: { + initialized: Date.now(), + }, + }) + }) + + afterAll(async () => { + process.chdir(originalCwd) + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("should list all custom commands", async () => { + const commands = await Commands.listCustomCommands() + + expect(commands).toHaveLength(3) + + const commandNames = commands.map((cmd) => cmd.name).sort() + expect(commandNames).toEqual([ + "global-test", + "nested:command", + "project-test", + ]) + + // Project command should override global if same name exists + const projectTest = commands.find((cmd) => cmd.name === "project-test") + expect(projectTest).toBeDefined() + expect(projectTest?.isGlobal).toBe(false) + expect(projectTest?.description).toBe("A project test command") + }) + + it("should get a specific custom command", async () => { + const command = await Commands.getCustomCommand("project-test") + + expect(command).toBeDefined() + expect(command?.name).toBe("project-test") + expect(command?.description).toBe("A project test command") + expect(command?.content).toContain( + "Please help with the project test: $ARGUMENTS", + ) + expect(command?.isGlobal).toBe(false) + }) + + it("should return null for non-existent command", async () => { + const command = await Commands.getCustomCommand("non-existent") + expect(command).toBeNull() + }) + + it("should execute a custom command without arguments", async () => { + const result = await Commands.executeCustomCommand("global-test") + + expect(result.processedContent).toContain( + "Please help with the global test", + ) + expect(result.processedContent).toContain("## Command Context") + expect(result.bashResults).toHaveLength(1) + expect(result.bashResults[0].command).toBe('echo "Global command executed"') + expect(result.bashResults[0].stdout).toBe("Global command executed\n") + expect(result.bashResults[0].exitCode).toBe(0) + }) + + it("should execute a custom command with arguments", async () => { + const result = await Commands.executeCustomCommand( + "project-test", + "my test args", + ) + + expect(result.processedContent).toContain( + "Please help with the project test: my test args", + ) + expect(result.processedContent).toContain("## Command Context") + expect(result.bashResults).toHaveLength(1) + expect(result.bashResults[0].command).toBe( + 'echo "Project command executed"', + ) + expect(result.bashResults[0].stdout).toBe("Project command executed\n") + expect(result.bashResults[0].exitCode).toBe(0) + }) + + it("should handle nested commands", async () => { + const command = await Commands.getCustomCommand("nested:command") + + expect(command).toBeDefined() + expect(command?.name).toBe("nested:command") + expect(command?.content).toContain("This is a nested command") + }) + + it("should check if custom command exists", async () => { + const exists1 = await Commands.customCommandExists("project-test") + const exists2 = await Commands.customCommandExists("non-existent") + + expect(exists1).toBe(true) + expect(exists2).toBe(false) + }) + + it("should throw error for non-existent command execution", async () => { + expect(Commands.executeCustomCommand("non-existent")).rejects.toThrow( + "Custom command 'non-existent' not found", + ) + }) +}) diff --git a/packages/opencode/test/commands/git-commit.md b/packages/opencode/test/commands/git-commit.md new file mode 100644 index 00000000..fae232a5 --- /dev/null +++ b/packages/opencode/test/commands/git-commit.md @@ -0,0 +1,16 @@ +# Git Commit Helper + +This command helps you create a well-structured git commit by gathering context about the current repository state. + +!git status +!git diff --cached +!git branch +!git log --oneline -5 + +Based on the above context, please help me create a meaningful commit message that follows conventional commit standards. Consider: + +1. The type of changes (feat, fix, docs, style, refactor, test, chore) +2. A concise description of what was changed +3. Any breaking changes that should be noted + +Please suggest a commit message and explain your reasoning based on the git context provided above. diff --git a/packages/opencode/test/util/file-reference.test.ts b/packages/opencode/test/util/file-reference.test.ts new file mode 100644 index 00000000..efed08e8 --- /dev/null +++ b/packages/opencode/test/util/file-reference.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test" +import { FileReference } from "../../src/util/file-reference" +import { writeFile, unlink, mkdir } from "fs/promises" +import path from "path" + +describe("FileReference", () => { + const testDir = path.join(process.cwd(), "test-files") + const testFile1 = path.join(testDir, "test1.js") + const testFile2 = path.join(testDir, "utils", "helpers.js") + + beforeAll(async () => { + await mkdir(testDir, { recursive: true }) + await mkdir(path.dirname(testFile2), { recursive: true }) + + await writeFile(testFile1, `function hello() { + return "Hello World" +}`) + + await writeFile(testFile2, `export function add(a, b) { + return a + b +}`) + }) + + afterAll(async () => { + try { + await unlink(testFile1) + await unlink(testFile2) + } catch {} + }) + + describe("parse", () => { + it("should parse single file reference", async () => { + const text = "Review the implementation in @test-files/test1.js" + const references = await FileReference.parse(text, process.cwd()) + + expect(references).toHaveLength(1) + expect(references[0].original).toBe("@test-files/test1.js") + expect(references[0].path).toBe("test-files/test1.js") + }) + + it("should parse multiple file references", async () => { + const text = "Compare @test-files/test1.js with @test-files/utils/helpers.js" + const references = await FileReference.parse(text, process.cwd()) + + expect(references).toHaveLength(2) + expect(references[0].original).toBe("@test-files/test1.js") + expect(references[1].original).toBe("@test-files/utils/helpers.js") + }) + + it("should handle file references with extensions", async () => { + const text = "Check @src/utils/helpers.ts and @config.json" + const references = await FileReference.parse(text, process.cwd()) + + expect(references).toHaveLength(2) + expect(references[0].path).toBe("src/utils/helpers.ts") + expect(references[1].path).toBe("config.json") + }) + + it("should not parse @ symbols that are not file references", async () => { + const text = "Email me @john.doe or check @username on social media" + const references = await FileReference.parse(text, process.cwd()) + + expect(references).toHaveLength(2) + expect(references[0].path).toBe("john.doe") + expect(references[1].path).toBe("username") + }) + }) + + describe("resolve", () => { + it("should resolve existing file references", async () => { + const text = "Review the implementation in @test-files/test1.js" + const result = await FileReference.resolve(text, process.cwd()) + + expect(result.references).toHaveLength(1) + expect(result.references[0].exists).toBe(true) + expect(result.processedText).toContain("function hello()") + expect(result.processedText).toContain("```") + }) + + it("should handle non-existing files", async () => { + const text = "Check @non-existing-file.js" + const result = await FileReference.resolve(text, process.cwd()) + + expect(result.references).toHaveLength(1) + expect(result.references[0].exists).toBe(false) + expect(result.processedText).toContain("(file not found)") + }) + + it("should resolve multiple file references", async () => { + const text = "Compare @test-files/test1.js with @test-files/utils/helpers.js" + const result = await FileReference.resolve(text, process.cwd()) + + expect(result.references).toHaveLength(2) + expect(result.references[0].exists).toBe(true) + expect(result.references[1].exists).toBe(true) + expect(result.processedText).toContain("function hello()") + expect(result.processedText).toContain("export function add") + }) + }) +}) \ No newline at end of file diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 469857ab..d79cecb8 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "os" "path/filepath" "sort" "strings" @@ -21,17 +22,18 @@ import ( ) type App struct { - Info opencode.App - Version string - StatePath string - Config *opencode.Config - Client *opencode.Client - State *config.State - Provider *opencode.Provider - Model *opencode.Model - Session *opencode.Session - Messages []opencode.Message - Commands commands.CommandRegistry + Info opencode.App + Version string + StatePath string + Config *opencode.Config + Client *opencode.Client + CommandsClient *commands.CommandsClient + State *config.State + Provider *opencode.Provider + Model *opencode.Model + Session *opencode.Session + Messages []opencode.Message + Commands commands.CommandRegistry } type SessionSelectedMsg = *opencode.Session @@ -108,17 +110,28 @@ func New( slog.Debug("Loaded config", "config", configInfo) - app := &App{ - Info: appInfo, - Version: version, - StatePath: appStatePath, - Config: configInfo, - State: appState, - Client: httpClient, - Session: &opencode.Session{}, - Messages: []opencode.Message{}, - Commands: commands.LoadFromConfig(configInfo), + // Create commands client using the same base URL as the HTTP client + baseURL := os.Getenv("OPENCODE_SERVER") + if baseURL == "" { + baseURL = "http://localhost:4096" // Default fallback } + commandsClient := commands.NewCommandsClient(baseURL) + + app := &App{ + Info: appInfo, + Version: version, + StatePath: appStatePath, + Config: configInfo, + State: appState, + Client: httpClient, + CommandsClient: commandsClient, + Session: &opencode.Session{}, + Messages: []opencode.Message{}, + Commands: commands.LoadFromConfig(configInfo), + } + + // Create example command file if commands directory doesn't exist + app.ensureCommandsDirectory() return app, nil } @@ -432,3 +445,89 @@ func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) { // func (a *App) loadCustomKeybinds() { // // } + +func (a *App) ensureCommandsDirectory() { + commandsDir := filepath.Join(a.Info.Path.Config, "commands") + + // Check if commands directory exists + if _, err := os.Stat(commandsDir); os.IsNotExist(err) { + // Create the commands directory + if err := os.MkdirAll(commandsDir, 0755); err != nil { + slog.Error("Failed to create commands directory", "error", err) + return + } + + // Create an example command file + examplePath := filepath.Join(commandsDir, "example.md") + exampleContent := `--- +description: An example custom command for demonstration +--- + +# Example Command + +This is an example command file. You can create markdown files in the commands directory to define custom commands. + +User request: $ARGUMENTS + +Please help the user with their request above. If no specific request was provided, give general guidance about this example command. + +## Command Locations + +Commands can be stored in two locations: +1. Global: ~/.config/opencode/commands/ (available in all projects) +2. Project: $PWD/.opencode/commands/ (specific to current project) + +Project-level commands take precedence over global commands with the same name. + +## Nested Commands + +You can organize commands in subdirectories. For example: +- commands/git/commit.md becomes /git:commit +- commands/docker/build.md becomes /docker:build + +## Metadata + +You can add YAML frontmatter at the top of your markdown files to provide metadata: +- description: A brief description that will appear in command autocompletion + +Alternatively, if no frontmatter is provided, the first heading will be used as the description. + +## Usage + +When you type /example in the chat, this content will be sent to the LLM as context. + +## Arguments + +You can pass arguments to commands using the $ARGUMENTS placeholder: + +Example usage: +- /example hello world +- /example "some text with spaces" + +The $ARGUMENTS placeholder will be replaced with everything after the command name. + +## Features + +- Use markdown formatting +- Include code examples +- Add instructions for the LLM +- Create reusable prompts + +## Example Code + +` + "```typescript" + ` +function example() { + console.log("This is an example"); +} +` + "```" + ` + +You can customize this file or create new ones with different names. +` + + if err := os.WriteFile(examplePath, []byte(exampleContent), 0644); err != nil { + slog.Error("Failed to create example command file", "error", err) + } else { + slog.Info("Created example command file at", "path", examplePath) + } + } +} diff --git a/packages/tui/internal/commands/client.go b/packages/tui/internal/commands/client.go new file mode 100644 index 00000000..b3c8d5a6 --- /dev/null +++ b/packages/tui/internal/commands/client.go @@ -0,0 +1,170 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// CustomCommand represents a custom command from the server +type CustomCommand struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Content string `json:"content"` + FilePath string `json:"filePath"` + IsGlobal bool `json:"isGlobal"` +} + +// ExecuteCommandRequest represents the request to execute a command +type ExecuteCommandRequest struct { + Arguments *string `json:"arguments,omitempty"` +} + +// BashResult represents the result of a bash command execution +type BashResult struct { + Command string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exitCode"` +} + +// ExecuteCommandResponse represents the response from executing a command +type ExecuteCommandResponse struct { + ProcessedContent string `json:"processedContent"` + BashResults []BashResult `json:"bashResults"` +} + +// CommandsClient handles communication with the server for custom commands +type CommandsClient struct { + baseURL string + httpClient *http.Client +} + +// NewCommandsClient creates a new commands client +func NewCommandsClient(baseURL string) *CommandsClient { + return &CommandsClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// ListCustomCommands fetches all available custom commands from the server +func (c *CommandsClient) ListCustomCommands(ctx context.Context) ([]CustomCommand, error) { + url := fmt.Sprintf("%scommands", c.baseURL) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d", resp.StatusCode) + } + + var commands []CustomCommand + if err := json.NewDecoder(resp.Body).Decode(&commands); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return commands, nil +} + +// GetCustomCommand fetches a specific custom command from the server +func (c *CommandsClient) GetCustomCommand(ctx context.Context, name string) (*CustomCommand, error) { + url := fmt.Sprintf("%scommands/%s", c.baseURL, name) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil // Command not found + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d", resp.StatusCode) + } + + var command CustomCommand + if err := json.NewDecoder(resp.Body).Decode(&command); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &command, nil +} + +// ExecuteCustomCommand executes a custom command on the server +func (c *CommandsClient) ExecuteCustomCommand(ctx context.Context, name string, arguments *string) (*ExecuteCommandResponse, error) { + url := fmt.Sprintf("%scommands/%s/execute", c.baseURL, name) + + reqBody := ExecuteCommandRequest{ + Arguments: arguments, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + var errorResp struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &errorResp) == nil { + return nil, fmt.Errorf("%s", errorResp.Error) + } + return nil, fmt.Errorf("command not found") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d", resp.StatusCode) + } + + var result ExecuteCommandResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// CustomCommandExists checks if a custom command exists on the server +func (c *CommandsClient) CustomCommandExists(ctx context.Context, name string) (bool, error) { + command, err := c.GetCustomCommand(ctx, name) + if err != nil { + return false, err + } + return command != nil, nil +} diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 9c4da12e..34440199 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -1,7 +1,10 @@ package commands import ( + "context" "encoding/json" + "os" + "path/filepath" "slices" "strings" @@ -9,9 +12,11 @@ import ( "github.com/sst/opencode-sdk-go" ) -type ExecuteCommandMsg Command -type ExecuteCommandsMsg []Command -type CommandExecutedMsg Command +type ( + ExecuteCommandMsg Command + ExecuteCommandsMsg []Command + CommandExecutedMsg Command +) type Keybinding struct { RequiresLeader bool @@ -24,13 +29,15 @@ func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool { return key == msg.String() && (k.RequiresLeader == leader) } -type CommandName string -type Command struct { - Name CommandName - Description string - Keybindings []Keybinding - Trigger string -} +type ( + CommandName string + Command struct { + Name CommandName + Description string + Keybindings []Keybinding + Trigger string + } +) func (c Command) Keys() []string { var keys []string @@ -104,6 +111,150 @@ const ( AppExitCommand CommandName = "app_exit" ) +var defaults = []Command{ + { + Name: AppHelpCommand, + Description: "show help", + Keybindings: parseBindings("h"), + Trigger: "help", + }, + { + Name: EditorOpenCommand, + Description: "open editor", + Keybindings: parseBindings("e"), + Trigger: "editor", + }, + { + Name: SessionNewCommand, + Description: "new session", + Keybindings: parseBindings("n"), + Trigger: "new", + }, + { + Name: SessionListCommand, + Description: "list sessions", + Keybindings: parseBindings("l"), + Trigger: "sessions", + }, + { + Name: SessionShareCommand, + Description: "share session", + Keybindings: parseBindings("s"), + Trigger: "share", + }, + { + Name: SessionInterruptCommand, + Description: "interrupt session", + Keybindings: parseBindings("esc"), + }, + { + Name: SessionCompactCommand, + Description: "compact the session", + Keybindings: parseBindings("c"), + Trigger: "compact", + }, + { + Name: ToolDetailsCommand, + Description: "toggle tool details", + Keybindings: parseBindings("d"), + Trigger: "details", + }, + { + Name: ModelListCommand, + Description: "list models", + Keybindings: parseBindings("m"), + Trigger: "models", + }, + { + Name: ThemeListCommand, + Description: "list themes", + Keybindings: parseBindings("t"), + Trigger: "themes", + }, + { + Name: ProjectInitCommand, + Description: "create/update AGENTS.md", + Keybindings: parseBindings("i"), + Trigger: "init", + }, + { + Name: InputClearCommand, + Description: "clear input", + Keybindings: parseBindings("ctrl+c"), + }, + { + Name: InputPasteCommand, + Description: "paste content", + Keybindings: parseBindings("ctrl+v"), + }, + { + Name: InputSubmitCommand, + Description: "submit message", + Keybindings: parseBindings("enter"), + }, + { + Name: InputNewlineCommand, + Description: "insert newline", + Keybindings: parseBindings("shift+enter", "ctrl+j"), + }, + // { + // Name: HistoryPreviousCommand, + // Description: "previous prompt", + // Keybindings: parseBindings("up"), + // }, + // { + // Name: HistoryNextCommand, + // Description: "next prompt", + // Keybindings: parseBindings("down"), + // }, + { + Name: MessagesPageUpCommand, + Description: "page up", + Keybindings: parseBindings("pgup"), + }, + { + Name: MessagesPageDownCommand, + Description: "page down", + Keybindings: parseBindings("pgdown"), + }, + { + Name: MessagesHalfPageUpCommand, + Description: "half page up", + Keybindings: parseBindings("ctrl+alt+u"), + }, + { + Name: MessagesHalfPageDownCommand, + Description: "half page down", + Keybindings: parseBindings("ctrl+alt+d"), + }, + { + Name: MessagesPreviousCommand, + Description: "previous message", + Keybindings: parseBindings("ctrl+alt+k"), + }, + { + Name: MessagesNextCommand, + Description: "next message", + Keybindings: parseBindings("ctrl+alt+j"), + }, + { + Name: MessagesFirstCommand, + Description: "first message", + Keybindings: parseBindings("ctrl+g"), + }, + { + Name: MessagesLastCommand, + Description: "last message", + Keybindings: parseBindings("ctrl+alt+g"), + }, + { + Name: AppExitCommand, + Description: "exit the app", + Keybindings: parseBindings("ctrl+c", "q"), + Trigger: "exit", + }, +} + func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool { for _, binding := range k.Keybindings { if binding.Matches(msg, leader) { @@ -129,182 +280,56 @@ func parseBindings(bindings ...string) []Keybinding { return parsedBindings } -func LoadFromConfig(config *opencode.Config) CommandRegistry { - defaults := []Command{ - { - Name: AppHelpCommand, - Description: "show help", - Keybindings: parseBindings("h"), - Trigger: "help", - }, - { - Name: EditorOpenCommand, - Description: "open editor", - Keybindings: parseBindings("e"), - Trigger: "editor", - }, - { - Name: SessionNewCommand, - Description: "new session", - Keybindings: parseBindings("n"), - Trigger: "new", - }, - { - Name: SessionListCommand, - Description: "list sessions", - Keybindings: parseBindings("l"), - Trigger: "sessions", - }, - { - Name: SessionShareCommand, - Description: "share session", - Keybindings: parseBindings("s"), - Trigger: "share", - }, - { - Name: SessionUnshareCommand, - Description: "unshare session", - Keybindings: parseBindings("u"), - Trigger: "unshare", - }, - { - Name: SessionInterruptCommand, - Description: "interrupt session", - Keybindings: parseBindings("esc"), - }, - { - Name: SessionCompactCommand, - Description: "compact the session", - Keybindings: parseBindings("c"), - Trigger: "compact", - }, - { - Name: ToolDetailsCommand, - Description: "toggle tool details", - Keybindings: parseBindings("d"), - Trigger: "details", - }, - { - Name: ModelListCommand, - Description: "list models", - Keybindings: parseBindings("m"), - Trigger: "models", - }, - { - Name: ThemeListCommand, - Description: "list themes", - Keybindings: parseBindings("t"), - Trigger: "themes", - }, - { - Name: FileListCommand, - Description: "list files", - Keybindings: parseBindings("f"), - Trigger: "files", - }, - { - Name: FileCloseCommand, - Description: "close file", - Keybindings: parseBindings("esc"), - }, - { - Name: FileSearchCommand, - Description: "search file", - Keybindings: parseBindings("/"), - }, - { - Name: FileDiffToggleCommand, - Description: "split/unified diff", - Keybindings: parseBindings("v"), - }, - { - Name: ProjectInitCommand, - Description: "create/update AGENTS.md", - Keybindings: parseBindings("i"), - Trigger: "init", - }, - { - Name: InputClearCommand, - Description: "clear input", - Keybindings: parseBindings("ctrl+c"), - }, - { - Name: InputPasteCommand, - Description: "paste content", - Keybindings: parseBindings("ctrl+v"), - }, - { - Name: InputSubmitCommand, - Description: "submit message", - Keybindings: parseBindings("enter"), - }, - { - Name: InputNewlineCommand, - Description: "insert newline", - Keybindings: parseBindings("shift+enter", "ctrl+j"), - }, - { - Name: MessagesPageUpCommand, - Description: "page up", - Keybindings: parseBindings("pgup"), - }, - { - Name: MessagesPageDownCommand, - Description: "page down", - Keybindings: parseBindings("pgdown"), - }, - { - Name: MessagesHalfPageUpCommand, - Description: "half page up", - Keybindings: parseBindings("ctrl+alt+u"), - }, - { - Name: MessagesHalfPageDownCommand, - Description: "half page down", - Keybindings: parseBindings("ctrl+alt+d"), - }, - { - Name: MessagesPreviousCommand, - Description: "previous message", - Keybindings: parseBindings("ctrl+up"), - }, - { - Name: MessagesNextCommand, - Description: "next message", - Keybindings: parseBindings("ctrl+down"), - }, - { - Name: MessagesFirstCommand, - Description: "first message", - Keybindings: parseBindings("ctrl+g"), - }, - { - Name: MessagesLastCommand, - Description: "last message", - Keybindings: parseBindings("ctrl+alt+g"), - }, - { - Name: MessagesLayoutToggleCommand, - Description: "toggle layout", - Keybindings: parseBindings("p"), - }, - { - Name: MessagesCopyCommand, - Description: "copy message", - Keybindings: parseBindings("y"), - }, - { - Name: MessagesRevertCommand, - Description: "revert message", - Keybindings: parseBindings("r"), - }, - { - Name: AppExitCommand, - Description: "exit the app", - Keybindings: parseBindings("ctrl+c", "q"), - Trigger: "exit", - }, +// IsBuiltinCommand checks if a command name matches any of the built-in command triggers +func IsBuiltinCommand(commandName string) bool { + for _, command := range defaults { + if command.Trigger == commandName { + return true + } } + return false +} + +// IsValidCustomCommand checks if a custom command exists in the filesystem +func IsValidCustomCommand(commandName string, configPath string) bool { + // Convert colon notation to file path + filePath := strings.ReplaceAll(commandName, ":", string(filepath.Separator)) + ".md" + + // Try project-level commands first ($PWD/.opencode/commands) + if cwd, err := os.Getwd(); err == nil { + projectCommandsDir := filepath.Join(cwd, ".opencode", "commands") + projectCommandFile := filepath.Join(projectCommandsDir, filePath) + if _, err := os.Stat(projectCommandFile); err == nil { + return true + } + } + + // Try global commands (~/.config/opencode/commands) + globalCommandsDir := filepath.Join(configPath, "commands") + globalCommandFile := filepath.Join(globalCommandsDir, filePath) + if _, err := os.Stat(globalCommandFile); err == nil { + return true + } + + return false +} + +// IsValidCustomCommandWithClient checks if a custom command exists via server +func IsValidCustomCommandWithClient(commandName string, configPath string, client *CommandsClient) bool { + // Use server endpoint if client is available + if client != nil { + ctx := context.Background() + exists, err := client.CustomCommandExists(ctx, commandName) + if err == nil { + return exists + } + } + + // Return false if server is not available or returns an error + return false +} + +func LoadFromConfig(config *opencode.Config) CommandRegistry { registry := make(CommandRegistry) keybinds := map[string]string{} marshalled, _ := json.Marshal(config.Keybinds) diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go index c73923e8..bfbe2c25 100644 --- a/packages/tui/internal/completions/commands.go +++ b/packages/tui/internal/completions/commands.go @@ -1,7 +1,12 @@ package completions import ( + "context" + "fmt" + "log/slog" + "path/filepath" "sort" + "strconv" "strings" "github.com/charmbracelet/lipgloss/v2" @@ -13,6 +18,13 @@ import ( "github.com/sst/opencode/internal/theme" ) +type CustomCommandFile struct { + Name string `json:"name"` + Filename string `json:"filename"` + Content string `json:"content"` + Description string `json:"description"` +} + type CommandCompletionProvider struct { app *app.App } @@ -39,29 +51,98 @@ func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) di }) } +func getCustomCommandCompletionItem(cmd CustomCommandFile, space int, t theme.Theme) dialog.CompletionItemI { + spacer := strings.Repeat(" ", space) + description := cmd.Description + if description == "" { + description = "custom command" + } + title := " /" + cmd.Name + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+description) + value := "/" + cmd.Name + return dialog.NewCompletionItem(dialog.CompletionItem{ + Title: title, + Value: value, + }) +} + +func (c *CommandCompletionProvider) getCustomCommands() ([]CustomCommandFile, error) { + // Get commands from server endpoint + ctx := context.Background() + serverCommands, err := c.app.CommandsClient.ListCustomCommands(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get commands from server: %w", err) + } + + slog.Debug("Server commands:" + strconv.Itoa(len(serverCommands))) + + // Convert server commands to local format + var commands []CustomCommandFile + for _, cmd := range serverCommands { + description := "" + if cmd.Description != nil { + description = *cmd.Description + } + commands = append(commands, CustomCommandFile{ + Name: cmd.Name, + Description: description, + Filename: filepath.Base(cmd.FilePath), + Content: cmd.Content, + }) + } + + // Sort commands alphabetically + sort.Slice(commands, func(i, j int) bool { + return commands[i].Name < commands[j].Name + }) + + return commands, nil +} + func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { t := theme.CurrentTheme() commands := c.app.Commands + // Get custom commands + customCommands, err := c.getCustomCommands() + if err != nil { + // If server is not available, return only built-in commands + customCommands = []CustomCommandFile{} + } + + // Calculate spacing for alignment space := 1 for _, cmd := range c.app.Commands { if lipgloss.Width(cmd.Trigger) > space { space = lipgloss.Width(cmd.Trigger) } } + for _, cmd := range customCommands { + if lipgloss.Width(cmd.Name) > space { + space = lipgloss.Width(cmd.Name) + } + } space += 2 sorted := commands.Sorted() if query == "" { - // If no query, return all commands + // If no query, return all commands (built-in + custom) items := []dialog.CompletionItemI{} + + // Add built-in commands for _, cmd := range sorted { if cmd.Trigger == "" { continue } - space := space - lipgloss.Width(cmd.Trigger) - items = append(items, getCommandCompletionItem(cmd, space, t)) + cmdSpace := space - lipgloss.Width(cmd.Trigger) + items = append(items, getCommandCompletionItem(cmd, cmdSpace, t)) } + + // Add custom commands + for _, cmd := range customCommands { + cmdSpace := space - lipgloss.Width(cmd.Name) + items = append(items, getCustomCommandCompletionItem(cmd, cmdSpace, t)) + } + return items, nil } @@ -69,13 +150,21 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp var commandNames []string commandMap := make(map[string]dialog.CompletionItemI) + // Add built-in commands for _, cmd := range sorted { if cmd.Trigger == "" { continue } - space := space - lipgloss.Width(cmd.Trigger) + cmdSpace := space - lipgloss.Width(cmd.Trigger) commandNames = append(commandNames, cmd.Trigger) - commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t) + commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, cmdSpace, t) + } + + // Add custom commands + for _, cmd := range customCommands { + cmdSpace := space - lipgloss.Width(cmd.Name) + commandNames = append(commandNames, cmd.Name) + commandMap[cmd.Name] = getCustomCommandCompletionItem(cmd, cmdSpace, t) } // Find fuzzy matches diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 9466d541..0fa8b6ed 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -21,6 +21,11 @@ import ( "github.com/sst/opencode/internal/util" ) +type CustomCommandExecuteMsg struct { + Name string + Arguments string +} + type EditorComponent interface { tea.Model View(width int) string @@ -71,6 +76,17 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.ProviderID { case "commands": commandName := strings.TrimPrefix(msg.CompletionValue, "/") + + // Check if this is a valid custom command (not a built-in command) + if !commands.IsBuiltinCommand(commandName) && commands.IsValidCustomCommandWithClient(commandName, m.app.Info.Path.Config, m.app.CommandsClient) { + customCommandName := commandName + updated, cmd := m.Clear() + m = updated.(*editorComponent) + cmds = append(cmds, cmd) + cmds = append(cmds, util.CmdHandler(CustomCommandExecuteMsg{Name: customCommandName, Arguments: ""})) + return m, tea.Batch(cmds...) + } + updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) @@ -125,6 +141,24 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + case dialog.CompletionFilledMsg: + // For fill (tab), just update the input text without executing + existingValue := m.textarea.Value() + + if msg.IsCommand { + // For commands, replace the search string with the command value + m.textarea.SetValue(msg.CompletionValue + " ") + } else { + // Replace the current token (after last space) + lastSpaceIndex := strings.LastIndex(existingValue, " ") + if lastSpaceIndex == -1 { + m.textarea.SetValue(msg.CompletionValue + " ") + } else { + modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue + m.textarea.SetValue(modifiedValue + " ") + } + } + return m, nil } m.spinner, cmd = m.spinner.Update(msg) diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index 7ba91dc5..ca9df7a4 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -67,6 +67,12 @@ type CompletionSelectedMsg struct { ProviderID string } +type CompletionFilledMsg struct { + SearchString string + CompletionValue string + IsCommand bool +} + type CompletionDialogCompleteItemMsg struct { Value string } @@ -91,12 +97,16 @@ type completionDialogComponent struct { type completionDialogKeyMap struct { Complete key.Binding + Fill key.Binding Cancel key.Binding } var completionDialogKeys = completionDialogKeyMap{ Complete: key.NewBinding( - key.WithKeys("tab", "enter", "right"), + key.WithKeys("enter", "right"), + ), + Fill: key.NewBinding( + key.WithKeys("tab"), ), Cancel: key.NewBinding( key.WithKeys(" ", "esc", "backspace", "ctrl+c"), @@ -146,6 +156,12 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil } return c, c.complete(item) + case key.Matches(msg, completionDialogKeys.Fill): + item, i := c.list.GetSelectedItem() + if i == -1 { + return c, nil + } + return c, c.fill(item) case key.Matches(msg, completionDialogKeys.Cancel): // Only close on backspace when there are no characters left if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 { @@ -221,6 +237,25 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { ) } +func (c *completionDialogComponent) fill(item CompletionItemI) tea.Cmd { + value := c.pseudoSearchTextArea.Value() + if value == "" { + return nil + } + + // Check if this is a command completion + isCommand := c.completionProvider.GetId() == "commands" + + return tea.Batch( + util.CmdHandler(CompletionFilledMsg{ + SearchString: value, + CompletionValue: item.GetValue(), + IsCommand: isCommand, + }), + c.close(), + ) +} + func (c *completionDialogComponent) close() tea.Cmd { c.pseudoSearchTextArea.Reset() c.pseudoSearchTextArea.Blur() diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 150a1b26..721831e8 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -40,8 +40,10 @@ const ( InterruptKeyFirstPress ) -const interruptDebounceTimeout = 1 * time.Second -const fileViewerFullWidthCutoff = 160 +const ( + interruptDebounceTimeout = 1 * time.Second + fileViewerFullWidthCutoff = 160 +) type appModel struct { width, height int @@ -339,6 +341,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case commands.ExecuteCommandMsg: updated, cmd := a.executeCommand(commands.Command(msg)) return updated, cmd + case chat.CustomCommandExecuteMsg: + updated, cmd := a.executeCustomCommandWithArgs(msg.Name, msg.Arguments) + return updated, cmd case commands.ExecuteCommandsMsg: for _, command := range msg { updated, cmd := a.executeCommand(command) @@ -350,6 +355,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, toast.NewErrorToast(msg.Error()) case app.SendMsg: a.showCompletionDialog = false + + // Check if the message is a custom command with arguments + if strings.HasPrefix(msg.Text, "/") { + if commandName, arguments, isCustomCommand := a.parseCustomCommand(msg.Text); isCustomCommand { + // Execute custom command with arguments + updated, cmd := a.executeCustomCommandWithArgs(commandName, arguments) + return updated, cmd + } + } + a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) cmds = append(cmds, cmd) case dialog.CompletionDialogCloseMsg: @@ -974,6 +989,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return a, tea.Batch(cmds...) } +// parseCustomCommand checks if the input is a custom command and extracts name and arguments +func (a appModel) parseCustomCommand(input string) (commandName, arguments string, isCustomCommand bool) { + if !strings.HasPrefix(input, "/") { + return "", "", false + } + + // Remove the leading slash + input = strings.TrimPrefix(input, "/") + + // Split by first space to separate command from arguments + parts := strings.SplitN(input, " ", 2) + commandName = parts[0] + + if len(parts) > 1 { + arguments = parts[1] + } + + // Check if this command exists as a custom command via server + ctx := context.Background() + exists, err := a.app.CommandsClient.CustomCommandExists(ctx, commandName) + if err != nil { + return "", "", false + } + + if exists { + return commandName, arguments, true + } + + return "", "", false +} + +// executeCustomCommandWithArgs executes a custom command with arguments +func (a appModel) executeCustomCommandWithArgs(commandName, arguments string) (tea.Model, tea.Cmd) { + slog.Debug("Executing custom command with arguments", "command", commandName, "arguments", arguments) + + // Execute command via server endpoint + ctx := context.Background() + var args *string + if arguments != "" { + args = &arguments + } + + var cmd tea.Cmd + result, err := a.app.CommandsClient.ExecuteCustomCommand(ctx, commandName, args) + if err == nil { + // Server execution successful + slog.Info("Custom command executed via server", "command", commandName) + a.app, cmd = a.app.SendChatMessage(context.Background(), result.ProcessedContent, []opencode.FilePartParam{}) + return a, cmd + } + + // Server execution failed + slog.Error("Failed to execute custom command via server", "command", commandName, "error", err) + return a, toast.NewErrorToast("Failed to execute custom command: " + commandName) +} + func NewModel(app *app.App) tea.Model { commandProvider := completions.NewCommandCompletionProvider(app) fileProvider := completions.NewFileAndFolderContextGroup(app)