Skip to content

Commit 9edccb0

Browse files
jmelahmanclaude
andcommitted
feat(cli): expose local skills as slash commands in the chat TUI
Discover SKILL.md files under .agents/skills/ (project and home) and register each as a /<name> slash command. Invoking one sends the skill instructions plus the user's request to the agent as one message. Adds /skills to list discovered skills; built-in commands win on collision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4a8ed76 commit 9edccb0

8 files changed

Lines changed: 443 additions & 5 deletions

File tree

cli/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,34 @@ onyx-cli install-skill --agent claude-code
183183
| `/agent` | List and switch agents |
184184
| `/attach <path>` | Attach a file to next message |
185185
| `/sessions` | List recent chat sessions |
186+
| `/skills` | List available skills |
186187
| `/configure` | Re-run connection setup |
187188
| `/connectors` | Open connectors in browser |
188189
| `/settings` | Open settings in browser |
189190
| `/quit` | Exit Onyx CLI |
191+
| `/<skill> [request]` | Run a local skill (see below) |
192+
193+
### Skills as slash commands
194+
195+
The TUI discovers local agent skills and exposes each one as a slash command.
196+
A skill is a `SKILL.md` file at `.agents/skills/<name>/SKILL.md`, in the
197+
current directory or in your home directory. Skills in the current directory
198+
win on name collisions; built-in commands always win over skills.
199+
200+
`SKILL.md` starts with optional YAML frontmatter:
201+
202+
```markdown
203+
---
204+
name: release-notes
205+
description: Draft release notes from recent changes.
206+
---
207+
208+
Instructions for the agent...
209+
```
210+
211+
Type `/release-notes summarize this week` to run it. The CLI sends the skill
212+
instructions and your request to the agent as one message. Use `/skills` to
213+
list discovered skills.
190214

191215
## Keyboard Shortcuts
192216

cli/internal/skills/skills.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Package skills discovers local agent skills (SKILL.md files) so the chat
2+
// TUI can expose them as slash commands.
3+
package skills
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"sort"
10+
"strings"
11+
)
12+
13+
// Skill is a parsed SKILL.md file.
14+
type Skill struct {
15+
// Name is the skill's frontmatter name (falls back to the directory name).
16+
Name string
17+
// Description is the frontmatter description (may be empty).
18+
Description string
19+
// Body is the markdown content after the frontmatter.
20+
Body string
21+
// Path is the absolute path of the SKILL.md file.
22+
Path string
23+
}
24+
25+
// Command returns the slash command that invokes the skill.
26+
func (s Skill) Command() string {
27+
return "/" + s.Name
28+
}
29+
30+
// skillsSubdir is the canonical skills directory relative to a base directory.
31+
var skillsSubdir = filepath.Join(".agents", "skills")
32+
33+
// validName matches skill names that are safe to use as slash commands.
34+
var validName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
35+
36+
// Discover loads skills from the project (working directory) and the user's
37+
// home directory. Project skills shadow home skills with the same name.
38+
// Results are sorted by name. Discovery errors are silently skipped — a
39+
// malformed skill must not break the chat TUI.
40+
func Discover() []Skill {
41+
var bases []string
42+
if cwd, err := os.Getwd(); err == nil {
43+
bases = append(bases, cwd)
44+
}
45+
if home, err := os.UserHomeDir(); err == nil {
46+
bases = append(bases, home)
47+
}
48+
return discoverFrom(bases)
49+
}
50+
51+
// discoverFrom loads skills from each base directory in order. Earlier bases
52+
// shadow later ones on name collisions.
53+
func discoverFrom(bases []string) []Skill {
54+
seen := make(map[string]bool)
55+
var result []Skill
56+
57+
for _, base := range bases {
58+
dir := filepath.Join(base, skillsSubdir)
59+
entries, err := os.ReadDir(dir)
60+
if err != nil {
61+
continue
62+
}
63+
for _, entry := range entries {
64+
// Allow symlinked skill directories (install-skill creates them).
65+
info, err := os.Stat(filepath.Join(dir, entry.Name()))
66+
if err != nil || !info.IsDir() {
67+
continue
68+
}
69+
path := filepath.Join(dir, entry.Name(), "SKILL.md")
70+
raw, err := os.ReadFile(path)
71+
if err != nil {
72+
continue
73+
}
74+
skill := Parse(string(raw), entry.Name())
75+
skill.Path = path
76+
if !validName.MatchString(skill.Name) || seen[skill.Name] {
77+
continue
78+
}
79+
seen[skill.Name] = true
80+
result = append(result, skill)
81+
}
82+
}
83+
84+
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
85+
return result
86+
}
87+
88+
// Parse extracts the frontmatter name/description and the body from SKILL.md
89+
// content. fallbackName is used when the frontmatter has no name.
90+
func Parse(content string, fallbackName string) Skill {
91+
skill := Skill{Name: fallbackName, Body: strings.TrimSpace(content)}
92+
93+
normalized := strings.ReplaceAll(content, "\r\n", "\n")
94+
if !strings.HasPrefix(normalized, "---\n") {
95+
return skill
96+
}
97+
rest := normalized[len("---\n"):]
98+
end := strings.Index(rest, "\n---")
99+
if end < 0 {
100+
return skill
101+
}
102+
frontmatter := rest[:end]
103+
body := rest[end+len("\n---"):]
104+
if i := strings.Index(body, "\n"); i >= 0 {
105+
body = body[i+1:]
106+
} else {
107+
body = ""
108+
}
109+
skill.Body = strings.TrimSpace(body)
110+
111+
for _, line := range strings.Split(frontmatter, "\n") {
112+
key, value, ok := strings.Cut(line, ":")
113+
if !ok {
114+
continue
115+
}
116+
value = strings.TrimSpace(value)
117+
value = strings.Trim(value, `"'`)
118+
switch strings.TrimSpace(key) {
119+
case "name":
120+
if value != "" {
121+
skill.Name = value
122+
}
123+
case "description":
124+
skill.Description = value
125+
}
126+
}
127+
return skill
128+
}

cli/internal/skills/skills_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package skills
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func writeSkill(t *testing.T, base, name, content string) {
10+
t.Helper()
11+
dir := filepath.Join(base, ".agents", "skills", name)
12+
if err := os.MkdirAll(dir, 0o755); err != nil {
13+
t.Fatal(err)
14+
}
15+
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
16+
t.Fatal(err)
17+
}
18+
}
19+
20+
func TestParseFrontmatter(t *testing.T) {
21+
content := "---\nname: my-skill\ndescription: Does a thing.\n---\n\n# Heading\n\nBody text.\n"
22+
skill := Parse(content, "dir-name")
23+
24+
if skill.Name != "my-skill" {
25+
t.Errorf("Name = %q, want %q", skill.Name, "my-skill")
26+
}
27+
if skill.Description != "Does a thing." {
28+
t.Errorf("Description = %q, want %q", skill.Description, "Does a thing.")
29+
}
30+
if skill.Body != "# Heading\n\nBody text." {
31+
t.Errorf("Body = %q", skill.Body)
32+
}
33+
if skill.Command() != "/my-skill" {
34+
t.Errorf("Command() = %q", skill.Command())
35+
}
36+
}
37+
38+
func TestParseNoFrontmatter(t *testing.T) {
39+
skill := Parse("# Just markdown\n", "fallback")
40+
if skill.Name != "fallback" {
41+
t.Errorf("Name = %q, want %q", skill.Name, "fallback")
42+
}
43+
if skill.Body != "# Just markdown" {
44+
t.Errorf("Body = %q", skill.Body)
45+
}
46+
}
47+
48+
func TestParseQuotedValues(t *testing.T) {
49+
content := "---\nname: \"quoted\"\ndescription: 'single'\n---\nbody\n"
50+
skill := Parse(content, "x")
51+
if skill.Name != "quoted" || skill.Description != "single" {
52+
t.Errorf("got name=%q description=%q", skill.Name, skill.Description)
53+
}
54+
}
55+
56+
func TestParseUnterminatedFrontmatter(t *testing.T) {
57+
content := "---\nname: broken\nno end marker\n"
58+
skill := Parse(content, "fallback")
59+
if skill.Name != "fallback" {
60+
t.Errorf("Name = %q, want fallback", skill.Name)
61+
}
62+
}
63+
64+
func TestDiscoverFrom(t *testing.T) {
65+
project := t.TempDir()
66+
home := t.TempDir()
67+
68+
writeSkill(t, project, "alpha", "---\nname: alpha\ndescription: Project alpha.\n---\nA\n")
69+
writeSkill(t, project, "shared", "---\nname: shared\ndescription: Project copy.\n---\nP\n")
70+
writeSkill(t, home, "shared", "---\nname: shared\ndescription: Home copy.\n---\nH\n")
71+
writeSkill(t, home, "beta", "---\nname: beta\n---\nB\n")
72+
// Invalid name — must be skipped.
73+
writeSkill(t, home, "bad", "---\nname: Bad Name!\n---\nX\n")
74+
75+
found := discoverFrom([]string{project, home})
76+
77+
if len(found) != 3 {
78+
t.Fatalf("got %d skills, want 3: %+v", len(found), found)
79+
}
80+
if found[0].Name != "alpha" || found[1].Name != "beta" || found[2].Name != "shared" {
81+
t.Errorf("unexpected order: %+v", found)
82+
}
83+
if found[2].Description != "Project copy." {
84+
t.Errorf("project skill should shadow home skill, got %q", found[2].Description)
85+
}
86+
}
87+
88+
func TestDiscoverFromMissingDir(t *testing.T) {
89+
if found := discoverFrom([]string{t.TempDir()}); len(found) != 0 {
90+
t.Errorf("expected no skills, got %+v", found)
91+
}
92+
}

cli/internal/tui/app.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/onyx-dot-app/onyx/cli/internal/api"
1616
"github.com/onyx-dot-app/onyx/cli/internal/config"
1717
"github.com/onyx-dot-app/onyx/cli/internal/models"
18+
"github.com/onyx-dot-app/onyx/cli/internal/skills"
1819
)
1920

2021
// Model is the root Bubble Tea model.
@@ -44,6 +45,7 @@ type Model struct {
4445
attachedFiles []models.FileDescriptorPayload
4546
needsRename bool
4647
agentStarted bool
48+
skills []skills.Skill
4749

4850
// Configure state
4951
configState *configState
@@ -58,16 +60,37 @@ type Model struct {
5860
func NewModel(cfg config.OnyxCliConfig, client api.ClientAPI) Model {
5961
parentID := -1
6062

63+
localSkills := skills.Discover()
64+
filtered := localSkills[:0]
65+
for _, s := range localSkills {
66+
if !isBuiltinCommand(s.Command()) {
67+
filtered = append(filtered, s)
68+
}
69+
}
70+
localSkills = filtered
71+
72+
input := newInputModel()
73+
skillCmds := make([]slashCommand, len(localSkills))
74+
for i, s := range localSkills {
75+
desc := s.Description
76+
if desc == "" {
77+
desc = "Run the " + s.Name + " skill"
78+
}
79+
skillCmds[i] = slashCommand{command: s.Command(), description: desc}
80+
}
81+
input.setSkillCommands(skillCmds)
82+
6183
return Model{
6284
config: cfg,
6385
client: client,
6486
viewport: newViewport(80, cfg.Features.StreamMarkdownEnabled()),
65-
input: newInputModel(),
87+
input: input,
6688
status: newStatusBar(),
6789
agentID: cfg.DefaultAgentID,
6890
agentName: "Default",
6991
parentMessageID: &parentID,
7092
citations: make(map[int]string),
93+
skills: localSkills,
7194
}
7295
}
7396

@@ -366,11 +389,17 @@ func (m Model) cancelStream() (Model, tea.Cmd) {
366389
}
367390

368391
func (m Model) sendMessage(message string) (Model, tea.Cmd) {
392+
return m.sendMessageWithDisplay(message, message)
393+
}
394+
395+
// sendMessageWithDisplay sends message to the agent but shows display in the
396+
// chat view. Used by skill commands, whose full prompt is too long to show.
397+
func (m Model) sendMessageWithDisplay(message string, display string) (Model, tea.Cmd) {
369398
if m.isStreaming {
370399
return m, nil
371400
}
372401

373-
m.viewport.addUserMessage(message)
402+
m.viewport.addUserMessage(display)
374403
m.viewport.startAgent()
375404

376405
// Prepare file descriptors

cli/internal/tui/commands.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/onyx-dot-app/onyx/cli/internal/browser"
1212
"github.com/onyx-dot-app/onyx/cli/internal/config"
1313
"github.com/onyx-dot-app/onyx/cli/internal/models"
14+
"github.com/onyx-dot-app/onyx/cli/internal/skills"
1415
)
1516

1617
// handleSlashCommand dispatches slash commands and returns updated model + cmd.
@@ -24,7 +25,11 @@ func handleSlashCommand(m Model, text string) (Model, tea.Cmd) {
2425

2526
switch command {
2627
case "/help":
27-
m.viewport.addInfo(helpText)
28+
m.viewport.addInfo(renderHelp(m.skills))
29+
return m, nil
30+
31+
case "/skills":
32+
m.viewport.addInfo(renderSkillList(m.skills))
2833
return m, nil
2934

3035
case "/agent":
@@ -74,11 +79,47 @@ func handleSlashCommand(m Model, text string) (Model, tea.Cmd) {
7479
return m, tea.Quit
7580

7681
default:
82+
for _, s := range m.skills {
83+
if s.Command() == command {
84+
return cmdRunSkill(m, s, arg)
85+
}
86+
}
7787
m.viewport.addWarning(fmt.Sprintf("Unknown command: %s. Type /help for available commands.", command))
7888
return m, nil
7989
}
8090
}
8191

92+
// cmdRunSkill sends the skill's instructions (plus the user's request, if
93+
// any) to the agent as a chat message.
94+
func cmdRunSkill(m Model, s skills.Skill, arg string) (Model, tea.Cmd) {
95+
if m.isStreaming {
96+
m.viewport.addWarning("Wait for the current response to finish before running a skill.")
97+
return m, nil
98+
}
99+
100+
arg = strings.TrimSpace(arg)
101+
display := s.Command()
102+
if arg != "" {
103+
display += " " + arg
104+
}
105+
return m.sendMessageWithDisplay(skillPrompt(s, arg), display)
106+
}
107+
108+
// skillPrompt builds the chat message for a skill invocation.
109+
func skillPrompt(s skills.Skill, arg string) string {
110+
var b strings.Builder
111+
b.WriteString("Follow the instructions in this skill to complete my request.\n\n")
112+
b.WriteString("<skill name=\"" + s.Name + "\">\n")
113+
b.WriteString(s.Body)
114+
b.WriteString("\n</skill>\n\n")
115+
if arg != "" {
116+
b.WriteString("My request: " + arg)
117+
} else {
118+
b.WriteString("My request: run this skill.")
119+
}
120+
return b.String()
121+
}
122+
82123
func cmdNew(m Model) (Model, tea.Cmd) {
83124
if m.isStreaming {
84125
m, _ = m.cancelStream()

0 commit comments

Comments
 (0)