mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
Merge 717a5acbd1
into d87922c0eb
This commit is contained in:
commit
4fecfb6546
16 changed files with 2101 additions and 215 deletions
554
packages/opencode/src/commands/index.ts
Normal file
554
packages/opencode/src/commands/index.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
86
packages/opencode/src/util/file-reference.ts
Normal file
86
packages/opencode/src/util/file-reference.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
169
packages/opencode/test/commands/api-integration.test.ts
Normal file
169
packages/opencode/test/commands/api-integration.test.ts
Normal 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")
|
||||
})
|
||||
})
|
124
packages/opencode/test/commands/bash-commands.test.ts
Normal file
124
packages/opencode/test/commands/bash-commands.test.ts
Normal 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")
|
||||
})
|
||||
})
|
184
packages/opencode/test/commands/custom-commands.test.ts
Normal file
184
packages/opencode/test/commands/custom-commands.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
16
packages/opencode/test/commands/git-commit.md
Normal file
16
packages/opencode/test/commands/git-commit.md
Normal 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.
|
100
packages/opencode/test/util/file-reference.test.ts
Normal file
100
packages/opencode/test/util/file-reference.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
170
packages/tui/internal/commands/client.go
Normal file
170
packages/tui/internal/commands/client.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue