初始化仓库
This commit is contained in:
+244
@@ -0,0 +1,244 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user