diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3d89528a..66318c52 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,9 +11,9 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - name: Run linters - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v7 with: - version: latest + version: v2.1 args: --timeout=3m go-test: strategy: diff --git a/.golangci.yml b/.golangci.yml index 4ab47e29..71294530 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,103 +1,83 @@ -linters-settings: - exhaustive: - default-signifies-exhaustive: true - - gocritic: - # The list of supported checkers can be find in https://go-critic.github.io/overview. - settings: - underef: - # Whether to skip (*x).method() calls where x is a pointer receiver. - skipRecvDeref: false - - govet: - enable-all: true - disable: - - fieldalignment # too strict - - shadow # complains too much about shadowing errors. All research points to this being fine. - - nakedret: - max-func-lines: 0 - - nolintlint: - allow-no-explanation: [ forbidigo, tracecheck, gomnd, gochecknoinits, makezero ] - require-explanation: true - require-specific: true - - revive: - ignore-generated-header: true - severity: error - rules: - - name: atomic - - name: line-length-limit - arguments: [ 200 ] - # These are functions that we use without checking the errors often. Most of these can't return an error even - # though they implement an interface that can. - - name: unhandled-error - arguments: - - fmt.Printf - - fmt.Println - - fmt.Fprintf - - fmt.Fprintln - - os.Stderr.Sync - - sb.WriteString - - buf.WriteString - - hasher.Write - - os.Setenv - - os.RemoveAll - - name: var-naming - arguments: [["ID", "URL", "HTTP", "API"], []] - - tenv: - all: true - - varcheck: - exported-fields: false # this appears to improperly detect exported variables as unused when they are used from a package with the same name - +version: "2" linters: - disable-all: true enable: - - errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases - - gosimple # Linter for Go source code that specializes in simplifying a code - - govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - - ineffassign # Detects when assignments to existing variables are not used - - staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks - - typecheck # Like the front-end of a Go compiler, parses and type-checks Go code - - unused # Checks Go code for unused constants, variables, functions and types - - asasalint # Check for pass []any as any in variadic func(...any) - - asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers - - bidichk # Checks for dangerous unicode character sequences - - bodyclose # checks whether HTTP response body is closed successfully - - durationcheck # check for two durations multiplied together - - errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. - - exhaustive # check exhaustiveness of enum switch statements - - forbidigo # Forbids identifiers - - gochecknoinits # Checks that no init functions are present in Go code - - goconst # Finds repeated strings that could be replaced by a constant - - gocritic # Provides diagnostics that check for bugs, performance and style issues. - - godot # Check if comments end in a period - - goimports # In addition to fixing imports, goimports also formats your code in the same style as gofmt. - - gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod. - - goprintffuncname # Checks that printf-like functions are named with f at the end - - gosec # Inspects source code for security problems - - nakedret # Finds naked returns in functions greater than a specified function length - - nilerr # Finds the code that returns nil even if it checks that the error is not nil. - - noctx # noctx finds sending http request without context.Context - - nolintlint # Reports ill-formed or insufficient nolint directives - - nonamedreturns # Reports all named returns - - nosprintfhostport # Checks for misuse of Sprintf to construct a host with port in a URL. - - predeclared # find code that shadows one of Go's predeclared identifiers - - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. - - tenv # tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17 - - tparallel # tparallel detects inappropriate usage of t.Parallel() method in your Go test codes - - unconvert # Remove unnecessary type conversions - - usestdlibvars # detect the possibility to use variables/constants from the Go standard library - - whitespace # Tool for detection of leading and trailing whitespace + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - asasalint + - asciicheck + - bidichk + - bodyclose + - durationcheck + - errorlint + - exhaustive + - forbidigo + - gochecknoinits + - goconst + - gocritic + - godot + - gomoddirectives + - goprintffuncname + - gosec + - nakedret + - nilerr + - noctx + - nolintlint + - nonamedreturns + - nosprintfhostport + - predeclared + - revive + - tparallel + - unconvert + - usestdlibvars + - whitespace + settings: + exhaustive: + default-signifies-exhaustive: true + goconst: + min-occurrences: 5 + gocritic: + settings: + underef: + skipRecvDeref: false + govet: + enable-all: true + disable: + - fieldalignment + - shadow + nakedret: + max-func-lines: 0 + nolintlint: + allow-no-explanation: [forbidigo, tracecheck, gomnd, gochecknoinits, makezero] + require-explanation: true + require-specific: true + revive: + severity: error + rules: + - name: atomic + - name: line-length-limit + arguments: [200] + - name: unhandled-error + arguments: + - fmt.Printf + - fmt.Println + - fmt.Fprintf + - fmt.Fprintln + - os.Stderr.Sync + - sb.WriteString + - buf.WriteString + - hasher.Write + - os.Setenv + - os.RemoveAll + - name: var-naming + arguments: [["ID", "URL", "HTTP", "API"], []] + +formatters: + enable: + - goimports issues: max-same-issues: 50 - - exclude-rules: - # Don't require TODO comments to end in a period - - source: "(TODO)" - linters: [ godot ] diff --git a/cmd/cone/aws.go b/cmd/cone/aws.go index f451ffe1..496fc935 100644 --- a/cmd/cone/aws.go +++ b/cmd/cone/aws.go @@ -221,7 +221,7 @@ func createAWSProfile(entitlement *shared.AppEntitlement, resource *shared.AppRe } configPath := filepath.Join(awsConfigDir, "config") - configContent, err := os.ReadFile(configPath) + configContent, err := os.ReadFile(configPath) //nolint:gosec // path from known config dir if err != nil && !os.IsNotExist(err) { return "", fmt.Errorf("failed to read AWS config: %w", err) } @@ -390,7 +390,7 @@ func awsCredentialsRun(cmd *cobra.Command, args []string) error { awsConfigDir := filepath.Join(os.Getenv("HOME"), ".aws") configPath := filepath.Join(awsConfigDir, "config") - configContent, err := os.ReadFile(configPath) + configContent, err := os.ReadFile(configPath) //nolint:gosec // path from known config dir if err != nil { return fmt.Errorf("failed to read AWS config: %w", err) } @@ -419,7 +419,7 @@ func awsCredentialsRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to marshal credentials: %w", err) } - fmt.Fprintln(os.Stdout, string(jsonOutput)) + fmt.Fprintln(os.Stdout, string(jsonOutput)) //nolint:errcheck // writing to stdout return nil } @@ -469,7 +469,7 @@ func getSSOToken(ssoStartURL string) (string, error) { if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") { continue } - content, err := os.ReadFile(filepath.Join(cacheDir, file.Name())) + content, err := os.ReadFile(filepath.Join(cacheDir, file.Name())) //nolint:gosec // path from known cache dir if err != nil { continue } diff --git a/cmd/cone/install_mcp.go b/cmd/cone/install_mcp.go new file mode 100644 index 00000000..4984ced6 --- /dev/null +++ b/cmd/cone/install_mcp.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +func installMCPCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "install-mcp", + Short: "Connect Claude Code to ConductorOne's hosted MCP gateway.", + Long: `Registers ConductorOne's hosted MCP endpoint with Claude Code. + +Claude Code handles OAuth (including DCR) on first connection. +Cone just provides the endpoint URL based on your existing login. + +Requires 'cone login ' to have been run first.`, + RunE: installMCPRun, + } + + cmd.Flags().String("scope", "user", "Claude Code scope: user or project") + cmd.Flags().Bool("dry-run", false, "Print what would happen without doing it") + cmd.Flags().Bool("manual", false, "Print config snippet instead of running claude CLI") + + return cmd +} + +func installMCPRun(cmd *cobra.Command, _ []string) error { + v, err := getSubViperForProfile(cmd) + if err != nil { + return err + } + + clientID := v.GetString("client-id") + if clientID == "" { + return fmt.Errorf("not authenticated. Run 'cone login ' first") + } + + // Parse tenant host from client-id. + // Format: {name}@{host}/{path} + tenantHost, err := parseTenantHost(clientID) + if err != nil { + return fmt.Errorf("could not determine tenant from client-id %q: %w", clientID, err) + } + + mcpURL := fmt.Sprintf("https://%s/api/v1alpha/mcp", tenantHost) + + profile := v.GetString("profile") + if profile == "" { + profile = "default" + } + serverName := "conductorone" + if profile != "default" { + serverName = fmt.Sprintf("conductorone-%s", profile) + } + + scope, _ := cmd.Flags().GetString("scope") + dryRun, _ := cmd.Flags().GetBool("dry-run") + manual, _ := cmd.Flags().GetBool("manual") + + if dryRun { + pterm.Printf("Would run:\n claude mcp add --transport http --scope %s %s %s\n", scope, serverName, mcpURL) + return nil + } + + if manual { + printManualMCPConfig(serverName, mcpURL) + return nil + } + + claudePath, _ := exec.LookPath("claude") + if claudePath == "" { + pterm.Printf("claude CLI not found on PATH. Falling back to manual config.\n\n") + printManualMCPConfig(serverName, mcpURL) + return nil + } + + spinner, err := pterm.DefaultSpinner.Start(fmt.Sprintf("Registering MCP server %q...", serverName)) + if err != nil { + return err + } + + out, err := exec.CommandContext(cmd.Context(), claudePath, "mcp", "add", //nolint:gosec // args are from config, not user input + "--transport", "http", + "--scope", scope, + serverName, mcpURL, + ).CombinedOutput() + if err != nil { + spinner.Fail(fmt.Sprintf("Failed to register: %s", strings.TrimSpace(string(out)))) + return fmt.Errorf("claude mcp add failed: %w", err) + } + + spinner.Success(fmt.Sprintf("Registered %q (scope: %s)", serverName, scope)) + pterm.Printf("\nEndpoint: %s\n", mcpURL) + pterm.Printf("Claude Code will handle OAuth on first connection.\n") + pterm.Printf("Restart Claude Code or run /mcp to connect.\n") + + return nil +} + +// parseTenantHost extracts the host from a C1 client-id. +// Client-id format: {name}@{host}/{path}. +func parseTenantHost(clientID string) (string, error) { + parts := strings.SplitN(clientID, "@", 2) + if len(parts) != 2 { + return "", fmt.Errorf("expected format {name}@{host}/{path}") + } + hostPath := parts[1] + hostParts := strings.SplitN(hostPath, "/", 2) + if len(hostParts) != 2 { + return "", fmt.Errorf("expected format {name}@{host}/{path}") + } + return hostParts[0], nil +} + +func printManualMCPConfig(serverName, mcpURL string) { + config := map[string]any{ + "type": "http", + "url": mcpURL, + } + configJSON, _ := json.MarshalIndent(map[string]any{ + serverName: config, + }, "", " ") + + pterm.Printf("Add the following to your Claude Code MCP config:\n\n") + pterm.Printf("%s\n", configJSON) +} diff --git a/cmd/cone/install_mcp_test.go b/cmd/cone/install_mcp_test.go new file mode 100644 index 00000000..c7d5347e --- /dev/null +++ b/cmd/cone/install_mcp_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "testing" +) + +func TestParseTenantHost(t *testing.T) { + tests := []struct { + name string + clientID string + want string + wantErr bool + }{ + { + name: "standard client-id", + clientID: "myapp@mycompany.conductor.one/api", + want: "mycompany.conductor.one", + }, + { + name: "client-id with longer path", + clientID: "svc@staging.conductor.one/api/v1/something", + want: "staging.conductor.one", + }, + { + name: "missing @ separator", + clientID: "nohostpart", + wantErr: true, + }, + { + name: "missing / after host", + clientID: "name@hostonly", + wantErr: true, + }, + { + name: "empty string", + clientID: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTenantHost(tt.clientID) + if tt.wantErr { + if err == nil { + t.Errorf("parseTenantHost(%q) expected error, got %q", tt.clientID, got) + } + return + } + if err != nil { + t.Errorf("parseTenantHost(%q) unexpected error: %v", tt.clientID, err) + return + } + if got != tt.want { + t.Errorf("parseTenantHost(%q) = %q, want %q", tt.clientID, got, tt.want) + } + }) + } +} diff --git a/cmd/cone/main.go b/cmd/cone/main.go index 23e78a2c..19acb70c 100644 --- a/cmd/cone/main.go +++ b/cmd/cone/main.go @@ -80,6 +80,7 @@ func runCli(ctx context.Context) int { cliCmd.AddCommand(hasCmd()) cliCmd.AddCommand(tokenCmd()) cliCmd.AddCommand(decryptCredentialCmd()) + cliCmd.AddCommand(installMCPCmd()) cliCmd.AddCommand(virtualEntitlementsCmd()) cliCmd.AddCommand(generateAliasCmd()) cliCmd.AddCommand(awsCmd()) diff --git a/cmd/cone/virtual_entitlements.go b/cmd/cone/virtual_entitlements.go index b009343d..89f3ab5e 100644 --- a/cmd/cone/virtual_entitlements.go +++ b/cmd/cone/virtual_entitlements.go @@ -109,7 +109,7 @@ func virtualEntitlementsCreateRun(cmd *cobra.Command, args []string) error { var resources []virtualResourceYAML if fromFile != "" { - data, err := os.ReadFile(fromFile) + data, err := os.ReadFile(fromFile) //nolint:gosec // path from CLI flag if err != nil { return fmt.Errorf("reading file %s: %w", fromFile, err) } diff --git a/pkg/client/token_exchange.go b/pkg/client/token_exchange.go index b611ad3e..ea32d706 100644 --- a/pkg/client/token_exchange.go +++ b/pkg/client/token_exchange.go @@ -54,7 +54,7 @@ func (t *tokenExchangeSource) Token() (*oauth2.Token, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // best-effort close if resp.StatusCode != http.StatusOK { return nil, status.Errorf(codes.Unauthenticated, "token exchange failed: %s", resp.Status) diff --git a/pkg/client/token_source.go b/pkg/client/token_source.go index 7c166897..e85b25d2 100644 --- a/pkg/client/token_source.go +++ b/pkg/client/token_source.go @@ -152,7 +152,7 @@ func (c *c1TokenSource) Token() (*oauth2.Token, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // best-effort close if resp.StatusCode != http.StatusOK { return nil, status.Errorf(codes.Unauthenticated, "failed to get token: %s", resp.Status)