mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
Add ability to define custom slash commands that execute shell commands directly: - Extend CommandConfig with Exec and WorkDir fields - Add cmdCommandsAddExec handler for exec commands - Implement executeShellCommand with timeout and output handling - Add work_dir support for exec commands - Update documentation and i18n messages - Add tests and error handling generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
273 lines
6.1 KiB
Go
273 lines
6.1 KiB
Go
package core
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestCommandRegistry_AddAndResolve(t *testing.T) {
|
|
r := NewCommandRegistry()
|
|
r.Add("greet", "Say hello", "Hello {{1}}", "", "", "config")
|
|
|
|
cmd, ok := r.Resolve("greet")
|
|
if !ok {
|
|
t.Fatal("expected to resolve 'greet'")
|
|
}
|
|
if cmd.Name != "greet" {
|
|
t.Errorf("Name = %q, want greet", cmd.Name)
|
|
}
|
|
if cmd.Prompt != "Hello {{1}}" {
|
|
t.Errorf("Prompt = %q", cmd.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_CaseInsensitive(t *testing.T) {
|
|
r := NewCommandRegistry()
|
|
r.Add("Hello", "test", "prompt", "", "", "config")
|
|
|
|
_, ok := r.Resolve("hello")
|
|
if !ok {
|
|
t.Error("resolve should be case-insensitive")
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_Remove(t *testing.T) {
|
|
r := NewCommandRegistry()
|
|
r.Add("tmp", "temp", "prompt", "", "", "config")
|
|
|
|
if !r.Remove("tmp") {
|
|
t.Error("Remove should return true")
|
|
}
|
|
if r.Remove("tmp") {
|
|
t.Error("second Remove should return false")
|
|
}
|
|
if _, ok := r.Resolve("tmp"); ok {
|
|
t.Error("should not resolve after remove")
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_ClearSource(t *testing.T) {
|
|
r := NewCommandRegistry()
|
|
r.Add("a", "", "", "", "", "config")
|
|
r.Add("b", "", "", "", "", "config")
|
|
r.Add("c", "", "", "", "", "agent")
|
|
|
|
r.ClearSource("config")
|
|
|
|
if _, ok := r.Resolve("a"); ok {
|
|
t.Error("'a' should be cleared")
|
|
}
|
|
if _, ok := r.Resolve("c"); !ok {
|
|
t.Error("'c' from agent source should remain")
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_AgentDirResolve(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "deploy.md"), []byte("Deploy to production"), 0644)
|
|
|
|
r := NewCommandRegistry()
|
|
r.SetAgentDirs([]string{dir})
|
|
|
|
cmd, ok := r.Resolve("deploy")
|
|
if !ok {
|
|
t.Fatal("expected to resolve 'deploy' from agent dir")
|
|
}
|
|
if cmd.Source != "agent" {
|
|
t.Errorf("Source = %q, want agent", cmd.Source)
|
|
}
|
|
if cmd.Prompt != "Deploy to production" {
|
|
t.Errorf("Prompt = %q", cmd.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_ConfigOverridesAgent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "deploy.md"), []byte("agent prompt"), 0644)
|
|
|
|
r := NewCommandRegistry()
|
|
r.SetAgentDirs([]string{dir})
|
|
r.Add("deploy", "config deploy", "config prompt", "", "", "config")
|
|
|
|
cmd, ok := r.Resolve("deploy")
|
|
if !ok {
|
|
t.Fatal("expected to resolve")
|
|
}
|
|
if cmd.Source != "config" {
|
|
t.Errorf("config command should take priority, got source=%q", cmd.Source)
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_PathTraversal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
parent := filepath.Dir(dir)
|
|
os.WriteFile(filepath.Join(parent, "secret.md"), []byte("secret"), 0644)
|
|
|
|
r := NewCommandRegistry()
|
|
r.SetAgentDirs([]string{dir})
|
|
|
|
_, ok := r.Resolve("../secret")
|
|
if ok {
|
|
t.Error("path traversal should be blocked")
|
|
}
|
|
}
|
|
|
|
func TestCommandRegistry_ListAll(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "build.md"), []byte("Build project"), 0644)
|
|
|
|
r := NewCommandRegistry()
|
|
r.Add("test", "Run tests", "go test ./...", "", "", "config")
|
|
r.SetAgentDirs([]string{dir})
|
|
|
|
all := r.ListAll()
|
|
if len(all) < 2 {
|
|
t.Errorf("expected at least 2 commands, got %d", len(all))
|
|
}
|
|
|
|
names := map[string]bool{}
|
|
for _, c := range all {
|
|
names[c.Name] = true
|
|
}
|
|
if !names["test"] {
|
|
t.Error("missing 'test' command")
|
|
}
|
|
if !names["build"] {
|
|
t.Error("missing 'build' command from agent dir")
|
|
}
|
|
}
|
|
|
|
func TestExpandPrompt_NoPlaceholders(t *testing.T) {
|
|
got := ExpandPrompt("Do the thing", []string{"arg1"})
|
|
if got != "Do the thing\n\narg1" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExpandPrompt_NoArgs(t *testing.T) {
|
|
got := ExpandPrompt("Just a template", nil)
|
|
if got != "Just a template" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExpandPrompt_Positional(t *testing.T) {
|
|
got := ExpandPrompt("Find user {{1}} in {{2}}", []string{"john", "prod"})
|
|
if got != "Find user john in prod" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExpandPrompt_PositionalDefault(t *testing.T) {
|
|
got := ExpandPrompt("Deploy to {{1:staging}}", nil)
|
|
if got != "Deploy to staging" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExpandPrompt_Star(t *testing.T) {
|
|
got := ExpandPrompt("Search: {{2*}}", []string{"prefix", "foo", "bar", "baz"})
|
|
if got != "Search: foo bar baz" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExpandPrompt_Args(t *testing.T) {
|
|
got := ExpandPrompt("Run with: {{args}}", []string{"a", "b", "c"})
|
|
if got != "Run with: a b c" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExpandPrompt_ArgsDefault(t *testing.T) {
|
|
got := ExpandPrompt("Run with: {{args:defaults}}", nil)
|
|
if got != "Run with: defaults" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchSubCommand(t *testing.T) {
|
|
candidates := []string{"list", "add", "del", "delete"}
|
|
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"list", "list"},
|
|
{"l", "list"},
|
|
{"a", "add"},
|
|
{"del", "del"},
|
|
{"delete", "delete"},
|
|
{"d", "d"}, // ambiguous: del, delete
|
|
{"xyz", "xyz"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := matchSubCommand(tt.input, candidates)
|
|
if got != tt.want {
|
|
t.Errorf("matchSubCommand(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchPrefix(t *testing.T) {
|
|
candidates := []struct {
|
|
names []string
|
|
id string
|
|
}{
|
|
{[]string{"help"}, "help"},
|
|
{[]string{"provider", "pro"}, "provider"},
|
|
{[]string{"list", "ls"}, "list"},
|
|
{[]string{"new"}, "new"},
|
|
}
|
|
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"help", "help"},
|
|
{"h", "help"},
|
|
{"provider", "provider"},
|
|
{"pro", "provider"},
|
|
{"p", "provider"},
|
|
{"list", "list"},
|
|
{"ls", "list"},
|
|
{"l", "list"},
|
|
{"new", "new"},
|
|
{"n", "new"},
|
|
{"xyz", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := matchPrefix(tt.input, candidates)
|
|
if got != tt.want {
|
|
t.Errorf("matchPrefix(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllowList(t *testing.T) {
|
|
tests := []struct {
|
|
allow string
|
|
user string
|
|
expect bool
|
|
}{
|
|
{"", "anyone", true},
|
|
{"*", "anyone", true},
|
|
{"user1", "user1", true},
|
|
{"user1", "USER1", true},
|
|
{"user1,user2", "user2", true},
|
|
{"user1, user2", "user2", true},
|
|
{"user1", "user3", false},
|
|
{" user1 , user2 ", "user2", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := AllowList(tt.allow, tt.user)
|
|
if got != tt.expect {
|
|
t.Errorf("AllowList(%q, %q) = %v, want %v", tt.allow, tt.user, got, tt.expect)
|
|
}
|
|
}
|
|
}
|