Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
09f8e6d
Initial plan
Copilot Mar 1, 2026
ecc0e75
Initial plan
Copilot Mar 1, 2026
06d37ac
Update internal/interactive/repl.go
kriserickson Mar 1, 2026
cc53685
Initial plan
Copilot Mar 1, 2026
46f7ec7
Initial plan
Copilot Mar 1, 2026
7715525
Fix JSON schema inconsistency: add optional explanation field to prom…
Copilot Mar 1, 2026
01bdacc
Move non-Windows shell tests to detect_other_test.go with !windows bu…
Copilot Mar 1, 2026
02fd73d
Propagate explain flag into interactive mode
Copilot Mar 1, 2026
3d00895
Fix TestRunWithExplainTrue assertion and build failure
Copilot Mar 1, 2026
90bcf86
Merge pull request #31 from kriserickson/copilot/sub-pr-27-another-one
kriserickson Mar 1, 2026
89eecf6
Merge pull request #32 from kriserickson/copilot/sub-pr-27-yet-again
kriserickson Mar 1, 2026
4568baa
Merge pull request #29 from kriserickson/copilot/sub-pr-27
kriserickson Mar 1, 2026
7901318
Merge branch 'add-explain' into copilot/sub-pr-27-again
kriserickson Mar 2, 2026
71aba58
Add safety checks and tools for file operations
kriserickson Mar 2, 2026
e38c613
Initial plan
Copilot Mar 2, 2026
1615816
Fix gofumpt formatting: remove trailing newline from detect_test.go
Copilot Mar 2, 2026
8818e5a
Add tests asserting explain flag propagation into interactive mode
Copilot Mar 2, 2026
1d1d2e5
Merge pull request #43 from kriserickson/copilot/sub-pr-27
kriserickson Mar 2, 2026
c9ba600
Sort environment variables in execEnvironment and update tests for tr…
kriserickson Mar 2, 2026
69850da
Merge pull request #30 from kriserickson/copilot/sub-pr-27-again
kriserickson Mar 2, 2026
a6a73aa
Merge branch 'main' into add-explain
kriserickson Mar 2, 2026
785dc46
Refactor shell detection and PowerShell handling for improved clarity…
kriserickson Mar 2, 2026
307e073
Removed useless tests.
kriserickson Mar 2, 2026
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
4 changes: 2 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,15 @@ tasks:
PATH: '{{.GOPATH}}/bin:{{.PATH}}'
cmds:
- task: install:lint
- golangci-lint run
- golangci-lint run ./...

lint:fix:
desc: Run golangci-lint and fix issues
env:
PATH: '{{.GOPATH}}/bin:{{.PATH}}'
cmds:
- task: install:lint
- golangci-lint run --fix
- golangci-lint run ./... --fix

install:lint:
desc: Install golangci-lint if missing
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func runRoot(_ *cobra.Command, args []string) error {
return nil
},
}
return interactiveRun(Version, cmds, cfg, client, shellInfo)
return interactiveRun(Version, cmds, cfg, client, shellInfo, explainFlag)
}

// Single-shot mode
Expand Down
48 changes: 44 additions & 4 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func TestRunRoot_InteractiveMode_ConfigRunShow(t *testing.T) {
t.Cleanup(func() { interactiveRun = origRun })

var capturedCmds interactive.BuiltinCommands
interactiveRun = func(_ string, cmds interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info) error {
interactiveRun = func(_ string, cmds interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info, _ bool) error {
capturedCmds = cmds
return nil
}
Expand Down Expand Up @@ -160,7 +160,7 @@ func TestRunRoot_InteractiveMode_MemoryRun(t *testing.T) {
t.Cleanup(func() { interactiveRun = origRun })

var capturedCmds interactive.BuiltinCommands
interactiveRun = func(_ string, cmds interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info) error {
interactiveRun = func(_ string, cmds interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info, _ bool) error {
capturedCmds = cmds
return nil
}
Expand Down Expand Up @@ -219,7 +219,7 @@ func TestRunRoot_InteractiveMode_StatusDoctorSetModel(t *testing.T) {
t.Cleanup(func() { interactiveRun = origRun })

var capturedCmds interactive.BuiltinCommands
interactiveRun = func(_ string, cmds interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info) error {
interactiveRun = func(_ string, cmds interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info, _ bool) error {
capturedCmds = cmds
return nil
}
Expand Down Expand Up @@ -253,7 +253,7 @@ func TestRunRoot_InteractiveError(t *testing.T) {
origRun := interactiveRun
t.Cleanup(func() { interactiveRun = origRun })

interactiveRun = func(_ string, _ interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info) error {
interactiveRun = func(_ string, _ interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info, _ bool) error {
return errors.New("interactive failed")
}

Expand All @@ -267,6 +267,46 @@ func TestRunRoot_InteractiveError(t *testing.T) {
}
}

func TestRunRoot_InteractiveMode_ExplainFlag(t *testing.T) {
tempHome(t)
cfg := config.DefaultConfig()
cfg.Provider.Default = config.ProviderOpenAI
cfg.Provider.OpenAI.APIKey = "sk-test-interactive"
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
}

origRun := interactiveRun
origExplain := explainFlag
t.Cleanup(func() {
interactiveRun = origRun
explainFlag = origExplain
})

var capturedExplain bool
interactiveRun = func(_ string, _ interactive.BuiltinCommands, _ *config.Config, _ llm.Client, _ shell.Info, explain bool) error {
capturedExplain = explain
return nil
}

debugFlag = ""
explainFlag = false
if err := runRoot(nil, nil); err != nil {
t.Fatalf("runRoot() error: %v", err)
}
if capturedExplain {
t.Fatal("explain = true, want false (default)")
}

explainFlag = true
if err := runRoot(nil, nil); err != nil {
t.Fatalf("runRoot() error: %v", err)
}
if !capturedExplain {
t.Fatal("explain = false, want true")
}
}

func TestRunRoot_DebugFileError(t *testing.T) {
tempHome(t)
cfg := config.DefaultConfig()
Expand Down
2 changes: 1 addition & 1 deletion internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func Run(commands []llm.Command, cfg *config.Config, shellInfo shell.Info, expla
dimColor.Printf(" %d%% certainty\n", cmd.Certainty)

if explain && cmd.Explanation != "" {
dimColor.Printf(" 💡 %s\n", cmd.Explanation)
dimColor.Fprintf(os.Stdout, " 💡 %s\n", cmd.Explanation)
}

if ShouldConfirm(cmd, cfg) {
Expand Down
7 changes: 4 additions & 3 deletions internal/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,13 @@ func TestRunExecutesSafeCommandWithoutConfirmation(t *testing.T) {

func TestRunWithExplainTrue(t *testing.T) {
cfg := defaultSafetyCfg()
const explanationText = "Unique explanation string that only appears in the explanation output."
cmds := []llm.Command{{
Command: "echo explain-test",
Command: "echo hello",
Description: "echo with explanation",
Risk: "safe",
Certainty: 100,
Explanation: "Prints the text 'explain-test' to stdout.",
Explanation: explanationText,
}}

// Capture stdout to verify explanation is printed
Expand All @@ -160,7 +161,7 @@ func TestRunWithExplainTrue(t *testing.T) {
t.Fatalf("Run() error = %v, want nil", runErr)
}
output := buf.String()
if !strings.Contains(output, "explain-test") {
if !strings.Contains(output, explanationText) {
t.Errorf("output missing explanation text:\n%s", output)
}
}
Expand Down
16 changes: 7 additions & 9 deletions internal/interactive/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,8 @@ type replLineReader interface {
var (
replConfigDir = config.Dir
replNewReadline = func(cfg *readline.Config) (replLineReader, error) { return readline.NewEx(cfg) }
replBuildSystemPrompt = func(osName, shellName, shellVersion, cwd string, toolsEnabled bool) string {
return llm.BuildSystemPrompt(osName, shellName, shellVersion, cwd, false, toolsEnabled)
}
replHandleResponse = handleResponse
replBuildSystemPrompt = llm.BuildSystemPrompt
replHandleResponse = handleResponse
)

// BuiltinCommands holds handlers for built-in REPL commands so the interactive
Expand All @@ -45,7 +43,7 @@ type BuiltinCommands struct {
MemoryRun func(args []string) error
}

func Run(version string, cmds BuiltinCommands, cfg *config.Config, client llm.Client, shellInfo shell.Info) error {
func Run(version string, cmds BuiltinCommands, cfg *config.Config, client llm.Client, shellInfo shell.Info, explain bool) error {
configDir, err := replConfigDir()
if err != nil {
return err
Expand All @@ -63,7 +61,7 @@ func Run(version string, cmds BuiltinCommands, cfg *config.Config, client llm.Cl
fmt.Printf("AI CLI %s — interactive mode. Type 'help' for commands or 'exit' to quit.\n", version)

toolsEnabled := cfg.Safety.ToolCalling != config.ToolCallingNever
systemPrompt := replBuildSystemPrompt(shellInfo.OS, shellInfo.Shell, shellInfo.Version, "", toolsEnabled)
systemPrompt := replBuildSystemPrompt(shellInfo.OS, shellInfo.Shell, shellInfo.Version, "", explain, toolsEnabled)

for {
line, err := rl.Readline()
Expand Down Expand Up @@ -140,7 +138,7 @@ func Run(version string, cmds BuiltinCommands, cfg *config.Config, client llm.Cl
color.Red("Error: %v", err)
continue
}
if err := replHandleResponse(resp, cfg, shellInfo); err != nil {
if err := replHandleResponse(resp, cfg, shellInfo, explain); err != nil {
color.Red("Error: %v", err)
}
}
Expand All @@ -167,10 +165,10 @@ func printHelp() {
fmt.Println()
}

func handleResponse(resp *llm.Response, cfg *config.Config, shellInfo shell.Info) error {
func handleResponse(resp *llm.Response, cfg *config.Config, shellInfo shell.Info, explain bool) error {
switch resp.Type {
case "commands":
return executor.Run(resp.Commands, cfg, shellInfo, false)
return executor.Run(resp.Commands, cfg, shellInfo, explain)
case "config":
return applyConfig(resp, cfg)
default:
Expand Down
78 changes: 57 additions & 21 deletions internal/interactive/repl_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,22 @@ import (
)

type fakeClient struct {
chat func(systemPrompt, userMessage string) (*llm.Response, error)
chat func(systemPrompt, userMessage string) (*llm.Response, error)
chatMessages func(messages []llm.Message) (*llm.Response, error)
}

func (f fakeClient) Chat(systemPrompt, userMessage string) (*llm.Response, error) {
if f.chat == nil {
return nil, errors.New("fakeClient.Chat not implemented")
}
return f.chat(systemPrompt, userMessage)
}

func (f fakeClient) ChatMessages(messages []llm.Message) (*llm.Response, error) {
// Extract system and user message for compatibility with existing tests
var system, user string
for _, m := range messages {
switch m.Role {
case "system":
system = m.Content
case "user":
user = m.Content
}
if f.chatMessages == nil {
return nil, errors.New("fakeClient.ChatMessages not implemented")
}
return f.chat(system, user)
return f.chatMessages(messages)
}

type fakeReadlineStep struct {
Expand Down Expand Up @@ -107,7 +104,7 @@ func TestRun_ConfigDirError(t *testing.T) {
err := Run("dev", BuiltinCommands{}, testCfg(t), fakeClient{chat: func(string, string) (*llm.Response, error) {
t.Fatal("client.Chat should not be called")
return nil, nil
}}, shell.Info{})
}}, shell.Info{}, false)
if err == nil {
t.Fatal("Run() error = nil, want error")
}
Expand All @@ -126,7 +123,7 @@ func TestRun_ReadlineInitError(t *testing.T) {
err := Run("dev", BuiltinCommands{}, testCfg(t), fakeClient{chat: func(string, string) (*llm.Response, error) {
t.Fatal("client.Chat should not be called")
return nil, nil
}}, shell.Info{})
}}, shell.Info{}, false)
if err == nil {
t.Fatal("Run() error = nil, want error")
}
Expand Down Expand Up @@ -155,7 +152,7 @@ func TestRun_ExitOnEOFAndInterrupt(t *testing.T) {
err := Run("dev", BuiltinCommands{}, testCfg(t), fakeClient{chat: func(string, string) (*llm.Response, error) {
t.Fatal("client.Chat should not be called")
return nil, nil
}}, shell.Info{})
}}, shell.Info{}, false)
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
Expand All @@ -180,7 +177,7 @@ func TestRun_ReadlineUnexpectedError(t *testing.T) {
err := Run("dev", BuiltinCommands{}, testCfg(t), fakeClient{chat: func(string, string) (*llm.Response, error) {
t.Fatal("client.Chat should not be called")
return nil, nil
}}, shell.Info{})
}}, shell.Info{}, false)
if err == nil {
t.Fatal("Run() error = nil, want error")
}
Expand Down Expand Up @@ -212,13 +209,13 @@ func TestRun_DispatchesBuiltinsAndLLM(t *testing.T) {
replNewReadline = func(*readline.Config) (replLineReader, error) { return rl, nil }

var promptArgs []string
replBuildSystemPrompt = func(osName, shellName, shellVersion, cwd string, _ bool) string {
replBuildSystemPrompt = func(osName, shellName, shellVersion, cwd string, _, _ bool) string {
promptArgs = []string{osName, shellName, shellVersion, cwd}
return "system-prompt"
}

var handled []*llm.Response
replHandleResponse = func(resp *llm.Response, _ *config.Config, info shell.Info) error {
replHandleResponse = func(resp *llm.Response, _ *config.Config, info shell.Info, _ bool) error {
if info.Shell != "powershell" {
t.Fatalf("shellInfo not passed through: %+v", info)
}
Expand Down Expand Up @@ -260,7 +257,7 @@ func TestRun_DispatchesBuiltinsAndLLM(t *testing.T) {
OS: "windows/amd64",
Shell: "powershell",
Version: "7.5.0",
})
}, false)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
Expand Down Expand Up @@ -305,10 +302,10 @@ func TestRun_ContinuesAfterBuiltinLLMAndHandleErrors(t *testing.T) {
},
}
replNewReadline = func(*readline.Config) (replLineReader, error) { return rl, nil }
replBuildSystemPrompt = func(_, _, _, _ string, _ bool) string { return "sys" }
replBuildSystemPrompt = func(_, _, _, _ string, _ bool, _ bool) string { return "sys" }

handleCalls := 0
replHandleResponse = func(_ *llm.Response, _ *config.Config, _ shell.Info) error {
replHandleResponse = func(_ *llm.Response, _ *config.Config, _ shell.Info, _ bool) error {
handleCalls++
return errors.New("handle failed")
}
Expand All @@ -332,7 +329,7 @@ func TestRun_ContinuesAfterBuiltinLLMAndHandleErrors(t *testing.T) {
}

out := captureOutput(t, func() {
if err := Run("dev", cmds, testCfg(t), client, shell.Info{}); err != nil {
if err := Run("dev", cmds, testCfg(t), client, shell.Info{}, false); err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
})
Expand All @@ -350,3 +347,42 @@ func TestRun_ContinuesAfterBuiltinLLMAndHandleErrors(t *testing.T) {
t.Fatalf("output missing Bye!\n%s", out)
}
}

func TestRun_ExplainFlagPropagated(t *testing.T) {
stubReplHooks(t)
replConfigDir = func() (string, error) { return t.TempDir(), nil }
rl := &fakeReadline{
steps: []fakeReadlineStep{
{line: "do something"},
{line: "exit"},
},
}
replNewReadline = func(*readline.Config) (replLineReader, error) { return rl, nil }

var capturedPromptExplain bool
replBuildSystemPrompt = func(_, _, _, _ string, explain, _ bool) string {
capturedPromptExplain = explain
return "sys"
}

var capturedHandleExplain bool
replHandleResponse = func(_ *llm.Response, _ *config.Config, _ shell.Info, explain bool) error {
capturedHandleExplain = explain
return nil
}

client := fakeClient{chat: func(_, _ string) (*llm.Response, error) {
return &llm.Response{Type: "commands"}, nil
}}

if err := Run("dev", BuiltinCommands{}, testCfg(t), client, shell.Info{}, true); err != nil {
t.Fatalf("Run() error = %v", err)
}

if !capturedPromptExplain {
t.Fatal("replBuildSystemPrompt: explain = false, want true")
}
if !capturedHandleExplain {
t.Fatal("replHandleResponse: explain = false, want true")
}
}
6 changes: 3 additions & 3 deletions internal/interactive/repl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func TestPrintHelp(_ *testing.T) {
}

func TestHandleResponseCommands(t *testing.T) {
err := handleResponse(&llm.Response{Type: "commands"}, testCfg(t), shell.Info{Shell: "cmd"})
err := handleResponse(&llm.Response{Type: "commands"}, testCfg(t), shell.Info{Shell: "cmd"}, false)
if err != nil {
t.Fatalf("handleResponse(commands) error = %v", err)
}
Expand All @@ -61,7 +61,7 @@ func TestHandleResponseConfig(t *testing.T) {
}

withStdin(t, "y\n", func() {
if err := handleResponse(resp, cfg, shell.Info{}); err != nil {
if err := handleResponse(resp, cfg, shell.Info{}, false); err != nil {
t.Fatalf("handleResponse(config) error = %v", err)
}
})
Expand All @@ -72,7 +72,7 @@ func TestHandleResponseConfig(t *testing.T) {
}

func TestHandleResponseUnknownType(t *testing.T) {
err := handleResponse(&llm.Response{Type: "mystery"}, testCfg(t), shell.Info{})
err := handleResponse(&llm.Response{Type: "mystery"}, testCfg(t), shell.Info{}, false)
if err == nil {
t.Fatal("handleResponse() error = nil, want error")
}
Expand Down
Loading