Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,25 +204,26 @@ func (e RuntimeError) Unwrap() error {
return e.Err
}

// isFirstRun checks if this is the first time cagent is being run
// It creates a marker file in the user's config directory
// isFirstRun checks if this is the first time cagent is being run.
// It atomically creates a marker file in the user's config directory
// using os.O_EXCL to avoid a race condition when multiple processes
// start concurrently.
func isFirstRun() bool {
configDir := paths.GetConfigDir()
markerFile := filepath.Join(configDir, ".cagent_first_run")

// Check if marker file exists
if _, err := os.Stat(markerFile); err == nil {
return false // File exists, not first run
}

// Create marker file to indicate this run has happened
// Ensure the config directory exists before trying to create the marker file
if err := os.MkdirAll(configDir, 0o755); err != nil {
return false // Can't create config dir, assume not first run
slog.Warn("Failed to create config directory for first run marker", "error", err)
return false
}

if err := os.WriteFile(markerFile, []byte(""), 0o644); err != nil {
return false // Can't create marker file, assume not first run
// Atomically create the marker file. If it already exists, OpenFile returns an error.
f, err := os.OpenFile(markerFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return false // File already exists or other error, not first run
}
f.Close()

return true // Successfully created marker, this is first run
return true
}
14 changes: 12 additions & 2 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,18 @@ func (a *App) RunWithMessage(ctx context.Context, cancel context.CancelFunc, msg
}

func (a *App) RunBangCommand(ctx context.Context, command string) {
out, _ := exec.CommandContext(ctx, "/bin/sh", "-c", command).CombinedOutput()
a.events <- runtime.ShellOutput("$ " + command + "\n" + string(out))
command = strings.TrimSpace(command)
if command == "" {
a.events <- runtime.ShellOutput("Error: empty command")
return
}

out, err := exec.CommandContext(ctx, "/bin/sh", "-c", command).CombinedOutput()
output := "$ " + command + "\n" + string(out)
if err != nil && len(out) == 0 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Error information is lost when command fails with partial output

The condition if err != nil && len(out) == 0 means errors are only reported when there's no output. When a shell command produces any output (stdout or stderr) but exits with a non-zero status, the error is never displayed to the user.

For example, if a command writes to stderr and then fails, the user will see the stderr output but won't see that the command actually failed.

Consider changing the logic to always include error information when err != nil:

if err != nil {
    output = "$ " + command + "\n" + string(out) + "\nError: " + err.Error()
} else {
    output = "$ " + command + "\n" + string(out)
}

This ensures users are always informed when a command fails, regardless of whether it produced output.

output = "$ " + command + "\nError: " + err.Error()
}
a.events <- runtime.ShellOutput(output)
}

func (a *App) Subscribe(ctx context.Context, program *tea.Program) {
Expand Down
7 changes: 7 additions & 0 deletions pkg/tools/builtin/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"runtime"
Expand Down Expand Up @@ -137,6 +138,10 @@ func statusToString(status int32) string {
}

func (h *shellHandler) RunShell(ctx context.Context, params RunShellArgs) (*tools.ToolCallResult, error) {
if strings.TrimSpace(params.Cmd) == "" {
return tools.ResultError("Error: empty command"), nil
}

timeout := h.timeout
if params.Timeout > 0 {
timeout = time.Duration(params.Timeout) * time.Second
Expand All @@ -152,6 +157,8 @@ func (h *shellHandler) RunShell(ctx context.Context, params RunShellArgs) (*tool
return h.sandbox.runCommand(timeoutCtx, ctx, params.Cmd, cwd, timeout), nil
}

slog.Debug("Executing native shell command", "command", params.Cmd, "cwd", cwd)

return h.runNativeCommand(timeoutCtx, ctx, params.Cmd, cwd, timeout), nil
}

Expand Down