From 91f3e3687876e0066cd00340cfaacad6b54ce8e4 Mon Sep 17 00:00:00 2001 From: Mason Williams Date: Fri, 5 Dec 2025 17:22:49 -0700 Subject: [PATCH 1/7] feat(agents): add agent authentication commands --- cmd/agent_auth.go | 510 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + 2 files changed, 511 insertions(+) create mode 100644 cmd/agent_auth.go diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go new file mode 100644 index 0000000..945a0fa --- /dev/null +++ b/cmd/agent_auth.go @@ -0,0 +1,510 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" + "github.com/onkernel/kernel-go-sdk/option" + "github.com/pkg/browser" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// AgentsAuthService defines the subset of the Kernel SDK agent auth client that we use. +type AgentsAuthService interface { + Start(ctx context.Context, body kernel.AgentsAuthStartParams, opts ...option.RequestOption) (res *kernel.AgentsAuthStartResponse, err error) + Retrieve(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.AgentsAuthRetrieveResponse, err error) +} + +// AgentsAuthInvocationsService defines the subset we use for agent auth invocations. +type AgentsAuthInvocationsService interface { + Retrieve(ctx context.Context, invocationId string, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsRetrieveResponse, err error) + Exchange(ctx context.Context, invocationId string, body kernel.AgentsAuthInvocationsExchangeParams, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsExchangeResponse, err error) + Discover(ctx context.Context, invocationId string, body kernel.AgentsAuthInvocationsDiscoverParams, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsDiscoverResponse, err error) + Submit(ctx context.Context, invocationId string, body kernel.AgentsAuthInvocationsSubmitParams, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsSubmitResponse, err error) +} + +type AgentsAuthStartInput struct { + TargetDomain string + ProfileName string + LoginURL string + ProxyID string + Hosted bool +} + +type AgentsAuthStatusInput struct { + ID string +} + +// AgentsAuthCmd handles agent auth operations independent of cobra. +type AgentsAuthCmd struct { + auth AgentsAuthService + invocations AgentsAuthInvocationsService + browsers BrowsersService +} + +func (a AgentsAuthCmd) Start(ctx context.Context, in AgentsAuthStartInput) error { + pterm.Info.Println("Starting agent authentication flow...") + pterm.Println(fmt.Sprintf(" Target domain: %s", in.TargetDomain)) + pterm.Println(fmt.Sprintf(" Profile name: %s", in.ProfileName)) + if in.LoginURL != "" { + pterm.Println(fmt.Sprintf(" Login URL: %s", in.LoginURL)) + } + if in.ProxyID != "" { + pterm.Println(fmt.Sprintf(" Proxy ID: %s", in.ProxyID)) + } + pterm.Println() + + params := kernel.AgentsAuthStartParams{ + TargetDomain: in.TargetDomain, + ProfileName: in.ProfileName, + } + if in.LoginURL != "" { + params.LoginURL = kernel.Opt(in.LoginURL) + } + if in.ProxyID != "" { + params.Proxy = kernel.AgentsAuthStartParamsProxy{ + ProxyID: kernel.Opt(in.ProxyID), + } + } + + startResp, err := a.auth.Start(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + pterm.Success.Println("Auth flow started successfully!") + pterm.Println(fmt.Sprintf(" Invocation ID: %s", startResp.InvocationID)) + pterm.Println(fmt.Sprintf(" Auth Agent ID: %s", startResp.AuthAgentID)) + pterm.Println() + + if in.Hosted { + return a.handleHostedMode(ctx, startResp) + } + + return a.handleInteractiveMode(ctx, startResp) +} + +func (a AgentsAuthCmd) handleHostedMode(ctx context.Context, startResp *kernel.AgentsAuthStartResponse) error { + pterm.Info.Println("Hosted UI Mode") + pterm.Println(strings.Repeat("=", 60)) + pterm.Println() + pterm.Println("Please open this URL in your browser:") + pterm.Println() + pterm.Println(fmt.Sprintf(" %s", startResp.HostedURL)) + pterm.Println() + pterm.Println(strings.Repeat("=", 60)) + pterm.Println() + + // Try to open browser automatically + if err := browser.OpenURL(startResp.HostedURL); err != nil { + pterm.Warning.Printf("Could not open browser automatically: %v\n", err) + pterm.Info.Println("Please copy the URL above and open it manually.") + } + + // Wait for user to confirm they've opened it + pterm.DefaultInteractiveTextInput.DefaultText = "Press Enter once you've completed authentication in the browser..." + _, _ = pterm.DefaultInteractiveTextInput.Show() + pterm.Println() + + // Poll for completion + pterm.Info.Println("Polling for completion...") + pterm.Println(" Poll interval: 2s") + pterm.Println(" Max wait time: 5 minutes") + pterm.Println() + + startTime := time.Now() + maxWaitTime := 5 * time.Minute + pollInterval := 2 * time.Second + + for time.Since(startTime) < maxWaitTime { + invocation, err := a.invocations.Retrieve(ctx, startResp.InvocationID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + elapsed := int(time.Since(startTime).Seconds()) + pterm.Println(fmt.Sprintf(" [%ds] Status: %s", elapsed, invocation.Status)) + + switch invocation.Status { + case kernel.AgentsAuthInvocationsRetrieveResponseStatusSuccess: + pterm.Println() + pterm.Success.Println("Success! Profile is ready.") + // Get profile name from auth agent + authAgent, err := a.auth.Retrieve(ctx, startResp.AuthAgentID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return a.showSuccessAndOfferBrowser(ctx, startResp.AuthAgentID, authAgent.ProfileName) + case kernel.AgentsAuthInvocationsRetrieveResponseStatusExpired: + pterm.Println() + pterm.Error.Println("Error: Invocation expired before completion") + return nil + case kernel.AgentsAuthInvocationsRetrieveResponseStatusCanceled: + pterm.Println() + pterm.Error.Println("Error: Invocation was canceled") + return nil + } + + time.Sleep(pollInterval) + } + + pterm.Error.Println("Error: Polling timed out") + return nil +} + +func (a AgentsAuthCmd) handleInteractiveMode(ctx context.Context, startResp *kernel.AgentsAuthStartResponse) error { + pterm.Info.Println("Interactive Mode") + pterm.Println() + + // Step 2: Exchange handoff code for JWT + pterm.Info.Println("Exchanging handoff code for JWT...") + + exchangeResp, err := a.invocations.Exchange(ctx, startResp.InvocationID, kernel.AgentsAuthInvocationsExchangeParams{ + Code: startResp.HandoffCode, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + jwt := exchangeResp.JWT + pterm.Success.Println("JWT obtained successfully!") + pterm.Println() + + // Create JWT-authenticated client + client := getKernelClientFromJWT(jwt) + jwtInvocations := client.Agents.Auth.Invocations + + // Step 3: Discover login fields + pterm.Info.Println("Discovering login fields...") + pterm.Println() + + discoverParams := kernel.AgentsAuthInvocationsDiscoverParams{} + if startResp.LoginURL != nil && *startResp.LoginURL != "" { + discoverParams.LoginURL = kernel.Opt(*startResp.LoginURL) + } + + discoverResp, err := jwtInvocations.Discover(ctx, startResp.InvocationID, discoverParams) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if discoverResp.LoggedIn != nil && *discoverResp.LoggedIn { + pterm.Success.Println("Already logged in! Profile saved.") + // Get profile name from auth agent + authAgent, err := a.auth.Retrieve(ctx, startResp.AuthAgentID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return a.showSuccessAndOfferBrowser(ctx, startResp.AuthAgentID, authAgent.ProfileName) + } + + if discoverResp.Success == nil || !*discoverResp.Success { + errorMsg := "Discovery failed" + if discoverResp.ErrorMessage != nil { + errorMsg = fmt.Sprintf("%s: %s", errorMsg, *discoverResp.ErrorMessage) + } + pterm.Error.Println(errorMsg) + return nil + } + + pterm.Success.Println("Login fields discovered!") + if discoverResp.LoginURL != nil { + pterm.Println(fmt.Sprintf(" Login URL: %s", *discoverResp.LoginURL)) + } + if discoverResp.PageTitle != nil { + pterm.Println(fmt.Sprintf(" Page title: %s", *discoverResp.PageTitle)) + } + pterm.Println() + + fields := discoverResp.Fields + if fields == nil || len(*fields) == 0 { + pterm.Error.Println("No fields discovered!") + return nil + } + + pterm.Info.Println("Discovered fields:") + for _, field := range *fields { + label := "-" + if field.Label != nil { + label = *field.Label + } + pterm.Println(fmt.Sprintf(" - %s (type: %s, label: \"%s\")", field.Name, field.Type, label)) + } + pterm.Println() + + // Step 4: Collect credentials + pterm.Info.Println("Collecting credentials...") + pterm.Println() + + userCredentials := make(map[string]string) + for _, field := range *fields { + fieldLabel := field.Name + if field.Label != nil && *field.Label != "" { + fieldLabel = *field.Label + } + + isPassword := field.Type == "password" || strings.Contains(strings.ToLower(field.Name), "password") + + if isPassword { + pterm.Warning.Println(" (Note: Password will be visible as you type)") + } + + prompt := fmt.Sprintf(" Enter %s: ", fieldLabel) + value, err := pterm.DefaultInteractiveTextInput.WithDefaultText(prompt).Show() + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + userCredentials[field.Name] = value + } + + pterm.Println() + + // Step 5: Submit credentials + pterm.Info.Println("Submitting credentials...") + pterm.Println() + + fieldValues := make(map[string]string) + for _, field := range *fields { + if val, ok := userCredentials[field.Name]; ok { + fieldValues[field.Name] = val + } + } + + submitResp, err := jwtInvocations.Submit(ctx, startResp.InvocationID, kernel.AgentsAuthInvocationsSubmitParams{ + FieldValues: fieldValues, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + // Handle multi-step auth flows + for submitResp.NeedsAdditionalAuth != nil && *submitResp.NeedsAdditionalAuth { + if submitResp.AdditionalFields == nil || len(*submitResp.AdditionalFields) == 0 { + break + } + + pterm.Info.Println("Additional authentication required!") + pterm.Info.Println("Additional fields:") + for _, field := range *submitResp.AdditionalFields { + label := "-" + if field.Label != nil { + label = *field.Label + } + pterm.Println(fmt.Sprintf(" - %s (type: %s, label: \"%s\")", field.Name, field.Type, label)) + } + pterm.Println() + + additionalValues := make(map[string]string) + for _, field := range *submitResp.AdditionalFields { + fieldLabel := field.Name + if field.Label != nil && *field.Label != "" { + fieldLabel = *field.Label + } + + prompt := fmt.Sprintf(" Enter %s: ", fieldLabel) + value, err := pterm.DefaultInteractiveTextInput.WithDefaultText(prompt).Show() + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + additionalValues[field.Name] = value + } + + pterm.Println() + pterm.Info.Println("Submitting additional authentication...") + + submitResp, err = jwtInvocations.Submit(ctx, startResp.InvocationID, kernel.AgentsAuthInvocationsSubmitParams{ + FieldValues: additionalValues, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + pterm.Println() + } + + // Check final result + if submitResp.LoggedIn != nil && *submitResp.LoggedIn { + // Get profile name from auth agent + authAgent, err := a.auth.Retrieve(ctx, startResp.AuthAgentID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return a.showSuccessAndOfferBrowser(ctx, startResp.AuthAgentID, authAgent.ProfileName) + } + + if submitResp.ErrorMessage != nil { + pterm.Error.Println(strings.Repeat("=", 60)) + pterm.Error.Println("LOGIN FAILED") + pterm.Error.Println(strings.Repeat("=", 60)) + pterm.Error.Printf("Error: %s\n", *submitResp.ErrorMessage) + return nil + } + + pterm.Error.Println("Unexpected state - not logged in but no error message") + return nil +} + +func (a AgentsAuthCmd) showSuccessAndOfferBrowser(ctx context.Context, authAgentID string, profileName string) error { + pterm.Success.Println(strings.Repeat("=", 60)) + pterm.Success.Println("SUCCESS! Profile saved and ready for use.") + pterm.Success.Println(strings.Repeat("=", 60)) + pterm.Println() + + // Verify auth agent status + pterm.Info.Println("Verifying auth agent status...") + authAgent, err := a.auth.Retrieve(ctx, authAgentID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + pterm.Println(fmt.Sprintf(" Auth Agent ID: %s", authAgent.ID)) + pterm.Println(fmt.Sprintf(" Profile: %s", authAgent.ProfileName)) + pterm.Println(fmt.Sprintf(" Domain: %s", authAgent.Domain)) + pterm.Println(fmt.Sprintf(" Status: %s", authAgent.Status)) + + if authAgent.Status != kernel.AgentsAuthRetrieveResponseStatusAuthenticated { + pterm.Warning.Printf("Warning: Expected status AUTHENTICATED, got %s\n", authAgent.Status) + } else { + pterm.Success.Println("Auth agent status confirmed: AUTHENTICATED") + } + pterm.Println() + + pterm.Info.Printf("You can now create browsers with profile: %s\n", profileName) + pterm.Println() + + // Offer to create browser + pterm.DefaultInteractiveConfirm.DefaultText = "Would you like to create a browser with the saved profile? (y/n)" + result, _ := pterm.DefaultInteractiveConfirm.Show() + + if result { + pterm.Println() + pterm.Info.Println("Creating browser with saved profile...") + + if a.browsers == nil { + pterm.Warning.Println("Browser service not available") + return nil + } + + browserResp, err := a.browsers.New(ctx, kernel.BrowserNewParams{ + Stealth: kernel.Opt(true), + Profile: kernel.BrowserProfileParam{ + Name: kernel.Opt(profileName), + }, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + pterm.Success.Println("Browser created successfully!") + pterm.Println(fmt.Sprintf(" Session ID: %s", browserResp.SessionID)) + pterm.Println(fmt.Sprintf(" CDP WebSocket URL: %s", browserResp.CdpWsURL)) + if browserResp.BrowserLiveViewURL != "" { + pterm.Println(fmt.Sprintf(" Live View URL: %s", browserResp.BrowserLiveViewURL)) + } + pterm.Println() + } + + return nil +} + +func (a AgentsAuthCmd) Status(ctx context.Context, in AgentsAuthStatusInput) error { + authAgent, err := a.auth.Retrieve(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", authAgent.ID}) + rows = append(rows, []string{"Profile Name", authAgent.ProfileName}) + rows = append(rows, []string{"Domain", authAgent.Domain}) + rows = append(rows, []string{"Status", string(authAgent.Status)}) + + PrintTableNoPad(rows, true) + return nil +} + +// getKernelClientFromJWT creates a new Kernel client with a JWT token +func getKernelClientFromJWT(jwt string) kernel.Client { + return kernel.NewClient(option.WithAPIKey(jwt)) +} + +// --- Cobra wiring --- + +var agentsCmd = &cobra.Command{ + Use: "agents", + Short: "Manage agents", + Long: "Commands for managing Kernel agents", +} + +var agentsAuthCmd = &cobra.Command{ + Use: "auth", + Short: "Manage agent authentication", + Long: "Commands for managing agent authentication flows", +} + +var agentsAuthStartCmd = &cobra.Command{ + Use: "start", + Short: "Start an agent authentication flow", + Long: "Start an interactive authentication flow for a website. Use --hosted to open the authentication flow in a browser.", + Args: cobra.NoArgs, + RunE: runAgentsAuthStart, +} + +var agentsAuthStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Get auth agent status", + Args: cobra.ExactArgs(1), + RunE: runAgentsAuthStatus, +} + +func init() { + agentsAuthCmd.AddCommand(agentsAuthStartCmd) + agentsAuthCmd.AddCommand(agentsAuthStatusCmd) + agentsCmd.AddCommand(agentsAuthCmd) + + agentsAuthStartCmd.Flags().String("target-domain", "", "Target domain to authenticate with (required)") + agentsAuthStartCmd.Flags().String("profile-name", "", "Profile name to use or create (required)") + agentsAuthStartCmd.Flags().String("login-url", "", "Optional login URL to skip discovery") + agentsAuthStartCmd.Flags().String("proxy-id", "", "Optional proxy ID to use") + agentsAuthStartCmd.Flags().Bool("hosted", false, "Use hosted UI mode (opens browser)") + + _ = agentsAuthStartCmd.MarkFlagRequired("target-domain") + _ = agentsAuthStartCmd.MarkFlagRequired("profile-name") +} + +func runAgentsAuthStart(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + targetDomain, _ := cmd.Flags().GetString("target-domain") + profileName, _ := cmd.Flags().GetString("profile-name") + loginURL, _ := cmd.Flags().GetString("login-url") + proxyID, _ := cmd.Flags().GetString("proxy-id") + hosted, _ := cmd.Flags().GetBool("hosted") + + svc := client.Agents.Auth + invocationsSvc := client.Agents.Auth.Invocations + browsersSvc := client.Browsers + a := AgentsAuthCmd{auth: &svc, invocations: &invocationsSvc, browsers: &browsersSvc} + + return a.Start(cmd.Context(), AgentsAuthStartInput{ + TargetDomain: targetDomain, + ProfileName: profileName, + LoginURL: loginURL, + ProxyID: proxyID, + Hosted: hosted, + }) +} + +func runAgentsAuthStatus(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Agents.Auth + a := AgentsAuthCmd{auth: &svc} + + return a.Status(cmd.Context(), AgentsAuthStatusInput{ + ID: args[0], + }) +} diff --git a/cmd/root.go b/cmd/root.go index 06cce7c..774c15d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -128,6 +128,7 @@ func init() { rootCmd.AddCommand(profilesCmd) rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) + rootCmd.AddCommand(agentsCmd) rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error { // running synchronously so we never slow the command From 76c79f1defeff7401fba7f0cf4d625bfe564dd91 Mon Sep 17 00:00:00 2001 From: Mason Williams Date: Mon, 5 Jan 2026 12:19:04 -0800 Subject: [PATCH 2/7] refactor(auth): update agent authentication commands and SDK integration --- cmd/agent_auth.go | 630 +++++++++++++++++++--------------------------- go.mod | 2 + go.sum | 4 +- 3 files changed, 259 insertions(+), 377 deletions(-) diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go index 945a0fa..d71d09c 100644 --- a/cmd/agent_auth.go +++ b/cmd/agent_auth.go @@ -3,436 +3,271 @@ package cmd import ( "context" "fmt" - "strings" "time" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" + kernel "github.com/onkernel/kernel-go-sdk" "github.com/onkernel/kernel-go-sdk/option" "github.com/pkg/browser" "github.com/pterm/pterm" "github.com/spf13/cobra" ) -// AgentsAuthService defines the subset of the Kernel SDK agent auth client that we use. -type AgentsAuthService interface { - Start(ctx context.Context, body kernel.AgentsAuthStartParams, opts ...option.RequestOption) (res *kernel.AgentsAuthStartResponse, err error) - Retrieve(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.AgentsAuthRetrieveResponse, err error) +// AgentAuthService defines the subset of the Kernel SDK agent auth client that we use. +type AgentAuthService interface { + New(ctx context.Context, body kernel.AgentAuthNewParams, opts ...option.RequestOption) (*kernel.AuthAgent, error) + Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.AuthAgent, error) + Delete(ctx context.Context, id string, opts ...option.RequestOption) error } -// AgentsAuthInvocationsService defines the subset we use for agent auth invocations. -type AgentsAuthInvocationsService interface { - Retrieve(ctx context.Context, invocationId string, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsRetrieveResponse, err error) - Exchange(ctx context.Context, invocationId string, body kernel.AgentsAuthInvocationsExchangeParams, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsExchangeResponse, err error) - Discover(ctx context.Context, invocationId string, body kernel.AgentsAuthInvocationsDiscoverParams, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsDiscoverResponse, err error) - Submit(ctx context.Context, invocationId string, body kernel.AgentsAuthInvocationsSubmitParams, opts ...option.RequestOption) (res *kernel.AgentsAuthInvocationsSubmitResponse, err error) +// AgentAuthInvocationService defines the subset we use for agent auth invocations. +type AgentAuthInvocationService interface { + New(ctx context.Context, body kernel.AgentAuthInvocationNewParams, opts ...option.RequestOption) (*kernel.AuthAgentInvocationCreateResponse, error) + Get(ctx context.Context, invocationID string, opts ...option.RequestOption) (*kernel.AgentAuthInvocationResponse, error) } -type AgentsAuthStartInput struct { - TargetDomain string - ProfileName string - LoginURL string - ProxyID string - Hosted bool -} - -type AgentsAuthStatusInput struct { - ID string +// AgentAuthCmd handles agent auth operations. +type AgentAuthCmd struct { + auth AgentAuthService + invocations AgentAuthInvocationService + browsers BrowsersService } -// AgentsAuthCmd handles agent auth operations independent of cobra. -type AgentsAuthCmd struct { - auth AgentsAuthService - invocations AgentsAuthInvocationsService - browsers BrowsersService +// CreateInput holds input for creating an auth agent. +type CreateInput struct { + Domain string + ProfileName string + CredentialName string + LoginURL string + AllowedDomains []string } -func (a AgentsAuthCmd) Start(ctx context.Context, in AgentsAuthStartInput) error { - pterm.Info.Println("Starting agent authentication flow...") - pterm.Println(fmt.Sprintf(" Target domain: %s", in.TargetDomain)) - pterm.Println(fmt.Sprintf(" Profile name: %s", in.ProfileName)) - if in.LoginURL != "" { - pterm.Println(fmt.Sprintf(" Login URL: %s", in.LoginURL)) +// Create creates a new auth agent. +func (a AgentAuthCmd) Create(ctx context.Context, in CreateInput) error { + params := kernel.AgentAuthNewParams{ + AuthAgentCreateRequest: kernel.AuthAgentCreateRequestParam{ + Domain: in.Domain, + ProfileName: in.ProfileName, + }, } - if in.ProxyID != "" { - pterm.Println(fmt.Sprintf(" Proxy ID: %s", in.ProxyID)) - } - pterm.Println() - params := kernel.AgentsAuthStartParams{ - TargetDomain: in.TargetDomain, - ProfileName: in.ProfileName, + if in.CredentialName != "" { + params.AuthAgentCreateRequest.CredentialName = kernel.Opt(in.CredentialName) } if in.LoginURL != "" { - params.LoginURL = kernel.Opt(in.LoginURL) + params.AuthAgentCreateRequest.LoginURL = kernel.Opt(in.LoginURL) } - if in.ProxyID != "" { - params.Proxy = kernel.AgentsAuthStartParamsProxy{ - ProxyID: kernel.Opt(in.ProxyID), - } + if len(in.AllowedDomains) > 0 { + params.AuthAgentCreateRequest.AllowedDomains = in.AllowedDomains } - startResp, err := a.auth.Start(ctx, params) + agent, err := a.auth.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } - pterm.Success.Println("Auth flow started successfully!") - pterm.Println(fmt.Sprintf(" Invocation ID: %s", startResp.InvocationID)) - pterm.Println(fmt.Sprintf(" Auth Agent ID: %s", startResp.AuthAgentID)) - pterm.Println() - - if in.Hosted { - return a.handleHostedMode(ctx, startResp) - } - - return a.handleInteractiveMode(ctx, startResp) -} - -func (a AgentsAuthCmd) handleHostedMode(ctx context.Context, startResp *kernel.AgentsAuthStartResponse) error { - pterm.Info.Println("Hosted UI Mode") - pterm.Println(strings.Repeat("=", 60)) - pterm.Println() - pterm.Println("Please open this URL in your browser:") - pterm.Println() - pterm.Println(fmt.Sprintf(" %s", startResp.HostedURL)) - pterm.Println() - pterm.Println(strings.Repeat("=", 60)) - pterm.Println() - - // Try to open browser automatically - if err := browser.OpenURL(startResp.HostedURL); err != nil { - pterm.Warning.Printf("Could not open browser automatically: %v\n", err) - pterm.Info.Println("Please copy the URL above and open it manually.") + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", agent.ID}) + rows = append(rows, []string{"Domain", agent.Domain}) + rows = append(rows, []string{"Profile Name", agent.ProfileName}) + rows = append(rows, []string{"Status", string(agent.Status)}) + if agent.CredentialName != "" { + rows = append(rows, []string{"Credential", agent.CredentialName}) } - - // Wait for user to confirm they've opened it - pterm.DefaultInteractiveTextInput.DefaultText = "Press Enter once you've completed authentication in the browser..." - _, _ = pterm.DefaultInteractiveTextInput.Show() - pterm.Println() - - // Poll for completion - pterm.Info.Println("Polling for completion...") - pterm.Println(" Poll interval: 2s") - pterm.Println(" Max wait time: 5 minutes") - pterm.Println() - - startTime := time.Now() - maxWaitTime := 5 * time.Minute - pollInterval := 2 * time.Second - - for time.Since(startTime) < maxWaitTime { - invocation, err := a.invocations.Retrieve(ctx, startResp.InvocationID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - elapsed := int(time.Since(startTime).Seconds()) - pterm.Println(fmt.Sprintf(" [%ds] Status: %s", elapsed, invocation.Status)) - - switch invocation.Status { - case kernel.AgentsAuthInvocationsRetrieveResponseStatusSuccess: - pterm.Println() - pterm.Success.Println("Success! Profile is ready.") - // Get profile name from auth agent - authAgent, err := a.auth.Retrieve(ctx, startResp.AuthAgentID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - return a.showSuccessAndOfferBrowser(ctx, startResp.AuthAgentID, authAgent.ProfileName) - case kernel.AgentsAuthInvocationsRetrieveResponseStatusExpired: - pterm.Println() - pterm.Error.Println("Error: Invocation expired before completion") - return nil - case kernel.AgentsAuthInvocationsRetrieveResponseStatusCanceled: - pterm.Println() - pterm.Error.Println("Error: Invocation was canceled") - return nil - } - - time.Sleep(pollInterval) + if len(agent.AllowedDomains) > 0 { + rows = append(rows, []string{"Allowed Domains", fmt.Sprintf("%v", agent.AllowedDomains)}) } - pterm.Error.Println("Error: Polling timed out") + PrintTableNoPad(rows, true) return nil } -func (a AgentsAuthCmd) handleInteractiveMode(ctx context.Context, startResp *kernel.AgentsAuthStartResponse) error { - pterm.Info.Println("Interactive Mode") - pterm.Println() - - // Step 2: Exchange handoff code for JWT - pterm.Info.Println("Exchanging handoff code for JWT...") +// InvokeInput holds input for starting an invocation. +type InvokeInput struct { + AuthAgentID string + SaveCredentialAs string + NoBrowser bool +} - exchangeResp, err := a.invocations.Exchange(ctx, startResp.InvocationID, kernel.AgentsAuthInvocationsExchangeParams{ - Code: startResp.HandoffCode, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} +// Invoke starts an auth invocation and handles the hosted UI flow. +func (a AgentAuthCmd) Invoke(ctx context.Context, in InvokeInput) error { + params := kernel.AgentAuthInvocationNewParams{ + AuthAgentInvocationCreateRequest: kernel.AuthAgentInvocationCreateRequestParam{ + AuthAgentID: in.AuthAgentID, + }, } - jwt := exchangeResp.JWT - pterm.Success.Println("JWT obtained successfully!") - pterm.Println() - - // Create JWT-authenticated client - client := getKernelClientFromJWT(jwt) - jwtInvocations := client.Agents.Auth.Invocations - - // Step 3: Discover login fields - pterm.Info.Println("Discovering login fields...") - pterm.Println() - - discoverParams := kernel.AgentsAuthInvocationsDiscoverParams{} - if startResp.LoginURL != nil && *startResp.LoginURL != "" { - discoverParams.LoginURL = kernel.Opt(*startResp.LoginURL) + if in.SaveCredentialAs != "" { + params.AuthAgentInvocationCreateRequest.SaveCredentialAs = kernel.Opt(in.SaveCredentialAs) } - discoverResp, err := jwtInvocations.Discover(ctx, startResp.InvocationID, discoverParams) + invocation, err := a.invocations.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } - if discoverResp.LoggedIn != nil && *discoverResp.LoggedIn { - pterm.Success.Println("Already logged in! Profile saved.") - // Get profile name from auth agent - authAgent, err := a.auth.Retrieve(ctx, startResp.AuthAgentID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - return a.showSuccessAndOfferBrowser(ctx, startResp.AuthAgentID, authAgent.ProfileName) - } - - if discoverResp.Success == nil || !*discoverResp.Success { - errorMsg := "Discovery failed" - if discoverResp.ErrorMessage != nil { - errorMsg = fmt.Sprintf("%s: %s", errorMsg, *discoverResp.ErrorMessage) - } - pterm.Error.Println(errorMsg) - return nil - } - - pterm.Success.Println("Login fields discovered!") - if discoverResp.LoginURL != nil { - pterm.Println(fmt.Sprintf(" Login URL: %s", *discoverResp.LoginURL)) - } - if discoverResp.PageTitle != nil { - pterm.Println(fmt.Sprintf(" Page title: %s", *discoverResp.PageTitle)) - } + pterm.Info.Println("Invocation created") + pterm.Println(fmt.Sprintf(" Invocation ID: %s", invocation.InvocationID)) + pterm.Println(fmt.Sprintf(" Type: %s", invocation.Type)) + pterm.Println(fmt.Sprintf(" Expires: %s", invocation.ExpiresAt.Format(time.RFC3339))) pterm.Println() - fields := discoverResp.Fields - if fields == nil || len(*fields) == 0 { - pterm.Error.Println("No fields discovered!") - return nil - } - - pterm.Info.Println("Discovered fields:") - for _, field := range *fields { - label := "-" - if field.Label != nil { - label = *field.Label - } - pterm.Println(fmt.Sprintf(" - %s (type: %s, label: \"%s\")", field.Name, field.Type, label)) - } + pterm.Info.Println("Open this URL in your browser to log in:") pterm.Println() - - // Step 4: Collect credentials - pterm.Info.Println("Collecting credentials...") + pterm.Println(fmt.Sprintf(" %s", invocation.HostedURL)) pterm.Println() - userCredentials := make(map[string]string) - for _, field := range *fields { - fieldLabel := field.Name - if field.Label != nil && *field.Label != "" { - fieldLabel = *field.Label - } - - isPassword := field.Type == "password" || strings.Contains(strings.ToLower(field.Name), "password") - - if isPassword { - pterm.Warning.Println(" (Note: Password will be visible as you type)") - } - - prompt := fmt.Sprintf(" Enter %s: ", fieldLabel) - value, err := pterm.DefaultInteractiveTextInput.WithDefaultText(prompt).Show() - if err != nil { - return fmt.Errorf("failed to read input: %w", err) + if !in.NoBrowser { + if err := browser.OpenURL(invocation.HostedURL); err != nil { + pterm.Warning.Printf("Could not open browser automatically: %v\n", err) + } else { + pterm.Info.Println("(Opened in browser)") } - userCredentials[field.Name] = value } pterm.Println() + pterm.Info.Println("Polling for completion...") - // Step 5: Submit credentials - pterm.Info.Println("Submitting credentials...") - pterm.Println() + startTime := time.Now() + maxWaitTime := 5 * time.Minute + pollInterval := 2 * time.Second - fieldValues := make(map[string]string) - for _, field := range *fields { - if val, ok := userCredentials[field.Name]; ok { - fieldValues[field.Name] = val + for time.Since(startTime) < maxWaitTime { + state, err := a.invocations.Get(ctx, invocation.InvocationID) + if err != nil { + return util.CleanedUpSdkError{Err: err} } - } - submitResp, err := jwtInvocations.Submit(ctx, startResp.InvocationID, kernel.AgentsAuthInvocationsSubmitParams{ - FieldValues: fieldValues, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } + elapsed := int(time.Since(startTime).Seconds()) + pterm.Println(fmt.Sprintf(" [%ds] status=%s, step=%s", elapsed, state.Status, state.Step)) - // Handle multi-step auth flows - for submitResp.NeedsAdditionalAuth != nil && *submitResp.NeedsAdditionalAuth { - if submitResp.AdditionalFields == nil || len(*submitResp.AdditionalFields) == 0 { - break + // Show live view URL on first poll + if state.LiveViewURL != "" && elapsed < 5 { + pterm.Println(fmt.Sprintf(" Live view: %s", state.LiveViewURL)) } - pterm.Info.Println("Additional authentication required!") - pterm.Info.Println("Additional fields:") - for _, field := range *submitResp.AdditionalFields { - label := "-" - if field.Label != nil { - label = *field.Label + // Show pending fields if any + if len(state.PendingFields) > 0 { + var fieldNames []string + for _, f := range state.PendingFields { + fieldNames = append(fieldNames, f.Name) } - pterm.Println(fmt.Sprintf(" - %s (type: %s, label: \"%s\")", field.Name, field.Type, label)) + pterm.Println(fmt.Sprintf(" Fields: %v", fieldNames)) } - pterm.Println() - additionalValues := make(map[string]string) - for _, field := range *submitResp.AdditionalFields { - fieldLabel := field.Name - if field.Label != nil && *field.Label != "" { - fieldLabel = *field.Label + // Show SSO buttons if any + if len(state.PendingSSOButtons) > 0 { + var providers []string + for _, b := range state.PendingSSOButtons { + providers = append(providers, b.Provider) } + pterm.Println(fmt.Sprintf(" SSO buttons: %v", providers)) + } + + // Show external action message + if state.Step == kernel.AgentAuthInvocationResponseStepAwaitingExternalAction && state.ExternalActionMessage != "" { + pterm.Warning.Printf(" External action required: %s\n", state.ExternalActionMessage) + } + + switch state.Status { + case kernel.AgentAuthInvocationResponseStatusSuccess: + pterm.Println() + pterm.Success.Println("Login completed successfully!") - prompt := fmt.Sprintf(" Enter %s: ", fieldLabel) - value, err := pterm.DefaultInteractiveTextInput.WithDefaultText(prompt).Show() + // Fetch and display the auth agent + agent, err := a.auth.Get(ctx, in.AuthAgentID) if err != nil { - return fmt.Errorf("failed to read input: %w", err) + pterm.Warning.Printf("Could not fetch auth agent: %v\n", err) + return nil } - additionalValues[field.Name] = value - } - pterm.Println() - pterm.Info.Println("Submitting additional authentication...") + pterm.Println() + pterm.Println(fmt.Sprintf(" Auth Agent: %s", agent.ID)) + pterm.Println(fmt.Sprintf(" Profile: %s", agent.ProfileName)) + pterm.Println(fmt.Sprintf(" Domain: %s", agent.Domain)) + pterm.Println(fmt.Sprintf(" Status: %s", agent.Status)) + if agent.CredentialName != "" { + pterm.Println(fmt.Sprintf(" Credential: %s", agent.CredentialName)) + } - submitResp, err = jwtInvocations.Submit(ctx, startResp.InvocationID, kernel.AgentsAuthInvocationsSubmitParams{ - FieldValues: additionalValues, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } + pterm.Println() + pterm.Info.Printf("You can now create browsers with profile: %s\n", agent.ProfileName) + return nil - pterm.Println() - } + case kernel.AgentAuthInvocationResponseStatusExpired: + pterm.Println() + pterm.Error.Println("Invocation expired") + return nil - // Check final result - if submitResp.LoggedIn != nil && *submitResp.LoggedIn { - // Get profile name from auth agent - authAgent, err := a.auth.Retrieve(ctx, startResp.AuthAgentID) - if err != nil { - return util.CleanedUpSdkError{Err: err} + case kernel.AgentAuthInvocationResponseStatusCanceled: + pterm.Println() + pterm.Error.Println("Invocation was canceled") + return nil + + case kernel.AgentAuthInvocationResponseStatusFailed: + pterm.Println() + pterm.Error.Println("Invocation failed") + if state.ErrorMessage != "" { + pterm.Error.Printf(" Error: %s\n", state.ErrorMessage) + } + return nil } - return a.showSuccessAndOfferBrowser(ctx, startResp.AuthAgentID, authAgent.ProfileName) - } - if submitResp.ErrorMessage != nil { - pterm.Error.Println(strings.Repeat("=", 60)) - pterm.Error.Println("LOGIN FAILED") - pterm.Error.Println(strings.Repeat("=", 60)) - pterm.Error.Printf("Error: %s\n", *submitResp.ErrorMessage) - return nil + time.Sleep(pollInterval) } - pterm.Error.Println("Unexpected state - not logged in but no error message") + pterm.Error.Println("Polling timed out after 5 minutes") return nil } -func (a AgentsAuthCmd) showSuccessAndOfferBrowser(ctx context.Context, authAgentID string, profileName string) error { - pterm.Success.Println(strings.Repeat("=", 60)) - pterm.Success.Println("SUCCESS! Profile saved and ready for use.") - pterm.Success.Println(strings.Repeat("=", 60)) - pterm.Println() +// GetInput holds input for getting an auth agent. +type GetInput struct { + ID string +} - // Verify auth agent status - pterm.Info.Println("Verifying auth agent status...") - authAgent, err := a.auth.Retrieve(ctx, authAgentID) +// Get retrieves an auth agent by ID. +func (a AgentAuthCmd) Get(ctx context.Context, in GetInput) error { + agent, err := a.auth.Get(ctx, in.ID) if err != nil { return util.CleanedUpSdkError{Err: err} } - pterm.Println(fmt.Sprintf(" Auth Agent ID: %s", authAgent.ID)) - pterm.Println(fmt.Sprintf(" Profile: %s", authAgent.ProfileName)) - pterm.Println(fmt.Sprintf(" Domain: %s", authAgent.Domain)) - pterm.Println(fmt.Sprintf(" Status: %s", authAgent.Status)) - - if authAgent.Status != kernel.AgentsAuthRetrieveResponseStatusAuthenticated { - pterm.Warning.Printf("Warning: Expected status AUTHENTICATED, got %s\n", authAgent.Status) - } else { - pterm.Success.Println("Auth agent status confirmed: AUTHENTICATED") + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", agent.ID}) + rows = append(rows, []string{"Domain", agent.Domain}) + rows = append(rows, []string{"Profile Name", agent.ProfileName}) + rows = append(rows, []string{"Status", string(agent.Status)}) + if agent.CredentialName != "" { + rows = append(rows, []string{"Credential", agent.CredentialName}) } - pterm.Println() - - pterm.Info.Printf("You can now create browsers with profile: %s\n", profileName) - pterm.Println() - - // Offer to create browser - pterm.DefaultInteractiveConfirm.DefaultText = "Would you like to create a browser with the saved profile? (y/n)" - result, _ := pterm.DefaultInteractiveConfirm.Show() - - if result { - pterm.Println() - pterm.Info.Println("Creating browser with saved profile...") - - if a.browsers == nil { - pterm.Warning.Println("Browser service not available") - return nil - } - - browserResp, err := a.browsers.New(ctx, kernel.BrowserNewParams{ - Stealth: kernel.Opt(true), - Profile: kernel.BrowserProfileParam{ - Name: kernel.Opt(profileName), - }, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - pterm.Success.Println("Browser created successfully!") - pterm.Println(fmt.Sprintf(" Session ID: %s", browserResp.SessionID)) - pterm.Println(fmt.Sprintf(" CDP WebSocket URL: %s", browserResp.CdpWsURL)) - if browserResp.BrowserLiveViewURL != "" { - pterm.Println(fmt.Sprintf(" Live View URL: %s", browserResp.BrowserLiveViewURL)) - } - pterm.Println() + if len(agent.AllowedDomains) > 0 { + rows = append(rows, []string{"Allowed Domains", fmt.Sprintf("%v", agent.AllowedDomains)}) + } + rows = append(rows, []string{"Can Reauth", fmt.Sprintf("%t", agent.CanReauth)}) + rows = append(rows, []string{"Has Selectors", fmt.Sprintf("%t", agent.HasSelectors)}) + if !agent.LastAuthCheckAt.IsZero() { + rows = append(rows, []string{"Last Auth Check", agent.LastAuthCheckAt.Format(time.RFC3339)}) } + PrintTableNoPad(rows, true) return nil } -func (a AgentsAuthCmd) Status(ctx context.Context, in AgentsAuthStatusInput) error { - authAgent, err := a.auth.Retrieve(ctx, in.ID) - if err != nil { +// DeleteInput holds input for deleting an auth agent. +type DeleteInput struct { + ID string +} + +// Delete removes an auth agent. +func (a AgentAuthCmd) Delete(ctx context.Context, in DeleteInput) error { + if err := a.auth.Delete(ctx, in.ID); err != nil { return util.CleanedUpSdkError{Err: err} } - rows := pterm.TableData{{"Property", "Value"}} - rows = append(rows, []string{"ID", authAgent.ID}) - rows = append(rows, []string{"Profile Name", authAgent.ProfileName}) - rows = append(rows, []string{"Domain", authAgent.Domain}) - rows = append(rows, []string{"Status", string(authAgent.Status)}) - - PrintTableNoPad(rows, true) + pterm.Success.Printf("Auth agent %s deleted\n", in.ID) return nil } -// getKernelClientFromJWT creates a new Kernel client with a JWT token -func getKernelClientFromJWT(jwt string) kernel.Client { - return kernel.NewClient(option.WithAPIKey(jwt)) -} - // --- Cobra wiring --- var agentsCmd = &cobra.Command{ @@ -443,68 +278,113 @@ var agentsCmd = &cobra.Command{ var agentsAuthCmd = &cobra.Command{ Use: "auth", - Short: "Manage agent authentication", - Long: "Commands for managing agent authentication flows", + Short: "Manage auth agents", + Long: "Commands for managing agent authentication", } -var agentsAuthStartCmd = &cobra.Command{ - Use: "start", - Short: "Start an agent authentication flow", - Long: "Start an interactive authentication flow for a website. Use --hosted to open the authentication flow in a browser.", +var agentsAuthCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an auth agent", + Long: "Create a new auth agent for a domain and profile", Args: cobra.NoArgs, - RunE: runAgentsAuthStart, + RunE: runAgentsAuthCreate, +} + +var agentsAuthInvokeCmd = &cobra.Command{ + Use: "invoke ", + Short: "Start an auth invocation", + Long: "Start an authentication invocation using the hosted UI flow", + Args: cobra.ExactArgs(1), + RunE: runAgentsAuthInvoke, } -var agentsAuthStatusCmd = &cobra.Command{ - Use: "status ", - Short: "Get auth agent status", +var agentsAuthGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get auth agent details", Args: cobra.ExactArgs(1), - RunE: runAgentsAuthStatus, + RunE: runAgentsAuthGet, +} + +var agentsAuthDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an auth agent", + Args: cobra.ExactArgs(1), + RunE: runAgentsAuthDelete, } func init() { - agentsAuthCmd.AddCommand(agentsAuthStartCmd) - agentsAuthCmd.AddCommand(agentsAuthStatusCmd) + agentsAuthCmd.AddCommand(agentsAuthCreateCmd) + agentsAuthCmd.AddCommand(agentsAuthInvokeCmd) + agentsAuthCmd.AddCommand(agentsAuthGetCmd) + agentsAuthCmd.AddCommand(agentsAuthDeleteCmd) agentsCmd.AddCommand(agentsAuthCmd) - agentsAuthStartCmd.Flags().String("target-domain", "", "Target domain to authenticate with (required)") - agentsAuthStartCmd.Flags().String("profile-name", "", "Profile name to use or create (required)") - agentsAuthStartCmd.Flags().String("login-url", "", "Optional login URL to skip discovery") - agentsAuthStartCmd.Flags().String("proxy-id", "", "Optional proxy ID to use") - agentsAuthStartCmd.Flags().Bool("hosted", false, "Use hosted UI mode (opens browser)") - - _ = agentsAuthStartCmd.MarkFlagRequired("target-domain") - _ = agentsAuthStartCmd.MarkFlagRequired("profile-name") + // create flags + agentsAuthCreateCmd.Flags().String("domain", "", "Target domain to authenticate with (required)") + agentsAuthCreateCmd.Flags().String("profile-name", "", "Profile name to use or create (required)") + agentsAuthCreateCmd.Flags().String("credential-name", "", "Optional credential name to link") + agentsAuthCreateCmd.Flags().String("login-url", "", "Optional login URL to skip discovery") + agentsAuthCreateCmd.Flags().StringSlice("allowed-domains", nil, "Additional allowed domains for OAuth redirects") + _ = agentsAuthCreateCmd.MarkFlagRequired("domain") + _ = agentsAuthCreateCmd.MarkFlagRequired("profile-name") + + // invoke flags + agentsAuthInvokeCmd.Flags().String("save-credential-as", "", "Save credentials under this name after successful login") + agentsAuthInvokeCmd.Flags().Bool("no-browser", false, "Don't automatically open browser") } -func runAgentsAuthStart(cmd *cobra.Command, args []string) error { +func runAgentsAuthCreate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) - targetDomain, _ := cmd.Flags().GetString("target-domain") + + domain, _ := cmd.Flags().GetString("domain") profileName, _ := cmd.Flags().GetString("profile-name") + credentialName, _ := cmd.Flags().GetString("credential-name") loginURL, _ := cmd.Flags().GetString("login-url") - proxyID, _ := cmd.Flags().GetString("proxy-id") - hosted, _ := cmd.Flags().GetBool("hosted") + allowedDomains, _ := cmd.Flags().GetStringSlice("allowed-domains") svc := client.Agents.Auth - invocationsSvc := client.Agents.Auth.Invocations - browsersSvc := client.Browsers - a := AgentsAuthCmd{auth: &svc, invocations: &invocationsSvc, browsers: &browsersSvc} - - return a.Start(cmd.Context(), AgentsAuthStartInput{ - TargetDomain: targetDomain, - ProfileName: profileName, - LoginURL: loginURL, - ProxyID: proxyID, - Hosted: hosted, + a := AgentAuthCmd{auth: &svc} + + return a.Create(cmd.Context(), CreateInput{ + Domain: domain, + ProfileName: profileName, + CredentialName: credentialName, + LoginURL: loginURL, + AllowedDomains: allowedDomains, }) } -func runAgentsAuthStatus(cmd *cobra.Command, args []string) error { +func runAgentsAuthInvoke(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) + + saveCredentialAs, _ := cmd.Flags().GetString("save-credential-as") + noBrowser, _ := cmd.Flags().GetBool("no-browser") + svc := client.Agents.Auth - a := AgentsAuthCmd{auth: &svc} + invocationsSvc := client.Agents.Auth.Invocations + a := AgentAuthCmd{auth: &svc, invocations: &invocationsSvc} - return a.Status(cmd.Context(), AgentsAuthStatusInput{ - ID: args[0], + return a.Invoke(cmd.Context(), InvokeInput{ + AuthAgentID: args[0], + SaveCredentialAs: saveCredentialAs, + NoBrowser: noBrowser, }) } + +func runAgentsAuthGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + svc := client.Agents.Auth + a := AgentAuthCmd{auth: &svc} + + return a.Get(cmd.Context(), GetInput{ID: args[0]}) +} + +func runAgentsAuthDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + svc := client.Agents.Auth + a := AgentAuthCmd{auth: &svc} + + return a.Delete(cmd.Context(), DeleteInput{ID: args[0]}) +} diff --git a/go.mod b/go.mod index b17f5cc..dcec860 100644 --- a/go.mod +++ b/go.mod @@ -58,3 +58,5 @@ require ( golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20251217233338-2f28cd021449 diff --git a/go.sum b/go.sum index f5b74b9..a51486f 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,6 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/onkernel/kernel-go-sdk v0.21.0 h1:ah1uBl71pk5DJmge0Z8eyyk1dZw6ik9ETuyd+3tIrl4= -github.com/onkernel/kernel-go-sdk v0.21.0/go.mod h1:t80buN1uCA/hwvm4D2SpjTJzZWcV7bWOFo9d7qdXD8M= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -118,6 +116,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stainless-sdks/kernel-go v0.0.0-20251217233338-2f28cd021449 h1:jXVKKP943/fTkzcCyLMviZwa8swxe1z8iH+tAzGpN1A= +github.com/stainless-sdks/kernel-go v0.0.0-20251217233338-2f28cd021449/go.mod h1:t80buN1uCA/hwvm4D2SpjTJzZWcV7bWOFo9d7qdXD8M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From b19e15325e7962157b30c3906a0a9ef51cbc53aa Mon Sep 17 00:00:00 2001 From: Mason Williams Date: Wed, 7 Jan 2026 18:20:42 -0600 Subject: [PATCH 3/7] feat(cli): add full agent auth and credentials CLI coverage - Update SDK to v0.25.0 with new import path github.com/kernel/kernel-go-sdk - Add agents auth list command with filtering - Add agents auth invocation get/submit commands - Add full credentials CLI (create, get, list, update, delete, totp-code) - Add interactive mode (-i) for agent auth create with profile/credential selectors Co-Authored-By: Claude Opus 4.5 --- cmd/agent_auth.go | 353 +++++++++++++++++++++++++++++++- cmd/app.go | 2 +- cmd/browser_pools.go | 4 +- cmd/browsers.go | 12 +- cmd/browsers_test.go | 10 +- cmd/credentials.go | 409 +++++++++++++++++++++++++++++++++++++ cmd/deploy.go | 4 +- cmd/extensions.go | 4 +- cmd/extensions_test.go | 4 +- cmd/invoke.go | 4 +- cmd/logs.go | 4 +- cmd/profiles.go | 4 +- cmd/profiles_test.go | 4 +- cmd/proxies/common_test.go | 4 +- cmd/proxies/create.go | 2 +- cmd/proxies/create_test.go | 4 +- cmd/proxies/delete_test.go | 4 +- cmd/proxies/get.go | 2 +- cmd/proxies/get_test.go | 4 +- cmd/proxies/list.go | 2 +- cmd/proxies/list_test.go | 4 +- cmd/proxies/types.go | 4 +- cmd/root.go | 5 +- go.mod | 4 +- go.sum | 4 +- pkg/auth/client.go | 4 +- pkg/util/client.go | 4 +- pkg/util/errors.go | 2 +- 28 files changed, 809 insertions(+), 62 deletions(-) create mode 100644 cmd/credentials.go diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go index d71d09c..7651b58 100644 --- a/cmd/agent_auth.go +++ b/cmd/agent_auth.go @@ -6,8 +6,9 @@ import ( "time" "github.com/onkernel/cli/pkg/util" - kernel "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/pkg/browser" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -17,6 +18,7 @@ import ( type AgentAuthService interface { New(ctx context.Context, body kernel.AgentAuthNewParams, opts ...option.RequestOption) (*kernel.AuthAgent, error) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.AuthAgent, error) + List(ctx context.Context, query kernel.AgentAuthListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.AuthAgent], error) Delete(ctx context.Context, id string, opts ...option.RequestOption) error } @@ -24,6 +26,7 @@ type AgentAuthService interface { type AgentAuthInvocationService interface { New(ctx context.Context, body kernel.AgentAuthInvocationNewParams, opts ...option.RequestOption) (*kernel.AuthAgentInvocationCreateResponse, error) Get(ctx context.Context, invocationID string, opts ...option.RequestOption) (*kernel.AgentAuthInvocationResponse, error) + Submit(ctx context.Context, invocationID string, body kernel.AgentAuthInvocationSubmitParams, opts ...option.RequestOption) (*kernel.AgentAuthSubmitResponse, error) } // AgentAuthCmd handles agent auth operations. @@ -268,6 +271,148 @@ func (a AgentAuthCmd) Delete(ctx context.Context, in DeleteInput) error { return nil } +// ListInput holds input for listing auth agents. +type ListInput struct { + Domain string + ProfileName string + Limit int64 + Offset int64 +} + +// List lists auth agents. +func (a AgentAuthCmd) List(ctx context.Context, in ListInput) error { + params := kernel.AgentAuthListParams{} + if in.Domain != "" { + params.Domain = kernel.Opt(in.Domain) + } + if in.ProfileName != "" { + params.ProfileName = kernel.Opt(in.ProfileName) + } + if in.Limit > 0 { + params.Limit = kernel.Opt(in.Limit) + } + if in.Offset > 0 { + params.Offset = kernel.Opt(in.Offset) + } + + page, err := a.auth.List(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + agents := page.Items + if len(agents) == 0 { + pterm.Info.Println("No auth agents found") + return nil + } + + rows := pterm.TableData{{"ID", "Domain", "Profile", "Status", "Can Reauth"}} + for _, agent := range agents { + rows = append(rows, []string{ + agent.ID, + agent.Domain, + agent.ProfileName, + string(agent.Status), + fmt.Sprintf("%t", agent.CanReauth), + }) + } + + PrintTableNoPad(rows, true) + return nil +} + +// GetInvocationInput holds input for getting an invocation. +type GetInvocationInput struct { + InvocationID string +} + +// GetInvocation retrieves an invocation by ID. +func (a AgentAuthCmd) GetInvocation(ctx context.Context, in GetInvocationInput) error { + state, err := a.invocations.Get(ctx, in.InvocationID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"Status", string(state.Status)}) + rows = append(rows, []string{"Step", string(state.Step)}) + rows = append(rows, []string{"Type", string(state.Type)}) + rows = append(rows, []string{"Domain", state.Domain}) + rows = append(rows, []string{"App Name", state.AppName}) + rows = append(rows, []string{"Expires", state.ExpiresAt.Format(time.RFC3339)}) + if state.LiveViewURL != "" { + rows = append(rows, []string{"Live View", state.LiveViewURL}) + } + if state.ErrorMessage != "" { + rows = append(rows, []string{"Error", state.ErrorMessage}) + } + if state.ExternalActionMessage != "" { + rows = append(rows, []string{"External Action", state.ExternalActionMessage}) + } + + PrintTableNoPad(rows, true) + + // Show pending fields if any + if len(state.PendingFields) > 0 { + pterm.Println() + pterm.Info.Println("Pending Fields:") + fieldRows := pterm.TableData{{"Name", "Type", "Label", "Required"}} + for _, f := range state.PendingFields { + fieldRows = append(fieldRows, []string{f.Name, string(f.Type), f.Label, fmt.Sprintf("%t", f.Required)}) + } + PrintTableNoPad(fieldRows, true) + } + + // Show SSO buttons if any + if len(state.PendingSSOButtons) > 0 { + pterm.Println() + pterm.Info.Println("SSO Buttons:") + buttonRows := pterm.TableData{{"Provider", "Label", "Selector"}} + for _, b := range state.PendingSSOButtons { + buttonRows = append(buttonRows, []string{b.Provider, b.Label, b.Selector}) + } + PrintTableNoPad(buttonRows, true) + } + + return nil +} + +// SubmitInvocationInput holds input for submitting to an invocation. +type SubmitInvocationInput struct { + InvocationID string + FieldValues map[string]string + SSOButton string +} + +// SubmitInvocation submits field values or clicks an SSO button. +func (a AgentAuthCmd) SubmitInvocation(ctx context.Context, in SubmitInvocationInput) error { + var params kernel.AgentAuthInvocationSubmitParams + + if in.SSOButton != "" { + params.OfSSOButton = &kernel.AgentAuthInvocationSubmitParamsBodySSOButton{ + SSOButton: in.SSOButton, + } + } else if len(in.FieldValues) > 0 { + params.OfFieldValues = &kernel.AgentAuthInvocationSubmitParamsBodyFieldValues{ + FieldValues: in.FieldValues, + } + } else { + return fmt.Errorf("must provide either --field or --sso-button") + } + + resp, err := a.invocations.Submit(ctx, in.InvocationID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if resp.Accepted { + pterm.Success.Println("Submission accepted") + } else { + pterm.Warning.Println("Submission not accepted") + } + return nil +} + // --- Cobra wiring --- var agentsCmd = &cobra.Command{ @@ -312,40 +457,190 @@ var agentsAuthDeleteCmd = &cobra.Command{ RunE: runAgentsAuthDelete, } +var agentsAuthListCmd = &cobra.Command{ + Use: "list", + Short: "List auth agents", + Long: "List auth agents with optional filters", + Args: cobra.NoArgs, + RunE: runAgentsAuthList, +} + +var agentsAuthInvocationCmd = &cobra.Command{ + Use: "invocation", + Short: "Manage auth invocations", + Long: "Commands for managing individual auth invocations", +} + +var agentsAuthInvocationGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get invocation details", + Long: "Get the current status and details of an invocation", + Args: cobra.ExactArgs(1), + RunE: runAgentsAuthInvocationGet, +} + +var agentsAuthInvocationSubmitCmd = &cobra.Command{ + Use: "submit ", + Short: "Submit to an invocation", + Long: "Submit field values or click an SSO button for an invocation", + Args: cobra.ExactArgs(1), + RunE: runAgentsAuthInvocationSubmit, +} + func init() { agentsAuthCmd.AddCommand(agentsAuthCreateCmd) agentsAuthCmd.AddCommand(agentsAuthInvokeCmd) agentsAuthCmd.AddCommand(agentsAuthGetCmd) agentsAuthCmd.AddCommand(agentsAuthDeleteCmd) + agentsAuthCmd.AddCommand(agentsAuthListCmd) + agentsAuthCmd.AddCommand(agentsAuthInvocationCmd) agentsCmd.AddCommand(agentsAuthCmd) + // invocation subcommands + agentsAuthInvocationCmd.AddCommand(agentsAuthInvocationGetCmd) + agentsAuthInvocationCmd.AddCommand(agentsAuthInvocationSubmitCmd) + // create flags - agentsAuthCreateCmd.Flags().String("domain", "", "Target domain to authenticate with (required)") - agentsAuthCreateCmd.Flags().String("profile-name", "", "Profile name to use or create (required)") + agentsAuthCreateCmd.Flags().String("domain", "", "Target domain to authenticate with") + agentsAuthCreateCmd.Flags().String("profile-name", "", "Profile name to use or create") agentsAuthCreateCmd.Flags().String("credential-name", "", "Optional credential name to link") agentsAuthCreateCmd.Flags().String("login-url", "", "Optional login URL to skip discovery") agentsAuthCreateCmd.Flags().StringSlice("allowed-domains", nil, "Additional allowed domains for OAuth redirects") - _ = agentsAuthCreateCmd.MarkFlagRequired("domain") - _ = agentsAuthCreateCmd.MarkFlagRequired("profile-name") + agentsAuthCreateCmd.Flags().BoolP("interactive", "i", false, "Interactive mode - select profile and credential from lists") // invoke flags agentsAuthInvokeCmd.Flags().String("save-credential-as", "", "Save credentials under this name after successful login") agentsAuthInvokeCmd.Flags().Bool("no-browser", false, "Don't automatically open browser") + + // list flags + agentsAuthListCmd.Flags().String("domain", "", "Filter by domain") + agentsAuthListCmd.Flags().String("profile-name", "", "Filter by profile name") + agentsAuthListCmd.Flags().Int64("limit", 0, "Maximum number of results") + agentsAuthListCmd.Flags().Int64("offset", 0, "Number of results to skip") + + // invocation submit flags + agentsAuthInvocationSubmitCmd.Flags().StringToString("field", nil, "Field values to submit (key=value)") + agentsAuthInvocationSubmitCmd.Flags().String("sso-button", "", "SSO button selector to click") } func runAgentsAuthCreate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) + ctx := cmd.Context() domain, _ := cmd.Flags().GetString("domain") profileName, _ := cmd.Flags().GetString("profile-name") credentialName, _ := cmd.Flags().GetString("credential-name") loginURL, _ := cmd.Flags().GetString("login-url") allowedDomains, _ := cmd.Flags().GetStringSlice("allowed-domains") + interactive, _ := cmd.Flags().GetBool("interactive") + + if interactive { + var err error + + // Prompt for domain if not provided + if domain == "" { + domain, err = pterm.DefaultInteractiveTextInput. + WithDefaultText("Enter target domain (e.g., github.com)"). + Show() + if err != nil { + return fmt.Errorf("failed to get domain: %w", err) + } + } + + // Let user select profile or create new + if profileName == "" { + profiles, err := client.Profiles.List(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + options := []string{"[Create new profile]"} + profileMap := make(map[string]string) // display -> name + if profiles != nil { + for _, p := range *profiles { + display := p.Name + if display == "" { + display = p.ID + } + options = append(options, display) + profileMap[display] = p.Name + if p.Name == "" { + profileMap[display] = p.ID + } + } + } + + selected, err := pterm.DefaultInteractiveSelect. + WithDefaultText("Select a profile"). + WithOptions(options). + WithFilter(true). + Show() + if err != nil { + return fmt.Errorf("failed to select profile: %w", err) + } + + if selected == "[Create new profile]" { + profileName, err = pterm.DefaultInteractiveTextInput. + WithDefaultText("Enter new profile name"). + Show() + if err != nil { + return fmt.Errorf("failed to get profile name: %w", err) + } + } else { + profileName = profileMap[selected] + } + } + + // Let user select credential (optional) + if credentialName == "" { + credsPage, err := client.Credentials.List(ctx, kernel.CredentialListParams{}) + if err != nil { + pterm.Warning.Printf("Could not fetch credentials: %v\n", err) + } else if len(credsPage.Items) > 0 { + options := []string{"[Skip - no credential]"} + credMap := make(map[string]string) // display -> name + for _, c := range credsPage.Items { + display := fmt.Sprintf("%s (%s)", c.Name, c.Domain) + options = append(options, display) + credMap[display] = c.Name + } + + selected, err := pterm.DefaultInteractiveSelect. + WithDefaultText("Select a credential (optional)"). + WithOptions(options). + WithFilter(true). + Show() + if err != nil { + return fmt.Errorf("failed to select credential: %w", err) + } + + if selected != "[Skip - no credential]" { + credentialName = credMap[selected] + } + } + } + + // Validate we have required fields + if domain == "" { + return fmt.Errorf("domain is required") + } + if profileName == "" { + return fmt.Errorf("profile name is required") + } + } else { + // Non-interactive mode - validate required fields + if domain == "" { + return fmt.Errorf("--domain is required (or use -i for interactive mode)") + } + if profileName == "" { + return fmt.Errorf("--profile-name is required (or use -i for interactive mode)") + } + } svc := client.Agents.Auth a := AgentAuthCmd{auth: &svc} - return a.Create(cmd.Context(), CreateInput{ + return a.Create(ctx, CreateInput{ Domain: domain, ProfileName: profileName, CredentialName: credentialName, @@ -388,3 +683,47 @@ func runAgentsAuthDelete(cmd *cobra.Command, args []string) error { return a.Delete(cmd.Context(), DeleteInput{ID: args[0]}) } + +func runAgentsAuthList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + domain, _ := cmd.Flags().GetString("domain") + profileName, _ := cmd.Flags().GetString("profile-name") + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + + svc := client.Agents.Auth + a := AgentAuthCmd{auth: &svc} + + return a.List(cmd.Context(), ListInput{ + Domain: domain, + ProfileName: profileName, + Limit: limit, + Offset: offset, + }) +} + +func runAgentsAuthInvocationGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + invocationsSvc := client.Agents.Auth.Invocations + a := AgentAuthCmd{invocations: &invocationsSvc} + + return a.GetInvocation(cmd.Context(), GetInvocationInput{InvocationID: args[0]}) +} + +func runAgentsAuthInvocationSubmit(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + fieldValues, _ := cmd.Flags().GetStringToString("field") + ssoButton, _ := cmd.Flags().GetString("sso-button") + + invocationsSvc := client.Agents.Auth.Invocations + a := AgentAuthCmd{invocations: &invocationsSvc} + + return a.SubmitInvocation(cmd.Context(), SubmitInvocationInput{ + InvocationID: args[0], + FieldValues: fieldValues, + SSOButton: ssoButton, + }) +} diff --git a/cmd/app.go b/cmd/app.go index 16cfbbf..90fa140 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk" "github.com/pterm/pterm" "github.com/samber/lo" "github.com/spf13/cobra" diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 08a31fe..d688805 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -7,8 +7,8 @@ import ( "strings" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/browsers.go b/cmd/browsers.go index eb4e27b..f2852b5 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -16,18 +16,18 @@ import ( "strings" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" - "github.com/onkernel/kernel-go-sdk/packages/pagination" - "github.com/onkernel/kernel-go-sdk/packages/ssestream" - "github.com/onkernel/kernel-go-sdk/shared" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/kernel/kernel-go-sdk/shared" "github.com/pterm/pterm" "github.com/spf13/cobra" "github.com/spf13/pflag" ) // BrowsersService defines the subset of the Kernel SDK browser client that we use. -// See https://github.com/onkernel/kernel-go-sdk/blob/main/browser.go +// See https://github.com/kernel/kernel-go-sdk/blob/main/browser.go type BrowsersService interface { Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserGetResponse, err error) List(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.BrowserListResponse], err error) diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 2fe489d..901480d 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -13,11 +13,11 @@ import ( "testing" "time" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" - "github.com/onkernel/kernel-go-sdk/packages/pagination" - "github.com/onkernel/kernel-go-sdk/packages/ssestream" - "github.com/onkernel/kernel-go-sdk/shared" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/kernel/kernel-go-sdk/shared" "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) diff --git a/cmd/credentials.go b/cmd/credentials.go new file mode 100644 index 0000000..a89c41a --- /dev/null +++ b/cmd/credentials.go @@ -0,0 +1,409 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/onkernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// CredentialService defines the subset of the Kernel SDK credential client that we use. +type CredentialService interface { + New(ctx context.Context, body kernel.CredentialNewParams, opts ...option.RequestOption) (*kernel.Credential, error) + Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Credential, error) + Update(ctx context.Context, idOrName string, body kernel.CredentialUpdateParams, opts ...option.RequestOption) (*kernel.Credential, error) + List(ctx context.Context, query kernel.CredentialListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Credential], error) + Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error + TotpCode(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.CredentialTotpCodeResponse, error) +} + +// CredentialsCmd handles credential operations. +type CredentialsCmd struct { + credentials CredentialService +} + +// CreateCredentialInput holds input for creating a credential. +type CreateCredentialInput struct { + Name string + Domain string + Values map[string]string + SSOProvider string + TotpSecret string +} + +// Create creates a new credential. +func (c CredentialsCmd) Create(ctx context.Context, in CreateCredentialInput) error { + params := kernel.CredentialNewParams{ + CreateCredentialRequest: kernel.CreateCredentialRequestParam{ + Name: in.Name, + Domain: in.Domain, + Values: in.Values, + }, + } + + if in.SSOProvider != "" { + params.CreateCredentialRequest.SSOProvider = kernel.Opt(in.SSOProvider) + } + if in.TotpSecret != "" { + params.CreateCredentialRequest.TotpSecret = kernel.Opt(in.TotpSecret) + } + + cred, err := c.credentials.New(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", cred.ID}) + rows = append(rows, []string{"Name", cred.Name}) + rows = append(rows, []string{"Domain", cred.Domain}) + rows = append(rows, []string{"Created", cred.CreatedAt.Format(time.RFC3339)}) + if cred.SSOProvider != "" { + rows = append(rows, []string{"SSO Provider", cred.SSOProvider}) + } + rows = append(rows, []string{"Has TOTP", fmt.Sprintf("%t", cred.HasTotpSecret)}) + if cred.TotpCode != "" { + rows = append(rows, []string{"TOTP Code", cred.TotpCode}) + rows = append(rows, []string{"TOTP Expires", cred.TotpCodeExpiresAt.Format(time.RFC3339)}) + } + + PrintTableNoPad(rows, true) + return nil +} + +// GetCredentialInput holds input for getting a credential. +type GetCredentialInput struct { + IDOrName string +} + +// Get retrieves a credential by ID or name. +func (c CredentialsCmd) Get(ctx context.Context, in GetCredentialInput) error { + cred, err := c.credentials.Get(ctx, in.IDOrName) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", cred.ID}) + rows = append(rows, []string{"Name", cred.Name}) + rows = append(rows, []string{"Domain", cred.Domain}) + rows = append(rows, []string{"Created", cred.CreatedAt.Format(time.RFC3339)}) + rows = append(rows, []string{"Updated", cred.UpdatedAt.Format(time.RFC3339)}) + if cred.SSOProvider != "" { + rows = append(rows, []string{"SSO Provider", cred.SSOProvider}) + } + rows = append(rows, []string{"Has TOTP", fmt.Sprintf("%t", cred.HasTotpSecret)}) + + PrintTableNoPad(rows, true) + return nil +} + +// ListCredentialsInput holds input for listing credentials. +type ListCredentialsInput struct { + Domain string + Limit int64 + Offset int64 +} + +// List lists credentials. +func (c CredentialsCmd) List(ctx context.Context, in ListCredentialsInput) error { + params := kernel.CredentialListParams{} + if in.Domain != "" { + params.Domain = kernel.Opt(in.Domain) + } + if in.Limit > 0 { + params.Limit = kernel.Opt(in.Limit) + } + if in.Offset > 0 { + params.Offset = kernel.Opt(in.Offset) + } + + page, err := c.credentials.List(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + creds := page.Items + if len(creds) == 0 { + pterm.Info.Println("No credentials found") + return nil + } + + rows := pterm.TableData{{"ID", "Name", "Domain", "SSO", "TOTP"}} + for _, cred := range creds { + sso := "-" + if cred.SSOProvider != "" { + sso = cred.SSOProvider + } + rows = append(rows, []string{ + cred.ID, + cred.Name, + cred.Domain, + sso, + fmt.Sprintf("%t", cred.HasTotpSecret), + }) + } + + PrintTableNoPad(rows, true) + return nil +} + +// UpdateCredentialInput holds input for updating a credential. +type UpdateCredentialInput struct { + IDOrName string + Name string + Values map[string]string + SSOProvider string + TotpSecret string +} + +// Update updates a credential. +func (c CredentialsCmd) Update(ctx context.Context, in UpdateCredentialInput) error { + params := kernel.CredentialUpdateParams{ + UpdateCredentialRequest: kernel.UpdateCredentialRequestParam{}, + } + + if in.Name != "" { + params.UpdateCredentialRequest.Name = kernel.Opt(in.Name) + } + if len(in.Values) > 0 { + params.UpdateCredentialRequest.Values = in.Values + } + if in.SSOProvider != "" { + params.UpdateCredentialRequest.SSOProvider = kernel.Opt(in.SSOProvider) + } + if in.TotpSecret != "" { + params.UpdateCredentialRequest.TotpSecret = kernel.Opt(in.TotpSecret) + } + + cred, err := c.credentials.Update(ctx, in.IDOrName, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", cred.ID}) + rows = append(rows, []string{"Name", cred.Name}) + rows = append(rows, []string{"Domain", cred.Domain}) + rows = append(rows, []string{"Updated", cred.UpdatedAt.Format(time.RFC3339)}) + if cred.SSOProvider != "" { + rows = append(rows, []string{"SSO Provider", cred.SSOProvider}) + } + rows = append(rows, []string{"Has TOTP", fmt.Sprintf("%t", cred.HasTotpSecret)}) + if cred.TotpCode != "" { + rows = append(rows, []string{"TOTP Code", cred.TotpCode}) + rows = append(rows, []string{"TOTP Expires", cred.TotpCodeExpiresAt.Format(time.RFC3339)}) + } + + pterm.Success.Println("Credential updated") + PrintTableNoPad(rows, true) + return nil +} + +// DeleteCredentialInput holds input for deleting a credential. +type DeleteCredentialInput struct { + IDOrName string +} + +// Delete removes a credential. +func (c CredentialsCmd) Delete(ctx context.Context, in DeleteCredentialInput) error { + if err := c.credentials.Delete(ctx, in.IDOrName); err != nil { + return util.CleanedUpSdkError{Err: err} + } + + pterm.Success.Printf("Credential %s deleted\n", in.IDOrName) + return nil +} + +// TotpCodeInput holds input for getting a TOTP code. +type TotpCodeInput struct { + IDOrName string +} + +// TotpCode gets the current TOTP code for a credential. +func (c CredentialsCmd) TotpCode(ctx context.Context, in TotpCodeInput) error { + resp, err := c.credentials.TotpCode(ctx, in.IDOrName) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + pterm.Info.Printf("TOTP Code: %s\n", resp.Code) + pterm.Info.Printf("Expires: %s\n", resp.ExpiresAt.Format(time.RFC3339)) + return nil +} + +// --- Cobra wiring --- + +var credentialsCmd = &cobra.Command{ + Use: "credentials", + Short: "Manage credentials", + Long: "Commands for managing stored credentials for automatic authentication", +} + +var credentialsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a credential", + Long: "Create a new credential for storing login information", + Args: cobra.NoArgs, + RunE: runCredentialsCreate, +} + +var credentialsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get credential details", + Long: "Retrieve a credential by its ID or name", + Args: cobra.ExactArgs(1), + RunE: runCredentialsGet, +} + +var credentialsListCmd = &cobra.Command{ + Use: "list", + Short: "List credentials", + Long: "List credentials with optional filters", + Args: cobra.NoArgs, + RunE: runCredentialsList, +} + +var credentialsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a credential", + Long: "Update an existing credential's name or values", + Args: cobra.ExactArgs(1), + RunE: runCredentialsUpdate, +} + +var credentialsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a credential", + Long: "Delete a credential by its ID or name", + Args: cobra.ExactArgs(1), + RunE: runCredentialsDelete, +} + +var credentialsTotpCodeCmd = &cobra.Command{ + Use: "totp-code ", + Short: "Get TOTP code", + Long: "Get the current 6-digit TOTP code for a credential with a configured totp_secret", + Args: cobra.ExactArgs(1), + RunE: runCredentialsTotpCode, +} + +func init() { + credentialsCmd.AddCommand(credentialsCreateCmd) + credentialsCmd.AddCommand(credentialsGetCmd) + credentialsCmd.AddCommand(credentialsListCmd) + credentialsCmd.AddCommand(credentialsUpdateCmd) + credentialsCmd.AddCommand(credentialsDeleteCmd) + credentialsCmd.AddCommand(credentialsTotpCodeCmd) + + // create flags + credentialsCreateCmd.Flags().String("name", "", "Unique name for the credential (required)") + credentialsCreateCmd.Flags().String("domain", "", "Target domain this credential is for (required)") + credentialsCreateCmd.Flags().StringToString("value", nil, "Field values (key=value, can specify multiple)") + credentialsCreateCmd.Flags().String("sso-provider", "", "SSO provider (e.g., google, github, microsoft)") + credentialsCreateCmd.Flags().String("totp-secret", "", "Base32-encoded TOTP secret for 2FA") + _ = credentialsCreateCmd.MarkFlagRequired("name") + _ = credentialsCreateCmd.MarkFlagRequired("domain") + + // list flags + credentialsListCmd.Flags().String("domain", "", "Filter by domain") + credentialsListCmd.Flags().Int64("limit", 0, "Maximum number of results") + credentialsListCmd.Flags().Int64("offset", 0, "Number of results to skip") + + // update flags + credentialsUpdateCmd.Flags().String("name", "", "New name for the credential") + credentialsUpdateCmd.Flags().StringToString("value", nil, "Field values to update (key=value)") + credentialsUpdateCmd.Flags().String("sso-provider", "", "SSO provider (e.g., google, github, microsoft)") + credentialsUpdateCmd.Flags().String("totp-secret", "", "Base32-encoded TOTP secret for 2FA") +} + +func runCredentialsCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + name, _ := cmd.Flags().GetString("name") + domain, _ := cmd.Flags().GetString("domain") + values, _ := cmd.Flags().GetStringToString("value") + ssoProvider, _ := cmd.Flags().GetString("sso-provider") + totpSecret, _ := cmd.Flags().GetString("totp-secret") + + svc := client.Credentials + c := CredentialsCmd{credentials: &svc} + + return c.Create(cmd.Context(), CreateCredentialInput{ + Name: name, + Domain: domain, + Values: values, + SSOProvider: ssoProvider, + TotpSecret: totpSecret, + }) +} + +func runCredentialsGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + svc := client.Credentials + c := CredentialsCmd{credentials: &svc} + + return c.Get(cmd.Context(), GetCredentialInput{IDOrName: args[0]}) +} + +func runCredentialsList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + domain, _ := cmd.Flags().GetString("domain") + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + + svc := client.Credentials + c := CredentialsCmd{credentials: &svc} + + return c.List(cmd.Context(), ListCredentialsInput{ + Domain: domain, + Limit: limit, + Offset: offset, + }) +} + +func runCredentialsUpdate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + name, _ := cmd.Flags().GetString("name") + values, _ := cmd.Flags().GetStringToString("value") + ssoProvider, _ := cmd.Flags().GetString("sso-provider") + totpSecret, _ := cmd.Flags().GetString("totp-secret") + + svc := client.Credentials + c := CredentialsCmd{credentials: &svc} + + return c.Update(cmd.Context(), UpdateCredentialInput{ + IDOrName: args[0], + Name: name, + Values: values, + SSOProvider: ssoProvider, + TotpSecret: totpSecret, + }) +} + +func runCredentialsDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + svc := client.Credentials + c := CredentialsCmd{credentials: &svc} + + return c.Delete(cmd.Context(), DeleteCredentialInput{IDOrName: args[0]}) +} + +func runCredentialsTotpCode(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + svc := client.Credentials + c := CredentialsCmd{credentials: &svc} + + return c.TotpCode(cmd.Context(), TotpCodeInput{IDOrName: args[0]}) +} diff --git a/cmd/deploy.go b/cmd/deploy.go index 600dca6..3d2e1bf 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -16,8 +16,8 @@ import ( "github.com/joho/godotenv" "github.com/onkernel/cli/pkg/util" - kernel "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/samber/lo" "github.com/spf13/cobra" diff --git a/cmd/extensions.go b/cmd/extensions.go index 68b880b..6a7ca07 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -11,8 +11,8 @@ import ( "time" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index 9a7897d..4a1770c 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -12,8 +12,8 @@ import ( "testing" "time" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) diff --git a/cmd/invoke.go b/cmd/invoke.go index 913a643..00fe502 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -14,8 +14,8 @@ import ( "time" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/logs.go b/cmd/logs.go index 7d7dbf4..3e1d5ff 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -5,8 +5,8 @@ import ( "time" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/profiles.go b/cmd/profiles.go index c068453..7b4ee47 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -10,8 +10,8 @@ import ( "os" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index 3573197..3833924 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) diff --git a/cmd/proxies/common_test.go b/cmd/proxies/common_test.go index 0e4d607..9f815a6 100644 --- a/cmd/proxies/common_test.go +++ b/cmd/proxies/common_test.go @@ -6,8 +6,8 @@ import ( "os" "testing" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" ) diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 0a4eb46..f349d38 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -6,7 +6,7 @@ import ( "github.com/onkernel/cli/pkg/table" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/proxies/create_test.go b/cmd/proxies/create_test.go index 6669c55..cda254f 100644 --- a/cmd/proxies/create_test.go +++ b/cmd/proxies/create_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/stretchr/testify/assert" ) diff --git a/cmd/proxies/delete_test.go b/cmd/proxies/delete_test.go index 192e893..8edce63 100644 --- a/cmd/proxies/delete_test.go +++ b/cmd/proxies/delete_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/stretchr/testify/assert" ) diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index 49c89ee..f995af6 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -6,7 +6,7 @@ import ( "github.com/onkernel/cli/pkg/table" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/proxies/get_test.go b/cmd/proxies/get_test.go index 9f726c9..922abe3 100644 --- a/cmd/proxies/get_test.go +++ b/cmd/proxies/get_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/stretchr/testify/assert" ) diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index e5a86f5..6bc638b 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -7,7 +7,7 @@ import ( "github.com/onkernel/cli/pkg/table" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index aada9d8..dcb6f71 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/stretchr/testify/assert" ) diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index ae871b6..979f071 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -3,8 +3,8 @@ package proxies import ( "context" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" ) // ProxyService defines the subset of the Kernel SDK proxy client that we use. diff --git a/cmd/root.go b/cmd/root.go index 6abb7de..8261c6b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,8 +16,8 @@ import ( "github.com/onkernel/cli/pkg/auth" "github.com/onkernel/cli/pkg/update" "github.com/onkernel/cli/pkg/util" - "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -140,6 +140,7 @@ func init() { rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(agentsCmd) + rootCmd.AddCommand(credentialsCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) diff --git a/go.mod b/go.mod index bdb6787..e7f22d2 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/onkernel/kernel-go-sdk v0.24.0 + github.com/kernel/kernel-go-sdk v0.25.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 @@ -58,5 +58,3 @@ require ( golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20251217233338-2f28cd021449 diff --git a/go.sum b/go.sum index a51486f..847e196 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kernel/kernel-go-sdk v0.25.0 h1:I6EpQKcOasiuVi6gX8HcqEIxK9dAs6xMGSnlUW7AxXY= +github.com/kernel/kernel-go-sdk v0.25.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= @@ -116,8 +118,6 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stainless-sdks/kernel-go v0.0.0-20251217233338-2f28cd021449 h1:jXVKKP943/fTkzcCyLMviZwa8swxe1z8iH+tAzGpN1A= -github.com/stainless-sdks/kernel-go v0.0.0-20251217233338-2f28cd021449/go.mod h1:t80buN1uCA/hwvm4D2SpjTJzZWcV7bWOFo9d7qdXD8M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= diff --git a/pkg/auth/client.go b/pkg/auth/client.go index 58bf2c4..6446967 100644 --- a/pkg/auth/client.go +++ b/pkg/auth/client.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - kernel "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" ) diff --git a/pkg/util/client.go b/pkg/util/client.go index 6b12f1d..db67b89 100644 --- a/pkg/util/client.go +++ b/pkg/util/client.go @@ -9,8 +9,8 @@ import ( "os" "sync/atomic" - kernel "github.com/onkernel/kernel-go-sdk" - "github.com/onkernel/kernel-go-sdk/option" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 45a4843..76fe233 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "github.com/onkernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk" ) // CleanedUpSdkError extracts a message field from the raw JSON resposne. From fcd3e7a0faeb4bf5226c4d1080fcb5f85e7aee7b Mon Sep 17 00:00:00 2001 From: Mason Williams Date: Wed, 7 Jan 2026 18:26:06 -0600 Subject: [PATCH 4/7] feat(cli): add next step hint after creating auth agent Shows how to invoke the auth agent after creation. Co-Authored-By: Claude Opus 4.5 --- cmd/agent_auth.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go index 7651b58..a765e26 100644 --- a/cmd/agent_auth.go +++ b/cmd/agent_auth.go @@ -82,6 +82,11 @@ func (a AgentAuthCmd) Create(ctx context.Context, in CreateInput) error { } PrintTableNoPad(rows, true) + + pterm.Println() + pterm.Info.Println("Next step: Start the authentication flow with:") + pterm.Printf(" kernel agents auth invoke %s\n", agent.ID) + return nil } From a8ad502ec118b1b129c4059a5f4dff30109492f1 Mon Sep 17 00:00:00 2001 From: Mason Williams Date: Wed, 7 Jan 2026 18:27:04 -0600 Subject: [PATCH 5/7] feat(cli): add interactive mode to agents auth invoke Users can now run `kernel agents auth invoke -i` to select an auth agent from a list instead of providing the ID directly. Co-Authored-By: Claude Opus 4.5 --- cmd/agent_auth.go | 52 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go index a765e26..5df27f4 100644 --- a/cmd/agent_auth.go +++ b/cmd/agent_auth.go @@ -441,10 +441,10 @@ var agentsAuthCreateCmd = &cobra.Command{ } var agentsAuthInvokeCmd = &cobra.Command{ - Use: "invoke ", + Use: "invoke [auth-agent-id]", Short: "Start an auth invocation", Long: "Start an authentication invocation using the hosted UI flow", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: runAgentsAuthInvoke, } @@ -516,6 +516,7 @@ func init() { // invoke flags agentsAuthInvokeCmd.Flags().String("save-credential-as", "", "Save credentials under this name after successful login") agentsAuthInvokeCmd.Flags().Bool("no-browser", false, "Don't automatically open browser") + agentsAuthInvokeCmd.Flags().BoolP("interactive", "i", false, "Interactive mode - select auth agent from list") // list flags agentsAuthListCmd.Flags().String("domain", "", "Filter by domain") @@ -656,16 +657,59 @@ func runAgentsAuthCreate(cmd *cobra.Command, args []string) error { func runAgentsAuthInvoke(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) + ctx := cmd.Context() saveCredentialAs, _ := cmd.Flags().GetString("save-credential-as") noBrowser, _ := cmd.Flags().GetBool("no-browser") + interactive, _ := cmd.Flags().GetBool("interactive") + + var authAgentID string + if len(args) > 0 { + authAgentID = args[0] + } + + if interactive || authAgentID == "" { + // Fetch auth agents and let user select + page, err := client.Agents.Auth.List(ctx, kernel.AgentAuthListParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if len(page.Items) == 0 { + pterm.Error.Println("No auth agents found. Create one first with: kernel agents auth create") + return nil + } + + options := []string{} + agentMap := make(map[string]string) // display -> id + for _, agent := range page.Items { + display := fmt.Sprintf("%s - %s (%s)", agent.ID[:8], agent.Domain, agent.ProfileName) + options = append(options, display) + agentMap[display] = agent.ID + } + + selected, err := pterm.DefaultInteractiveSelect. + WithDefaultText("Select an auth agent to invoke"). + WithOptions(options). + WithFilter(true). + Show() + if err != nil { + return fmt.Errorf("failed to select auth agent: %w", err) + } + + authAgentID = agentMap[selected] + } + + if authAgentID == "" { + return fmt.Errorf("auth-agent-id is required (or use -i for interactive mode)") + } svc := client.Agents.Auth invocationsSvc := client.Agents.Auth.Invocations a := AgentAuthCmd{auth: &svc, invocations: &invocationsSvc} - return a.Invoke(cmd.Context(), InvokeInput{ - AuthAgentID: args[0], + return a.Invoke(ctx, InvokeInput{ + AuthAgentID: authAgentID, SaveCredentialAs: saveCredentialAs, NoBrowser: noBrowser, }) From b91388c07e0dc307a9af6ad6ffacdfaa4b4cc6f8 Mon Sep 17 00:00:00 2001 From: Mason Williams Date: Wed, 7 Jan 2026 18:33:54 -0600 Subject: [PATCH 6/7] fix(agent-auth): use live_view_url instead of hosted_url for login The hosted URL was causing 404 errors. Now we fetch the live view URL from the first poll after creating the invocation and use that instead. Co-Authored-By: Claude Opus 4.5 --- cmd/agent_auth.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go index 5df27f4..28e099e 100644 --- a/cmd/agent_auth.go +++ b/cmd/agent_auth.go @@ -120,20 +120,28 @@ func (a AgentAuthCmd) Invoke(ctx context.Context, in InvokeInput) error { pterm.Println(fmt.Sprintf(" Expires: %s", invocation.ExpiresAt.Format(time.RFC3339))) pterm.Println() - pterm.Info.Println("Open this URL in your browser to log in:") - pterm.Println() - pterm.Println(fmt.Sprintf(" %s", invocation.HostedURL)) - pterm.Println() + // Get the live view URL from the first poll + state, err := a.invocations.Get(ctx, invocation.InvocationID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } - if !in.NoBrowser { - if err := browser.OpenURL(invocation.HostedURL); err != nil { - pterm.Warning.Printf("Could not open browser automatically: %v\n", err) - } else { - pterm.Info.Println("(Opened in browser)") + if state.LiveViewURL != "" { + pterm.Info.Println("Open this URL in your browser to log in:") + pterm.Println() + pterm.Println(fmt.Sprintf(" %s", state.LiveViewURL)) + pterm.Println() + + if !in.NoBrowser { + if err := browser.OpenURL(state.LiveViewURL); err != nil { + pterm.Warning.Printf("Could not open browser automatically: %v\n", err) + } else { + pterm.Info.Println("(Opened in browser)") + } } + pterm.Println() } - pterm.Println() pterm.Info.Println("Polling for completion...") startTime := time.Now() From dc09ad8319229ecfb3cfecec9602eb5055ea3eac Mon Sep 17 00:00:00 2001 From: Mason Williams Date: Wed, 7 Jan 2026 18:34:41 -0600 Subject: [PATCH 7/7] revert: use hosted_url for login, keep live_view_url for polling logs Co-Authored-By: Claude Opus 4.5 --- cmd/agent_auth.go | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go index 28e099e..5df27f4 100644 --- a/cmd/agent_auth.go +++ b/cmd/agent_auth.go @@ -120,28 +120,20 @@ func (a AgentAuthCmd) Invoke(ctx context.Context, in InvokeInput) error { pterm.Println(fmt.Sprintf(" Expires: %s", invocation.ExpiresAt.Format(time.RFC3339))) pterm.Println() - // Get the live view URL from the first poll - state, err := a.invocations.Get(ctx, invocation.InvocationID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if state.LiveViewURL != "" { - pterm.Info.Println("Open this URL in your browser to log in:") - pterm.Println() - pterm.Println(fmt.Sprintf(" %s", state.LiveViewURL)) - pterm.Println() + pterm.Info.Println("Open this URL in your browser to log in:") + pterm.Println() + pterm.Println(fmt.Sprintf(" %s", invocation.HostedURL)) + pterm.Println() - if !in.NoBrowser { - if err := browser.OpenURL(state.LiveViewURL); err != nil { - pterm.Warning.Printf("Could not open browser automatically: %v\n", err) - } else { - pterm.Info.Println("(Opened in browser)") - } + if !in.NoBrowser { + if err := browser.OpenURL(invocation.HostedURL); err != nil { + pterm.Warning.Printf("Could not open browser automatically: %v\n", err) + } else { + pterm.Info.Println("(Opened in browser)") } - pterm.Println() } + pterm.Println() pterm.Info.Println("Polling for completion...") startTime := time.Now()