Skip to content

Commit a35b1f2

Browse files
committed
Support dynamic command expansion in skills (\!command syntax)
Add support for the Claude Code `!\`command\`` pattern in SKILL.md files. When a local skill is read, any `!\`command\`` patterns are expanded by executing the shell command and replacing the pattern with its stdout. This enables skills to dynamically inject context (e.g. git branch, PR diff, script output) into the prompt at read time. Commands run in the agent's configured working directory (from RuntimeConfig.WorkingDir), not in the skill's own directory, so that project-relative commands like `git status` work correctly. Security: command expansion is restricted to local (filesystem) skills. Remote skills fetched over HTTP are never expanded, preventing arbitrary code execution from untrusted sources. Command stdout is capped at 1 MB via io.LimitReader with remaining output drained to avoid pipe deadlocks. Assisted-By: docker-agent
1 parent 09f877f commit a35b1f2

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)