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
9 changes: 2 additions & 7 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,6 @@ tasks:
cmds:
- go test -cover ./...

lint:
desc: Lint the Go code using golangci-lint
cmds:
- golangci-lint run ./...

test:coverage:
desc: Run all Go tests
cmds:
Expand All @@ -100,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
7 changes: 4 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,7 @@ func runRoot(_ *cobra.Command, args []string) error {
return nil
},
}
cfg.Explain = explainFlag
return interactiveRun(Version, cmds, cfg, client, shellInfo)
return interactiveRun(Version, cmds, cfg, client, shellInfo, explainFlag)
}

// Single-shot mode
Expand All @@ -181,7 +180,9 @@ func runRoot(_ *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
systemPrompt := llm.BuildSystemPrompt(shellInfo.OS, shellInfo.Shell, shellInfo.Version, cwd, explainFlag)

toolsEnabled := cfg.Safety.ToolCalling != config.ToolCallingNever
systemPrompt := llm.BuildSystemPrompt(shellInfo.OS, shellInfo.Shell, shellInfo.Version, cwd, explainFlag, toolsEnabled)

// Inject matching memories into the system prompt
entries, err := memory.Load()
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
}
Comment on lines 103 to 107

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

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

Interactive mode tests stub interactiveRun but never assert that the new explain flag is forwarded. Add/extend a test to set explainFlag=true before runRoot and verify the stub receives explain=true (and similarly verify false by default) so this regression can’t reappear.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added TestRunRoot_InteractiveMode_ExplainFlag in commit 8818e5a. It calls runRoot with explainFlag=false and verifies the stub receives false, then again with explainFlag=true and verifies it receives true.

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
5 changes: 5 additions & 0 deletions internal/config/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ func ApplyAction(cfg *Config, action, key, value string) error {
return fmt.Errorf("always_confirm %w", err)
}
cfg.Safety.AlwaysConfirm = b
case "tool_calling":
if !ValidToolCallingMode(value) {
return errors.New("tool_calling must be 'never', 'always_prompt', 'dangerous_prompt', or 'always_allow'")
}
cfg.Safety.ToolCalling = value
case "min_certainty":
n, err := strconv.Atoi(value)
if err != nil {
Expand Down
34 changes: 34 additions & 0 deletions internal/config/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,40 @@ func TestApplyAction_SetSafety_MinCertainty(t *testing.T) {
}
}

func TestApplyAction_SetSafety_ToolCalling(t *testing.T) {
tests := []struct {
name string
value string
want string
wantErr bool
}{
{name: "never", value: ToolCallingNever, want: ToolCallingNever},
{name: "always_prompt", value: ToolCallingAlwaysPrompt, want: ToolCallingAlwaysPrompt},
{name: "dangerous_prompt", value: ToolCallingDangerousPrompt, want: ToolCallingDangerousPrompt},
{name: "always_allow", value: ToolCallingAlwaysAllow, want: ToolCallingAlwaysAllow},
{name: "invalid", value: "sometimes", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := applyTestCfg(t)
err := ApplyAction(cfg, "set_safety", "tool_calling", tt.value)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Safety.ToolCalling != tt.want {
t.Errorf("ToolCalling = %q, want %q", cfg.Safety.ToolCalling, tt.want)
}
})
}
}

func TestApplyAction_SetSafety_UnknownKey(t *testing.T) {
cfg := applyTestCfg(t)
err := ApplyAction(cfg, "set_safety", "nonexistent", "value")
Expand Down
18 changes: 17 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ const (
DebugNone = "none"
DebugScreen = "screen"
DebugFile = "file"

// ToolCalling modes control whether the AI can use read-only tools.
ToolCallingNever = "never" // Tools disabled entirely
ToolCallingAlwaysPrompt = "always_prompt" // Prompt the user before every tool call
ToolCallingDangerousPrompt = "dangerous_prompt" // Only prompt when a tool hits a safety rule
ToolCallingAlwaysAllow = "always_allow" // Execute all tools without prompting
)

type Config struct {
Provider ProviderConfig `toml:"provider"`
Safety SafetyConfig `toml:"safety"`
Debug string `toml:"debug"` // "none", "screen", or "file"
Explain bool `toml:"-"` // runtime-only; set from CLI flag, not persisted
}

type ProviderConfig struct {
Expand All @@ -40,11 +45,21 @@ type ProviderDetail struct {

type SafetyConfig struct {
AlwaysConfirm bool `toml:"always_confirm"`
ToolCalling string `toml:"tool_calling"`
MinCertainty int `toml:"min_certainty"`
AllowlistPrefixes []string `toml:"allowlist_prefixes"`
WhitelistPrefixes []string `toml:"whitelist_prefixes,omitempty"` // Deprecated: use allowlist_prefixes
}

// ValidToolCallingMode returns true if the given mode is a valid tool_calling value.
func ValidToolCallingMode(mode string) bool {
switch mode {
case ToolCallingNever, ToolCallingAlwaysPrompt, ToolCallingDangerousPrompt, ToolCallingAlwaysAllow:
return true
}
return false
}

func DefaultConfig() *Config {
return &Config{
Provider: ProviderConfig{
Expand All @@ -62,6 +77,7 @@ func DefaultConfig() *Config {
},
Safety: SafetyConfig{
AlwaysConfirm: false,
ToolCalling: ToolCallingNever,
MinCertainty: 80,
AllowlistPrefixes: []string{"git", "ls", "cat", "echo", "pwd", "head", "tail", "wc", "grep", "find", "which", "man"},
},
Expand Down
29 changes: 29 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,35 @@ func TestDefaultConfig(t *testing.T) {
}
}

func TestValidToolCallingMode(t *testing.T) {
validModes := []string{
ToolCallingNever,
ToolCallingAlwaysPrompt,
ToolCallingDangerousPrompt,
ToolCallingAlwaysAllow,
}

for _, mode := range validModes {
if !ValidToolCallingMode(mode) {
t.Errorf("ValidToolCallingMode(%q) = false, want true", mode)
}
}

invalidModes := []string{
"",
"always",
"dangerous",
"prompt",
"ALLOW",
}

for _, mode := range invalidModes {
if ValidToolCallingMode(mode) {
t.Errorf("ValidToolCallingMode(%q) = true, want false", mode)
}
}
}

func TestSaveAndLoad(t *testing.T) {
// Use a temp dir to avoid touching the real config.
// Set both HOME (Unix) and USERPROFILE (Windows) since os.UserHomeDir()
Expand Down
17 changes: 8 additions & 9 deletions internal/interactive/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,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) string {
return llm.BuildSystemPrompt(osName, shellName, shellVersion, cwd, false)
}
replHandleResponse = handleResponse
replBuildSystemPrompt = llm.BuildSystemPrompt
replHandleResponse = handleResponse
)

// BuiltinCommands holds handlers for built-in REPL commands so the interactive
Expand All @@ -44,7 +42,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 @@ -61,7 +59,8 @@ 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)

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

for {
line, err := rl.Readline()
Expand Down Expand Up @@ -138,7 +137,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 @@ -165,10 +164,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, cfg.Explain)
return executor.Run(resp.Commands, cfg, shellInfo, explain)
case "config":
return applyConfig(resp, cfg)
default:
Expand Down
Loading