diff --git a/cmd/agent_auth.go b/cmd/agent_auth.go new file mode 100644 index 0000000..5df27f4 --- /dev/null +++ b/cmd/agent_auth.go @@ -0,0 +1,778 @@ +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/pkg/browser" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// 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) + 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 +} + +// 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) + Submit(ctx context.Context, invocationID string, body kernel.AgentAuthInvocationSubmitParams, opts ...option.RequestOption) (*kernel.AgentAuthSubmitResponse, error) +} + +// AgentAuthCmd handles agent auth operations. +type AgentAuthCmd struct { + auth AgentAuthService + invocations AgentAuthInvocationService + browsers BrowsersService +} + +// CreateInput holds input for creating an auth agent. +type CreateInput struct { + Domain string + ProfileName string + CredentialName string + LoginURL string + AllowedDomains []string +} + +// 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.CredentialName != "" { + params.AuthAgentCreateRequest.CredentialName = kernel.Opt(in.CredentialName) + } + if in.LoginURL != "" { + params.AuthAgentCreateRequest.LoginURL = kernel.Opt(in.LoginURL) + } + if len(in.AllowedDomains) > 0 { + params.AuthAgentCreateRequest.AllowedDomains = in.AllowedDomains + } + + agent, err := a.auth.New(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + 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}) + } + if len(agent.AllowedDomains) > 0 { + rows = append(rows, []string{"Allowed Domains", fmt.Sprintf("%v", agent.AllowedDomains)}) + } + + 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 +} + +// InvokeInput holds input for starting an invocation. +type InvokeInput struct { + AuthAgentID string + SaveCredentialAs string + NoBrowser bool +} + +// 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, + }, + } + + if in.SaveCredentialAs != "" { + params.AuthAgentInvocationCreateRequest.SaveCredentialAs = kernel.Opt(in.SaveCredentialAs) + } + + invocation, err := a.invocations.New(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + 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() + + 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(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.Info.Println("Polling for completion...") + + startTime := time.Now() + maxWaitTime := 5 * time.Minute + pollInterval := 2 * time.Second + + for time.Since(startTime) < maxWaitTime { + state, err := a.invocations.Get(ctx, invocation.InvocationID) + 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)) + + // Show live view URL on first poll + if state.LiveViewURL != "" && elapsed < 5 { + pterm.Println(fmt.Sprintf(" Live view: %s", state.LiveViewURL)) + } + + // 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(" Fields: %v", fieldNames)) + } + + // 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!") + + // Fetch and display the auth agent + agent, err := a.auth.Get(ctx, in.AuthAgentID) + if err != nil { + pterm.Warning.Printf("Could not fetch auth agent: %v\n", err) + return nil + } + + 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)) + } + + pterm.Println() + pterm.Info.Printf("You can now create browsers with profile: %s\n", agent.ProfileName) + return nil + + case kernel.AgentAuthInvocationResponseStatusExpired: + pterm.Println() + pterm.Error.Println("Invocation expired") + return nil + + 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 + } + + time.Sleep(pollInterval) + } + + pterm.Error.Println("Polling timed out after 5 minutes") + return nil +} + +// GetInput holds input for getting an auth agent. +type GetInput struct { + ID string +} + +// 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} + } + + 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}) + } + 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 +} + +// 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} + } + + pterm.Success.Printf("Auth agent %s deleted\n", in.ID) + 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{ + Use: "agents", + Short: "Manage agents", + Long: "Commands for managing Kernel agents", +} + +var agentsAuthCmd = &cobra.Command{ + Use: "auth", + Short: "Manage auth agents", + Long: "Commands for managing agent authentication", +} + +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: runAgentsAuthCreate, +} + +var agentsAuthInvokeCmd = &cobra.Command{ + Use: "invoke [auth-agent-id]", + Short: "Start an auth invocation", + Long: "Start an authentication invocation using the hosted UI flow", + Args: cobra.MaximumNArgs(1), + RunE: runAgentsAuthInvoke, +} + +var agentsAuthGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get auth agent details", + Args: cobra.ExactArgs(1), + RunE: runAgentsAuthGet, +} + +var agentsAuthDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an auth agent", + Args: cobra.ExactArgs(1), + 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") + 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.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") + agentsAuthInvokeCmd.Flags().BoolP("interactive", "i", false, "Interactive mode - select auth agent from list") + + // 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(ctx, CreateInput{ + Domain: domain, + ProfileName: profileName, + CredentialName: credentialName, + LoginURL: loginURL, + AllowedDomains: allowedDomains, + }) +} + +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(ctx, InvokeInput{ + AuthAgentID: authAgentID, + 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]}) +} + +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/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/root.go b/cmd/root.go index 6116d54..d7d7f15 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -139,6 +139,8 @@ func init() { rootCmd.AddCommand(profilesCmd) rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) + rootCmd.AddCommand(agentsCmd) + rootCmd.AddCommand(credentialsCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd)