This commit is contained in:
Liang-Shih Lin 2025-07-07 11:40:05 +08:00 committed by GitHub
commit 4fecfb6546
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2101 additions and 215 deletions

View file

@ -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<typeof CustomCommand>
export const ExecuteCommandRequest = z
.object({
arguments: z.string().optional(),
})
.openapi({
ref: "ExecuteCommandRequest",
})
export type ExecuteCommandRequest = z.infer<typeof ExecuteCommandRequest>
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<typeof ExecuteCommandResponse>
export async function getCommandsDirectory(): Promise<string> {
return path.join(Global.Path.config, "commands")
}
export async function ensureCommandsDirectory(): Promise<void> {
const commandsDir = await getCommandsDirectory()
await fs.mkdir(commandsDir, { recursive: true })
}
export async function listCommandFiles(): Promise<CommandFile[]> {
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<CommandFile | null> {
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<BashCommandResult> {
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<CommandFile> {
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<CommandFile | null> {
const commandFile = await getCommandFile(name)
if (!commandFile) return null
return processCommandWithBash(commandFile)
}
export async function createExampleCommandFile(): Promise<void> {
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<CustomCommand[]> {
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<string, CustomCommand>()
// 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<CustomCommand | null> {
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<ExecuteCommandResponse> {
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<boolean> {
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<CustomCommand[]> {
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
}
}

View file

@ -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
}

View file

@ -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<Message.MessagePart[]> => {
@ -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(),

View file

@ -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<Reference[]> {
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
}
}
}

View file

@ -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")
})
})

View file

@ -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")
})
})

View file

@ -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",
)
})
})

View file

@ -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.

View file

@ -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")
})
})
})

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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("<leader>h"),
Trigger: "help",
},
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: "editor",
},
{
Name: SessionNewCommand,
Description: "new session",
Keybindings: parseBindings("<leader>n"),
Trigger: "new",
},
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: "sessions",
},
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
},
{
Name: SessionInterruptCommand,
Description: "interrupt session",
Keybindings: parseBindings("esc"),
},
{
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: "compact",
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: "details",
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: "models",
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>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", "<leader>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("<leader>h"),
Trigger: "help",
},
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: "editor",
},
{
Name: SessionNewCommand,
Description: "new session",
Keybindings: parseBindings("<leader>n"),
Trigger: "new",
},
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: "sessions",
},
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
},
{
Name: SessionUnshareCommand,
Description: "unshare session",
Keybindings: parseBindings("<leader>u"),
Trigger: "unshare",
},
{
Name: SessionInterruptCommand,
Description: "interrupt session",
Keybindings: parseBindings("esc"),
},
{
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: "compact",
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: "details",
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: "models",
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: FileListCommand,
Description: "list files",
Keybindings: parseBindings("<leader>f"),
Trigger: "files",
},
{
Name: FileCloseCommand,
Description: "close file",
Keybindings: parseBindings("esc"),
},
{
Name: FileSearchCommand,
Description: "search file",
Keybindings: parseBindings("<leader>/"),
},
{
Name: FileDiffToggleCommand,
Description: "split/unified diff",
Keybindings: parseBindings("<leader>v"),
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>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("<leader>p"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesRevertCommand,
Description: "revert message",
Keybindings: parseBindings("<leader>r"),
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>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)

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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)