From 77b1e63a88939310b091d9d7f724381f2b98a962 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Wed, 22 Jul 2026 13:44:49 -0700 Subject: [PATCH] feat: reserve built-in agent slash commands --- agent/skills.go | 25 +++++++++++++++++++++++++ agent/skills_test.go | 24 ++++++++++++++++++++++++ cmd/agent_tui.go | 3 +++ cmd/agent_tui_test.go | 33 +++++++++++++++++++++++++++++++++ cmd/tui/chat/input.go | 18 ++++++++++++++++++ 5 files changed, 103 insertions(+) diff --git a/agent/skills.go b/agent/skills.go index 1d863b03f..85cb6727c 100644 --- a/agent/skills.go +++ b/agent/skills.go @@ -325,6 +325,31 @@ func (c *SkillCatalog) Diagnostics() []error { return append([]error(nil), c.diagnostics...) } +// ExcludeNames removes skills whose names are reserved by a caller. It returns +// the excluded names in sorted order. +func (c *SkillCatalog) ExcludeNames(names []string) []string { + if c == nil { + return nil + } + reserved := make(map[string]struct{}, len(names)) + for _, name := range names { + name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/") + if name != "" { + reserved[name] = struct{}{} + } + } + var excluded []string + for name := range c.skills { + if _, ok := reserved[name]; !ok { + continue + } + delete(c.skills, name) + excluded = append(excluded, name) + } + sort.Strings(excluded) + return excluded +} + func (c *SkillCatalog) Load(name string) (Skill, error) { name = strings.TrimSpace(name) if !skillName.MatchString(name) { diff --git a/agent/skills_test.go b/agent/skills_test.go index 12059812f..79a82da0b 100644 --- a/agent/skills_test.go +++ b/agent/skills_test.go @@ -257,6 +257,30 @@ func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) { } } +func TestSkillCatalogExcludeNames(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"release-notes", "system", "exit"} { + writeCatalogSkill(t, dir, name, "instructions") + } + catalog, err := DiscoverSkills(dir) + if err != nil { + t.Fatal(err) + } + + if got, want := strings.Join(catalog.ExcludeNames([]string{"/system", "EXIT"}), ","), "exit,system"; got != want { + t.Fatalf("excluded skills = %q, want %q", got, want) + } + if _, err := catalog.Load("system"); err == nil { + t.Fatal("excluded system skill should not load") + } + if _, err := catalog.Load("exit"); err == nil { + t.Fatal("excluded exit skill should not load") + } + if _, err := catalog.Load("release-notes"); err != nil { + t.Fatalf("non-conflicting skill should remain available: %v", err) + } +} + func TestSkillContentListsDirectoryAndResources(t *testing.T) { root := t.TempDir() skillDir := filepath.Join(root, "pdf-processing") diff --git a/cmd/agent_tui.go b/cmd/agent_tui.go index 447ecbeb2..7eecc8b8c 100644 --- a/cmd/agent_tui.go +++ b/cmd/agent_tui.go @@ -87,6 +87,9 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio if err != nil { return fmt.Errorf("load agent skills: %w", err) } + if ignored := skillCatalog.ExcludeNames(agentchat.BuiltinSlashCommandNames()); len(ignored) > 0 { + fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignoring agent skill(s): %s\n", strings.Join(ignored, ", ")) + } for _, diagnostic := range skillCatalog.Diagnostics() { fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic) } diff --git a/cmd/agent_tui_test.go b/cmd/agent_tui_test.go index ad12fbd67..a8a683304 100644 --- a/cmd/agent_tui_test.go +++ b/cmd/agent_tui_test.go @@ -95,6 +95,39 @@ func TestAgentSkillSystemContextRequiresAvailableEnabledSkillTool(t *testing.T) } } +func TestAgentSkillCommandCollisionsAreIgnored(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"release-notes", "system", "exit"} { + if err := os.Mkdir(filepath.Join(dir, name), 0o755); err != nil { + t.Fatal(err) + } + content := "---\nname: " + name + "\ndescription: Test skill.\n---\nInstructions." + if err := os.WriteFile(filepath.Join(dir, name, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + catalog, err := coreagent.DiscoverSkills(dir) + if err != nil { + t.Fatal(err) + } + + ignored := catalog.ExcludeNames(agentchat.BuiltinSlashCommandNames()) + if got, want := strings.Join(ignored, ", "), "exit, system"; got != want { + t.Fatalf("ignored skills = %q, want %q", got, want) + } + if _, err := catalog.Load("release-notes"); err != nil { + t.Fatalf("non-conflicting skill should remain available: %v", err) + } + if context := catalog.SystemContext(); !strings.Contains(context, "release-notes: Test skill.") || strings.Contains(context, "system: Test skill.") || strings.Contains(context, "exit: Test skill.") { + t.Fatalf("skill context = %q", context) + } + for _, name := range []string{"system", "exit"} { + if _, err := catalog.Load(name); err == nil { + t.Fatalf("conflicting skill %q should be ignored", name) + } + } +} + func TestAgentSelectionItemsUseLaunchSections(t *testing.T) { items := agentSelectionItems([]agentchat.ModelOption{ {Name: "glm-5.2:cloud", Description: "cloud", Recommended: true, Cloud: true}, diff --git a/cmd/tui/chat/input.go b/cmd/tui/chat/input.go index 2efea4288..2ff01308a 100644 --- a/cmd/tui/chat/input.go +++ b/cmd/tui/chat/input.go @@ -64,6 +64,24 @@ var chatSlashCommands = []chatSlashCommand{ {name: "/save", usage: "/save ", description: "save request JSON; saved as .json"}, } +// BuiltinSlashCommandNames returns the names reserved by built-in slash +// commands, including aliases. +func BuiltinSlashCommandNames() []string { + names := make(map[string]struct{}) + for _, command := range chatSlashCommands { + names[strings.TrimPrefix(command.name, "/")] = struct{}{} + for _, alias := range command.aliases { + names[strings.TrimPrefix(alias, "/")] = struct{}{} + } + } + reserved := make([]string, 0, len(names)) + for name := range names { + reserved = append(reserved, name) + } + sort.Strings(reserved) + return reserved +} + func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) { m.syncInputPlaceholders() input := strings.TrimSpace(string(m.input))