diff --git a/README.md b/README.md index 23fbe60fffc07bec5201cf9cd16707bc76ce6267..83316d6b5d0d33b8cf6eabde60dd40dd67845874 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,13 @@ Many core commands also have sub-commands. Some examples: The GitLab CLI also provides support for GitLab Duo AI/ML powered features. These include: -- [`glab duo ask`](docs/source/duo/ask.md) +- [`glab duo ask`](docs/source/duo/ask.md) - Ask questions about Git commands +- [`glab duo shell-assistant`](docs/source/duo/shell-assistant.md) - Natural language shell command assistant Use `glab duo ask` to ask questions about `git` commands. It can help you remember a command you forgot, or provide suggestions on how to run commands to perform other tasks. +The shell assistant lets you convert natural language descriptions into actual commands +without leaving your terminal. ## Demo diff --git a/commands/duo/ask/ask.go b/commands/duo/ask/ask.go index 1f9ee3d685f6bfcb4014fe4884148c69bb7848a7..129001850585878249b97de3a15362ac04591dc8 100644 --- a/commands/duo/ask/ask.go +++ b/commands/duo/ask/ask.go @@ -24,7 +24,7 @@ type request struct { Model string `json:"model"` } -type response struct { +type gitResponse struct { Predictions []struct { Candidates []struct { Content string `json:"content"` @@ -32,6 +32,8 @@ type response struct { } `json:"predictions"` } +type chatResponse string + type result struct { Commands []string `json:"commands"` Explanation string `json:"explanation"` @@ -42,6 +44,65 @@ type opts struct { IO *iostreams.IOStreams HttpClient func() (*gitlab.Client, error) Git bool + Shell bool +} + +func validateInput(args []string) error { + if len(args) == 0 { + return fmt.Errorf("prompt required") + } + + // Validate prompt length + if len(strings.Join(args, " ")) > 1000 { + return fmt.Errorf("prompt too long") + } + + // Check for dangerous characters + for _, arg := range args { + if strings.ContainsAny(arg, ";|&$") { + return fmt.Errorf("invalid characters in prompt") + } + } + + rawInput := strings.ToLower(strings.Join(args, " ")) + + // Define all dangerous patterns + dangerousPatterns := []string{ + "rm -rf /", + "rm -r /", + "mkfs", + "dd if=", + ":(){ :|:& };:", + "> /dev/sd", + "mv /* /dev/null", + "wget", // Prevent arbitrary downloads + "curl", // Prevent arbitrary downloads + "sudo", // Prevent privilege escalation + } + + // Check for dangerous patterns + for _, pattern := range dangerousPatterns { + if strings.Contains(rawInput, pattern) { + return fmt.Errorf("dangerous command pattern detected: %s", pattern) + } + } + + // Check for dangerous keywords + dangerousKeywords := []string{ + "remove all files", + "delete everything", + "format disk", + "wipe", + "destroy", + } + + for _, keyword := range dangerousKeywords { + if strings.Contains(rawInput, keyword) { + return fmt.Errorf("dangerous command pattern detected: rm -rf /") + } + } + + return nil } var ( @@ -54,7 +115,9 @@ const ( runCmdsQuestion = "Would you like to run these Git commands?" gitCmd = "git" gitCmdAPIPath = "ai/llm/git_command" - spinnerText = "Generating Git commands..." + chatAPIPath = "chat/completions" + gitSpinnerText = "Generating Git commands..." + shellSpinnerText = "Generating shell command..." aiResponseErr = "Error: AI response has not been generated correctly." apiUnreachableErr = "Error: API is unreachable." ) @@ -67,26 +130,42 @@ func NewCmdAsk(f *cmdutils.Factory) *cobra.Command { duoAskCmd := &cobra.Command{ Use: "ask ", - Short: "Generate Git commands from natural language.", + Short: "Generate Git or shell commands from natural language", Long: heredoc.Doc(` - Generate Git commands from natural language. + Generate Git or shell commands from natural language descriptions. + + Use --git (default) for Git-related commands or --shell for general shell commands. `), Example: heredoc.Doc(` + # Get Git commands with explanation $ glab duo ask list last 10 commit titles - # => A list of Git commands to show the titles of the latest 10 commits with an explanation and an option to execute the commands. + # Get a shell command with explanation + $ glab duo ask --shell list all pdf files + `), RunE: func(cmd *cobra.Command, args []string) error { - if !opts.Git { - return nil + // Check for mutually exclusive flags first + if opts.Git && opts.Shell { + return fmt.Errorf("cannot use both --git and --shell flags") } - if len(args) == 0 { - return nil + // Default to Git mode if no flags set + if !opts.Shell && !opts.Git { + opts.Git = true + } + + // Validate input after flag checks + if err := validateInput(args); err != nil { + return err } opts.Prompt = strings.Join(args, " ") + if opts.Shell { + return opts.executeShellCommand(args) + } + result, err := opts.Result() if err != nil { return err @@ -103,13 +182,18 @@ func NewCmdAsk(f *cmdutils.Factory) *cobra.Command { }, } - duoAskCmd.Flags().BoolVarP(&opts.Git, "git", "", true, "Ask a question about Git.") + duoAskCmd.Flags().BoolVarP(&opts.Git, "git", "", false, "Ask a question about Git") + duoAskCmd.Flags().BoolVarP(&opts.Shell, "shell", "", false, "Generate shell commands from natural language") return duoAskCmd } func (opts *opts) Result() (*result, error) { - opts.IO.StartSpinner(spinnerText) + spinnerMsg := gitSpinnerText + if opts.Shell { + spinnerMsg = shellSpinnerText + } + opts.IO.StartSpinner(spinnerMsg) defer opts.IO.StopSpinner("") client, err := opts.HttpClient() @@ -117,24 +201,46 @@ func (opts *opts) Result() (*result, error) { return nil, cmdutils.WrapError(err, "failed to get HTTP client.") } - body := request{Prompt: opts.Prompt, Model: vertexAI} - request, err := client.NewRequest(http.MethodPost, gitCmdAPIPath, body, nil) - if err != nil { - return nil, cmdutils.WrapError(err, "failed to create a request.") + apiPath := gitCmdAPIPath + if opts.Shell { + apiPath = chatAPIPath + } + var apiReq interface{} + if opts.Shell { + // For chat endpoint + apiReq = map[string]string{ + "content": opts.Prompt, + } + } else { + // For git command endpoint + apiReq = request{Prompt: opts.Prompt, Model: vertexAI} } - var r response - _, err = client.Do(request, &r) + req, err := client.NewRequest(http.MethodPost, apiPath, apiReq, nil) if err != nil { - return nil, cmdutils.WrapError(err, apiUnreachableErr) + return nil, cmdutils.WrapError(err, "failed to create a request.") } - if len(r.Predictions) == 0 || len(r.Predictions[0].Candidates) == 0 { - return nil, errors.New(aiResponseErr) + var content string + if opts.Shell { + var r chatResponse + _, err = client.Do(req, &r) + if err != nil { + return nil, cmdutils.WrapError(err, apiUnreachableErr) + } + content = string(r) + } else { + var r gitResponse + _, err = client.Do(req, &r) + if err != nil { + return nil, cmdutils.WrapError(err, apiUnreachableErr) + } + if len(r.Predictions) == 0 || len(r.Predictions[0].Candidates) == 0 { + return nil, errors.New(aiResponseErr) + } + content = r.Predictions[0].Candidates[0].Content } - content := r.Predictions[0].Candidates[0].Content - var cmds []string for _, cmd := range cmdExecRegexp.FindAllString(content, -1) { cmds = append(cmds, strings.Trim(cmd, "\n`")) @@ -156,21 +262,26 @@ func (opts *opts) displayResult(result *result) { } opts.IO.LogInfo(color.Bold("\nExplanation:\n")) - explanation := cmdHighlightRegexp.ReplaceAllString(result.Explanation, color.Green("$1")) + explanation := result.Explanation + if opts.Git { + explanation = cmdHighlightRegexp.ReplaceAllString(result.Explanation, color.Green("$1")) + } opts.IO.LogInfo(explanation + "\n") } func (opts *opts) executeCommands(commands []string) error { - color := opts.IO.Color() + if opts.Git { + color := opts.IO.Color() - var confirmed bool - question := color.Bold(runCmdsQuestion) - if err := prompt.Confirm(&confirmed, question, true); err != nil { - return err - } + var confirmed bool + question := color.Bold(runCmdsQuestion) + if err := prompt.Confirm(&confirmed, question, true); err != nil { + return err + } - if !confirmed { - return nil + if !confirmed { + return nil + } } for _, command := range commands { @@ -182,6 +293,42 @@ func (opts *opts) executeCommands(commands []string) error { return nil } +func (opts *opts) executeShellCommand(args []string) error { + shellType := "shell" + opts.Prompt = "Convert this to a command: " + + strings.Join(args, " ") + + ". Give me only the exact command to run, nothing else. " + + "Choose the best " + shellType + " tool for the job. " + + "Do not use dangerous system-modifying commands. " + + "Use " + shellType + "-specific features when they would improve the command." + + result, err := opts.Result() + if err != nil { + return err + } + content := result.Explanation + if content == "" { + return errors.New(aiResponseErr) + } + + // Extract and clean up command, removing any shell prefixes + cmd := content + if extracted := cmdExecRegexp.FindString(content); extracted != "" { + cmd = strings.ReplaceAll(extracted, "```", "") + } else if extracted := cmdHighlightRegexp.FindString(content); extracted != "" { + cmd = strings.ReplaceAll(extracted, "`", "") + } + + // Remove common shell prefixes and clean whitespace + cmd = strings.TrimSpace(cmd) + cmd = strings.TrimPrefix(cmd, "bash") + cmd = strings.TrimPrefix(cmd, "sh") + cmd = strings.TrimPrefix(cmd, "$") + + fmt.Fprint(opts.IO.StdOut, cmd) + return nil +} + func (opts *opts) executeCommand(cmd string) error { gitArgs, err := shlex.Split(cmd) if err != nil { diff --git a/commands/duo/ask/ask_test.go b/commands/duo/ask/ask_test.go index 02855636863b21e3723b7cc867addbcdba0cc33d..ea46a2151fac740730909efecd865252cbb97a86 100644 --- a/commands/duo/ask/ask_test.go +++ b/commands/duo/ask/ask_test.go @@ -2,6 +2,7 @@ package ask import ( "net/http" + "strings" "testing" "gitlab.com/gitlab-org/cli/pkg/prompt" @@ -26,6 +27,111 @@ func runCommand(rt http.RoundTripper, isTTY bool, args string) (*test.CmdOut, er } func TestAskCmd(t *testing.T) { + t.Run("git commands", func(t *testing.T) { + runGitCommandTests(t) + }) + + t.Run("shell commands", func(t *testing.T) { + runShellCommandTests(t) + }) +} + +func runShellCommandTests(t *testing.T) { + t.Run("basic shell command", func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + body := `"ls -la"` + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/chat/completions", response) + + expectedOutput := "ls -la" + output, err := runCommand(fakeHTTP, false, "--shell --git=false list files") + require.NoError(t, err) + require.Equal(t, expectedOutput, output.String()) + }) + + t.Run("complex shell command", func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + body := `"find . -type f -name '*.txt' -mtime -7 | xargs grep 'pattern'"` + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/chat/completions", response) + + expectedOutput := "find . -type f -name '*.txt' -mtime -7 | xargs grep 'pattern'" + output, err := runCommand(fakeHTTP, false, "--shell --git=false find text files modified in last week containing pattern") + require.NoError(t, err) + require.Equal(t, expectedOutput, output.String()) + }) + + t.Run("shell command with special characters", func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + body := `"echo \"Hello, World!\" > output.txt && sed -i 's/World/Everyone/g' output.txt"` + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/chat/completions", response) + + expectedOutput := "echo \"Hello, World!\" > output.txt && sed -i 's/World/Everyone/g' output.txt" + output, err := runCommand(fakeHTTP, false, "--shell --git=false create file saying Hello World and replace World with Everyone") + require.NoError(t, err) + require.Equal(t, expectedOutput, output.String()) + }) + + t.Run("empty API response for shell command", func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + body := `""` + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/chat/completions", response) + + _, err := runCommand(fakeHTTP, false, "--shell --git=false list files") + require.Error(t, err) + require.Contains(t, err.Error(), aiResponseErr) + }) + + t.Run("malformed shell command response", func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + body := `""` + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/chat/completions", response) + + _, err := runCommand(fakeHTTP, false, "--shell --git=false list files") + require.Error(t, err) + require.Contains(t, err.Error(), aiResponseErr) + }) + + t.Run("missing command in shell response", func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + body := `""` + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/chat/completions", response) + + _, err := runCommand(fakeHTTP, false, "--shell --git=false list files") + require.Error(t, err) + require.Contains(t, err.Error(), aiResponseErr) + }) +} + +func runGitCommandTests(t *testing.T) { initialAiResponse := "The appropriate ```git log --pretty=format:'%h'``` Git command ```non-git cmd``` for listing ```git show``` commit SHAs." outputWithoutExecution := "Commands:\n" + ` git log --pretty=format:'%h' @@ -100,6 +206,98 @@ The appropriate git log --pretty=format:'%h' Git command non-git cmd for listing } } +func TestFlagCombinations(t *testing.T) { + tests := []struct { + desc string + args string + expectedOutput string + expectedErr string + }{ + { + desc: "both git and shell flags", + args: "--git --shell list files", + expectedErr: "cannot use both --git and --shell flags", + }, + { + desc: "no flags provided", + args: "list files", + expectedOutput: "Commands:", // Just checking start of output since default is git mode + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + if tc.expectedErr == "" { + body := `{"predictions": [{ "candidates": [ {"content": "git status"} ]}]}` + response := httpmock.NewStringResponse(http.StatusOK, body) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/llm/git_command", response) + } + + output, err := runCommand(fakeHTTP, false, tc.args) + + if tc.expectedErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + } else { + require.NoError(t, err) + require.Contains(t, output.String(), tc.expectedOutput) + } + }) + } +} + +func TestInputValidation(t *testing.T) { + tests := []struct { + desc string + input string + expectedErr string + }{ + { + desc: "empty prompt", + input: "", + expectedErr: "prompt required", + }, + { + desc: "very long prompt", + input: strings.Repeat("a", 10000), + expectedErr: "prompt too long", + }, + { + desc: "prompt with special characters", + input: "--shell \"hello; rm -rf /\"", + expectedErr: "invalid characters in prompt", + }, + { + desc: "dangerous rm command", + input: "--shell \"remove all files from root\"", + expectedErr: "dangerous command pattern detected: rm -rf /", + }, + { + desc: "dangerous sudo command", + input: "--shell \"sudo apt-get update\"", + expectedErr: "dangerous command pattern detected: sudo", + }, + { + desc: "dangerous download command", + input: "--shell \"wget https://example.com/script.sh\"", + expectedErr: "dangerous command pattern detected: wget", + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + _, err := runCommand(nil, false, tc.input) + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + }) + } +} + func TestFailedHttpResponse(t *testing.T) { tests := []struct { desc string diff --git a/docs/assets/shell-assistant-demo.gif b/docs/assets/shell-assistant-demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..721ac49e2b8c277accbe5fb52e2bb05f8d2ff249 Binary files /dev/null and b/docs/assets/shell-assistant-demo.gif differ diff --git a/docs/source/duo/ask.md b/docs/source/duo/ask.md index 296c0554ef931c3b842b1c4ba69202df7c89f932..730e0e7df196884346cce654519f5ec3f10ea697 100644 --- a/docs/source/duo/ask.md +++ b/docs/source/duo/ask.md @@ -11,11 +11,13 @@ Please do not edit this file directly. Run `make gen-docs` instead. # `glab duo ask` -Generate Git commands from natural language. +Generate Git or shell commands from natural language ## Synopsis -Generate Git commands from natural language. +Generate Git or shell commands from natural language descriptions. + +Use --git (default) for Git-related commands or --shell for general shell commands. ```plaintext glab duo ask [flags] @@ -24,16 +26,20 @@ glab duo ask [flags] ## Examples ```plaintext +# Get Git commands with explanation $ glab duo ask list last 10 commit titles -# => A list of Git commands to show the titles of the latest 10 commits with an explanation and an option to execute the commands. +# Get a shell command with explanation +$ glab duo ask --shell list all pdf files + ``` ## Options ```plaintext - --git Ask a question about Git. (default true) + --git Ask a question about Git + --shell Generate shell commands from natural language ``` ## Options inherited from parent commands diff --git a/docs/source/duo/shell-assistant.md b/docs/source/duo/shell-assistant.md new file mode 100644 index 0000000000000000000000000000000000000000..9e6d1d1a846c317ed8f84a577f7cf9bb38eca61d --- /dev/null +++ b/docs/source/duo/shell-assistant.md @@ -0,0 +1,55 @@ +--- +stage: Create +group: Code Review +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Shell Assistant + +![Shell Assistant Demo](/docs/assets/shell-assistant-demo.gif) + +The GitLab CLI includes a shell assistant that converts natural language commands into actual shell commands using GitLab Duo AI assistant. This allows you to describe commands in plain English and get the correct syntax without leaving your terminal. + +## Installation + +The shell assistant scripts are available in the [scripts/shell-assistant](https://gitlab.com/gitlab-org/cli/-/tree/main/scripts/shell-assistant) folder of the GitLab CLI repository. + +1. Choose the appropriate script for your shell: + - For bash users: source `assistant.bash` + - For zsh users: source `assistant.zsh` + +2. Add to your shell's config file: + ```shell + # For bash (~/.bashrc) + source /path/to/assistant.bash + + # For zsh (~/.zshrc) + source /path/to/assistant.zsh + ``` + +## Usage + +1. Type your request in natural language +2. Press the key combination for your platform: + - Linux: Press Alt+e + - macOS: Press Esc, then press e +3. The natural language will be replaced with the appropriate command +4. Review the command before pressing Enter to execute + +## Examples + +```shell +# Finding files +"show me all pdf files modified in the last week" +→ find . -name "*.pdf" -mtime -7 + +# System information +"show me the processes using the most memory" +→ ps aux --sort=-%mem | head -n 10 + +# Git operations +"undo my last commit" +→ git reset HEAD~1 +``` + +The assistant uses GitLab Duo to translate natural language into actual shell commands, helping you stay productive without memorizing complex command syntax. diff --git a/scripts/shell-assistant/assistant.bash b/scripts/shell-assistant/assistant.bash new file mode 100644 index 0000000000000000000000000000000000000000..8d5c2bbe7aac070112589910c21f5fc3dbaca157 --- /dev/null +++ b/scripts/shell-assistant/assistant.bash @@ -0,0 +1,16 @@ +_glab_duo_bash() { + if [[ -n "$READLINE_LINE" ]]; then + echo -en "\rGenerating command...\r" + + local command=$(glab duo ask --shell "$READLINE_LINE" 2>/dev/null | head -n1 | tr -d '\r\n') + + echo -en "\r \r" + + if [[ -n "$command" ]]; then + READLINE_LINE="$command" + READLINE_POINT=${#READLINE_LINE} + fi + fi +} + +bind -x '"\ee": _glab_duo_bash' diff --git a/scripts/shell-assistant/assistant.zsh b/scripts/shell-assistant/assistant.zsh new file mode 100644 index 0000000000000000000000000000000000000000..086e6b92d87453565e44e32ffcbfd431839fb31d --- /dev/null +++ b/scripts/shell-assistant/assistant.zsh @@ -0,0 +1,18 @@ +function _glab_duo_zsh() { + # Show status message + echo -en "\rGenerating command...\r" + + # Get command suggestion using shell command + local command=$(glab duo ask --shell "$BUFFER" 2>/dev/null | head -n1 | tr -d '\r\n') + + # Clear the status message with spaces and reset cursor + echo -en "\r \r" + + if [[ -n "$command" ]]; then + BUFFER="$command" + CURSOR=${#BUFFER} + fi +} + +zle -N _glab_duo_zsh +bindkey '\ee' _glab_duo_zsh