feat: parse execute tool commands and render them in the chat UI (#25478)

When the execute tool runs a chained shell command, the UI previously
rendered the raw string. Long chains like "cd /repo && git pull &&
git add . && git commit -m fix" were hard to scan.

A new ChatMessagePart.ParsedCommands [][]string field on tool-call
parts carries one entry per simple command, parsed in chatd from args
via mvdan.cc/sh/v3/syntax. The frontend renders the joined list ("cd,
git pull, git add, git commit") in place of the raw command, and falls
back to the raw command when the field is absent.

Closes CODAGT-446
This commit is contained in:
Mathias Fredriksson
2026-05-21 08:12:34 +00:00
committed by GitHub
parent ec1e861152
commit f1b772928d
24 changed files with 749 additions and 22 deletions
+98
View File
@@ -0,0 +1,98 @@
// Package shellparse extracts command steps from shell scripts.
package shellparse
import (
"strings"
"mvdan.cc/sh/v3/syntax"
)
// Parse returns one slice per simple command in src, in source order.
// Each is [program] or [program, arg], where arg is the first non-flag
// positional argument.
//
// Some malformed inputs (e.g. trailing unterminated tokens after valid
// semicolon-separated commands) yield partial results alongside a
// non-nil error. Callers that show parsed output to users should treat
// a non-nil err as a signal to fall back to the raw input rather than
// display the partial.
func Parse(src string) ([][]string, error) {
if src == "" {
return nil, nil
}
f, err := syntax.NewParser().Parse(strings.NewReader(src), "")
if f == nil {
return nil, err
}
var out [][]string
syntax.Walk(f, func(node syntax.Node) bool {
call, ok := node.(*syntax.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
prog := wordLiteral(call.Args[0])
if prog == "" {
return true
}
step := []string{prog}
if arg := firstNonFlagLiteral(call.Args[1:]); arg != "" {
step = append(step, arg)
}
out = append(out, step)
return true
})
return out, err
}
// wordLiteral returns the literal content of w by concatenating the
// literal pieces of its parts. Bare literals, single-quoted strings,
// and double-quoted strings (when the inner parts are all literals)
// contribute their text. Any part involving variable expansion,
// command substitution, or arithmetic returns "" for the whole word
// because we cannot resolve those without executing the shell.
func wordLiteral(w *syntax.Word) string {
if w == nil {
return ""
}
var sb strings.Builder
for _, part := range w.Parts {
switch p := part.(type) {
case *syntax.Lit:
_, _ = sb.WriteString(p.Value)
case *syntax.SglQuoted:
_, _ = sb.WriteString(p.Value)
case *syntax.DblQuoted:
for _, inner := range p.Parts {
lit, ok := inner.(*syntax.Lit)
if !ok {
return ""
}
_, _ = sb.WriteString(lit.Value)
}
default:
return ""
}
}
return sb.String()
}
// firstNonFlagLiteral returns the literal value of the first word in
// ws that does not start with "-", or "" if none qualifies.
//
// Known limitation: no flag-arity knowledge. For programs whose global
// flags take a separate-word value ("git -C path verb", "kubectl -n ns
// verb", "docker --context X verb"), this returns the flag's value as
// the first positional, not the actual verb. Consumers that need the
// verb in those cases need per-program awareness; this function does
// not provide it.
func firstNonFlagLiteral(ws []*syntax.Word) string {
for _, w := range ws {
lit := wordLiteral(w)
if lit == "" || strings.HasPrefix(lit, "-") {
continue
}
return lit
}
return ""
}
+148
View File
@@ -0,0 +1,148 @@
package shellparse_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/util/shellparse"
)
func TestParse(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want [][]string
}{
{
name: "chained-git-workflow",
in: `cd /path && git pull && git add . && git commit -m "x"`,
want: [][]string{{"cd", "/path"}, {"git", "pull"}, {"git", "add"}, {"git", "commit"}},
},
{
name: "single-command-with-flags",
in: `ls -la /tmp`,
want: [][]string{{"ls", "/tmp"}},
},
{
name: "no-arg",
in: `pwd`,
want: [][]string{{"pwd"}},
},
{
name: "find-xargs-grep-pipeline",
in: `find /repo -type f | xargs grep "foo" 2>/dev/null | grep -i "bar" | head -30`,
want: [][]string{{"find", "/repo"}, {"xargs", "grep"}, {"grep", "bar"}, {"head"}},
},
{
name: "stash-build-pop-exit",
// "ES=$?" is a pure assignment; not a command.
in: `cd /repo && git stash && go build ./... 2>&1; ES=$?; git stash pop 2>&1 | tail -1; exit $ES`,
want: [][]string{
{"cd", "/repo"},
{"git", "stash"},
{"go", "build"},
{"git", "stash"},
{"tail"},
{"exit"},
},
},
{
name: "command-substitution-and-if",
in: `cd /repo && TOKEN=$(cat /tmp/tok || echo "") && if [ -n "$TOKEN" ]; then echo "$TOKEN" | gh auth login --with-token; else echo "missing"; fi`,
want: [][]string{
{"cd", "/repo"},
{"cat", "/tmp/tok"},
{"echo"},
{"[", "]"},
{"echo"},
{"gh", "auth"},
{"echo", "missing"},
},
},
{
name: "for-loop-with-sed",
in: `cd /repo && for line in 1 2 3; do
sed -i "${line}s|a|b|" file
done`,
want: [][]string{{"cd", "/repo"}, {"sed", "file"}},
},
{
name: "subshell-and-brace-group",
in: `(cd /tmp && ls) && { echo a; echo b; }`,
want: [][]string{{"cd", "/tmp"}, {"ls"}, {"echo", "a"}, {"echo", "b"}},
},
{
name: "variable-program-not-literal",
in: `$cmd --help && echo done`,
want: [][]string{{"echo", "done"}},
},
{
name: "double-quoted-positional",
in: `cd "/repo with spaces"`,
want: [][]string{{"cd", "/repo with spaces"}},
},
{
name: "single-quoted-positional",
in: `grep 'fix bug'`,
want: [][]string{{"grep", "fix bug"}},
},
{
name: "quoted-program-name",
in: `"/usr/bin/git" pull`,
want: [][]string{{"/usr/bin/git", "pull"}},
},
{
name: "double-quoted-with-variable-expansion-skipped",
in: `echo "hello $name"`,
// The quoted word contains a parameter expansion, so the
// parser cannot extract a literal; only the program survives.
want: [][]string{{"echo"}},
},
{
name: "empty",
in: ``,
want: nil,
},
{
name: "comment-only",
in: `# just a comment`,
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := shellparse.Parse(tc.in)
require.NoError(t, err, "parse failed for %q", tc.in)
assert.Equal(t, tc.want, got, "input: %q", tc.in)
})
}
}
func TestParse_ParseError(t *testing.T) {
t.Parallel()
t.Run("unterminated-string-no-results", func(t *testing.T) {
t.Parallel()
cmds, err := shellparse.Parse(`echo "unterminated`)
require.Error(t, err)
require.Nil(t, cmds)
})
t.Run("semicolon-prefix-yields-partial-results-plus-error", func(t *testing.T) {
t.Parallel()
// Some malformed inputs (e.g. trailing unterminated tokens after
// valid semicolon-separated commands) yield partial results
// alongside a non-nil error. Pin both sides of the contract so
// future mvdan.cc/sh upgrades that change partial-parse behavior
// fail this test loudly.
cmds, err := shellparse.Parse(`ls; cat; echo "unterminated`)
require.Error(t, err)
require.Equal(t, [][]string{{"ls"}, {"cat"}}, cmds)
})
}