first commit
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxContextFileSize = 32 * 1024 // 32KB
|
||||
|
||||
// RegisterBuiltins adds all built-in slash commands to the registry.
|
||||
func RegisterBuiltins(r *Registry) {
|
||||
r.Register(&Command{
|
||||
Name: "help",
|
||||
Aliases: []string{"h", "?"},
|
||||
Description: "Show help overlay with shortcuts and commands",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{Action: ActionShowHelp}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "clear",
|
||||
Description: "Clear conversation history",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{
|
||||
Text: "Conversation cleared.",
|
||||
Action: ActionClear,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "new",
|
||||
Description: "Start a fresh conversation",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{
|
||||
Text: "New conversation started.",
|
||||
Action: ActionClear,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "model",
|
||||
Aliases: []string{"m"},
|
||||
Description: "Show, switch, or list models",
|
||||
Usage: "/model [name|list|fast|smart]",
|
||||
Handler: func(ctx *Context, args []string) Result {
|
||||
if len(args) == 0 {
|
||||
return Result{Action: ActionShowModelPicker}
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list", "ls":
|
||||
var b strings.Builder
|
||||
b.WriteString("Available models:\n")
|
||||
for _, m := range ctx.ModelList {
|
||||
marker := " "
|
||||
if m == ctx.Model {
|
||||
marker = "* "
|
||||
}
|
||||
fmt.Fprintf(&b, " %s%s\n", marker, m)
|
||||
}
|
||||
b.WriteString("\n* = current")
|
||||
return Result{Text: b.String()}
|
||||
|
||||
case "fast":
|
||||
if len(ctx.ModelList) > 0 {
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Switching to fastest model: %s", ctx.ModelList[0]),
|
||||
Action: ActionSwitchModel,
|
||||
Data: ctx.ModelList[0],
|
||||
}
|
||||
}
|
||||
return Result{Error: "No models available"}
|
||||
|
||||
case "smart":
|
||||
if len(ctx.ModelList) > 0 {
|
||||
smartModel := ctx.ModelList[len(ctx.ModelList)-1]
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Switching to smartest model: %s", smartModel),
|
||||
Action: ActionSwitchModel,
|
||||
Data: smartModel,
|
||||
}
|
||||
}
|
||||
return Result{Error: "No models available"}
|
||||
|
||||
default:
|
||||
for _, m := range ctx.ModelList {
|
||||
if m == args[0] {
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Switching to model: %s", m),
|
||||
Action: ActionSwitchModel,
|
||||
Data: m,
|
||||
}
|
||||
}
|
||||
}
|
||||
return Result{Error: fmt.Sprintf("Unknown model: %s (use /model list to see available)", args[0])}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "models",
|
||||
Aliases: []string{"ml"},
|
||||
Description: "Open model picker",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{Action: ActionShowModelPicker}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "agent",
|
||||
Aliases: []string{"a"},
|
||||
Description: "Show or switch agent profile",
|
||||
Usage: "/agent [name|list]",
|
||||
Handler: func(ctx *Context, args []string) Result {
|
||||
if len(args) == 0 || args[0] == "list" {
|
||||
var b strings.Builder
|
||||
if len(ctx.AgentList) == 0 {
|
||||
b.WriteString("No agent profiles found in ~/.agents/agents/")
|
||||
return Result{Text: b.String()}
|
||||
}
|
||||
b.WriteString("Available agent profiles:\n")
|
||||
for _, a := range ctx.AgentList {
|
||||
marker := " "
|
||||
if a == ctx.AgentProfile {
|
||||
marker = "* "
|
||||
}
|
||||
fmt.Fprintf(&b, " %s%s\n", marker, a)
|
||||
}
|
||||
b.WriteString("\n* = current")
|
||||
return Result{Text: b.String()}
|
||||
}
|
||||
|
||||
for _, a := range ctx.AgentList {
|
||||
if a == args[0] {
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Switching to agent: %s", a),
|
||||
Action: ActionSwitchAgent,
|
||||
Data: a,
|
||||
}
|
||||
}
|
||||
}
|
||||
return Result{Error: fmt.Sprintf("Unknown agent: %s (use /agent list to see available)", args[0])}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "load",
|
||||
Aliases: []string{"l"},
|
||||
Description: "Load a markdown file as context",
|
||||
Usage: "/load <path>",
|
||||
Handler: func(_ *Context, args []string) Result {
|
||||
if len(args) == 0 {
|
||||
return Result{Error: "Usage: /load <path>"}
|
||||
}
|
||||
path := strings.Join(args, " ")
|
||||
|
||||
// Expand ~ to home directory.
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
path = home + path[1:]
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Result{Error: fmt.Sprintf("Cannot access %s: %v", path, err)}
|
||||
}
|
||||
if info.Size() > maxContextFileSize {
|
||||
return Result{Error: fmt.Sprintf("File too large (%d bytes, max %d)", info.Size(), maxContextFileSize)}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Result{Error: fmt.Sprintf("Cannot read %s: %v", path, err)}
|
||||
}
|
||||
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Loaded context: %s (%d bytes)", path, len(data)),
|
||||
Action: ActionLoadContext,
|
||||
Data: path + "\x00" + string(data), // path\0content
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "unload",
|
||||
Description: "Remove loaded context file",
|
||||
Handler: func(ctx *Context, _ []string) Result {
|
||||
if ctx.LoadedFile == "" {
|
||||
return Result{Text: "No context file loaded."}
|
||||
}
|
||||
return Result{
|
||||
Text: "Context unloaded.",
|
||||
Action: ActionUnloadContext,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "skill",
|
||||
Aliases: []string{"sk"},
|
||||
Description: "Manage skills (list, activate, deactivate)",
|
||||
Usage: "/skill [list|activate|deactivate] [name]",
|
||||
Handler: func(ctx *Context, args []string) Result {
|
||||
if len(args) == 0 || args[0] == "list" {
|
||||
return skillList(ctx)
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return Result{Error: "Usage: /skill [list|activate|deactivate] <name>"}
|
||||
}
|
||||
switch args[0] {
|
||||
case "activate", "on":
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Activated skill: %s", args[1]),
|
||||
Action: ActionActivateSkill,
|
||||
Data: args[1],
|
||||
}
|
||||
case "deactivate", "off":
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Deactivated skill: %s", args[1]),
|
||||
Action: ActionDeactivateSkill,
|
||||
Data: args[1],
|
||||
}
|
||||
default:
|
||||
return Result{Error: fmt.Sprintf("Unknown skill action: %s (use list, activate, or deactivate)", args[0])}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "servers",
|
||||
Description: "List connected MCP servers",
|
||||
Handler: func(ctx *Context, _ []string) Result {
|
||||
if len(ctx.ServerNames) == 0 {
|
||||
return Result{Text: "No MCP servers connected."}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Connected servers (%d):\n", len(ctx.ServerNames)))
|
||||
for _, name := range ctx.ServerNames {
|
||||
fmt.Fprintf(&b, " - %s\n", name)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nTotal tools: %d", ctx.ToolCount))
|
||||
return Result{Text: b.String()}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "ice",
|
||||
Description: "Show Infinite Context Engine status",
|
||||
Handler: func(ctx *Context, _ []string) Result {
|
||||
if !ctx.ICEEnabled {
|
||||
return Result{Text: "ICE is not enabled. Add `ice: {enabled: true}` to your config.yaml"}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Infinite Context Engine (ICE)\n")
|
||||
fmt.Fprintf(&b, " Status: enabled\n")
|
||||
fmt.Fprintf(&b, " Conversations: %d stored\n", ctx.ICEConversations)
|
||||
fmt.Fprintf(&b, " Session ID: %s\n", ctx.ICESessionID)
|
||||
fmt.Fprintf(&b, " Embed model: nomic-embed-text\n")
|
||||
return Result{Text: b.String()}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "sessions",
|
||||
Aliases: []string{"ss"},
|
||||
Description: "Browse and restore saved sessions",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{Action: ActionShowSessions}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "changes",
|
||||
Description: "List files modified by the agent this session",
|
||||
Handler: func(ctx *Context, _ []string) Result {
|
||||
if len(ctx.FileChanges) == 0 {
|
||||
return Result{Text: "No files modified this session."}
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Files modified (%d):\n", len(ctx.FileChanges))
|
||||
for path, count := range ctx.FileChanges {
|
||||
if count > 1 {
|
||||
fmt.Fprintf(&b, " ✎ %s (%dx)\n", path, count)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " ✎ %s\n", path)
|
||||
}
|
||||
}
|
||||
return Result{Text: b.String()}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "commit",
|
||||
Aliases: []string{"ci"},
|
||||
Description: "Generate commit message from staged changes and commit",
|
||||
Handler: func(_ *Context, args []string) Result {
|
||||
return Result{Action: ActionCommit, Data: strings.Join(args, " ")}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "stats",
|
||||
Description: "Show token usage statistics for this session",
|
||||
Handler: func(ctx *Context, _ []string) Result {
|
||||
if ctx.SessionTurnCount == 0 {
|
||||
return Result{Text: "No token usage recorded yet."}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Session Token Stats\n")
|
||||
fmt.Fprintf(&b, " Model: %s\n", ctx.CurrentModel)
|
||||
fmt.Fprintf(&b, " Turns: %d\n", ctx.SessionTurnCount)
|
||||
fmt.Fprintf(&b, " Output tokens: %d\n", ctx.SessionEvalTotal)
|
||||
fmt.Fprintf(&b, " Prompt tokens: %d (last turn)\n", ctx.SessionPromptTotal)
|
||||
if ctx.NumCtx > 0 {
|
||||
fmt.Fprintf(&b, " Context window: %d\n", ctx.NumCtx)
|
||||
pct := ctx.SessionPromptTotal * 100 / ctx.NumCtx
|
||||
fmt.Fprintf(&b, " Context used: %d%%\n", pct)
|
||||
}
|
||||
avgOut := ctx.SessionEvalTotal / ctx.SessionTurnCount
|
||||
fmt.Fprintf(&b, " Avg out/turn: %d\n", avgOut)
|
||||
return Result{Text: b.String()}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "export",
|
||||
Description: "Export conversation to a markdown file",
|
||||
Usage: "/export [path]",
|
||||
Handler: func(_ *Context, args []string) Result {
|
||||
if len(args) < 1 || args[0] == "" {
|
||||
return Result{Error: "usage: /export <filepath>"}
|
||||
}
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Exporting conversation to: %s", args[0]),
|
||||
Action: ActionExport,
|
||||
Data: args[0],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "import",
|
||||
Description: "Import conversation from a markdown file",
|
||||
Usage: "/import [path]",
|
||||
Handler: func(_ *Context, args []string) Result {
|
||||
if len(args) < 1 || args[0] == "" {
|
||||
return Result{Error: "usage: /import <filepath>"}
|
||||
}
|
||||
return Result{
|
||||
Text: fmt.Sprintf("Importing conversation from: %s", args[0]),
|
||||
Action: ActionImport,
|
||||
Data: args[0],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "exit",
|
||||
Aliases: []string{"quit", "q"},
|
||||
Description: "Quit ai-agent",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{Action: ActionQuit}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func skillList(ctx *Context) Result {
|
||||
if len(ctx.Skills) == 0 {
|
||||
return Result{Text: "No skills found. Add .md files to ~/.config/ai-agent/skills/"}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Skills (%d):\n", len(ctx.Skills)))
|
||||
for _, s := range ctx.Skills {
|
||||
status := " "
|
||||
if s.Active {
|
||||
status = "* "
|
||||
}
|
||||
fmt.Fprintf(&b, " %s%s — %s\n", status, s.Name, s.Description)
|
||||
}
|
||||
b.WriteString("\n* = active")
|
||||
return Result{Text: b.String()}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestRegistry() *Registry {
|
||||
r := NewRegistry()
|
||||
RegisterBuiltins(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestBuiltin_Help(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
result := r.Execute(&Context{}, "help", nil)
|
||||
if result.Action != ActionShowHelp {
|
||||
t.Errorf("help action = %d, want %d (ActionShowHelp)", result.Action, ActionShowHelp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltin_Clear(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
result := r.Execute(&Context{}, "clear", nil)
|
||||
if result.Action != ActionClear {
|
||||
t.Errorf("clear action = %d, want %d (ActionClear)", result.Action, ActionClear)
|
||||
}
|
||||
if result.Text == "" {
|
||||
t.Error("clear should have text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltin_New(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
result := r.Execute(&Context{}, "new", nil)
|
||||
if result.Action != ActionClear {
|
||||
t.Errorf("new action = %d, want %d (ActionClear)", result.Action, ActionClear)
|
||||
}
|
||||
if result.Text == "" {
|
||||
t.Error("new should have text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltin_Model(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
ctx := &Context{
|
||||
Model: "qwen3.5:0.8b",
|
||||
ModelList: []string{"qwen3.5:0.8b", "qwen3.5:2b", "qwen3.5:4b", "qwen3.5:9b"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantAction Action
|
||||
wantData string
|
||||
wantErr bool
|
||||
checkText string
|
||||
}{
|
||||
{
|
||||
name: "no args opens model picker",
|
||||
args: nil,
|
||||
wantAction: ActionShowModelPicker,
|
||||
},
|
||||
{
|
||||
name: "list shows models",
|
||||
args: []string{"list"},
|
||||
checkText: "Available models",
|
||||
},
|
||||
{
|
||||
name: "fast switches to first",
|
||||
args: []string{"fast"},
|
||||
wantAction: ActionSwitchModel,
|
||||
wantData: "qwen3.5:0.8b",
|
||||
},
|
||||
{
|
||||
name: "smart switches to last",
|
||||
args: []string{"smart"},
|
||||
wantAction: ActionSwitchModel,
|
||||
wantData: "qwen3.5:9b",
|
||||
},
|
||||
{
|
||||
name: "valid name switches",
|
||||
args: []string{"qwen3.5:2b"},
|
||||
wantAction: ActionSwitchModel,
|
||||
wantData: "qwen3.5:2b",
|
||||
},
|
||||
{
|
||||
name: "invalid name errors",
|
||||
args: []string{"nonexistent"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := r.Execute(ctx, "model", tt.args)
|
||||
if tt.wantErr {
|
||||
if result.Error == "" {
|
||||
t.Error("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("unexpected error: %s", result.Error)
|
||||
return
|
||||
}
|
||||
if tt.wantAction != ActionNone && result.Action != tt.wantAction {
|
||||
t.Errorf("action = %d, want %d", result.Action, tt.wantAction)
|
||||
}
|
||||
if tt.wantData != "" && result.Data != tt.wantData {
|
||||
t.Errorf("data = %q, want %q", result.Data, tt.wantData)
|
||||
}
|
||||
if tt.checkText != "" && !strings.Contains(result.Text, tt.checkText) {
|
||||
t.Errorf("text %q does not contain %q", result.Text, tt.checkText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltin_Models(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
ctx := &Context{
|
||||
Model: "qwen3.5:0.8b",
|
||||
ModelList: []string{"qwen3.5:0.8b", "qwen3.5:2b"},
|
||||
}
|
||||
result := r.Execute(ctx, "models", nil)
|
||||
if result.Action != ActionShowModelPicker {
|
||||
t.Errorf("expected ActionShowModelPicker, got %d", result.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltin_Agent(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
|
||||
t.Run("no args lists agents", func(t *testing.T) {
|
||||
ctx := &Context{AgentList: []string{"coder", "reviewer"}, AgentProfile: "coder"}
|
||||
result := r.Execute(ctx, "agent", nil)
|
||||
if !strings.Contains(result.Text, "Available agent profiles") {
|
||||
t.Errorf("expected agent list, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list subcommand", func(t *testing.T) {
|
||||
ctx := &Context{AgentList: []string{"coder"}}
|
||||
result := r.Execute(ctx, "agent", []string{"list"})
|
||||
if !strings.Contains(result.Text, "Available agent profiles") {
|
||||
t.Errorf("expected agent list, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid switch", func(t *testing.T) {
|
||||
ctx := &Context{AgentList: []string{"coder", "reviewer"}}
|
||||
result := r.Execute(ctx, "agent", []string{"reviewer"})
|
||||
if result.Action != ActionSwitchAgent {
|
||||
t.Errorf("action = %d, want %d", result.Action, ActionSwitchAgent)
|
||||
}
|
||||
if result.Data != "reviewer" {
|
||||
t.Errorf("data = %q, want %q", result.Data, "reviewer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid errors", func(t *testing.T) {
|
||||
ctx := &Context{AgentList: []string{"coder"}}
|
||||
result := r.Execute(ctx, "agent", []string{"unknown"})
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for unknown agent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no agents", func(t *testing.T) {
|
||||
ctx := &Context{AgentList: []string{}}
|
||||
result := r.Execute(ctx, "agent", nil)
|
||||
if !strings.Contains(result.Text, "No agent profiles") {
|
||||
t.Errorf("expected no agents message, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuiltin_Load(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
|
||||
t.Run("no args errors", func(t *testing.T) {
|
||||
result := r.Execute(&Context{}, "load", nil)
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for no args")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid file loads", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "test.md")
|
||||
if err := os.WriteFile(path, []byte("# Hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := r.Execute(&Context{}, "load", []string{path})
|
||||
if result.Error != "" {
|
||||
t.Errorf("unexpected error: %s", result.Error)
|
||||
}
|
||||
if result.Action != ActionLoadContext {
|
||||
t.Errorf("action = %d, want %d", result.Action, ActionLoadContext)
|
||||
}
|
||||
// Data should be path\0content
|
||||
parts := strings.SplitN(result.Data, "\x00", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected path\\0content, got %q", result.Data)
|
||||
}
|
||||
if parts[0] != path {
|
||||
t.Errorf("data path = %q, want %q", parts[0], path)
|
||||
}
|
||||
if parts[1] != "# Hello" {
|
||||
t.Errorf("data content = %q, want %q", parts[1], "# Hello")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too large errors", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "big.md")
|
||||
data := make([]byte, 33*1024) // > 32KB
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := r.Execute(&Context{}, "load", []string{path})
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for oversized file")
|
||||
}
|
||||
if !strings.Contains(result.Error, "too large") {
|
||||
t.Errorf("error = %q, want containing 'too large'", result.Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent errors", func(t *testing.T) {
|
||||
result := r.Execute(&Context{}, "load", []string{"/nonexistent/file.md"})
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuiltin_Unload(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
|
||||
t.Run("no loaded file", func(t *testing.T) {
|
||||
result := r.Execute(&Context{LoadedFile: ""}, "unload", nil)
|
||||
if !strings.Contains(result.Text, "No context") {
|
||||
t.Errorf("expected 'No context' message, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("loaded file unloads", func(t *testing.T) {
|
||||
result := r.Execute(&Context{LoadedFile: "something.md"}, "unload", nil)
|
||||
if result.Action != ActionUnloadContext {
|
||||
t.Errorf("action = %d, want %d", result.Action, ActionUnloadContext)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuiltin_Skill(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
ctx := &Context{
|
||||
Skills: []SkillInfo{
|
||||
{Name: "coder", Description: "Code generation", Active: true},
|
||||
{Name: "reviewer", Description: "Code review", Active: false},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("no args lists skills", func(t *testing.T) {
|
||||
result := r.Execute(ctx, "skill", nil)
|
||||
if !strings.Contains(result.Text, "Skills") {
|
||||
t.Errorf("expected skills list, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list subcommand", func(t *testing.T) {
|
||||
result := r.Execute(ctx, "skill", []string{"list"})
|
||||
if !strings.Contains(result.Text, "Skills") {
|
||||
t.Errorf("expected skills list, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("activate", func(t *testing.T) {
|
||||
result := r.Execute(ctx, "skill", []string{"activate", "reviewer"})
|
||||
if result.Action != ActionActivateSkill {
|
||||
t.Errorf("action = %d, want %d", result.Action, ActionActivateSkill)
|
||||
}
|
||||
if result.Data != "reviewer" {
|
||||
t.Errorf("data = %q, want %q", result.Data, "reviewer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deactivate", func(t *testing.T) {
|
||||
result := r.Execute(ctx, "skill", []string{"deactivate", "coder"})
|
||||
if result.Action != ActionDeactivateSkill {
|
||||
t.Errorf("action = %d, want %d", result.Action, ActionDeactivateSkill)
|
||||
}
|
||||
if result.Data != "coder" {
|
||||
t.Errorf("data = %q, want %q", result.Data, "coder")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown action errors", func(t *testing.T) {
|
||||
result := r.Execute(ctx, "skill", []string{"unknown", "foo"})
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for unknown skill action")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing name errors", func(t *testing.T) {
|
||||
result := r.Execute(ctx, "skill", []string{"activate"})
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for missing skill name")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuiltin_Servers(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
|
||||
t.Run("no servers", func(t *testing.T) {
|
||||
result := r.Execute(&Context{ServerNames: nil}, "servers", nil)
|
||||
if !strings.Contains(result.Text, "No MCP servers") {
|
||||
t.Errorf("expected no servers message, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with servers", func(t *testing.T) {
|
||||
ctx := &Context{
|
||||
ServerNames: []string{"server-a", "server-b"},
|
||||
ToolCount: 10,
|
||||
}
|
||||
result := r.Execute(ctx, "servers", nil)
|
||||
if !strings.Contains(result.Text, "server-a") {
|
||||
t.Errorf("expected server-a in output, got %q", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "server-b") {
|
||||
t.Errorf("expected server-b in output, got %q", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "10") {
|
||||
t.Errorf("expected tool count in output, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuiltin_ICE(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
result := r.Execute(&Context{ICEEnabled: false}, "ice", nil)
|
||||
if !strings.Contains(result.Text, "not enabled") {
|
||||
t.Errorf("expected disabled message, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled shows status", func(t *testing.T) {
|
||||
ctx := &Context{
|
||||
ICEEnabled: true,
|
||||
ICEConversations: 5,
|
||||
ICESessionID: "abc-123",
|
||||
}
|
||||
result := r.Execute(ctx, "ice", nil)
|
||||
if !strings.Contains(result.Text, "enabled") {
|
||||
t.Errorf("expected enabled status, got %q", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "5") {
|
||||
t.Errorf("expected conversation count, got %q", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "abc-123") {
|
||||
t.Errorf("expected session ID, got %q", result.Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuiltin_Exit(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
result := r.Execute(&Context{}, "exit", nil)
|
||||
if result.Action != ActionQuit {
|
||||
t.Errorf("exit action = %d, want %d (ActionQuit)", result.Action, ActionQuit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CustomCommand represents a user-defined command loaded from a markdown file.
|
||||
type CustomCommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Template string // prompt template with {{input}} placeholder
|
||||
}
|
||||
|
||||
// LoadCustomCommands reads .md files from the commands directory and returns
|
||||
// parsed custom commands. Each file should have YAML-like frontmatter:
|
||||
//
|
||||
// ---
|
||||
// name: review
|
||||
// description: Code review prompt
|
||||
// ---
|
||||
// Review this code: {{input}}
|
||||
func LoadCustomCommands(dir string) []CustomCommand {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cmds []CustomCommand
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cmd, ok := parseCustomCommand(string(data)); ok {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
// parseCustomCommand parses a markdown file with YAML frontmatter.
|
||||
func parseCustomCommand(content string) (CustomCommand, bool) {
|
||||
content = strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return CustomCommand{}, false
|
||||
}
|
||||
|
||||
// Find end of frontmatter.
|
||||
rest := content[3:]
|
||||
idx := strings.Index(rest, "---")
|
||||
if idx < 0 {
|
||||
return CustomCommand{}, false
|
||||
}
|
||||
|
||||
frontmatter := rest[:idx]
|
||||
body := strings.TrimSpace(rest[idx+3:])
|
||||
|
||||
cmd := CustomCommand{Template: body}
|
||||
|
||||
// Parse simple key: value pairs from frontmatter.
|
||||
for _, line := range strings.Split(frontmatter, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
switch key {
|
||||
case "name":
|
||||
cmd.Name = val
|
||||
case "description":
|
||||
cmd.Description = val
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Name == "" || cmd.Template == "" {
|
||||
return CustomCommand{}, false
|
||||
}
|
||||
|
||||
return cmd, true
|
||||
}
|
||||
|
||||
// RegisterCustomCommands loads and registers custom commands from the given directory.
|
||||
func RegisterCustomCommands(r *Registry, dir string) {
|
||||
cmds := LoadCustomCommands(dir)
|
||||
for _, cc := range cmds {
|
||||
// Capture for closure.
|
||||
tmpl := cc.Template
|
||||
desc := cc.Description
|
||||
if desc == "" {
|
||||
desc = "Custom command"
|
||||
}
|
||||
|
||||
r.Register(&Command{
|
||||
Name: cc.Name,
|
||||
Description: desc,
|
||||
Handler: func(_ *Context, args []string) Result {
|
||||
input := strings.Join(args, " ")
|
||||
prompt := strings.ReplaceAll(tmpl, "{{input}}", input)
|
||||
return Result{
|
||||
Action: ActionSendPrompt,
|
||||
Data: prompt,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCustomCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantOK bool
|
||||
wantCmd CustomCommand
|
||||
}{
|
||||
{
|
||||
name: "valid command",
|
||||
content: `---
|
||||
name: review
|
||||
description: Code review prompt
|
||||
---
|
||||
Review this code: {{input}}`,
|
||||
wantOK: true,
|
||||
wantCmd: CustomCommand{
|
||||
Name: "review",
|
||||
Description: "Code review prompt",
|
||||
Template: "Review this code: {{input}}",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no description",
|
||||
content: `---
|
||||
name: explain
|
||||
---
|
||||
Explain this: {{input}}`,
|
||||
wantOK: true,
|
||||
wantCmd: CustomCommand{
|
||||
Name: "explain",
|
||||
Template: "Explain this: {{input}}",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no frontmatter",
|
||||
content: "just some text",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "no name",
|
||||
content: `---
|
||||
description: something
|
||||
---
|
||||
body`,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "no body",
|
||||
content: `---
|
||||
name: empty
|
||||
---`,
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, ok := parseCustomCommand(tt.content)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("parseCustomCommand() ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if cmd.Name != tt.wantCmd.Name {
|
||||
t.Errorf("Name = %q, want %q", cmd.Name, tt.wantCmd.Name)
|
||||
}
|
||||
if cmd.Description != tt.wantCmd.Description {
|
||||
t.Errorf("Description = %q, want %q", cmd.Description, tt.wantCmd.Description)
|
||||
}
|
||||
if cmd.Template != tt.wantCmd.Template {
|
||||
t.Errorf("Template = %q, want %q", cmd.Template, tt.wantCmd.Template)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomCommands(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Write a valid command file.
|
||||
err := os.WriteFile(filepath.Join(dir, "review.md"), []byte(`---
|
||||
name: review
|
||||
description: Review code
|
||||
---
|
||||
Review: {{input}}`), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write an invalid file (no frontmatter).
|
||||
err = os.WriteFile(filepath.Join(dir, "invalid.md"), []byte("just text"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write a non-md file (should be ignored).
|
||||
err = os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a command"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmds := LoadCustomCommands(dir)
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("LoadCustomCommands() returned %d commands, want 1", len(cmds))
|
||||
}
|
||||
if cmds[0].Name != "review" {
|
||||
t.Errorf("Name = %q, want %q", cmds[0].Name, "review")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomCommands_MissingDir(t *testing.T) {
|
||||
cmds := LoadCustomCommands("/nonexistent/path")
|
||||
if len(cmds) != 0 {
|
||||
t.Errorf("expected empty result for missing dir, got %d", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCustomCommands(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "test.md"), []byte(`---
|
||||
name: testcmd
|
||||
description: A test command
|
||||
---
|
||||
Do this: {{input}}`), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reg := NewRegistry()
|
||||
RegisterCustomCommands(reg, dir)
|
||||
|
||||
result := reg.Execute(&Context{}, "testcmd", []string{"hello", "world"})
|
||||
if result.Action != ActionSendPrompt {
|
||||
t.Errorf("Action = %v, want ActionSendPrompt", result.Action)
|
||||
}
|
||||
if result.Data != "Do this: hello world" {
|
||||
t.Errorf("Data = %q, want %q", result.Data, "Do this: hello world")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Command represents a slash command.
|
||||
type Command struct {
|
||||
Name string
|
||||
Aliases []string
|
||||
Description string
|
||||
Usage string
|
||||
Handler func(ctx *Context, args []string) Result
|
||||
}
|
||||
|
||||
// Context provides commands with read access to application state.
|
||||
type Context struct {
|
||||
Model string
|
||||
ModelList []string
|
||||
AgentProfile string
|
||||
AgentList []string
|
||||
ToolCount int
|
||||
ServerCount int
|
||||
ServerNames []string
|
||||
Skills []SkillInfo
|
||||
LoadedFile string
|
||||
ICEEnabled bool
|
||||
ICEConversations int
|
||||
ICESessionID string
|
||||
// Token stats
|
||||
SessionEvalTotal int
|
||||
SessionPromptTotal int
|
||||
SessionTurnCount int
|
||||
NumCtx int
|
||||
CurrentModel string
|
||||
// File changes
|
||||
FileChanges map[string]int // path → modification count
|
||||
}
|
||||
|
||||
// SkillInfo is a read-only view of a skill for command display.
|
||||
type SkillInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// Result is returned by command handlers to describe what to do.
|
||||
type Result struct {
|
||||
Text string // Display text (shown as system message)
|
||||
Action Action // Side effect for the TUI to execute
|
||||
Data string // Optional payload (e.g. file path, model name)
|
||||
Error string // Error text (takes priority over Text)
|
||||
}
|
||||
|
||||
// Action describes a side effect the TUI should perform.
|
||||
type Action int
|
||||
|
||||
const (
|
||||
ActionNone Action = iota
|
||||
ActionShowHelp // Show help overlay
|
||||
ActionClear // Clear conversation history
|
||||
ActionQuit // Exit the application
|
||||
ActionLoadContext // Load markdown context (Data = path)
|
||||
ActionUnloadContext // Remove loaded context
|
||||
ActionActivateSkill // Activate skill (Data = name)
|
||||
ActionDeactivateSkill // Deactivate skill (Data = name)
|
||||
ActionSwitchModel // Switch model (Data = model name)
|
||||
ActionSwitchAgent // Switch agent profile (Data = agent name)
|
||||
ActionShowSessions // Open sessions picker
|
||||
ActionShowModelPicker // Open model picker overlay
|
||||
ActionCommit // Generate commit message and commit
|
||||
ActionSendPrompt // Send Data as a message to the agent
|
||||
ActionExport // Export conversation (Data = path)
|
||||
ActionImport // Import conversation (Data = path)
|
||||
)
|
||||
|
||||
// Registry holds all registered slash commands.
|
||||
type Registry struct {
|
||||
commands map[string]*Command // name/alias → command
|
||||
all []*Command // ordered list
|
||||
}
|
||||
|
||||
// NewRegistry creates an empty command registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
commands: make(map[string]*Command),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a command to the registry.
|
||||
func (r *Registry) Register(cmd *Command) {
|
||||
r.all = append(r.all, cmd)
|
||||
r.commands[cmd.Name] = cmd
|
||||
for _, alias := range cmd.Aliases {
|
||||
r.commands[alias] = cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Execute dispatches a slash command by name and returns the result.
|
||||
func (r *Registry) Execute(ctx *Context, name string, args []string) Result {
|
||||
cmd, ok := r.commands[name]
|
||||
if !ok {
|
||||
return Result{Error: fmt.Sprintf("unknown command: /%s — type /help for available commands", name)}
|
||||
}
|
||||
return cmd.Handler(ctx, args)
|
||||
}
|
||||
|
||||
// All returns all registered commands in registration order.
|
||||
func (r *Registry) All() []*Command {
|
||||
return r.all
|
||||
}
|
||||
|
||||
// Match returns commands whose name starts with the given prefix.
|
||||
func (r *Registry) Match(prefix string) []*Command {
|
||||
var matches []*Command
|
||||
seen := make(map[string]bool)
|
||||
for _, cmd := range r.all {
|
||||
if strings.HasPrefix(cmd.Name, prefix) && !seen[cmd.Name] {
|
||||
matches = append(matches, cmd)
|
||||
seen[cmd.Name] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i].Name < matches[j].Name
|
||||
})
|
||||
return matches
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package command
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRegistry_Register(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
cmd := &Command{
|
||||
Name: "test",
|
||||
Description: "A test command",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{Text: "ok"}
|
||||
},
|
||||
}
|
||||
r.Register(cmd)
|
||||
|
||||
all := r.All()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("expected 1 command, got %d", len(all))
|
||||
}
|
||||
if all[0].Name != "test" {
|
||||
t.Errorf("command name = %q, want %q", all[0].Name, "test")
|
||||
}
|
||||
|
||||
// Execute to verify it was registered correctly
|
||||
result := r.Execute(&Context{}, "test", nil)
|
||||
if result.Text != "ok" {
|
||||
t.Errorf("result text = %q, want %q", result.Text, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Execute(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
called := false
|
||||
r.Register(&Command{
|
||||
Name: "run",
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
called = true
|
||||
return Result{Text: "executed"}
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("found command executes handler", func(t *testing.T) {
|
||||
result := r.Execute(&Context{}, "run", nil)
|
||||
if !called {
|
||||
t.Error("handler was not called")
|
||||
}
|
||||
if result.Text != "executed" {
|
||||
t.Errorf("result text = %q, want %q", result.Text, "executed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found returns error", func(t *testing.T) {
|
||||
result := r.Execute(&Context{}, "nonexistent", nil)
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for unknown command")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistry_ExecuteByAlias(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(&Command{
|
||||
Name: "mycommand",
|
||||
Aliases: []string{"mc", "m"},
|
||||
Handler: func(_ *Context, _ []string) Result {
|
||||
return Result{Text: "alias works"}
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cmdName string
|
||||
wantOk bool
|
||||
}{
|
||||
{name: "by name", cmdName: "mycommand", wantOk: true},
|
||||
{name: "by alias mc", cmdName: "mc", wantOk: true},
|
||||
{name: "by alias m", cmdName: "m", wantOk: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := r.Execute(&Context{}, tt.cmdName, nil)
|
||||
if tt.wantOk && result.Error != "" {
|
||||
t.Errorf("unexpected error: %s", result.Error)
|
||||
}
|
||||
if tt.wantOk && result.Text != "alias works" {
|
||||
t.Errorf("result text = %q, want %q", result.Text, "alias works")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_All(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
names := []string{"alpha", "beta", "gamma"}
|
||||
for _, name := range names {
|
||||
n := name // capture
|
||||
r.Register(&Command{
|
||||
Name: n,
|
||||
Handler: func(_ *Context, _ []string) Result { return Result{} },
|
||||
})
|
||||
}
|
||||
|
||||
all := r.All()
|
||||
if len(all) != len(names) {
|
||||
t.Fatalf("expected %d commands, got %d", len(names), len(all))
|
||||
}
|
||||
for i, cmd := range all {
|
||||
if cmd.Name != names[i] {
|
||||
t.Errorf("All()[%d].Name = %q, want %q", i, cmd.Name, names[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Match(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(&Command{
|
||||
Name: "model",
|
||||
Aliases: []string{"m"},
|
||||
Handler: func(_ *Context, _ []string) Result { return Result{} },
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "models",
|
||||
Aliases: []string{"ml"},
|
||||
Handler: func(_ *Context, _ []string) Result { return Result{} },
|
||||
})
|
||||
r.Register(&Command{
|
||||
Name: "help",
|
||||
Handler: func(_ *Context, _ []string) Result { return Result{} },
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
want int
|
||||
}{
|
||||
{name: "prefix mo matches model and models", prefix: "mo", want: 2},
|
||||
{name: "prefix model matches model and models", prefix: "model", want: 2},
|
||||
{name: "prefix models matches only models", prefix: "models", want: 1},
|
||||
{name: "prefix h matches help", prefix: "h", want: 1},
|
||||
{name: "no match", prefix: "z", want: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches := r.Match(tt.prefix)
|
||||
if len(matches) != tt.want {
|
||||
t.Errorf("Match(%q) returned %d results, want %d", tt.prefix, len(matches), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verify no duplicates from aliases
|
||||
t.Run("aliases dont create dupes", func(t *testing.T) {
|
||||
matches := r.Match("model")
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range matches {
|
||||
if seen[m.Name] {
|
||||
t.Errorf("duplicate match for %q", m.Name)
|
||||
}
|
||||
seen[m.Name] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user