Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
9da2d71
Add explain flag and detailed command explanations
Mar 1, 2026
e0c33e7
Implement tool execution framework with safety checks and environment…
Mar 1, 2026
ed19106
Merge remote-tracking branch 'origin/main' into add-explain
Mar 1, 2026
571ad79
Add tool calling configuration to README with usage modes
Mar 1, 2026
496207f
Refactor tool calling error handling and improve command execution sa…
Mar 1, 2026
fc1d618
Add tests for ToolCalling safety settings in ApplyAction
Mar 1, 2026
8b639b8
Initial plan
Copilot Mar 1, 2026
5317ed6
Initial plan
Copilot Mar 1, 2026
c5e4b5b
Update internal/tools/tools.go
kriserickson Mar 1, 2026
bd70c38
Initial plan
Copilot Mar 1, 2026
119594a
Initial plan
Copilot Mar 1, 2026
9afeb3e
Initial plan
Copilot Mar 1, 2026
b74c581
Fix doc comment typo: ValidToolCallingModes -> ValidToolCallingMode
Copilot Mar 1, 2026
73339af
Update internal/tools/tools_test.go
kriserickson Mar 1, 2026
8c00994
Initial plan
Copilot Mar 1, 2026
d54b80b
Initial plan
Copilot Mar 1, 2026
fbdd833
Update internal/tools/safety.go
kriserickson Mar 1, 2026
38a9381
Update internal/interactive/repl.go
kriserickson Mar 1, 2026
4bd9b68
Filter blocked entries from list_directory output using os.ReadDir
Copilot Mar 1, 2026
598508b
Initial plan
Copilot Mar 1, 2026
593bf89
Initial plan
Copilot Mar 1, 2026
e967ef9
Initial plan
Copilot Mar 1, 2026
de65697
Filter blocked entries from list_directory tool output using Go-nativ…
Copilot Mar 1, 2026
f6aa77c
Fix PowerShell injection in execCommandHelp and execCheckCommand on W…
Copilot Mar 1, 2026
990c9b5
Use ss over netstat in execNetworkConnections with LookPath fallback
Copilot Mar 1, 2026
98aa7d1
Stub system commands in TestExecute_ProcessAndNetworkTools to prevent…
Copilot Mar 1, 2026
8407423
Merge pull request #39 from kriserickson/copilot/sub-pr-28-6d17c84d-5…
kriserickson Mar 1, 2026
a6aad9b
Merge pull request #37 from kriserickson/copilot/sub-pr-28-one-more-time
kriserickson Mar 1, 2026
a94d8e7
Merge pull request #36 from kriserickson/copilot/sub-pr-28-yet-again
kriserickson Mar 1, 2026
cfdfcc1
Fail closed on unknown tool_calling modes
Copilot Mar 1, 2026
82b28df
Add context.WithTimeout to all tool exec functions to prevent hangs
Copilot Mar 1, 2026
b06e29e
Merge pull request #35 from kriserickson/copilot/sub-pr-28-another-one
kriserickson Mar 1, 2026
4a85c51
Fix ValidatePath to resolve symlinks and re-validate resolved path
Copilot Mar 1, 2026
3b487a9
Merge branch 'tool-calling' into copilot/sub-pr-28-again
kriserickson Mar 1, 2026
30261c1
Merge pull request #34 from kriserickson/copilot/sub-pr-28-again
kriserickson Mar 1, 2026
a4a83ee
Merge pull request #42 from kriserickson/copilot/sub-pr-28-0af2f0aa-b…
kriserickson Mar 1, 2026
2278221
Fix ValidatePath to resolve symlinks before enforcing cwd boundary
Copilot Mar 1, 2026
a84bdd7
Merge branch 'tool-calling' into copilot/sub-pr-28-9f07b043-1c95-478a…
kriserickson Mar 1, 2026
27b0c44
Merge pull request #40 from kriserickson/copilot/sub-pr-28-9f07b043-1…
kriserickson Mar 1, 2026
97faeb8
Merge pull request #38 from kriserickson/copilot/sub-pr-28-please-work
kriserickson Mar 1, 2026
394a821
Merge branch 'tool-calling' into copilot/sub-pr-28
kriserickson Mar 2, 2026
daa4970
Merge branch 'tool-calling' into copilot/sub-pr-28-e187be21-d8ea-447d…
kriserickson Mar 2, 2026
1f3eece
Refactor execNetworkConnections to use context and improve error hand…
kriserickson Mar 2, 2026
7307ab1
Skip non-Windows tests in TestParentShellProcess and TestPreferredPow…
kriserickson Mar 2, 2026
5f28377
Refactor tests to improve platform compatibility and error handling
kriserickson Mar 2, 2026
c05b509
Refactor TestValidatePath to define cwd as a variable instead of usin…
kriserickson Mar 2, 2026
06a3354
Merge pull request #41 from kriserickson/copilot/sub-pr-28-e187be21-d…
kriserickson Mar 2, 2026
8d98943
Enhance environment tool execution to prevent output truncation and i…
kriserickson Mar 2, 2026
5813549
Merge branch 'tool-calling' into copilot/sub-pr-28
kriserickson Mar 2, 2026
2f345b3
Remove lint task from Taskfile and refactor TestValidatePath to impro…
kriserickson Mar 2, 2026
6811511
Merge pull request #33 from kriserickson/copilot/sub-pr-28
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: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"Bash(go get:*)",
"Bash(go tool cover -func=coverage.out 2>&1)",
"Bash(echo done:*)",
"Bash(go:*)"
"Bash(go:*)",
"Bash(golangci-lint run:*)",
"Bash(gofumpt:*)"
]
}
}
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ To customize allowlist prefixes, edit `~/.ai-cli/config.toml`:
```toml
[safety]
always_confirm = false
tool_calling = "never"
min_certainty = 80
allowlist_prefixes = ["git", "ls", "cat", "echo", "pwd", "head", "tail", "wc", "grep", "find", "which", "man"]
```
Expand All @@ -206,6 +207,33 @@ After editing, run:
ai status
```

## Tool Calling

AI CLI can optionally let the model use built-in read-only tools before it generates shell commands. This is controlled by `safety.tool_calling`.

| Mode | Description |
|------|-------------|
| `never` | Tools are fully disabled. No tool instructions are added to the prompt and no tool loop runs. Default. |
| `always_prompt` | Prompt before every tool call. |
| `dangerous_prompt` | Auto-approve safe tool calls, but prompt when a tool triggers a safety rule. |
| `always_allow` | Execute all tool calls without prompting. |

Set it with:

```sh
ai config set tool_calling never
ai config set tool_calling always_prompt
ai config set tool_calling dangerous_prompt
ai config set tool_calling always_allow
```

Notes:

- `tool_calling` only controls AI tool usage
- generated shell commands still follow `always_confirm`, `min_certainty`, risk classification, and the allowlist
- `dangerous_prompt` only prompts when a tool call hits a safety rule, such as trying to read a restricted path
- `never` makes AI CLI skip the tool loop entirely and respond directly

## Memories

Memories let you store named context (like server addresses, port mappings, or project conventions) that automatically gets injected into the AI prompt when the keyword appears in your input.
Expand Down Expand Up @@ -349,6 +377,7 @@ Available `ai config get/set` keys:
| `llm_key` | API key for the current provider | (empty) |
| `llm_url` | Base URL for the current provider | (provider default) |
| `always_confirm` | Always prompt before execution (`true`/`false`) | `false` |
| `tool_calling` | Tool usage mode (`never`, `always_prompt`, `dangerous_prompt`, `always_allow`) | `never` |
| `min_certainty` | Auto-execute threshold (0-100) | `80` |
| `debug` | Debug mode (`none`, `screen`, `file`) | `none` |

Expand Down
5 changes: 0 additions & 5 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 Down
11 changes: 10 additions & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var configKeys = []string{
"llm_key",
"llm_url",
"always_confirm",
"tool_calling",
"min_certainty",
"debug",
}
Expand All @@ -27,6 +28,7 @@ var configKeys = []string{
var configKeyValues = map[string][]string{
"provider": {config.ProviderOpenAI, config.ProviderOpenRouter, config.ProviderLocal},
"always_confirm": {"true", "false"},
"tool_calling": {config.ToolCallingNever, config.ToolCallingAlwaysPrompt, config.ToolCallingDangerousPrompt, config.ToolCallingAlwaysAllow},
"debug": {config.DebugNone, config.DebugScreen, config.DebugFile},
}

Expand Down Expand Up @@ -133,6 +135,8 @@ func getConfigValue(cfg *config.Config, key string) (string, error) {
return currentProviderDetail(cfg).BaseURL, nil
case "always_confirm":
return strconv.FormatBool(cfg.Safety.AlwaysConfirm), nil
case "tool_calling":
return cfg.Safety.ToolCalling, nil
case "min_certainty":
return strconv.Itoa(cfg.Safety.MinCertainty), nil
case "allowlist":
Expand Down Expand Up @@ -163,6 +167,11 @@ func setConfigValue(cfg *config.Config, key, value string) error {
return fmt.Errorf("always_confirm %w", err)
}
cfg.Safety.AlwaysConfirm = b
case "tool_calling":
if !config.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 All @@ -178,7 +187,7 @@ func setConfigValue(cfg *config.Config, key, value string) error {
}
cfg.Debug = value
default:
return fmt.Errorf("unknown config key: %s\nValid keys: provider, model, llm_key, llm_url, always_confirm, min_certainty, debug", key)
return fmt.Errorf("unknown config key: %s\nValid keys: provider, model, llm_key, llm_url, always_confirm, tool_calling, min_certainty, debug", key)
}
return nil
}
Expand Down
14 changes: 10 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ import (
"github.com/kriserickson/ai-cli/internal/llm"
"github.com/kriserickson/ai-cli/internal/memory"
"github.com/kriserickson/ai-cli/internal/shell"
"github.com/kriserickson/ai-cli/internal/tools"
)

const windows = "windows"

var debugFlag string
var (
debugFlag string
explainFlag bool
)

// interactiveRun is the entry point for interactive mode, stubbable for testing.
var interactiveRun = interactive.Run
Expand All @@ -40,6 +44,7 @@ func init() {
rootCmd.Flags().StringVar(&debugFlag, "debug", "", "Debug mode: screen (default) or file (overrides config)")
// When --debug is given without a value, default to config.DebugScreen
rootCmd.Flags().Lookup("debug").NoOptDefVal = config.DebugScreen
rootCmd.Flags().BoolVar(&explainFlag, "explain", false, "Show detailed explanation of each command")
// Allow flags to be interspersed with args
rootCmd.Flags().SetInterspersed(true)
}
Expand Down Expand Up @@ -176,7 +181,8 @@ 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)
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 All @@ -191,14 +197,14 @@ func runRoot(_ *cobra.Command, args []string) error {
}
}

resp, err := client.Chat(systemPrompt, instruction)
resp, err := tools.RunWithTools(client, systemPrompt, instruction, cfg, shellInfo, 3)
if err != nil {
return err
}

switch resp.Type {
case "commands":
return executor.Run(resp.Commands, cfg, shellInfo)
return executor.Run(resp.Commands, cfg, shellInfo, explainFlag)
case "config":
return handleConfig(resp, cfg)
default:
Expand Down
19 changes: 19 additions & 0 deletions cmd/root_single_shot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func TestRunRootSingleShot_CommandsResponse(t *testing.T) {

saveRootConfig(t, server.URL)
debugFlag = ""
explainFlag = false

if err := runRoot(nil, []string{"say", "hello"}); err != nil {
t.Fatalf("runRoot() error: %v", err)
Expand All @@ -54,6 +55,7 @@ func TestRunRootSingleShot_UnexpectedResponseType(t *testing.T) {

saveRootConfig(t, server.URL)
debugFlag = ""
explainFlag = false

err := runRoot(nil, []string{"test"})
if err == nil {
Expand Down Expand Up @@ -147,3 +149,20 @@ func TestRunRootSingleShot_ConfigResponse(t *testing.T) {
}
})
}

func TestRunRootSingleShot_WithExplainFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"type\":\"commands\",\"commands\":[{\"command\":\"echo hello\",\"description\":\"say hello\",\"risk\":\"safe\",\"certainty\":99,\"explanation\":\"Prints hello to stdout.\"}]}"}}]}`))
}))
defer server.Close()

saveRootConfig(t, server.URL)
debugFlag = ""
explainFlag = true
defer func() { explainFlag = false }()

if err := runRoot(nil, []string{"say", "hello"}); err != nil {
t.Fatalf("runRoot() error: %v", err)
}
}
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
17 changes: 17 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ 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 {
Expand All @@ -39,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 @@ -61,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
6 changes: 5 additions & 1 deletion internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var (
)

// Run executes a list of commands sequentially, prompting for confirmation as needed.
func Run(commands []llm.Command, cfg *config.Config, shellInfo shell.Info) error {
func Run(commands []llm.Command, cfg *config.Config, shellInfo shell.Info, explain bool) error {
for i, cmd := range commands {
if len(commands) > 1 {
dimColor.Printf("\n[%d/%d] ", i+1, len(commands))
Expand All @@ -43,6 +43,10 @@ func Run(commands []llm.Command, cfg *config.Config, shellInfo shell.Info) error
}
dimColor.Printf(" %d%% certainty\n", cmd.Certainty)

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

if ShouldConfirm(cmd, cfg) {
if !askConfirmation() {
fmt.Println("Skipped.")
Expand Down
Loading