Skip to content

Commit 8787fbb

Browse files
AnnatarHeclaude
andcommitted
feat(ai): add AI auto-run configuration and command classification
- Add AI configuration section to ShellTimeConfig with agent auto-run settings - Create command classifier to detect view/edit/delete actions - Implement AI auto-run in query command with safety checks - Add confirmation prompt for delete commands - Update documentation with AI configuration examples - Display helpful tips when auto-run is not configured The AI auto-run feature allows users to configure automatic execution of AI-suggested commands based on their type (view, edit, or delete). Delete commands require explicit confirmation for safety. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 18f2f94 commit 8787fbb

7 files changed

Lines changed: 383 additions & 4 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ The CLI stores its configuration in `$HOME/.shelltime/config.toml`.
2626
| `dataMasking` | boolean | `true` | Enable/disable masking of sensitive data in tracked commands |
2727
| `enableMetrics` | boolean | `false` | Enable detailed command metrics tracking (WARNING: May impact performance) |
2828
| `endpoints` | array | `[]` | Additional API endpoints for development or testing |
29+
| `ai.agent.view` | boolean | `false` | Auto-run AI for view commands (e.g., ls, cat, show) |
30+
| `ai.agent.edit` | boolean | `false` | Auto-run AI for edit commands (e.g., vim, nano, code) |
31+
| `ai.agent.delete` | boolean | `false` | Auto-run AI for delete commands (e.g., rm, rmdir) |
2932

3033
Example configuration:
3134
```toml
@@ -36,10 +39,18 @@ flushCount = 10
3639
gcTime = 14
3740
dataMasking = true
3841
enableMetrics = false
42+
43+
# AI configuration (optional)
44+
[ai.agent]
45+
view = false # Auto-run AI for view commands
46+
edit = false # Auto-run AI for edit commands
47+
delete = false # Auto-run AI for delete commands
3948
```
4049

4150
⚠️ Note: Setting `enableMetrics` to `true` will track detailed metrics for every command execution. Only enable this when requested by developers for debugging purposes, as it may impact shell performance.
4251

52+
📤 AI Auto-run: When AI agent auto-run is enabled for specific command types, the AI will automatically analyze and provide suggestions when you run matching commands. This feature requires the AI service to be properly configured.
53+
4354
## Commands
4455

4556
### Authentication

commands/query.go

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package commands
22

33
import (
4+
"context"
45
"fmt"
56
"os"
7+
"os/exec"
68
"runtime"
79
"strings"
810
"time"
@@ -60,7 +62,7 @@ func commandQuery(c *cli.Context) error {
6062
userId := ""
6163

6264
// Query the AI
63-
response, err := aiService.QueryCommand(ctx, systemContext, userId)
65+
newCommand, err := aiService.QueryCommand(ctx, systemContext, userId)
6466
if err != nil {
6567
s.Stop()
6668
color.Red.Printf("❌ Failed to query AI: %v\n", err)
@@ -69,9 +71,101 @@ func commandQuery(c *cli.Context) error {
6971

7072
s.Stop()
7173

72-
// Display the response
74+
// Trim the command
75+
newCommand = strings.TrimSpace(newCommand)
76+
77+
// Check auto-run configuration
78+
cfg, err := configService.ReadConfigFile(ctx)
79+
if err != nil {
80+
logrus.Warnf("Failed to read config for auto-run check: %v", err)
81+
// If can't read config, just display the command
82+
displayCommand(newCommand)
83+
return nil
84+
}
85+
86+
// Check if AI auto-run is configured
87+
if cfg.AI != nil && (cfg.AI.Agent.View || cfg.AI.Agent.Edit || cfg.AI.Agent.Delete) {
88+
// Classify the command
89+
actionType := model.ClassifyCommand(newCommand)
90+
91+
// Check if this action type is enabled for auto-run
92+
canAutoRun := false
93+
switch actionType {
94+
case model.ActionView:
95+
canAutoRun = cfg.AI.Agent.View
96+
case model.ActionEdit:
97+
canAutoRun = cfg.AI.Agent.Edit
98+
case model.ActionDelete:
99+
canAutoRun = cfg.AI.Agent.Delete
100+
}
101+
102+
if canAutoRun {
103+
// For delete commands, add an extra confirmation
104+
if actionType == model.ActionDelete {
105+
color.Green.Printf("💡 Suggested command:\n")
106+
color.Cyan.Printf("%s\n\n", newCommand)
107+
color.Yellow.Printf("⚠️ This is a DELETE command. Are you sure you want to run it? (y/N): ")
108+
109+
var response string
110+
fmt.Scanln(&response)
111+
if strings.ToLower(strings.TrimSpace(response)) != "y" {
112+
color.Yellow.Printf("Command execution cancelled.\n")
113+
return nil
114+
}
115+
} else {
116+
// Display the command and auto-run it
117+
color.Green.Printf("💡 Auto-running command:\n")
118+
color.Cyan.Printf("%s\n\n", newCommand)
119+
}
120+
121+
// Execute the command
122+
return executeCommand(ctx, newCommand)
123+
} else {
124+
// Display command with info about why it's not auto-running
125+
displayCommand(newCommand)
126+
if actionType != model.ActionOther {
127+
color.Yellow.Printf("\n💡 Tip: This is a %s command. Enable 'ai.agent.%s' in your config to auto-run it.\n",
128+
actionType, actionType)
129+
}
130+
}
131+
} else {
132+
// No auto-run configured, display the command and tip
133+
displayCommand(newCommand)
134+
color.Yellow.Printf("\n💡 Tip: You can enable AI auto-run in your config file:\n")
135+
color.Yellow.Printf(" [ai.agent]\n")
136+
color.Yellow.Printf(" view = true # Auto-run view commands\n")
137+
color.Yellow.Printf(" edit = true # Auto-run edit commands\n")
138+
color.Yellow.Printf(" delete = true # Auto-run delete commands\n")
139+
}
140+
141+
return nil
142+
}
143+
144+
func displayCommand(command string) {
73145
color.Green.Printf("💡 Suggested command:\n")
74-
color.Cyan.Printf("%s\n", strings.TrimSpace(response))
146+
color.Cyan.Printf("%s\n", command)
147+
}
148+
149+
func executeCommand(ctx context.Context, command string) error {
150+
// Get the shell to use
151+
shell := os.Getenv("SHELL")
152+
if shell == "" {
153+
shell = "/bin/sh"
154+
}
155+
156+
// Create command with shell
157+
cmd := exec.CommandContext(ctx, shell, "-c", command)
158+
159+
// Connect stdin, stdout, stderr
160+
cmd.Stdin = os.Stdin
161+
cmd.Stdout = os.Stdout
162+
cmd.Stderr = os.Stderr
163+
164+
// Run the command
165+
if err := cmd.Run(); err != nil {
166+
color.Red.Printf("\n❌ Command failed: %v\n", err)
167+
return err
168+
}
75169

76170
return nil
77171
}

model/ai_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func NewAIService(config AIServiceConfig) AIService {
3030
applyTokenFunc := func(ctx context.Context) (promptpal.ApplyTemporaryTokenResult, error) {
3131
// Read the config to get the user's token
3232
return promptpal.ApplyTemporaryTokenResult{
33-
Token: config.UserToken,
33+
Token: "Bearer " + config.UserToken,
3434
}, nil
3535
}
3636

model/command_classifier.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package model
2+
3+
import (
4+
"slices"
5+
"strings"
6+
)
7+
8+
// CommandActionType represents the type of action a command performs
9+
type CommandActionType string
10+
11+
const (
12+
ActionView CommandActionType = "view"
13+
ActionEdit CommandActionType = "edit"
14+
ActionDelete CommandActionType = "delete"
15+
ActionOther CommandActionType = "other"
16+
)
17+
18+
// ClassifyCommand analyzes a command and determines its action type
19+
func ClassifyCommand(command string) CommandActionType {
20+
// Normalize the command
21+
cmd := strings.TrimSpace(command)
22+
if cmd == "" {
23+
return ActionOther
24+
}
25+
26+
// Split the command to analyze
27+
parts := strings.Fields(cmd)
28+
if len(parts) == 0 {
29+
return ActionOther
30+
}
31+
32+
mainCmd := parts[0]
33+
34+
// Check for shell redirections and pipes
35+
hasOutputRedirection := false
36+
hasAppendRedirection := false
37+
for i, part := range parts {
38+
if part == ">" && i > 0 {
39+
hasOutputRedirection = true
40+
} else if part == ">>" && i > 0 {
41+
hasAppendRedirection = true
42+
}
43+
}
44+
45+
// Special case: echo with redirection
46+
if mainCmd == "echo" {
47+
if hasOutputRedirection || hasAppendRedirection {
48+
return ActionEdit
49+
}
50+
return ActionView
51+
}
52+
53+
// Classify based on the main command
54+
switch mainCmd {
55+
// View commands
56+
case "cat", "less", "more", "head", "tail", "grep", "find", "ls", "ll", "la",
57+
"ps", "top", "htop", "df", "du", "free", "netstat", "ss", "lsof",
58+
"which", "whereis", "file", "stat", "wc", "sort", "uniq",
59+
"cut", "paste", "join", "comm", "diff",
60+
"tree", "pwd", "whoami", "id", "groups", "hostname", "uname",
61+
"date", "cal", "uptime", "w", "who", "last", "history",
62+
"printenv", "env", "set", "alias", "type", "command",
63+
"man", "info", "help", "apropos", "whatis",
64+
"dig", "nslookup", "host", "ping", "traceroute", "curl", "wget",
65+
"systemctl", "service", "journalctl", "dmesg",
66+
"git", "docker", "kubectl":
67+
// Special handling for some commands that might have subcommands
68+
if mainCmd == "git" && len(parts) > 1 {
69+
switch parts[1] {
70+
case "rm", "clean":
71+
return ActionDelete
72+
case "add", "commit", "push", "pull", "merge", "rebase":
73+
return ActionEdit
74+
default:
75+
return ActionView
76+
}
77+
}
78+
if mainCmd == "docker" && len(parts) > 1 {
79+
switch parts[1] {
80+
case "rm", "rmi", "prune":
81+
return ActionDelete
82+
case "build", "run", "create", "start", "stop", "restart":
83+
return ActionEdit
84+
default:
85+
return ActionView
86+
}
87+
}
88+
if mainCmd == "systemctl" && len(parts) > 1 {
89+
switch parts[1] {
90+
case "start", "stop", "restart", "enable", "disable":
91+
return ActionEdit
92+
default:
93+
return ActionView
94+
}
95+
}
96+
return ActionView
97+
98+
// Edit commands
99+
case "vim", "vi", "nano", "emacs", "code", "subl", "atom", "gedit", "kate",
100+
"nvim", "neovim", "ed", "sed", "awk",
101+
"touch", "mkdir", "cp", "mv", "ln", "chmod", "chown", "chgrp",
102+
"tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "bunzip2",
103+
"tee", "dd", "rsync", "scp", "sftp",
104+
"apt", "apt-get", "yum", "dnf", "pacman", "brew", "snap",
105+
"npm", "yarn", "pip", "gem", "cargo", "go", "make", "cmake",
106+
"gcc", "g++", "clang", "python", "ruby", "node", "java", "javac":
107+
// Check if it's a package manager installing/removing
108+
if isPackageManager(mainCmd) && len(parts) > 1 {
109+
switch parts[1] {
110+
case "remove", "uninstall", "purge", "autoremove":
111+
return ActionDelete
112+
default:
113+
return ActionEdit
114+
}
115+
}
116+
return ActionEdit
117+
118+
// Delete commands
119+
case "rm", "rmdir", "unlink", "shred",
120+
"truncate", "wipefs":
121+
return ActionDelete
122+
123+
default:
124+
// Check for command with path that might be an editor
125+
if strings.Contains(mainCmd, "/") {
126+
baseName := mainCmd[strings.LastIndex(mainCmd, "/")+1:]
127+
return ClassifyCommand(baseName + " " + strings.Join(parts[1:], " "))
128+
}
129+
130+
// If we have output redirection with unknown command, consider it edit
131+
if hasOutputRedirection {
132+
return ActionEdit
133+
}
134+
135+
return ActionOther
136+
}
137+
}
138+
139+
func isPackageManager(cmd string) bool {
140+
packageManagers := []string{
141+
"apt", "apt-get", "yum", "dnf", "pacman", "brew", "snap",
142+
"npm", "yarn", "pip", "pip3", "gem", "cargo",
143+
}
144+
return slices.Contains(packageManagers, cmd)
145+
}

0 commit comments

Comments
 (0)