detached 6d56e22

Merge pull request #205 from m1lian/fix/windows-cmd-quoting-and-hints

Pass cmd.exe commands verbatim and describe the Windows shell
commit
6d56e226dbbcc825600bc48855c9e439ecf29a7a
author
Patric Eckhart · · 2026-09-24 11:35 UTC
committer
GitHub · · 2026-09-24 11:35 UTC
merge of
9a5c565 + b5ad736 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.92
7 files · +111 -3
M packages/agent/systemprompt.go +25 -0
@@ -2,6 +2,7 @@ package agent
22
33 import (
44 "fmt"
5+ "runtime"
56 "strings"
67 "time"
78 )
@@ -26,6 +27,8 @@ type SystemPromptOpts struct {
2627 Append []string // extra text appended at the end
2728 Now time.Time
2829 ZotDocsDir string
30+ GOOS string // defaults to runtime.GOOS; overridable for tests
31+ GOARCH string // defaults to runtime.GOARCH
2932 }
3033
3134 // BuildSystemPrompt constructs the system prompt.
@@ -79,9 +82,31 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
7982 }
8083
8184 fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd)
85+ sb.WriteString(platformLine(o.GOOS, o.GOARCH))
8286 return sb.String()
8387 }
8488
89+// platformLine tells the model which OS and shell dialect its commands
90+// run under. Without it models default to POSIX syntax, and on Windows
91+// every failed grep/ls/&&-chain round-trip wastes a tool call and its
92+// tokens. Kept to one or two lines because it sits in the cached prefix.
93+func platformLine(goos, goarch string) string {
94+ if goos == "" {
95+ goos = runtime.GOOS
96+ }
97+ if goarch == "" {
98+ goarch = runtime.GOARCH
99+ }
100+ line := fmt.Sprintf("Platform: %s/%s\n", goos, goarch)
101+ if goos == "windows" {
102+ line += windowsShellHint
103+ }
104+ return line
105+}
106+
107+const windowsShellHint = `The bash tool runs cmd.exe /C, not a POSIX shell: use cmd syntax (dir, type, findstr, where, set, %VAR%, backslash paths); grep, ls, cat, sed, head, $VAR, and single quotes do not work. Keep commands simple: cmd's quoting breaks on nested double quotes, so prefer the read and glob tools for searching, git grep for code, and powershell -NoProfile -File <script.ps1> for anything complex.
108+`
109+
85110 const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.
86111
87112 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
@@ -2,6 +2,7 @@ package agent
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "runtime"
5 "strings"6 "strings"
6 "time"7 "time"
7 )8 )
@@ -26,6 +27,8 @@ type SystemPromptOpts struct {
26 Append []string // extra text appended at the end27 Append []string // extra text appended at the end
27 Now time.Time28 Now time.Time
28 ZotDocsDir string29 ZotDocsDir string
30+ GOOS string // defaults to runtime.GOOS; overridable for tests
31+ GOARCH string // defaults to runtime.GOARCH
29 }32 }
30 33
31 // BuildSystemPrompt constructs the system prompt.34 // BuildSystemPrompt constructs the system prompt.
@@ -79,9 +82,31 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
79 }82 }
80 83
81 fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd)84 fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd)
85+ sb.WriteString(platformLine(o.GOOS, o.GOARCH))
82 return sb.String()86 return sb.String()
83 }87 }
84 88
89+// platformLine tells the model which OS and shell dialect its commands
90+// run under. Without it models default to POSIX syntax, and on Windows
91+// every failed grep/ls/&&-chain round-trip wastes a tool call and its
92+// tokens. Kept to one or two lines because it sits in the cached prefix.
93+func platformLine(goos, goarch string) string {
94+ if goos == "" {
95+ goos = runtime.GOOS
96+ }
97+ if goarch == "" {
98+ goarch = runtime.GOARCH
99+ }
100+ line := fmt.Sprintf("Platform: %s/%s\n", goos, goarch)
101+ if goos == "windows" {
102+ line += windowsShellHint
103+ }
104+ return line
105+}
106+
107+const windowsShellHint = `The bash tool runs cmd.exe /C, not a POSIX shell: use cmd syntax (dir, type, findstr, where, set, %VAR%, backslash paths); grep, ls, cat, sed, head, $VAR, and single quotes do not work. Keep commands simple: cmd's quoting breaks on nested double quotes, so prefer the read and glob tools for searching, git grep for code, and powershell -NoProfile -File <script.ps1> for anything complex.
108+`
109+
85 const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.110 const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.
86 111
87 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.112 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
M packages/agent/systemprompt_test.go +20 -0
@@ -30,6 +30,26 @@ func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) {
3030 }
3131 }
3232
33+func TestBuildSystemPromptPlatform(t *testing.T) {
34+ for _, tc := range []struct {
35+ goos string
36+ wantHint bool
37+ wantLabel string
38+ }{
39+ {"windows", true, "Platform: windows/amd64"},
40+ {"linux", false, "Platform: linux/amd64"},
41+ {"darwin", false, "Platform: darwin/amd64"},
42+ } {
43+ got := BuildSystemPrompt(SystemPromptOpts{CWD: "/w", GOOS: tc.goos, GOARCH: "amd64"})
44+ if !strings.Contains(got, tc.wantLabel) {
45+ t.Errorf("%s: missing %q:\n%s", tc.goos, tc.wantLabel, got)
46+ }
47+ if strings.Contains(got, "cmd.exe /C") != tc.wantHint {
48+ t.Errorf("%s: windows shell hint present=%v, want %v", tc.goos, !tc.wantHint, tc.wantHint)
49+ }
50+ }
51+}
52+
3353 func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) {
3454 got := BuildSystemPrompt(SystemPromptOpts{
3555 CWD: "/workspace",
@@ -30,6 +30,26 @@ func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) {
30 }30 }
31 }31 }
32 32
33+func TestBuildSystemPromptPlatform(t *testing.T) {
34+ for _, tc := range []struct {
35+ goos string
36+ wantHint bool
37+ wantLabel string
38+ }{
39+ {"windows", true, "Platform: windows/amd64"},
40+ {"linux", false, "Platform: linux/amd64"},
41+ {"darwin", false, "Platform: darwin/amd64"},
42+ } {
43+ got := BuildSystemPrompt(SystemPromptOpts{CWD: "/w", GOOS: tc.goos, GOARCH: "amd64"})
44+ if !strings.Contains(got, tc.wantLabel) {
45+ t.Errorf("%s: missing %q:\n%s", tc.goos, tc.wantLabel, got)
46+ }
47+ if strings.Contains(got, "cmd.exe /C") != tc.wantHint {
48+ t.Errorf("%s: windows shell hint present=%v, want %v", tc.goos, !tc.wantHint, tc.wantHint)
49+ }
50+ }
51+}
52+
33 func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) {53 func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) {
34 got := BuildSystemPrompt(SystemPromptOpts{54 got := BuildSystemPrompt(SystemPromptOpts{
35 CWD: "/workspace",55 CWD: "/workspace",
M packages/agent/tools/bash.go +4 -2
@@ -286,7 +286,7 @@ func isExecutableFile(path string) bool {
286286
287287 func shellDescription(shell shellCommand) string {
288288 if shell.flag == "/C" {
289- return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged."
289+ return "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged."
290290 }
291291 if shell.isBash {
292292 return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path)
@@ -296,5 +296,7 @@ func shellDescription(shell shellCommand) string {
296296
297297 func newShellCmd(ctx context.Context, command string) *exec.Cmd {
298298 shell := currentShell()
299- return exec.CommandContext(ctx, shell.path, shell.flag, command)
299+ cmd := exec.CommandContext(ctx, shell.path, shell.flag, command)
300+ applyRawCmdLine(cmd, shell, command)
301+ return cmd
300302 }
@@ -286,7 +286,7 @@ func isExecutableFile(path string) bool {
286 286
287 func shellDescription(shell shellCommand) string {287 func shellDescription(shell shellCommand) string {
288 if shell.flag == "/C" {288 if shell.flag == "/C" {
289- return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged."289+ return "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged."
290 }290 }
291 if shell.isBash {291 if shell.isBash {
292 return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path)292 return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path)
@@ -296,5 +296,7 @@ func shellDescription(shell shellCommand) string {
296 296
297 func newShellCmd(ctx context.Context, command string) *exec.Cmd {297 func newShellCmd(ctx context.Context, command string) *exec.Cmd {
298 shell := currentShell()298 shell := currentShell()
299- return exec.CommandContext(ctx, shell.path, shell.flag, command)299+ cmd := exec.CommandContext(ctx, shell.path, shell.flag, command)
300+ applyRawCmdLine(cmd, shell, command)
301+ return cmd
300 }302 }
M packages/agent/tools/bash_unix.go +3 -0
@@ -8,6 +8,9 @@ import (
88 "time"
99 )
1010
11+// applyRawCmdLine is only needed for cmd.exe quoting on Windows.
12+func applyRawCmdLine(*exec.Cmd, shellCommand, string) {}
13+
1114 // setProcessGroup puts the command in its own process group so
1215 // killProcessGroup can target the entire tree including background
1316 // children spawned with &.
@@ -8,6 +8,9 @@ import (
8 "time"8 "time"
9 )9 )
10 10
11+// applyRawCmdLine is only needed for cmd.exe quoting on Windows.
12+func applyRawCmdLine(*exec.Cmd, shellCommand, string) {}
13+
11 // setProcessGroup puts the command in its own process group so14 // setProcessGroup puts the command in its own process group so
12 // killProcessGroup can target the entire tree including background15 // killProcessGroup can target the entire tree including background
13 // children spawned with &.16 // children spawned with &.
M packages/agent/tools/bash_windows.go +17 -0
@@ -4,9 +4,26 @@ package tools
44
55 import (
66 "os/exec"
7+ "syscall"
78 "time"
89 )
910
11+// applyRawCmdLine hands the command to cmd.exe verbatim. Go's default
12+// argument quoting escapes embedded double quotes as \", which cmd.exe
13+// does not understand, so commands such as dir "C:\a b" or
14+// findstr /c:"x y" reached cmd with literal backslashes and failed.
15+// /S makes cmd strip exactly the outer quote pair and run the rest
16+// unchanged.
17+func applyRawCmdLine(cmd *exec.Cmd, shell shellCommand, command string) {
18+ if shell.flag != "/C" {
19+ return
20+ }
21+ if cmd.SysProcAttr == nil {
22+ cmd.SysProcAttr = &syscall.SysProcAttr{}
23+ }
24+ cmd.SysProcAttr.CmdLine = `cmd /S /C "` + command + `"`
25+}
26+
1027 func setProcessGroup(_ *exec.Cmd) {}
1128
1229 func killProcessGroup(cmd *exec.Cmd) {
@@ -4,9 +4,26 @@ package tools
4 4
5 import (5 import (
6 "os/exec"6 "os/exec"
7+ "syscall"
7 "time"8 "time"
8 )9 )
9 10
11+// applyRawCmdLine hands the command to cmd.exe verbatim. Go's default
12+// argument quoting escapes embedded double quotes as \", which cmd.exe
13+// does not understand, so commands such as dir "C:\a b" or
14+// findstr /c:"x y" reached cmd with literal backslashes and failed.
15+// /S makes cmd strip exactly the outer quote pair and run the rest
16+// unchanged.
17+func applyRawCmdLine(cmd *exec.Cmd, shell shellCommand, command string) {
18+ if shell.flag != "/C" {
19+ return
20+ }
21+ if cmd.SysProcAttr == nil {
22+ cmd.SysProcAttr = &syscall.SysProcAttr{}
23+ }
24+ cmd.SysProcAttr.CmdLine = `cmd /S /C "` + command + `"`
25+}
26+
10 func setProcessGroup(_ *exec.Cmd) {}27 func setProcessGroup(_ *exec.Cmd) {}
11 28
12 func killProcessGroup(cmd *exec.Cmd) {29 func killProcessGroup(cmd *exec.Cmd) {
A packages/agent/tools/bash_windows_test.go +41 -0
@@ -0,0 +1,41 @@
1+//go:build windows
2+
3+package tools
4+
5+import (
6+ "context"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+// Embedded double quotes must reach cmd.exe unchanged. Go's default
16+// argument escaping turned them into \" which cmd does not understand.
17+func TestBashWindowsQuotedArguments(t *testing.T) {
18+ dir := t.TempDir()
19+ sub := filepath.Join(dir, "a b")
20+ if err := os.MkdirAll(sub, 0o755); err != nil {
21+ t.Fatal(err)
22+ }
23+ if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("hello world\n"), 0o644); err != nil {
24+ t.Fatal(err)
25+ }
26+ tool := &BashTool{CWD: dir}
27+ for _, tc := range []struct{ command, want string }{
28+ {`dir /s /b "` + dir + `\SKILL.md"`, "SKILL.md"},
29+ {`findstr /c:"hello world" "a b\SKILL.md"`, "hello world"},
30+ {`echo "x y" & echo done`, "done"},
31+ } {
32+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": tc.command}), nil)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ got := res.Content[0].(provider.TextBlock).Text
37+ if res.IsError || !strings.Contains(got, tc.want) {
38+ t.Errorf("%s: got\n%s", tc.command, got)
39+ }
40+ }
41+}
@@ -0,0 +1,41 @@
1+//go:build windows
2+
3+package tools
4+
5+import (
6+ "context"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+// Embedded double quotes must reach cmd.exe unchanged. Go's default
16+// argument escaping turned them into \" which cmd does not understand.
17+func TestBashWindowsQuotedArguments(t *testing.T) {
18+ dir := t.TempDir()
19+ sub := filepath.Join(dir, "a b")
20+ if err := os.MkdirAll(sub, 0o755); err != nil {
21+ t.Fatal(err)
22+ }
23+ if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("hello world\n"), 0o644); err != nil {
24+ t.Fatal(err)
25+ }
26+ tool := &BashTool{CWD: dir}
27+ for _, tc := range []struct{ command, want string }{
28+ {`dir /s /b "` + dir + `\SKILL.md"`, "SKILL.md"},
29+ {`findstr /c:"hello world" "a b\SKILL.md"`, "hello world"},
30+ {`echo "x y" & echo done`, "done"},
31+ } {
32+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": tc.command}), nil)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ got := res.Content[0].(provider.TextBlock).Text
37+ if res.IsError || !strings.Contains(got, tc.want) {
38+ t.Errorf("%s: got\n%s", tc.command, got)
39+ }
40+ }
41+}
M packages/agent/tools/tools_test.go +1 -1
@@ -325,7 +325,7 @@ func TestShellDescription(t *testing.T) {
325325 {
326326 name: "Windows",
327327 shell: shellCommand{path: "cmd", flag: "/C"},
328- want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.",
328+ want: "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged.",
329329 },
330330 }
331331 for _, tt := range tests {
@@ -325,7 +325,7 @@ func TestShellDescription(t *testing.T) {
325 {325 {
326 name: "Windows",326 name: "Windows",
327 shell: shellCommand{path: "cmd", flag: "/C"},327 shell: shellCommand{path: "cmd", flag: "/C"},
328- want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.",328+ want: "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged.",
329 },329 },
330 }330 }
331 for _, tt := range tests {331 for _, tt := range tests {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close