Skip to content

Commit d871092

Browse files
authored
Merge pull request #2116 from dgageot/board/we-must-support-skills-that-work-on-clau-418d8ffc
Support dynamic command expansion in skills (\!`command` syntax)
2 parents 09f877f + a35b1f2 commit d871092

8 files changed

Lines changed: 297 additions & 31 deletions

File tree

pkg/app/app.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ func (a *App) CurrentAgentSkills() []skills.Skill {
194194

195195
// ResolveSkillCommand checks if the input matches a skill slash command (e.g. /skill-name args).
196196
// If matched, it reads the skill content and returns the resolved prompt. Otherwise returns "".
197-
func (a *App) ResolveSkillCommand(input string) (string, error) {
197+
func (a *App) ResolveSkillCommand(ctx context.Context, input string) (string, error) {
198198
if !strings.HasPrefix(input, "/") {
199199
return "", nil
200200
}
@@ -212,7 +212,7 @@ func (a *App) ResolveSkillCommand(input string) (string, error) {
212212
continue
213213
}
214214

215-
content, err := st.ReadSkillContent(skill.Name)
215+
content, err := st.ReadSkillContent(ctx, skill.Name)
216216
if err != nil {
217217
return "", fmt.Errorf("reading skill %q: %w", skill.Name, err)
218218
}
@@ -229,7 +229,7 @@ func (a *App) ResolveSkillCommand(input string) (string, error) {
229229
// ResolveInput resolves the user input by trying skill commands first,
230230
// then agent commands. Returns the resolved content ready to send to the agent.
231231
func (a *App) ResolveInput(ctx context.Context, input string) string {
232-
if resolved, err := a.ResolveSkillCommand(input); err != nil {
232+
if resolved, err := a.ResolveSkillCommand(ctx, input); err != nil {
233233
return fmt.Sprintf("Error loading skill: %v", err)
234234
} else if resolved != "" {
235235
return resolved

pkg/app/app_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ func TestApp_ResolveSkillCommand_NoLocalRuntime(t *testing.T) {
245245
app := New(ctx, rt, sess)
246246

247247
// mockRuntime is not a LocalRuntime, so no skills should be returned
248-
resolved, err := app.ResolveSkillCommand("/some-skill")
248+
resolved, err := app.ResolveSkillCommand(ctx, "/some-skill")
249249
require.NoError(t, err)
250250
assert.Empty(t, resolved)
251251
}
@@ -258,7 +258,7 @@ func TestApp_ResolveSkillCommand_NotSlashCommand(t *testing.T) {
258258
sess := session.New()
259259
app := New(ctx, rt, sess)
260260

261-
resolved, err := app.ResolveSkillCommand("not a slash command")
261+
resolved, err := app.ResolveSkillCommand(ctx, "not a slash command")
262262
require.NoError(t, err)
263263
assert.Empty(t, resolved)
264264
}

pkg/skills/expand.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package skills
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"os/exec"
10+
"regexp"
11+
"strings"
12+
"time"
13+
)
14+
15+
// commandTimeout is the maximum time allowed for a single command expansion.
16+
const commandTimeout = 30 * time.Second
17+
18+
// maxOutputSize is the maximum number of bytes read from a command's stdout.
19+
const maxOutputSize = 1 << 20 // 1 MB
20+
21+
// commandPattern matches the !`command` syntax used by Claude Code skills
22+
// to embed dynamic command output into skill content.
23+
var commandPattern = regexp.MustCompile("!`([^`]+)`")
24+
25+
// ExpandCommands replaces all !`command` patterns in the given content
26+
// with the stdout of executing each command via the system shell.
27+
// Commands are executed with the specified working directory.
28+
// If a command fails, the pattern is replaced with an error message
29+
// rather than failing the entire expansion.
30+
func ExpandCommands(ctx context.Context, content, workDir string) string {
31+
return commandPattern.ReplaceAllStringFunc(content, func(match string) string {
32+
command := match[2 : len(match)-1] // strip leading !` and trailing `
33+
34+
output, err := runCommand(ctx, command, workDir)
35+
if err != nil {
36+
slog.Warn("Skill command expansion failed", "command", command, "error", err)
37+
return fmt.Sprintf("[error executing `%s`: %s]", command, err)
38+
}
39+
40+
return strings.TrimRight(output, "\n")
41+
})
42+
}
43+
44+
// runCommand executes a shell command and returns its stdout (up to maxOutputSize bytes).
45+
// The command runs in the specified working directory.
46+
func runCommand(ctx context.Context, command, workDir string) (string, error) {
47+
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
48+
defer cancel()
49+
50+
cmd := exec.CommandContext(ctx, "sh", "-c", command)
51+
cmd.Dir = workDir
52+
53+
var stderr bytes.Buffer
54+
cmd.Stderr = &stderr
55+
56+
stdout, err := cmd.StdoutPipe()
57+
if err != nil {
58+
return "", err
59+
}
60+
61+
if err := cmd.Start(); err != nil {
62+
return "", err
63+
}
64+
65+
out, err := io.ReadAll(io.LimitReader(stdout, maxOutputSize))
66+
if err != nil {
67+
return "", err
68+
}
69+
70+
// Drain any remaining stdout so the process doesn't block on a full pipe
71+
// and hang until the context timeout kills it.
72+
_, _ = io.Copy(io.Discard, stdout)
73+
74+
if err := cmd.Wait(); err != nil {
75+
if ctx.Err() == context.DeadlineExceeded {
76+
return "", fmt.Errorf("command timed out after %s", commandTimeout)
77+
}
78+
if stderrMsg := strings.TrimSpace(stderr.String()); stderrMsg != "" {
79+
return "", fmt.Errorf("%w: %s", err, stderrMsg)
80+
}
81+
return "", err
82+
}
83+
84+
return string(out), nil
85+
}

pkg/skills/expand_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package skills
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func skipOnWindows(t *testing.T) {
15+
t.Helper()
16+
if runtime.GOOS == "windows" {
17+
t.Skip("skipping on windows")
18+
}
19+
}
20+
21+
func TestExpandCommands(t *testing.T) {
22+
skipOnWindows(t)
23+
24+
tests := []struct {
25+
name string
26+
content string
27+
want string
28+
}{
29+
{
30+
name: "no patterns",
31+
content: "# My Skill\n\nJust regular markdown content.",
32+
want: "# My Skill\n\nJust regular markdown content.",
33+
},
34+
{
35+
name: "simple echo",
36+
content: "Hello !`echo world`!",
37+
want: "Hello world!",
38+
},
39+
{
40+
name: "multiple commands",
41+
content: "Name: !`echo alice`, Age: !`echo 30`",
42+
want: "Name: alice, Age: 30",
43+
},
44+
{
45+
name: "multiline output",
46+
content: "Files:\n!`printf 'a.go\nb.go\nc.go\n'`\nEnd.",
47+
want: "Files:\na.go\nb.go\nc.go\nEnd.",
48+
},
49+
{
50+
name: "empty output",
51+
content: "Before !`true` after",
52+
want: "Before after",
53+
},
54+
{
55+
name: "pipes",
56+
content: "Count: !`printf 'a\nb\nc\n' | wc -l | tr -d ' '`",
57+
want: "Count: 3",
58+
},
59+
{
60+
name: "preserves regular backticks",
61+
content: "Use `echo hello` to print.\n\nCode: ```go\nfmt.Println()\n```",
62+
want: "Use `echo hello` to print.\n\nCode: ```go\nfmt.Println()\n```",
63+
},
64+
}
65+
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
result := ExpandCommands(t.Context(), tt.content, t.TempDir())
69+
assert.Equal(t, tt.want, result)
70+
})
71+
}
72+
}
73+
74+
func TestExpandCommands_WorkingDirectory(t *testing.T) {
75+
skipOnWindows(t)
76+
77+
tmpDir := t.TempDir()
78+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello"), 0o644))
79+
80+
result := ExpandCommands(t.Context(), "Content: !`cat test.txt`", tmpDir)
81+
assert.Equal(t, "Content: hello", result)
82+
}
83+
84+
func TestExpandCommands_ScriptExecution(t *testing.T) {
85+
skipOnWindows(t)
86+
87+
tmpDir := t.TempDir()
88+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "info.sh"), []byte("#!/bin/sh\necho from-script"), 0o755))
89+
90+
result := ExpandCommands(t.Context(), "Output: !`./info.sh`", tmpDir)
91+
assert.Equal(t, "Output: from-script", result)
92+
}
93+
94+
func TestExpandCommands_FailedCommand(t *testing.T) {
95+
skipOnWindows(t)
96+
97+
result := ExpandCommands(t.Context(), "Before !`nonexistent_command_12345` after", t.TempDir())
98+
assert.Contains(t, result, "Before ")
99+
assert.Contains(t, result, "[error executing `nonexistent_command_12345`:")
100+
assert.Contains(t, result, " after")
101+
}
102+
103+
func TestExpandCommands_CancelledContext(t *testing.T) {
104+
skipOnWindows(t)
105+
106+
ctx, cancel := context.WithCancel(t.Context())
107+
cancel()
108+
109+
result := ExpandCommands(ctx, "Result: !`echo hello`", t.TempDir())
110+
assert.Contains(t, result, "[error executing `echo hello`:")
111+
}

pkg/skills/skills.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Skill struct {
2222
FilePath string `yaml:"-"`
2323
BaseDir string `yaml:"-"`
2424
Files []string `yaml:"-"`
25+
Local bool `yaml:"-"` // true for filesystem-loaded skills, false for remote
2526
License string `yaml:"license"`
2627
Compatibility string `yaml:"compatibility"`
2728
Metadata map[string]string `yaml:"metadata"`
@@ -308,6 +309,7 @@ func loadSkillFile(path, dirName string) (Skill, bool) {
308309
skill.Name = cmp.Or(skill.Name, dirName)
309310
skill.FilePath = path
310311
skill.BaseDir = filepath.Dir(path)
312+
skill.Local = true
311313

312314
return skill, true
313315
}

pkg/teamloader/teamloader.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
218218
if agentConfig.Skills.Enabled() {
219219
loadedSkills := skills.Load(agentConfig.Skills.Sources)
220220
if len(loadedSkills) > 0 {
221-
agentTools = append(agentTools, builtin.NewSkillsToolset(loadedSkills))
221+
agentTools = append(agentTools, builtin.NewSkillsToolset(loadedSkills, runConfig.WorkingDir))
222222
}
223223
}
224224

pkg/tools/builtin/skills.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ var (
2525
// agent load skill content and supporting resources by name. It hides whether
2626
// a skill is local or remote — the agent just sees a name and description.
2727
type SkillsToolset struct {
28-
skills []skills.Skill
28+
skills []skills.Skill
29+
workingDir string
2930
}
3031

31-
func NewSkillsToolset(loadedSkills []skills.Skill) *SkillsToolset {
32+
func NewSkillsToolset(loadedSkills []skills.Skill, workingDir string) *SkillsToolset {
3233
return &SkillsToolset{
33-
skills: loadedSkills,
34+
skills: loadedSkills,
35+
workingDir: workingDir,
3436
}
3537
}
3638

@@ -49,7 +51,10 @@ func (s *SkillsToolset) findSkill(name string) *skills.Skill {
4951
}
5052

5153
// ReadSkillContent returns the content of a skill's SKILL.md by name.
52-
func (s *SkillsToolset) ReadSkillContent(name string) (string, error) {
54+
// For local skills, it expands any !`command` patterns in the content by
55+
// executing the commands and replacing the patterns with their stdout output.
56+
// Command expansion is disabled for remote skills to prevent arbitrary code execution.
57+
func (s *SkillsToolset) ReadSkillContent(ctx context.Context, name string) (string, error) {
5358
skill := s.findSkill(name)
5459
if skill == nil {
5560
return "", fmt.Errorf("skill %q not found", name)
@@ -60,6 +65,10 @@ func (s *SkillsToolset) ReadSkillContent(name string) (string, error) {
6065
return "", err
6166
}
6267

68+
if skill.Local {
69+
content = skills.ExpandCommands(ctx, content, s.workingDir)
70+
}
71+
6372
return content, nil
6473
}
6574

@@ -119,8 +128,8 @@ type readSkillFileArgs struct {
119128
Path string `json:"path" jsonschema:"The relative path to the file within the skill (e.g. references/FORMS.md)"`
120129
}
121130

122-
func (s *SkillsToolset) handleReadSkill(_ context.Context, args readSkillArgs) (*tools.ToolCallResult, error) {
123-
content, err := s.ReadSkillContent(args.Name)
131+
func (s *SkillsToolset) handleReadSkill(ctx context.Context, args readSkillArgs) (*tools.ToolCallResult, error) {
132+
content, err := s.ReadSkillContent(ctx, args.Name)
124133
if err != nil {
125134
return tools.ResultError(err.Error()), nil
126135
}

0 commit comments

Comments
 (0)