Skip to content

Commit b7fd09d

Browse files
committed
Add cmd subcommand
1 parent 76562dd commit b7fd09d

File tree

6 files changed

+259
-1
lines changed

6 files changed

+259
-1
lines changed

cmd/ask.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Requires a path to a repository or file as a positional argument.`,
112112
os.Exit(1)
113113
}
114114

115-
fmt.Println(resp)
115+
fmt.Println("Answer:", resp)
116116
},
117117
}
118118

cmd/cmd.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
Copyright © 2023 Chandler <chandler@chand1012.dev>
3+
*/
4+
package cmd
5+
6+
import (
7+
"fmt"
8+
"os"
9+
10+
"github.com/spf13/cobra"
11+
12+
"github.com/chand1012/ottodocs/pkg/ai"
13+
"github.com/chand1012/ottodocs/pkg/config"
14+
"github.com/chand1012/ottodocs/pkg/shell"
15+
)
16+
17+
// cmdCmd represents the cmd command
18+
var cmdCmd = &cobra.Command{
19+
Use: "cmd",
20+
Short: "Browses Bash",
21+
Long: `A longer description that spans multiple lines and likely contains examples
22+
and usage of using your command. For example:
23+
24+
Cobra is a CLI library for Go that empowers applications.
25+
This application is a tool to generate the needed files
26+
to quickly create a Cobra application.`,
27+
Run: func(cmd *cobra.Command, args []string) {
28+
conf, err := config.Load()
29+
if err != nil || conf.APIKey == "" {
30+
// if the API key is not set, prompt the user to login
31+
log.Error("Please login first.")
32+
log.Error("Run `ottodocs login` to login.")
33+
os.Exit(1)
34+
}
35+
36+
if chatPrompt == "" {
37+
chatPrompt = "What command do you recommend I use next?"
38+
}
39+
40+
log.Info("Thinking....")
41+
42+
history, err := shell.GetHistory(100)
43+
if err != nil {
44+
log.Error("This command is only supported on MacOS and Linux using Bash or Zsh. Windows and other shells coming soon!")
45+
log.Error(err)
46+
os.Exit(1)
47+
}
48+
49+
// fmt.Println("History:", history)
50+
51+
resp, err := ai.CmdQuestion(history, chatPrompt, conf.APIKey, conf.Model)
52+
53+
if err != nil {
54+
log.Error(err)
55+
os.Exit(1)
56+
}
57+
58+
fmt.Println("Answer:", resp)
59+
},
60+
}
61+
62+
func init() {
63+
RootCmd.AddCommand(cmdCmd)
64+
65+
// Here you will define your flags and configuration settings.
66+
67+
// Cobra supports Persistent Flags which will work for this command
68+
// and all subcommands, e.g.:
69+
// cmdCmd.PersistentFlags().String("foo", "", "A help for foo")
70+
71+
// Cobra supports local flags which will only run when this command
72+
// is called directly, e.g.:
73+
// cmdCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
74+
}

pkg/ai/cmd_question.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package ai
2+
3+
import (
4+
"fmt"
5+
6+
gopenai "github.com/CasualCodersProjects/gopenai"
7+
ai_types "github.com/CasualCodersProjects/gopenai/types"
8+
"github.com/chand1012/ottodocs/pkg/calc"
9+
"github.com/chand1012/ottodocs/pkg/constants"
10+
)
11+
12+
func CmdQuestion(history []string, chatPrompt, APIKey, model string) (string, error) {
13+
openai := gopenai.NewOpenAI(&gopenai.OpenAIOpts{
14+
APIKey: APIKey,
15+
})
16+
17+
questionNoHistory := "\nQuestion: " + chatPrompt + "\n\nAnswer:"
18+
historyQuestion := "Shell History:\n"
19+
20+
qTokens := calc.EstimateTokens(questionNoHistory)
21+
commandPromptTokens := calc.EstimateTokens(constants.COMMAND_QUESTION_PROMPT)
22+
23+
// loop backwards through history to find the most recent question
24+
for i := len(history) - 1; i >= 0; i-- {
25+
newHistory := history[i] + "\n"
26+
tokens := calc.EstimateTokens(newHistory) + qTokens + calc.EstimateTokens(historyQuestion) + commandPromptTokens
27+
if tokens < constants.OPENAI_MAX_TOKENS {
28+
historyQuestion += newHistory
29+
} else {
30+
break
31+
}
32+
}
33+
34+
question := historyQuestion + questionNoHistory
35+
36+
// fmt.Println(question)
37+
38+
messages := []ai_types.ChatMessage{
39+
{
40+
Content: constants.COMMAND_QUESTION_PROMPT,
41+
Role: "system",
42+
},
43+
{
44+
Content: question,
45+
Role: "user",
46+
},
47+
}
48+
49+
tokens := calc.EstimateTokens(messages[0].Content, messages[1].Content)
50+
51+
maxTokens := constants.OPENAI_MAX_TOKENS - tokens
52+
53+
if maxTokens < 0 {
54+
return "", fmt.Errorf("the prompt is too long. max length is %d. Got %d", constants.OPENAI_MAX_TOKENS, tokens)
55+
}
56+
57+
req := ai_types.NewDefaultChatRequest("")
58+
req.Messages = messages
59+
req.MaxTokens = maxTokens
60+
req.Model = model
61+
// lower the temperature to make the model more deterministic
62+
// req.Temperature = 0.3
63+
64+
resp, err := openai.CreateChat(req)
65+
if err != nil {
66+
fmt.Printf("Error: %s", err)
67+
return "", err
68+
}
69+
70+
message := resp.Choices[0].Message.Content
71+
72+
return message, nil
73+
}

pkg/constants/prompts.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ var QUESTION_PROMPT string = `You are a helpful assistant who answers questions
2323
- The answer must be in English.
2424
- If there is no way to answer the question, you should say so.
2525
- The answer must be AT LEAST one sentence long.`
26+
27+
var COMMAND_QUESTION_PROMPT string = `You are a helpful assistant who answers questions about shell commands. The answer doesn't have to be extremely verbose, but it should be enough to help a new developer understand the code. You must answer the question with the following rules:
28+
- The answer must be relevant to the question and the given shell commands.
29+
- The answer must be in English.
30+
- If there is no way to answer the question, you should say so.
31+
- The answer must be AT LEAST one sentence long.`

pkg/shell/shell.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package shell
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/chand1012/ottodocs/pkg/utils"
9+
)
10+
11+
const ZSH_HISTORY_PATH = ".zsh_history"
12+
const BASH_HISTORY_PATH = ".bash_history"
13+
14+
// for now we only support bash and zsh for unix
15+
// we will support more in the future if demand is there
16+
// windows is on the roadmap
17+
18+
// GetShellHistory gets the most recently used command from the shell history
19+
// file. It will attempt to open all of them, only getting the most recently
20+
// modified one. Only get n lines. If the history file is zsh, it will just get
21+
// the command and not the metadata.
22+
func GetHistory(n int) ([]string, error) {
23+
shellHistories := []string{ZSH_HISTORY_PATH, BASH_HISTORY_PATH}
24+
var mostRecentHistory string
25+
var mostRecentTime int64
26+
var shellUsed string
27+
for _, history := range shellHistories {
28+
// get the home directory
29+
homeDir, err := os.UserHomeDir()
30+
if err != nil {
31+
return nil, err
32+
}
33+
// get the history file path
34+
historyPath := filepath.Join(homeDir, history)
35+
// get the file info
36+
info, err := os.Stat(historyPath)
37+
if err != nil {
38+
continue
39+
}
40+
// check if the file is newer than the current most recent
41+
if info.ModTime().Unix() > mostRecentTime {
42+
mostRecentTime = info.ModTime().Unix()
43+
mostRecentHistory = historyPath
44+
shellUsed = history
45+
}
46+
}
47+
48+
// if we didn't find a history file, return an error
49+
if mostRecentHistory == "" {
50+
return nil, os.ErrNotExist
51+
}
52+
53+
// open the file
54+
contents, err := os.ReadFile(mostRecentHistory)
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
// fmt.Println("shell used: ", shellUsed)
60+
// fmt.Println("contents: ", string(contents))
61+
62+
var finalLines []string
63+
if shellUsed == ZSH_HISTORY_PATH {
64+
// split the file by newlines
65+
lines := strings.Split(string(contents), "\n")
66+
// reverse the lines
67+
utils.ReverseSlice(lines)
68+
for _, line := range lines {
69+
// get just the command. Split each line after the first semicolon
70+
// and get the first element
71+
split_command := strings.SplitN(line, ";", 2)
72+
if len(split_command) < 2 {
73+
continue
74+
}
75+
76+
command := split_command[1]
77+
// if the command is empty, skip it
78+
if command == "" {
79+
continue
80+
}
81+
// add the command to the final lines
82+
finalLines = append(finalLines, command)
83+
if len(finalLines) >= n {
84+
break
85+
}
86+
}
87+
}
88+
89+
if shellUsed == BASH_HISTORY_PATH {
90+
// just get the last 100 lines
91+
finalLines = strings.Split(string(contents), "\n")
92+
if len(finalLines) > n {
93+
finalLines = finalLines[len(finalLines)-100:]
94+
}
95+
}
96+
97+
return finalLines, nil
98+
}

pkg/utils/reverse.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package utils
2+
3+
func ReverseSlice[T any](s []T) {
4+
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
5+
s[i], s[j] = s[j], s[i]
6+
}
7+
}

0 commit comments

Comments
 (0)