mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
The changes add error handling for previously ignored operations and introduce logging for critical errors and warnings. This includes: - Adding error checks for file operations, JSON unmarshaling, system calls - Logging warnings/errors for failed operations using slog - Properly handling return values from functions that were previously ignored - Adding context to error messages for better debugging - Cleaning up unused code and variables generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
245 lines
6.4 KiB
Go
245 lines
6.4 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// CustomCommand represents a registered slash command (from config or agent command files).
|
|
type CustomCommand struct {
|
|
Name string // command name without leading "/"
|
|
Description string
|
|
Prompt string // template with {{1}}, {{2}}, {{2*}}, {{args}} placeholders
|
|
Exec string // shell command to execute (mutually exclusive with Prompt)
|
|
WorkDir string // optional: working directory for exec command
|
|
Source string // "config" or "agent" (for display)
|
|
}
|
|
|
|
// CommandRegistry holds all available custom commands and resolves agent command files.
|
|
type CommandRegistry struct {
|
|
mu sync.RWMutex
|
|
commands map[string]*CustomCommand // from config.toml or runtime add
|
|
agentDirs []string // directories to scan for *.md command files
|
|
}
|
|
|
|
func NewCommandRegistry() *CommandRegistry {
|
|
return &CommandRegistry{
|
|
commands: make(map[string]*CustomCommand),
|
|
}
|
|
}
|
|
|
|
// Add registers a custom command.
|
|
func (r *CommandRegistry) Add(name, description, prompt, exec, workDir, source string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.commands[strings.ToLower(name)] = &CustomCommand{
|
|
Name: name,
|
|
Description: description,
|
|
Prompt: prompt,
|
|
Exec: exec,
|
|
WorkDir: workDir,
|
|
Source: source,
|
|
}
|
|
}
|
|
|
|
// ClearSource removes all commands from a given source (e.g. "config").
|
|
func (r *CommandRegistry) ClearSource(source string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for k, c := range r.commands {
|
|
if c.Source == source {
|
|
delete(r.commands, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove deletes a config-defined custom command by name. Returns false if not found.
|
|
func (r *CommandRegistry) Remove(name string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
lower := strings.ToLower(name)
|
|
if _, ok := r.commands[lower]; ok {
|
|
delete(r.commands, lower)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetAgentDirs sets the directories to scan for agent command files.
|
|
func (r *CommandRegistry) SetAgentDirs(dirs []string) {
|
|
r.agentDirs = dirs
|
|
}
|
|
|
|
// Resolve looks up a command by name. Config commands take priority, then
|
|
// agent command directories are scanned for a matching .md file.
|
|
// Hyphens and underscores are treated as equivalent so that Telegram-sanitized
|
|
// names (e.g. "my_cmd") match original command names ("my-cmd").
|
|
func (r *CommandRegistry) Resolve(name string) (*CustomCommand, bool) {
|
|
lower := strings.ToLower(name)
|
|
norm := normalizeCommandName(name)
|
|
|
|
r.mu.RLock()
|
|
// Exact match first
|
|
if c, ok := r.commands[lower]; ok {
|
|
r.mu.RUnlock()
|
|
return c, true
|
|
}
|
|
// Normalized match (hyphen ↔ underscore)
|
|
for key, c := range r.commands {
|
|
if normalizeCommandName(key) == norm {
|
|
r.mu.RUnlock()
|
|
return c, true
|
|
}
|
|
}
|
|
r.mu.RUnlock()
|
|
|
|
// Scan agent command directories; try both original name and hyphenated variant
|
|
candidates := []string{name}
|
|
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
|
candidates = append(candidates, alt)
|
|
}
|
|
for _, dir := range r.agentDirs {
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, candidate := range candidates {
|
|
mdPath := filepath.Join(dir, candidate+".md")
|
|
absPath, err := filepath.Abs(mdPath)
|
|
if err != nil || !strings.HasPrefix(absPath, absDir+string(filepath.Separator)) {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(mdPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
content := strings.TrimSpace(string(data))
|
|
if content == "" {
|
|
continue
|
|
}
|
|
slog.Debug("command: loaded agent command file", "path", mdPath)
|
|
return &CustomCommand{
|
|
Name: candidate,
|
|
Prompt: content,
|
|
Source: "agent",
|
|
}, true
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// ListAll returns all registered commands (config + agent command files).
|
|
func (r *CommandRegistry) ListAll() []*CustomCommand {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
seen := make(map[string]bool)
|
|
var result []*CustomCommand
|
|
|
|
for _, c := range r.commands {
|
|
result = append(result, c)
|
|
seen[strings.ToLower(c.Name)] = true
|
|
}
|
|
|
|
for _, dir := range r.agentDirs {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(entry.Name(), ".md")
|
|
if seen[strings.ToLower(name)] {
|
|
continue
|
|
}
|
|
seen[strings.ToLower(name)] = true
|
|
|
|
desc := ""
|
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
|
if err == nil {
|
|
first, _, _ := strings.Cut(strings.TrimSpace(string(data)), "\n")
|
|
if len([]rune(first)) > 60 {
|
|
first = string([]rune(first)[:60]) + "..."
|
|
}
|
|
desc = first
|
|
}
|
|
|
|
result = append(result, &CustomCommand{
|
|
Name: name,
|
|
Description: desc,
|
|
Source: "agent",
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// placeholderRe matches {{1}}, {{2*}}, {{args}}, and variants with defaults like {{1:foo}}.
|
|
var placeholderRe = regexp.MustCompile(`\{\{(\d+\*?|args)(:[^}]*)?\}\}`)
|
|
|
|
// ExpandPrompt replaces template placeholders with the provided arguments.
|
|
//
|
|
// Supported placeholders:
|
|
// - {{1}}, {{2}}, ... — positional argument (1-based)
|
|
// - {{1:default}} — positional with default value if arg not provided
|
|
// - {{2*}} — argument N and everything after it
|
|
// - {{2*:default}} — same, with default
|
|
// - {{args}} — all arguments joined by space
|
|
// - {{args:default}} — all arguments, with default if none provided
|
|
//
|
|
// If the template has no placeholders, arguments are appended to the end.
|
|
func ExpandPrompt(template string, args []string) string {
|
|
if !placeholderRe.MatchString(template) {
|
|
if len(args) > 0 {
|
|
return template + "\n\n" + strings.Join(args, " ")
|
|
}
|
|
return template
|
|
}
|
|
|
|
result := placeholderRe.ReplaceAllStringFunc(template, func(match string) string {
|
|
inner := match[2 : len(match)-2] // strip {{ and }}
|
|
key, defaultVal, hasDefault := strings.Cut(inner, ":")
|
|
|
|
if key == "args" {
|
|
if len(args) > 0 {
|
|
return strings.Join(args, " ")
|
|
}
|
|
if hasDefault {
|
|
return defaultVal
|
|
}
|
|
return ""
|
|
}
|
|
if strings.HasSuffix(key, "*") {
|
|
idx := 0
|
|
_, _ = fmt.Sscanf(key, "%d", &idx)
|
|
if idx >= 1 && idx-1 < len(args) {
|
|
return strings.Join(args[idx-1:], " ")
|
|
}
|
|
if hasDefault {
|
|
return defaultVal
|
|
}
|
|
return ""
|
|
}
|
|
idx := 0
|
|
_, _ = fmt.Sscanf(key, "%d", &idx)
|
|
if idx >= 1 && idx-1 < len(args) {
|
|
return args[idx-1]
|
|
}
|
|
if hasDefault {
|
|
return defaultVal
|
|
}
|
|
return ""
|
|
})
|
|
|
|
return result
|
|
}
|