diff --git a/docs/source/duo/claude/index.md b/docs/source/duo/claude/index.md new file mode 100644 index 0000000000000000000000000000000000000000..a804bfeb1bcb330e58a4c719b662ee5939ab4126 --- /dev/null +++ b/docs/source/duo/claude/index.md @@ -0,0 +1,44 @@ +--- +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 +--- + + + +# `glab duo claude` + +Launch Claude Code with GitLab Duo integration + +## Synopsis + +Launch Claude Code with automatic GitLab authentication and proxy configuration. +All flags and arguments are passed through to the Claude executable. + +This command automatically configures Claude Code to work with GitLab AI services, +handling authentication tokens and API endpoints based on your current repository. + +```plaintext +glab duo claude [flags] [args] +``` + +## Examples + +```console +$ glab duo claude +$ glab duo claude -p "Write a function to calculate Fibonacci numbers" + +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. +``` + +## Subcommands + +- [`token`](token.md) diff --git a/docs/source/duo/claude/token.md b/docs/source/duo/claude/token.md new file mode 100644 index 0000000000000000000000000000000000000000..037052aa80ffe79ea0239aff214fede198acd0bd --- /dev/null +++ b/docs/source/duo/claude/token.md @@ -0,0 +1,38 @@ +--- +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 +--- + + + +# `glab duo claude token` + +Generate GitLab Duo access token for Claude Code + +## Synopsis + +Generate and display a GitLab Duo access token required for Claude Code authentication. + +This token allows Claude Code to authenticate with GitLab AI services. +The token is automatically used when running 'glab duo claude'. + +```plaintext +glab duo claude token [flags] +``` + +## Examples + +```console +$ glab duo claude token + +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. +``` diff --git a/docs/source/duo/index.md b/docs/source/duo/index.md index 7cab1ee7cd802e7b790f6cefd52da35d068b12c7..a44cad6ea4b46cd63ce075815d5836a6c0dce807 100644 --- a/docs/source/duo/index.md +++ b/docs/source/duo/index.md @@ -11,7 +11,7 @@ Please do not edit this file directly. Run `make gen-docs` instead. # `glab duo` -Generate terminal commands from natural language. +Work with GitLab Duo ## Options inherited from parent commands @@ -22,3 +22,4 @@ Generate terminal commands from natural language. ## Subcommands - [`ask`](ask.md) +- [`claude`](claude/index.md) diff --git a/internal/commands/duo/claude/claude.go b/internal/commands/duo/claude/claude.go new file mode 100644 index 0000000000000000000000000000000000000000..c573a7b12b723ea07d0bceb400c4bb6a620fa937 --- /dev/null +++ b/internal/commands/duo/claude/claude.go @@ -0,0 +1,114 @@ +// Package claude provides commands for integrating with Claude Code through GitLab Duo. +package claude + +import ( + "fmt" + "os" + "os/exec" + + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/cmdutils" + "gitlab.com/gitlab-org/cli/internal/glrepo" + "gitlab.com/gitlab-org/cli/internal/iostreams" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" +) + +// opts holds the configuration options for Claude commands. +type opts struct { + Prompt string + IO *iostreams.IOStreams + apiClient func(repoHost string) (*api.Client, error) + BaseRepo func() (glrepo.Interface, error) +} + +func NewCmdClaude(f cmdutils.Factory) *cobra.Command { + opts := &opts{ + IO: f.IO(), + apiClient: f.ApiClient, + BaseRepo: f.BaseRepo, + } + + duoClaudeCmd := &cobra.Command{ + Use: "claude [flags] [args]", + Short: "Launch Claude Code with GitLab Duo integration", + Long: heredoc.Doc(` + Launch Claude Code with automatic GitLab authentication and proxy configuration. + All flags and arguments are passed through to the Claude executable. + + This command automatically configures Claude Code to work with GitLab AI services, + handling authentication tokens and API endpoints based on your current repository. + `), + Example: heredoc.Doc(` + $ glab duo claude + $ glab duo claude -p "Write a function to calculate Fibonacci numbers" + `), + // Allow unknown flags to be passed through to the claude command + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Fetch repo host + var repoHost string + if baseRepo, err := opts.BaseRepo(); err == nil { + repoHost = baseRepo.RepoHost() + } + + // Get API client + c, err := opts.apiClient(repoHost) + if err != nil { + return err + } + + // Fetch direct_access token + token, err := fetchDirectAccessToken(c.Lab()) + if err != nil { + return fmt.Errorf("failed to retrieve GitLab Duo access token: %w", err) + } + + // Validate Claude executable exists + if err := validateClaudeExecutable(); err != nil { + return fmt.Errorf("claude executable validation failed: %w", err) + } + + wasAbleToSetApiKeyHelper := setClaudeSettings() + + // Extract Claude command arguments + claudeArgs, err := extractClaudeArgs() + if err != nil { + return fmt.Errorf("failed to parse command arguments: %w", err) + } + + // Execute Claude command with all arguments + claudeCmd := exec.Command(ClaudeExecutable, claudeArgs...) + + // Connect standard input/output/error + claudeCmd.Stdin = opts.IO.In + claudeCmd.Stdout = opts.IO.StdOut + claudeCmd.Stderr = opts.IO.StdErr + + // Set environment variables for the Claude command + claudeCmd.Env = append(os.Environ(), + fmt.Sprintf("%s=%s", EnvAnthropicCustomHeaders, getHeaderEnv(token.Headers)), + fmt.Sprintf("%s=%s", EnvAnthropicBaseURL, CloudConnectorUrl), + fmt.Sprintf("%s=%s", EnvAnthropicModel, DefaultClaudeModel), + ) + + if !wasAbleToSetApiKeyHelper { + claudeCmd.Env = append(claudeCmd.Env, fmt.Sprintf("%s=%s", EnvAnthropicAuthToken, token.Token)) + } + + // Execute the command + if err := claudeCmd.Run(); err != nil { + return fmt.Errorf("failed to execute Claude Code: %w", err) + } + + return nil + }, + } + + duoClaudeCmd.AddCommand(NewCmdToken(f)) + + return duoClaudeCmd +} diff --git a/internal/commands/duo/claude/claude_test.go b/internal/commands/duo/claude/claude_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a41c00ba7e413d4767c5efe11ea3abeb89939dd7 --- /dev/null +++ b/internal/commands/duo/claude/claude_test.go @@ -0,0 +1,109 @@ +package claude + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" + "gitlab.com/gitlab-org/cli/internal/testing/httpmock" +) + +func runClaudeCommand(t *testing.T, rt http.RoundTripper, args string, glInstanceHostname string) error { + ios, _, stdout, stderr := cmdtest.TestIOStreams() + + factory := cmdtest.NewTestFactory(ios, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, &http.Client{Transport: rt}, "", glInstanceHostname)), + cmdtest.WithBaseRepo("OWNER", "REPO", glInstanceHostname), + ) + + cmd := NewCmdClaude(factory) + + _, err := cmdtest.ExecuteCommand(cmd, args, stdout, stderr) + return err +} + +func TestNewCmdClaude(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + factory := cmdtest.NewTestFactory(ios) + + cmd := NewCmdClaude(factory) + + assert.NotNil(t, cmd) + assert.Equal(t, "claude [flags] [args]", cmd.Use) + assert.Equal(t, "Launch Claude Code with GitLab Duo integration", cmd.Short) + assert.True(t, cmd.FParseErrWhitelist.UnknownFlags) + + // Check that token subcommand is added + tokenCmd := cmd.Commands()[0] + assert.Equal(t, "token", tokenCmd.Use) +} + +func TestClaudeCmdFailedTokenRetrieval(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + expectedErr string + }{ + { + name: "API error", + statusCode: http.StatusUnauthorized, + responseBody: `{"error": "unauthorized"}`, + expectedErr: "failed to retrieve GitLab Duo access token", + }, + { + name: "Network error", + statusCode: http.StatusInternalServerError, + responseBody: `{"error": "server error"}`, + expectedErr: "failed to retrieve GitLab Duo access token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + response := httpmock.NewStringResponse(tc.statusCode, tc.responseBody) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/third_party_agents/direct_access", response) + + err := runClaudeCommand(t, fakeHTTP, "", "gitlab.com") + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr) + }) + } +} + +func TestClaudeCmdClaudeExecutableNotFound(t *testing.T) { + // This test fails at argument parsing since the test environment doesn't set up os.Args with 'claude' + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + // Mock successful token response + tokenResponse := httpmock.NewStringResponse(http.StatusCreated, + `{"token": "test-token", "headers": {"X-Auth": "test-header"}}`) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/third_party_agents/direct_access", tokenResponse) + + err := runClaudeCommand(t, fakeHTTP, "", "gitlab.com") + + require.Error(t, err) +} + +func TestClaudeCommandDescription(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + factory := cmdtest.NewTestFactory(ios) + + cmd := NewCmdClaude(factory) + + assert.Contains(t, cmd.Long, "Launch Claude Code with automatic GitLab authentication") + assert.Contains(t, cmd.Long, "handling authentication tokens and API endpoints") + assert.Contains(t, cmd.Example, "$ glab duo claude") + assert.Contains(t, cmd.Example, `$ glab duo claude -p "Write a function to calculate Fibonacci numbers"`) +} diff --git a/internal/commands/duo/claude/token.go b/internal/commands/duo/claude/token.go new file mode 100644 index 0000000000000000000000000000000000000000..43b46ea3946f9e670b1d06347a21d5460d29efdb --- /dev/null +++ b/internal/commands/duo/claude/token.go @@ -0,0 +1,62 @@ +package claude + +import ( + "fmt" + + "gitlab.com/gitlab-org/cli/internal/cmdutils" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" +) + +// NewCmdToken creates a new cobra command for generating GitLab Duo access tokens. +func NewCmdToken(f cmdutils.Factory) *cobra.Command { + opts := &opts{ + IO: f.IO(), + apiClient: f.ApiClient, + BaseRepo: f.BaseRepo, + } + + duoClaudeTokenCmd := &cobra.Command{ + Use: "token", + Short: "Generate GitLab Duo access token for Claude Code", + Long: heredoc.Doc(` + Generate and display a GitLab Duo access token required for Claude Code authentication. + + This token allows Claude Code to authenticate with GitLab AI services. + The token is automatically used when running 'glab duo claude'. + `), + Example: heredoc.Doc(` + $ glab duo claude token + `), + // Allow unknown flags to be passed through to the claude command + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Fetch repo host + var repoHost string + if baseRepo, err := opts.BaseRepo(); err == nil { + repoHost = baseRepo.RepoHost() + } + + // Get API client + c, err := opts.apiClient(repoHost) + if err != nil { + return err + } + + // Fetch direct_access token + token, err := fetchDirectAccessToken(c.Lab()) + if err != nil { + return fmt.Errorf("failed to retrieve GitLab Duo access token: %w", err) + } + + fmt.Fprintln(opts.IO.StdOut, token.Token) + + return nil + }, + } + + return duoClaudeTokenCmd +} diff --git a/internal/commands/duo/claude/token_test.go b/internal/commands/duo/claude/token_test.go new file mode 100644 index 0000000000000000000000000000000000000000..76c786371bdb2ebf90cbfc4c06ff9435074d8831 --- /dev/null +++ b/internal/commands/duo/claude/token_test.go @@ -0,0 +1,162 @@ +package claude + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" + "gitlab.com/gitlab-org/cli/internal/testing/httpmock" + "gitlab.com/gitlab-org/cli/test" +) + +func runTokenCommand(t *testing.T, rt http.RoundTripper, args string, glInstanceHostname string) (*test.CmdOut, *cmdtest.Factory, error) { + ios, _, stdout, stderr := cmdtest.TestIOStreams() + + factory := cmdtest.NewTestFactory(ios, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, &http.Client{Transport: rt}, "", glInstanceHostname)), + cmdtest.WithBaseRepo("OWNER", "REPO", glInstanceHostname), + ) + + cmd := NewCmdToken(factory) + + cmdOut, err := cmdtest.ExecuteCommand(cmd, args, stdout, stderr) + + return cmdOut, factory, err +} + +func TestNewCmdToken(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + factory := cmdtest.NewTestFactory(ios) + + cmd := NewCmdToken(factory) + + assert.NotNil(t, cmd) + assert.Equal(t, "token", cmd.Use) + assert.Equal(t, "Generate GitLab Duo access token for Claude Code", cmd.Short) + assert.True(t, cmd.FParseErrWhitelist.UnknownFlags) + assert.Contains(t, cmd.Long, "Generate and display a GitLab Duo access token") + assert.Contains(t, cmd.Long, "This token allows Claude Code to authenticate") + assert.Contains(t, cmd.Example, "$ glab duo claude token") +} + +func TestTokenCmdSuccessful(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + tokenResponse := httpmock.NewStringResponse(http.StatusCreated, + `{"token": "test-token-123", "headers": {"X-Auth": "test-header"}}`) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/third_party_agents/direct_access", tokenResponse) + + output, _, err := runTokenCommand(t, fakeHTTP, "", "gitlab.com") + + require.NoError(t, err) + assert.Equal(t, "test-token-123\n", output.String()) + assert.Empty(t, output.Stderr()) +} + +func TestTokenCmdFailedTokenRetrieval(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + expectedErr string + }{ + { + name: "unauthorized", + statusCode: http.StatusUnauthorized, + responseBody: `{"error": "unauthorized"}`, + expectedErr: "failed to retrieve GitLab Duo access token", + }, + { + name: "forbidden", + statusCode: http.StatusForbidden, + responseBody: `{"error": "forbidden"}`, + expectedErr: "failed to retrieve GitLab Duo access token", + }, + { + name: "server error", + statusCode: http.StatusInternalServerError, + responseBody: `{"error": "internal server error"}`, + expectedErr: "failed to retrieve GitLab Duo access token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + response := httpmock.NewStringResponse(tc.statusCode, tc.responseBody) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/third_party_agents/direct_access", response) + + _, _, err := runTokenCommand(t, fakeHTTP, "", "gitlab.com") + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr) + }) + } +} + +func TestTokenCmdWithDifferentHosts(t *testing.T) { + tests := []struct { + name string + hostname string + }{ + { + name: "gitlab.com", + hostname: "gitlab.com", + }, + { + name: "custom host", + hostname: "gitlab.example.com", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + tokenResponse := httpmock.NewStringResponse(http.StatusCreated, + `{"token": "test-token", "headers": {"X-Auth": "test"}}`) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/third_party_agents/direct_access", tokenResponse) + + output, factory, err := runTokenCommand(t, fakeHTTP, "", tc.hostname) + + require.NoError(t, err) + assert.Equal(t, "test-token\n", output.String()) + + baseRepo, _ := factory.BaseRepo() + assert.Equal(t, tc.hostname, baseRepo.RepoHost()) + }) + } +} + +func TestTokenCmdAPIClientError(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + + factory := cmdtest.NewTestFactory(ios, + cmdtest.WithApiClient(nil), // This will cause an error + ) + + cmd := NewCmdToken(factory) + + // Override the apiClient to return an error + factory.ApiClientStub = func(repoHost string) (*api.Client, error) { + return nil, fmt.Errorf("api client creation failed") + } + + _, err := cmd.ExecuteC() + require.Error(t, err) + assert.Contains(t, err.Error(), "api client creation failed") +} diff --git a/internal/commands/duo/claude/utils.go b/internal/commands/duo/claude/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..a869ca45c419cee0bce2d64b27c34f9f53deedb3 --- /dev/null +++ b/internal/commands/duo/claude/utils.go @@ -0,0 +1,182 @@ +package claude + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +const ( + // Standard claude environment variables defined [here](https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables) + EnvAnthropicCustomHeaders = "ANTHROPIC_CUSTOM_HEADERS" + EnvAnthropicBaseURL = "ANTHROPIC_BASE_URL" + EnvAnthropicModel = "ANTHROPIC_MODEL" + EnvAnthropicAuthToken = "ANTHROPIC_AUTH_TOKEN" + + // Default model: This needs to be configured as we don't support + // all models + DefaultClaudeModel = "claude-sonnet-4-20250514" + + // Claude executable name + ClaudeExecutable = "claude" + + // Settings configuration + ClaudeSettingsDir = ".claude" + SettingsFileName = "settings.json" + APIKeyHelperKey = "apiKeyHelper" + + CloudConnectorUrl = "https://cloud.gitlab.com/ai/v1/proxy/anthropic" +) + +// getHeaderEnv formats headers as environment variable value. +func getHeaderEnv(headers map[string]string) string { + var headerParts []string + for k, v := range headers { + headerParts = append(headerParts, fmt.Sprintf("%s: %s", k, v)) + } + return strings.Join(headerParts, "\n") +} + +// fetchDirectAccessToken retrieves a direct access token from GitLab AI service. +func fetchDirectAccessToken(client *gitlab.Client) (*DirectAccessResponse, error) { + req, err := client.NewRequest(http.MethodPost, "ai/third_party_agents/direct_access", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for direct access token: %w", err) + } + + var response DirectAccessResponse + resp, err := client.Do(req, &response) + if err != nil { + return nil, fmt.Errorf("failed to execute direct access token request: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("failed to retrieve direct access token: received status code %d instead of %d", resp.StatusCode, http.StatusCreated) + } + + return &response, nil +} + +// DirectAccessResponse represents the response from GitLab direct access token API. +type DirectAccessResponse struct { + Headers map[string]string `json:"headers"` + Token string `json:"token"` +} + +// setClaudeSettings configures Claude settings to use this binary as the API key helper. +// Returns true if successful, false otherwise. +func setClaudeSettings() bool { + homeDir, err := os.UserHomeDir() + if err != nil { + return false + } + + settingsPath := filepath.Join(homeDir, ClaudeSettingsDir, SettingsFileName) + + // Ensure the settings directory exists + if err := ensureSettingsDir(settingsPath); err != nil { + return false + } + + // Read existing settings + settings, err := readSettings(settingsPath) + if err != nil { + return false + } + + // Get current binary path + exePath, err := os.Executable() + if err != nil { + return false + } + + // Update apiKeyHelper setting + settings[APIKeyHelperKey] = fmt.Sprintf("%s duo claude token", exePath) + + // Write updated settings + return writeSettings(settingsPath, settings) +} + +// ensureSettingsDir creates the settings directory and file if they don't exist. +func ensureSettingsDir(settingsPath string) error { + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + return fmt.Errorf("failed to create settings directory: %w", err) + } + file, err := os.Create(settingsPath) + if err != nil { + return fmt.Errorf("failed to create settings file: %w", err) + } + file.Close() + } + return nil +} + +// readSettings reads and parses the Claude settings file. +func readSettings(settingsPath string) (map[string]any, error) { + settingsFile, err := os.ReadFile(settingsPath) + if err != nil { + return nil, fmt.Errorf("failed to read settings file: %w", err) + } + + var settings map[string]any + if len(settingsFile) > 0 { + if err := json.Unmarshal(settingsFile, &settings); err != nil { + return nil, fmt.Errorf("failed to parse settings JSON: %w", err) + } + } else { + settings = make(map[string]any) + } + + return settings, nil +} + +// writeSettings writes the settings to the Claude settings file. +func writeSettings(settingsPath string, settings map[string]any) bool { + updatedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false + } + + if err := os.WriteFile(settingsPath, updatedSettings, 0o644); err != nil { + return false + } + + return true +} + +// validateClaudeExecutable checks if the Claude executable exists and is accessible. +func validateClaudeExecutable() error { + _, err := exec.LookPath(ClaudeExecutable) + if err != nil { + return fmt.Errorf("claude executable not found in PATH: %w", err) + } + return nil +} + +// extractClaudeArgs extracts arguments after "claude" from os.Args. +func extractClaudeArgs() ([]string, error) { + osArgs := os.Args + + // Find the index where "claude" appears in the arguments + claudeIndex := -1 + for i, arg := range osArgs { + if arg == "claude" { + claudeIndex = i + break + } + } + + if claudeIndex == -1 { + return nil, fmt.Errorf("could not find 'claude' in command arguments") + } + + // Return all arguments after "claude" + return osArgs[claudeIndex+1:], nil +} diff --git a/internal/commands/duo/claude/utils_test.go b/internal/commands/duo/claude/utils_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cf925a23bf5c2745c98808a6e8ab24cb2177e692 --- /dev/null +++ b/internal/commands/duo/claude/utils_test.go @@ -0,0 +1,296 @@ +package claude + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gitlab "gitlab.com/gitlab-org/api/client-go" + "gitlab.com/gitlab-org/cli/internal/testing/httpmock" +) + +func TestGetHeaderEnv(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expected string + }{ + { + name: "empty headers", + headers: map[string]string{}, + expected: "", + }, + { + name: "single header", + headers: map[string]string{ + "Authorization": "Bearer token123", + }, + expected: "Authorization: Bearer token123", + }, + { + name: "multiple headers", + headers: map[string]string{ + "Authorization": "Bearer token123", + "X-Custom": "value", + }, + // Note: map iteration order is not guaranteed, so we check both orders + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := getHeaderEnv(tc.headers) + if len(tc.headers) == 0 { + assert.Equal(t, tc.expected, result) + } else if len(tc.headers) == 1 { + assert.Equal(t, tc.expected, result) + } else { + // For multiple headers, just check that all expected parts are present + for k, v := range tc.headers { + assert.Contains(t, result, k+": "+v) + } + } + }) + } +} + +func TestFetchDirectAccessToken(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + expectedError string + expectedToken string + expectedHeader string + }{ + { + name: "successful request", + statusCode: http.StatusCreated, + responseBody: `{"token": "test-token", "headers": {"X-Auth": "value"}}`, + expectedToken: "test-token", + expectedHeader: "value", + }, + { + name: "wrong status code", + statusCode: http.StatusBadRequest, + responseBody: `{"error": "bad request"}`, + expectedError: "failed to execute direct access token request", + }, + { + name: "invalid JSON", + statusCode: http.StatusCreated, + responseBody: `invalid json`, + expectedError: "failed to execute direct access token request", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fakeHTTP := &httpmock.Mocker{ + MatchURL: httpmock.PathAndQuerystring, + } + defer fakeHTTP.Verify(t) + + response := httpmock.NewStringResponse(tc.statusCode, tc.responseBody) + fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/ai/third_party_agents/direct_access", response) + + client, err := gitlab.NewClient("", gitlab.WithHTTPClient(&http.Client{Transport: fakeHTTP})) + require.NoError(t, err) + + result, err := fetchDirectAccessToken(client) + + if tc.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tc.expectedToken, result.Token) + if tc.expectedHeader != "" { + assert.Equal(t, tc.expectedHeader, result.Headers["X-Auth"]) + } + } + }) + } +} + +func TestValidateClaudeExecutable(t *testing.T) { + // Test when executable doesn't exist + originalPath := os.Getenv("PATH") + defer t.Setenv("PATH", originalPath) + + // Set PATH to empty to ensure claude is not found + t.Setenv("PATH", "") + + err := validateClaudeExecutable() + assert.Error(t, err) + assert.Contains(t, err.Error(), "claude executable not found in PATH") +} + +func TestExtractClaudeArgs(t *testing.T) { + tests := []struct { + name string + osArgs []string + expectedArgs []string + expectedError string + }{ + { + name: "claude with args", + osArgs: []string{"glab", "duo", "claude", "--help", "some", "args"}, + expectedArgs: []string{"--help", "some", "args"}, + }, + { + name: "claude without args", + osArgs: []string{"glab", "duo", "claude"}, + expectedArgs: []string{}, + }, + { + name: "no claude in args", + osArgs: []string{"glab", "duo", "ask", "something"}, + expectedError: "could not find 'claude' in command arguments", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Save original os.Args + originalArgs := os.Args + defer func() { os.Args = originalArgs }() + + // Set test args + os.Args = tc.osArgs + + result, err := extractClaudeArgs() + + if tc.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedArgs, result) + } + }) + } +} + +func TestSetClaudeSettings(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + defer t.Setenv("HOME", originalHome) + t.Setenv("HOME", tempDir) + + result := setClaudeSettings() + assert.True(t, result) + + // Verify the settings file was created + settingsPath := filepath.Join(tempDir, ClaudeSettingsDir, SettingsFileName) + assert.FileExists(t, settingsPath) + + // Verify the content + content, err := os.ReadFile(settingsPath) + require.NoError(t, err) + + var settings map[string]any + err = json.Unmarshal(content, &settings) + require.NoError(t, err) + + apiKeyHelper, exists := settings[APIKeyHelperKey] + assert.True(t, exists) + assert.Contains(t, apiKeyHelper.(string), "duo claude token") +} + +func TestEnsureSettingsDir(t *testing.T) { + tempDir := t.TempDir() + settingsPath := filepath.Join(tempDir, "test", SettingsFileName) + + err := ensureSettingsDir(settingsPath) + assert.NoError(t, err) + + // Verify directory was created + assert.DirExists(t, filepath.Dir(settingsPath)) + assert.FileExists(t, settingsPath) +} + +func TestReadSettings(t *testing.T) { + tests := []struct { + name string + fileContent string + expectedError string + expectedData map[string]any + }{ + { + name: "valid JSON", + fileContent: `{"apiKeyHelper": "test-value"}`, + expectedData: map[string]any{"apiKeyHelper": "test-value"}, + }, + { + name: "empty file", + fileContent: "", + expectedData: map[string]any{}, + }, + { + name: "invalid JSON", + fileContent: `{"invalid": json}`, + expectedError: "failed to parse settings JSON", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + settingsPath := filepath.Join(tempDir, "settings.json") + + err := os.WriteFile(settingsPath, []byte(tc.fileContent), 0o644) + require.NoError(t, err) + + result, err := readSettings(settingsPath) + + if tc.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedData, result) + } + }) + } +} + +func TestWriteSettings(t *testing.T) { + tempDir := t.TempDir() + settingsPath := filepath.Join(tempDir, "settings.json") + + settings := map[string]any{ + "apiKeyHelper": "test-value", + "otherKey": "other-value", + } + + result := writeSettings(settingsPath, settings) + assert.True(t, result) + + // Verify the file was written correctly + content, err := os.ReadFile(settingsPath) + require.NoError(t, err) + + var readSettings map[string]any + err = json.Unmarshal(content, &readSettings) + require.NoError(t, err) + + assert.Equal(t, settings, readSettings) +} + +func TestWriteSettingsInvalidPath(t *testing.T) { + // Try to write to an invalid path (directory doesn't exist and can't be created) + invalidPath := "/root/nonexistent/settings.json" + + settings := map[string]any{"test": "value"} + + result := writeSettings(invalidPath, settings) + assert.False(t, result) +} diff --git a/internal/commands/duo/duo.go b/internal/commands/duo/duo.go index fd244723c3b2730af66facbcc6d11d76749f2899..e83674789b5ec5ef971ba1b015ae31daa8e17bc8 100644 --- a/internal/commands/duo/duo.go +++ b/internal/commands/duo/duo.go @@ -3,6 +3,7 @@ package duo import ( "gitlab.com/gitlab-org/cli/internal/cmdutils" duoAskCmd "gitlab.com/gitlab-org/cli/internal/commands/duo/ask" + "gitlab.com/gitlab-org/cli/internal/commands/duo/claude" "github.com/spf13/cobra" ) @@ -10,11 +11,12 @@ import ( func NewCmdDuo(f cmdutils.Factory) *cobra.Command { duoCmd := &cobra.Command{ Use: "duo prompt", - Short: "Generate terminal commands from natural language.", + Short: "Work with GitLab Duo", Long: ``, } duoCmd.AddCommand(duoAskCmd.NewCmdAsk(f)) + duoCmd.AddCommand(claude.NewCmdClaude(f)) return duoCmd }