From a7205b146b88636392e000c7a615154e59028e2c Mon Sep 17 00:00:00 2001 From: Bryce Lelbach Date: Sat, 18 Apr 2026 08:20:09 +0000 Subject: [PATCH] chore(onboarding): make all interactive onboarding opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brev currently runs three pieces of onboarding *implicitly*, with no way for humans or AI coding agents to suppress them short of answering each one by hand: 1. `hello.CanWeOnboard` ("Want a quick tour?") — fires from the root command PostRun AND from `brev login`'s PostRunE. 2. The analytics opt-in prompt — fires from `brev login`'s handle- Onboarding. 3. `agentskill.RunInstallSkillIfWanted` ("Install agent skill?") — fires at the end of `brev login`. 4. `hello.Step1` (guided "now run brev shell" flow) — fires from `brev ls`'s PersistentPostRunE. None of these have a consent signal before they run. That's the wrong default for an agentic CLI: the snippet at brev.nvidia.com/settings/cli is run verbatim by scripts and Claude Code / Codex / Cursor sessions, and each prompt surfaces as a blocked shell waiting on stdin. This change makes onboarding **entirely opt-in**. The automatic triggers are removed; users who want any of these flows invoke them explicitly: - interactive tour: brev hello (already existed) - install AI agent skill: brev agent-skill install (already existed) - share anonymous usage: brev analytics enable (new in this PR) `brev login` prints a single "Optional next steps:" block at the end pointing at those three commands, so the discoverability that the prompts provided is preserved without the stdin dependency. Code changes: - pkg/cmd/cmd.go: drop the root PostRun that ran CanWeOnboard on every invocation of any brev subcommand. - pkg/cmd/login/login.go: drop the PostRunE that ran CanWeOnboard, drop the RunInstallSkillIfWanted call, drop the analytics prompt. Add printOptInHints at the end of a successful login. - pkg/cmd/ls/ls.go: drop the PersistentPostRunE that ran hello.Step1. - pkg/cmd/hello/onboarding_utils.go: delete CanWeOnboard, ShouldWeRunOnboarding, ShouldWeRunOnboardingLSStep (all dead after trigger removal). - pkg/cmd/agentskill/agentskill.go: delete RunInstallSkillIfWanted, PromptInstallSkill, PromptInstallSkillSimple (dead after trigger removal). - pkg/cmd/analytics/analytics.go: new brev analytics command with enable / disable / status subcommands, backed by the existing SetAnalyticsPreference / IsAnalyticsEnabled APIs. --- pkg/cmd/agentskill/agentskill.go | 63 ---------------------- pkg/cmd/analytics/analytics.go | 87 ++++++++++++++++++++++++++++++ pkg/cmd/cmd.go | 15 +----- pkg/cmd/hello/onboarding_utils.go | 89 ------------------------------- pkg/cmd/login/login.go | 48 ++++++----------- pkg/cmd/ls/ls.go | 31 ----------- 6 files changed, 104 insertions(+), 229 deletions(-) create mode 100644 pkg/cmd/analytics/analytics.go diff --git a/pkg/cmd/agentskill/agentskill.go b/pkg/cmd/agentskill/agentskill.go index 798a584cb..44abbc747 100644 --- a/pkg/cmd/agentskill/agentskill.go +++ b/pkg/cmd/agentskill/agentskill.go @@ -2,20 +2,16 @@ package agentskill import ( - "bufio" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" - "strings" "time" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/terminal" - "github.com/fatih/color" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -199,45 +195,6 @@ func IsSkillInstalled(homeDir string) bool { return false } -// PromptInstallSkill asks the user if they want to install the agent skill -// Returns true if they want to install, false otherwise -func PromptInstallSkill(t *terminal.Terminal, homeDir string) bool { - // Skip if skill is already installed - if IsSkillInstalled(homeDir) { - return false - } - - // Check if Claude Code appears to be installed - if !IsClaudeInstalled(homeDir) { - return false - } - - fmt.Println() - caretType := color.New(color.FgCyan, color.Bold).SprintFunc() - fmt.Println(" ", caretType("▸"), " AI Agent Integration") - fmt.Println() - fmt.Println(" We detected an AI coding agent on your system.") - fmt.Println(" Would you like to install the Brev CLI skill?") - fmt.Println() - fmt.Println(" This enables natural language commands like:") - fmt.Println(t.Yellow(" \"Create an A100 instance for ML training\"")) - fmt.Println(t.Yellow(" \"Search for GPUs with 40GB VRAM\"")) - fmt.Println(t.Yellow(" \"Stop all my running instances\"")) - fmt.Println() - - prompt := promptui.Select{ - Label: "Install agent skill", - Items: []string{"Yes, install it", "No, skip for now"}, - } - - idx, _, err := prompt.Run() - if err != nil { - return false - } - - return idx == 0 -} - // InstallSkill downloads and installs the agent skill to all install paths func InstallSkill(t *terminal.Terminal, homeDir string, quiet bool) error { skillDirs := GetSkillDirs(homeDir) @@ -322,18 +279,6 @@ func UninstallSkill(t *terminal.Terminal, homeDir string) error { return nil } -// RunInstallSkillIfWanted prompts and installs if user wants it -// This is called from the login flow -func RunInstallSkillIfWanted(t *terminal.Terminal, homeDir string) { - if PromptInstallSkill(t, homeDir) { - err := InstallSkill(t, homeDir, false) - if err != nil { - // Don't fail login for skill install errors - fmt.Printf(" %s Failed to install skill: %v\n", t.Yellow("Warning:"), err) - } - } -} - // downloadAndInstallFile downloads a single file and writes it to all skill dirs. // Returns true on success, false if the download or any write failed. func downloadAndInstallFile(client *http.Client, baseURL, file string, skillDirs []string, t *terminal.Terminal, quiet bool) bool { @@ -387,11 +332,3 @@ func downloadBytes(client *http.Client, url string) ([]byte, error) { return body, nil } -// PromptInstallSkillSimple is a simpler yes/no prompt for the login flow -func PromptInstallSkillSimple() bool { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Install agent skill? [y/N]: ") - response, _ := reader.ReadString('\n') - response = strings.ToLower(strings.TrimSpace(response)) - return response == "y" || response == "yes" -} diff --git a/pkg/cmd/analytics/analytics.go b/pkg/cmd/analytics/analytics.go new file mode 100644 index 000000000..3013c1ac3 --- /dev/null +++ b/pkg/cmd/analytics/analytics.go @@ -0,0 +1,87 @@ +// Package analytics exposes the `brev analytics` command for managing the +// user's opt-in preference for anonymous usage analytics. +package analytics + +import ( + "fmt" + + analyticspkg "github.com/brevdev/brev-cli/pkg/analytics" + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/spf13/cobra" +) + +// NewCmdAnalytics returns the `brev analytics` command with enable/disable/status subcommands. +func NewCmdAnalytics(t *terminal.Terminal) *cobra.Command { + cmd := &cobra.Command{ + Annotations: map[string]string{"configuration": ""}, + Use: "analytics", + DisableFlagsInUseLine: true, + Short: "Manage anonymous usage analytics", + Long: `Enable, disable, or check the status of anonymous usage analytics. + +Analytics are opt-in. When enabled, Brev reports command usage and error +rates to help the team prioritize fixes and improvements. No command +arguments, file contents, or credentials are ever captured.`, + Example: "brev analytics enable\nbrev analytics disable\nbrev analytics status", + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus(t) + }, + } + + cmd.AddCommand(&cobra.Command{ + Use: "enable", + Short: "Opt in to anonymous usage analytics", + Example: "brev analytics enable", + RunE: func(cmd *cobra.Command, args []string) error { + return runSet(t, true) + }, + }) + cmd.AddCommand(&cobra.Command{ + Use: "disable", + Short: "Opt out of anonymous usage analytics", + Example: "brev analytics disable", + RunE: func(cmd *cobra.Command, args []string) error { + return runSet(t, false) + }, + }) + cmd.AddCommand(&cobra.Command{ + Use: "status", + Short: "Show whether anonymous usage analytics are enabled", + Example: "brev analytics status", + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus(t) + }, + }) + + return cmd +} + +func runSet(t *terminal.Terminal, enabled bool) error { + if err := analyticspkg.SetAnalyticsPreference(enabled); err != nil { + return breverrors.WrapAndTrace(err) + } + analyticspkg.CaptureAnalyticsOptIn(enabled) + if enabled { + t.Vprintf("%s Analytics enabled. Thanks for helping improve Brev.\n", t.Green("✓")) + } else { + t.Vprintf("%s Analytics disabled.\n", t.Green("✓")) + } + return nil +} + +func runStatus(t *terminal.Terminal) error { + enabled, asked := analyticspkg.IsAnalyticsEnabled() + switch { + case !asked: + fmt.Println("Analytics: not configured (off by default).") + t.Vprintf("Run %s to opt in.\n", t.Yellow("brev analytics enable")) + case enabled: + fmt.Println("Analytics: enabled.") + t.Vprintf("Run %s to opt out.\n", t.Yellow("brev analytics disable")) + default: + fmt.Println("Analytics: disabled.") + t.Vprintf("Run %s to opt in.\n", t.Yellow("brev analytics enable")) + } + return nil +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 6912df3ac..49b009ed8 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -7,6 +7,7 @@ import ( "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/cmd/agentskill" + analyticscmd "github.com/brevdev/brev-cli/pkg/cmd/analytics" "github.com/brevdev/brev-cli/pkg/cmd/background" "github.com/brevdev/brev-cli/pkg/cmd/clipboard" "github.com/brevdev/brev-cli/pkg/cmd/configureenvvars" @@ -153,19 +154,6 @@ func NewBrevCommand() *cobra.Command { //nolint:funlen,gocognit,gocyclo // defin Find more information at: https://brev.nvidia.com`, - PostRun: func(cmd *cobra.Command, args []string) { - shouldWe := hello.ShouldWeRunOnboarding(noLoginCmdStore) - if shouldWe { - user, err := loginCmdStore.GetCurrentUser() - if err != nil { - return - } - err = hello.CanWeOnboard(t, user, loginCmdStore) - if err != nil { - return - } - } - }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { analytics.CaptureCommand(analytics.GetOrCreateAnalyticsID(), cmd, args) return nil @@ -330,6 +318,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(open.NewCmdOpen(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(ollama.NewCmdOllama(t, loginCmdStore)) cmd.AddCommand(agentskill.NewCmdAgentSkill(t, noLoginCmdStore)) + cmd.AddCommand(analyticscmd.NewCmdAnalytics(t)) cmd.AddCommand(background.NewCmdBackground(t, loginCmdStore)) cmd.AddCommand(status.NewCmdStatus(t, loginCmdStore)) cmd.AddCommand(sshkeys.NewCmdSSHKeys(t, loginCmdStore)) diff --git a/pkg/cmd/hello/onboarding_utils.go b/pkg/cmd/hello/onboarding_utils.go index 5165b38df..a6730a915 100644 --- a/pkg/cmd/hello/onboarding_utils.go +++ b/pkg/cmd/hello/onboarding_utils.go @@ -5,11 +5,8 @@ import ( "path/filepath" "strings" - "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/files" - "github.com/brevdev/brev-cli/pkg/terminal" - "github.com/brevdev/brev-cli/pkg/util" "github.com/spf13/afero" ) @@ -22,92 +19,6 @@ func GetFirstName(name string) string { return name } -// The LS step should get the GetOnboardingData from the user -// and use that to check the step "FinishedOnboarding" -// Either way. It should set it to True -func ShouldWeRunOnboardingLSStep(s HelloStore) bool { - user, err := s.GetCurrentUser() - if err != nil { - return false - } - - ob, err := user.GetOnboardingData() - if err != nil { - return false - } - - if ob.FinishedOnboarding { - return false - } else { - // set the value and return true - newOnboardingStatus := make(map[string]interface{}) - newOnboardingStatus["finishedOnboarding"] = true - - user, err = s.UpdateUser(user.ID, &entity.UpdateUser{ - // username, name, and email are required fields, but we only care about onboarding status - Username: user.Username, - Name: user.Name, - Email: user.Email, - OnboardingData: util.MapAppend(user.OnboardingData, newOnboardingStatus), - }) - if err != nil { - // TODO: what should we do here? - return true - } - - return true - } -} - -func ShouldWeRunOnboarding(s HelloStore) bool { - workspaceID, err := s.GetCurrentWorkspaceID() - if err != nil { - return false - } - if workspaceID != "" { - return false - } - - oo, err := GetOnboardingObject() - if err != nil { - return true - } - if oo.Step == 0 && !oo.HasRunBrevOpen && !oo.HasRunBrevShell { - return true - } else { - return false - } -} - -func CanWeOnboard(t *terminal.Terminal, user *entity.User, store HelloStore) error { - s := t.Green("\n\nHi " + GetFirstName(user.Name) + "! Looks like it's your first time using Brev!\n") - - TypeItToMeUnskippable(s) - - res := terminal.PromptSelectInput(terminal.PromptSelectContent{ - Label: "Want a quick tour?", - ErrorMsg: "Please pick yes or no", - Items: []string{"Yes!", "No, I'll read docs later"}, - }) - if res == "Yes!" { - err := RunOnboarding(t, user, store) - if err != nil { - return breverrors.WrapAndTrace(err) - } - } else { - _ = SetOnboardingObject(OnboardingObject{ - Step: 1, - HasRunBrevOpen: true, - HasRunBrevShell: true, - }) - - _ = SkippedOnboarding(user, store) - - t.Vprintf("\nOkay, you can always read the docs at %s\n\n", t.Yellow("https://brev.dev/docs")) - } - return nil -} - func GetOnboardingFilePath() (string, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/pkg/cmd/login/login.go b/pkg/cmd/login/login.go index 205e7ed91..a09b8e844 100644 --- a/pkg/cmd/login/login.go +++ b/pkg/cmd/login/login.go @@ -9,7 +9,6 @@ import ( "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/auth" - "github.com/brevdev/brev-cli/pkg/cmd/agentskill" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/cmd/hello" @@ -68,21 +67,7 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra. Short: "Log into Brev", Long: "Log into brev", Example: "brev login", - PostRunE: func(cmd *cobra.Command, args []string) error { - shouldWe := hello.ShouldWeRunOnboarding(loginStore) - if shouldWe { - user, err := loginStore.GetCurrentUser() - if err != nil { - return breverrors.WrapAndTrace(err) - } - err = hello.CanWeOnboard(t, user, loginStore) - if err != nil { - return breverrors.WrapAndTrace(err) - } - } - return nil - }, - Args: cmderrors.TransformToValidationError(cobra.NoArgs), + Args: cmderrors.TransformToValidationError(cobra.NoArgs), RunE: func(cmd *cobra.Command, args []string) error { err := opts.RunLogin(t, loginToken, skipBrowser, emailFlag, authProviderFlag) if err != nil { @@ -97,11 +82,7 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra. } return err //nolint:wrapcheck // we want to return the error from the login } - // Offer Claude Code skill installation after successful login - homeDir, homeErr := opts.LoginStore.UserHomeDir() - if homeErr == nil { - agentskill.RunInstallSkillIfWanted(t, homeDir) - } + printOptInHints(t) return nil }, } @@ -232,18 +213,6 @@ func (o LoginOptions) handleOnboarding(user *entity.User, _ *terminal.Terminal) newOnboardingStatus["usedCLI"] = true } - _, analyticsAsked := analytics.IsAnalyticsEnabled() - if !analyticsAsked && analytics.IsAnalyticsFeatureEnabled() { - choice := terminal.PromptSelectInput(terminal.PromptSelectContent{ - Label: "Help us improve Brev by sharing usage data?", - ErrorMsg: "Error: must choose an option", - Items: []string{"Yes, share usage data", "No, opt out"}, - }) - optIn := strings.HasPrefix(choice, "Yes") - _ = analytics.SetAnalyticsPreference(optIn) - analytics.CaptureAnalyticsOptIn(optIn) - } - analytics.IdentifyUser(user.ID) user, err = o.LoginStore.UpdateUser(user.ID, &entity.UpdateUser{ @@ -371,3 +340,16 @@ func (o LoginOptions) showBreadCrumbs(t *terminal.Terminal, org *entity.Organiza func makeFirstOrgName(username string) string { return fmt.Sprintf("%s-hq", username) } + +// printOptInHints advertises the opt-in entry points for features that used +// to run as interactive prompts during login (the guided tour, the AI +// coding-agent skill, and usage analytics). Keeping these as one-liners at +// the end of `brev login` gives users who want them a discoverable path +// without forcing a prompt on users (humans or agents) who don't. +func printOptInHints(t *terminal.Terminal) { + fmt.Println() + t.Vprintf("%s\n", t.Green("Optional next steps:")) + t.Vprintf(" interactive tour: %s\n", t.Yellow("brev hello")) + t.Vprintf(" install AI agent skill: %s\n", t.Yellow("brev agent-skill install")) + t.Vprintf(" share anonymous usage: %s\n", t.Yellow("brev analytics enable")) +} diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index 208a515cc..944c3af91 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -75,37 +75,6 @@ with other commands like stop, start, or delete.`, brev ls orgs --json `, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - if hello.ShouldWeRunOnboardingLSStep(noLoginLsStore) && hello.ShouldWeRunOnboarding(noLoginLsStore) { - // Getting the workspaces should go in the hello.go file but then - // requires passing in stores and that makes it hard to use in other commands - org, err := getOrgForRunLs(loginLsStore, org) - if err != nil { - return err - } - - allWorkspaces, err := loginLsStore.GetWorkspaces(org.ID, nil) - if err != nil { - return breverrors.WrapAndTrace(err) - } - - user, err := loginLsStore.GetCurrentUser() - if err != nil { - return breverrors.WrapAndTrace(err) - } - - var myWorkspaces []entity.Workspace - for _, v := range allWorkspaces { - if v.CreatedByUserID == user.ID { - myWorkspaces = append(myWorkspaces, v) - } - } - - err = hello.Step1(t, myWorkspaces, user, loginLsStore) - if err != nil { - return breverrors.WrapAndTrace(err) - } - - } return cmdcontext.InvokeParentPersistentPostRun(cmd, args) }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error {