chore: Clean up unused code and call commands endpoint for commands

This commit is contained in:
Liang-Shih Lin 2025-07-06 15:02:34 +08:00
parent f70e06a283
commit ae629b159a
5 changed files with 42 additions and 407 deletions

View file

@ -110,8 +110,12 @@ func New(
slog.Debug("Loaded config", "config", configInfo) slog.Debug("Loaded config", "config", configInfo)
// Create commands client - assuming server runs on same host with port 3000 // Create commands client using the same base URL as the HTTP client
commandsClient := commands.NewCommandsClient("http://localhost:3000") baseURL := os.Getenv("OPENCODE_SERVER")
if baseURL == "" {
baseURL = "http://localhost:4096" // Default fallback
}
commandsClient := commands.NewCommandsClient(baseURL)
app := &App{ app := &App{
Info: appInfo, Info: appInfo,

View file

@ -56,7 +56,7 @@ func NewCommandsClient(baseURL string) *CommandsClient {
// ListCustomCommands fetches all available custom commands from the server // ListCustomCommands fetches all available custom commands from the server
func (c *CommandsClient) ListCustomCommands(ctx context.Context) ([]CustomCommand, error) { func (c *CommandsClient) ListCustomCommands(ctx context.Context) ([]CustomCommand, error) {
url := fmt.Sprintf("%s/commands", c.baseURL) url := fmt.Sprintf("%scommands", c.baseURL)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
@ -83,7 +83,7 @@ func (c *CommandsClient) ListCustomCommands(ctx context.Context) ([]CustomComman
// GetCustomCommand fetches a specific custom command from the server // GetCustomCommand fetches a specific custom command from the server
func (c *CommandsClient) GetCustomCommand(ctx context.Context, name string) (*CustomCommand, error) { func (c *CommandsClient) GetCustomCommand(ctx context.Context, name string) (*CustomCommand, error) {
url := fmt.Sprintf("%s/commands/%s", c.baseURL, name) url := fmt.Sprintf("%scommands/%s", c.baseURL, name)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
@ -114,7 +114,7 @@ func (c *CommandsClient) GetCustomCommand(ctx context.Context, name string) (*Cu
// ExecuteCustomCommand executes a custom command on the server // ExecuteCustomCommand executes a custom command on the server
func (c *CommandsClient) ExecuteCustomCommand(ctx context.Context, name string, arguments *string) (*ExecuteCommandResponse, error) { func (c *CommandsClient) ExecuteCustomCommand(ctx context.Context, name string, arguments *string) (*ExecuteCommandResponse, error) {
url := fmt.Sprintf("%s/commands/%s/execute", c.baseURL, name) url := fmt.Sprintf("%scommands/%s/execute", c.baseURL, name)
reqBody := ExecuteCommandRequest{ reqBody := ExecuteCommandRequest{
Arguments: arguments, Arguments: arguments,

View file

@ -314,9 +314,9 @@ func IsValidCustomCommand(commandName string, configPath string) bool {
return false return false
} }
// IsValidCustomCommandWithClient checks if a custom command exists via server or filesystem // IsValidCustomCommandWithClient checks if a custom command exists via server
func IsValidCustomCommandWithClient(commandName string, configPath string, client *CommandsClient) bool { func IsValidCustomCommandWithClient(commandName string, configPath string, client *CommandsClient) bool {
// Try server first if client is available // Use server endpoint if client is available
if client != nil { if client != nil {
ctx := context.Background() ctx := context.Background()
exists, err := client.CustomCommandExists(ctx, commandName) exists, err := client.CustomCommandExists(ctx, commandName)
@ -325,8 +325,8 @@ func IsValidCustomCommandWithClient(commandName string, configPath string, clien
} }
} }
// Fallback to local filesystem check // Return false if server is not available or returns an error
return IsValidCustomCommand(commandName, configPath) return false
} }
func LoadFromConfig(config *opencode.Config) CommandRegistry { func LoadFromConfig(config *opencode.Config) CommandRegistry {

View file

@ -1,13 +1,12 @@
package completions package completions
import ( import (
"bufio"
"context" "context"
"io/fs" "fmt"
"os" "log/slog"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
@ -66,98 +65,29 @@ func getCustomCommandCompletionItem(cmd CustomCommandFile, space int, t theme.Th
}) })
} }
// parseMarkdownMetadata extracts description from markdown frontmatter or first heading
func parseMarkdownMetadata(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
return ""
}
defer file.Close()
scanner := bufio.NewScanner(file)
// Check for YAML frontmatter
if scanner.Scan() {
firstLine := strings.TrimSpace(scanner.Text())
if firstLine == "---" {
// Parse YAML frontmatter
descriptionRegex := regexp.MustCompile(`(?i)^description:\s*(.+)$`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "---" {
break
}
if matches := descriptionRegex.FindStringSubmatch(line); len(matches) > 1 {
return strings.Trim(matches[1], `"'`)
}
}
} else {
// Check if first line is a heading and use it as description
if strings.HasPrefix(firstLine, "#") {
return strings.TrimSpace(strings.TrimLeft(firstLine, "#"))
}
}
}
// If no frontmatter, look for first heading
file.Seek(0, 0)
scanner = bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#") {
return strings.TrimSpace(strings.TrimLeft(line, "#"))
}
// Stop at first non-empty, non-comment line
if line != "" && !strings.HasPrefix(line, "<!--") {
break
}
}
return ""
}
func (c *CommandCompletionProvider) getCustomCommands() ([]CustomCommandFile, error) { func (c *CommandCompletionProvider) getCustomCommands() ([]CustomCommandFile, error) {
// Try to get commands from server first // Get commands from server endpoint
ctx := context.Background() ctx := context.Background()
serverCommands, err := c.app.CommandsClient.ListCustomCommands(ctx) serverCommands, err := c.app.CommandsClient.ListCustomCommands(ctx)
if err == nil {
// 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,
})
}
return commands, nil
}
// Fallback to local filesystem scanning if server is not available
var commands []CustomCommandFile
// Get global commands from ~/.config/opencode/commands
globalCommandsDir := filepath.Join(c.app.Info.Path.Config, "commands")
globalCommands, err := c.scanCommandsDirectory(globalCommandsDir, "")
if err != nil { if err != nil {
// Log error but continue with project commands return nil, fmt.Errorf("failed to get commands from server: %w", err)
globalCommands = []CustomCommandFile{}
} }
commands = append(commands, globalCommands...)
// Get project-level commands from $PWD/.opencode/commands slog.Debug("Server commands:" + strconv.Itoa(len(serverCommands)))
cwd, err := os.Getwd()
if err == nil { // Convert server commands to local format
projectCommandsDir := filepath.Join(cwd, ".opencode", "commands") var commands []CustomCommandFile
projectCommands, err := c.scanCommandsDirectory(projectCommandsDir, "") for _, cmd := range serverCommands {
if err == nil { description := ""
commands = append(commands, projectCommands...) 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 commands alphabetically
@ -168,57 +98,6 @@ func (c *CommandCompletionProvider) getCustomCommands() ([]CustomCommandFile, er
return commands, nil return commands, nil
} }
// scanCommandsDirectory recursively scans a directory for markdown command files
func (c *CommandCompletionProvider) scanCommandsDirectory(baseDir, relativePath string) ([]CustomCommandFile, error) {
var commands []CustomCommandFile
currentDir := filepath.Join(baseDir, relativePath)
err := filepath.WalkDir(currentDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// If the commands directory doesn't exist, just return empty list
if strings.Contains(err.Error(), "no such file or directory") {
return nil
}
return err
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
// Calculate relative path from base commands directory
relPath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
// Convert file path to command name with colon notation
// e.g., "foo/bar.md" -> "foo:bar"
name := strings.TrimSuffix(relPath, ".md")
name = strings.ReplaceAll(name, string(filepath.Separator), ":")
description := parseMarkdownMetadata(path)
commands = append(commands, CustomCommandFile{
Name: name,
Filename: d.Name(),
Content: "", // We don't need content for completion
Description: description,
})
return nil
})
if err != nil {
return nil, err
}
return commands, nil
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
t := theme.CurrentTheme() t := theme.CurrentTheme()
commands := c.app.Commands commands := c.app.Commands
@ -226,7 +105,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
// Get custom commands // Get custom commands
customCommands, err := c.getCustomCommands() customCommands, err := c.getCustomCommands()
if err != nil { if err != nil {
// Log error but continue with built-in commands // If server is not available, return only built-in commands
customCommands = []CustomCommandFile{} customCommands = []CustomCommandFile{}
} }

View file

@ -1,14 +1,10 @@
package tui package tui
import ( import (
"bytes"
"context" "context"
"fmt"
"io/fs"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
@ -36,14 +32,6 @@ import (
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires // InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
type InterruptDebounceTimeoutMsg struct{} type InterruptDebounceTimeoutMsg struct{}
// CustomCommandFile represents a custom command file
type CustomCommandFile struct {
Name string `json:"name"`
Filename string `json:"filename"`
Content string `json:"content"`
Description string `json:"description"`
}
// InterruptKeyState tracks the state of interrupt key presses for debouncing // InterruptKeyState tracks the state of interrupt key presses for debouncing
type InterruptKeyState int type InterruptKeyState int
@ -1018,16 +1006,15 @@ func (a appModel) parseCustomCommand(input string) (commandName, arguments strin
arguments = parts[1] arguments = parts[1]
} }
// Check if this command exists as a custom command // Check if this command exists as a custom command via server
customCommands, err := a.getAvailableCustomCommands() ctx := context.Background()
exists, err := a.app.CommandsClient.CustomCommandExists(ctx, commandName)
if err != nil { if err != nil {
return "", "", false return "", "", false
} }
for _, cmd := range customCommands { if exists {
if cmd.Name == commandName { return commandName, arguments, true
return commandName, arguments, true
}
} }
return "", "", false return "", "", false
@ -1037,7 +1024,7 @@ func (a appModel) parseCustomCommand(input string) (commandName, arguments strin
func (a appModel) executeCustomCommandWithArgs(commandName, arguments string) (tea.Model, tea.Cmd) { func (a appModel) executeCustomCommandWithArgs(commandName, arguments string) (tea.Model, tea.Cmd) {
slog.Debug("Executing custom command with arguments", "command", commandName, "arguments", arguments) slog.Debug("Executing custom command with arguments", "command", commandName, "arguments", arguments)
// Try to execute command via server first // Execute command via server endpoint
ctx := context.Background() ctx := context.Background()
var args *string var args *string
if arguments != "" { if arguments != "" {
@ -1053,244 +1040,9 @@ func (a appModel) executeCustomCommandWithArgs(commandName, arguments string) (t
return a, cmd return a, cmd
} }
// Fallback to local execution if server is not available // Server execution failed
slog.Warn("Server execution failed, falling back to local execution", "command", commandName, "error", err) slog.Error("Failed to execute custom command via server", "command", commandName, "error", err)
return a, toast.NewErrorToast("Failed to execute custom command: " + commandName)
// Convert colon notation back to file path
filePath := strings.ReplaceAll(commandName, ":", string(filepath.Separator)) + ".md"
var commandFile string
var content []byte
var localErr error
// Try project-level commands first ($PWD/.opencode/commands)
if cwd, cwdErr := os.Getwd(); cwdErr == nil {
projectCommandsDir := filepath.Join(cwd, ".opencode", "commands")
projectCommandFile := filepath.Join(projectCommandsDir, filePath)
content, localErr = os.ReadFile(projectCommandFile)
if localErr == nil {
commandFile = projectCommandFile
}
}
// If not found in project, try global commands (~/.config/opencode/commands)
if localErr != nil {
globalCommandsDir := filepath.Join(a.app.Info.Path.Config, "commands")
globalCommandFile := filepath.Join(globalCommandsDir, filePath)
content, localErr = os.ReadFile(globalCommandFile)
if localErr == nil {
commandFile = globalCommandFile
}
}
if localErr != nil {
slog.Error("Failed to read custom command file", "command", commandName, "error", localErr)
return a, toast.NewErrorToast("Failed to read custom command: " + commandName)
}
slog.Debug("Executing custom command locally", "command", commandName, "file", commandFile, "arguments", arguments)
// Replace $ARGUMENTS placeholder with actual arguments
contentStr := string(content)
contentStr = strings.ReplaceAll(contentStr, "$ARGUMENTS", arguments)
// Process bash commands if any exist
processedContent, localErr := a.processBashCommands(contentStr)
if localErr != nil {
slog.Error("Failed to process bash commands", "error", localErr)
return a, toast.NewErrorToast("Failed to process bash commands: " + localErr.Error())
}
// Send the processed command content as a message to the LLM
a.app, cmd = a.app.SendChatMessage(context.Background(), processedContent, []opencode.FilePartParam{})
return a, cmd
}
// processBashCommands processes bash commands prefixed with ! in the content
func (a appModel) processBashCommands(content string) (string, error) {
lines := strings.Split(content, "\n")
var commands []string
var cleanLines []string
// Parse bash commands and clean content
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "!") {
command := strings.TrimSpace(trimmed[1:])
if command != "" {
commands = append(commands, command)
}
} else {
cleanLines = append(cleanLines, line)
}
}
// If no bash commands found, return original content
if len(commands) == 0 {
return content, nil
}
slog.Info("Processing bash commands", "count", len(commands))
// Execute bash commands and collect results
var contextSection strings.Builder
contextSection.WriteString("\n\n## Command Context\n\n")
contextSection.WriteString("The following bash commands were executed to gather context:\n\n")
for _, command := range commands {
result, err := a.executeBashCommand(command)
if err != nil {
slog.Error("Failed to execute bash command", "command", command, "error", err)
contextSection.WriteString(fmt.Sprintf("### Command: `%s`\n\n", command))
contextSection.WriteString(fmt.Sprintf("*Command failed: %s*\n\n", err.Error()))
continue
}
contextSection.WriteString(fmt.Sprintf("### Command: `%s`\n\n", command))
if result.ExitCode == 0 {
if strings.TrimSpace(result.Stdout) != "" {
contextSection.WriteString("```\n")
contextSection.WriteString(strings.TrimSpace(result.Stdout))
contextSection.WriteString("\n```\n\n")
} else {
contextSection.WriteString("*No output*\n\n")
}
} else {
contextSection.WriteString(fmt.Sprintf("*Command failed with exit code %d*\n\n", result.ExitCode))
if strings.TrimSpace(result.Stderr) != "" {
contextSection.WriteString("```\n")
contextSection.WriteString(strings.TrimSpace(result.Stderr))
contextSection.WriteString("\n```\n\n")
}
}
}
// Combine clean content with command context
cleanContent := strings.Join(cleanLines, "\n")
return cleanContent + contextSection.String(), nil
}
// BashCommandResult represents the result of executing a bash command
type BashCommandResult struct {
Command string
Stdout string
Stderr string
ExitCode int
}
// executeBashCommand executes a single bash command and returns the result
func (a appModel) executeBashCommand(command string) (*BashCommandResult, error) {
// List of banned commands for security
bannedCommands := []string{
"axel", "aria2c", "nc", "telnet", "lynx", "w3m", "links", "xh", "chrome", "firefox", "safari",
}
// Check if command is banned
for _, banned := range bannedCommands {
if strings.HasPrefix(command, banned) {
return nil, fmt.Errorf("command '%s' is not allowed", command)
}
}
// Execute the command
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", command)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
return nil, fmt.Errorf("failed to execute command: %w", err)
}
}
return &BashCommandResult{
Command: command,
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: exitCode,
}, nil
}
// getAvailableCustomCommands returns a list of available custom commands
func (a appModel) getAvailableCustomCommands() ([]CustomCommandFile, error) {
var commands []CustomCommandFile
// Get global commands from ~/.config/opencode/commands
globalCommandsDir := filepath.Join(a.app.Info.Path.Config, "commands")
globalCommands, err := a.scanCommandsDirectory(globalCommandsDir, "")
if err != nil {
globalCommands = []CustomCommandFile{}
}
commands = append(commands, globalCommands...)
// Get project-level commands from $PWD/.opencode/commands
cwd, err := os.Getwd()
if err == nil {
projectCommandsDir := filepath.Join(cwd, ".opencode", "commands")
projectCommands, err := a.scanCommandsDirectory(projectCommandsDir, "")
if err == nil {
commands = append(commands, projectCommands...)
}
}
return commands, nil
}
// scanCommandsDirectory recursively scans a directory for markdown command files
func (a appModel) scanCommandsDirectory(baseDir, relativePath string) ([]CustomCommandFile, error) {
var commands []CustomCommandFile
currentDir := filepath.Join(baseDir, relativePath)
err := filepath.WalkDir(currentDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if strings.Contains(err.Error(), "no such file or directory") {
return nil
}
return err
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
// Calculate relative path from base commands directory
relPath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
// Convert file path to command name with colon notation
name := strings.TrimSuffix(relPath, ".md")
name = strings.ReplaceAll(name, string(filepath.Separator), ":")
commands = append(commands, CustomCommandFile{
Name: name,
Filename: d.Name(),
Content: "",
Description: "",
})
return nil
})
if err != nil {
return nil, err
}
return commands, nil
} }
func NewModel(app *app.App) tea.Model { func NewModel(app *app.App) tea.Model {