diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go index 8ae161a71..db6b5d0c8 100644 --- a/pkg/analytics/analytics.go +++ b/pkg/analytics/analytics.go @@ -17,6 +17,10 @@ type EventData struct { } func TrackEvent(data EventData) error { + if !IsAnalyticsEnabled() { + return nil + } + conf := config.NewConstants() url := conf.GetBrevAPIURl() + "/api/brevent" diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go index 7750a9227..433328abb 100644 --- a/pkg/analytics/posthog.go +++ b/pkg/analytics/posthog.go @@ -48,8 +48,7 @@ func getClient() (posthog.Client, error) { return client, clientErr } -// IsAnalyticsFeatureEnabled checks the PostHog feature flag to determine -// whether to prompt the user about analytics opt-in. +// IsAnalyticsFeatureEnabled is the remote kill switch for PostHog telemetry only — gating PostHog capture lets us turn it off without a release. It does NOT gate analytics.TrackEvent (the brev-internal endpoint), which has its own channel. func IsAnalyticsFeatureEnabled() bool { anonID := GetOrCreateAnalyticsID() if anonID == "" { @@ -81,13 +80,26 @@ func RecordCommandStart(cmd *cobra.Command, args []string) { storedArgs = args } -// IsAnalyticsEnabled returns whether analytics is enabled and whether the user has been asked. -func IsAnalyticsEnabled() (enabled bool, hasBeenAsked bool) { +// IsAnalyticsEnabled defaults to true; DO_NOT_TRACK and BREV_NO_ANALYTICS override. +func IsAnalyticsEnabled() bool { + if disabled, _ := IsDisabledByEnv(); disabled { + return false + } settings := readSettings() if settings.AnalyticsEnabled == nil { - return false, false + return true + } + return *settings.AnalyticsEnabled +} + +func IsDisabledByEnv() (disabled bool, varName string) { + if os.Getenv("DO_NOT_TRACK") == "1" { + return true, "DO_NOT_TRACK" + } + if os.Getenv("BREV_NO_ANALYTICS") == "1" { + return true, "BREV_NO_ANALYTICS" } - return *settings.AnalyticsEnabled, true + return false, "" } // SetAnalyticsPreference persists the user's analytics preference. @@ -139,34 +151,15 @@ func GetOrCreateAnalyticsID() string { return settings.AnalyticsID } -// CaptureAnalyticsOptIn sends an event recording the user's analytics consent choice. -// This is sent regardless of the user's choice so we can measure opt-in rates. -func CaptureAnalyticsOptIn(optedIn bool) { - anonID := GetOrCreateAnalyticsID() - if anonID == "" { - return - } - - c, err := getClient() - if err != nil { - return - } - - _ = c.Enqueue(posthog.Capture{ - DistinctId: anonID, - Event: "analytics_opt_in", - Properties: posthog.NewProperties(). - Set("opted_in", optedIn). - Set("os", runtime.GOOS). - Set("arch", runtime.GOARCH). - Set("cli_version", version.Version), - }) +// shouldCapturePostHog returns true only when both the local opt-out and +// the remote PostHog kill switch agree. +func shouldCapturePostHog() bool { + return IsAnalyticsEnabled() && IsAnalyticsFeatureEnabled() } // IdentifyUser links the anonymous analytics ID to a real user ID using PostHog Alias. func IdentifyUser(userID string) { - enabled, asked := IsAnalyticsEnabled() - if !asked || !enabled { + if !shouldCapturePostHog() { return } @@ -202,22 +195,19 @@ func CaptureCommandError() { if storedCmd == nil { return } - // If CaptureCommand already ran (success path), don't double-capture. - // storedUser being set means PersistentPostRunE ran. - // We only get here on error, so PersistentPostRunE didn't run. - userID := storedUser - if userID == "" { - userID = GetOrCreateAnalyticsID() - } - captureEvent(userID, storedCmd, storedArgs, false) + captureEvent(storedUser, storedCmd, storedArgs, false) } func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bool) { - enabled, asked := IsAnalyticsEnabled() - if !asked || !enabled { + if !shouldCapturePostHog() { return } + // Resolve the analytics ID lazily, only after gates pass — avoids writing a + // persistent UUID to ~/.brev/personal_settings.json for opted-out users. + if userID == "" { + userID = GetOrCreateAnalyticsID() + } if userID == "" { return } diff --git a/pkg/analytics/posthog_test.go b/pkg/analytics/posthog_test.go new file mode 100644 index 000000000..f134c213f --- /dev/null +++ b/pkg/analytics/posthog_test.go @@ -0,0 +1,112 @@ +package analytics + +import ( + "testing" + + "github.com/brevdev/brev-cli/pkg/files" +) + +func boolPtr(b bool) *bool { return &b } + +func TestIsDisabledByEnv(t *testing.T) { + cases := []struct { + name string + envs map[string]string + wantDisabled bool + wantVar string + }{ + {"no env vars set", nil, false, ""}, + {"DO_NOT_TRACK=1", map[string]string{"DO_NOT_TRACK": "1"}, true, "DO_NOT_TRACK"}, + {"BREV_NO_ANALYTICS=1", map[string]string{"BREV_NO_ANALYTICS": "1"}, true, "BREV_NO_ANALYTICS"}, + {"DO_NOT_TRACK=0 (only \"1\" disables)", map[string]string{"DO_NOT_TRACK": "0"}, false, ""}, + {"DO_NOT_TRACK=true (only \"1\" disables)", map[string]string{"DO_NOT_TRACK": "true"}, false, ""}, + {"both set — DO_NOT_TRACK reported first", map[string]string{"DO_NOT_TRACK": "1", "BREV_NO_ANALYTICS": "1"}, true, "DO_NOT_TRACK"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("BREV_NO_ANALYTICS", "") + for k, v := range c.envs { + t.Setenv(k, v) + } + disabled, varName := IsDisabledByEnv() + if disabled != c.wantDisabled { + t.Errorf("disabled = %v, want %v", disabled, c.wantDisabled) + } + if varName != c.wantVar { + t.Errorf("varName = %q, want %q", varName, c.wantVar) + } + }) + } +} + +func TestIsAnalyticsEnabled(t *testing.T) { + cases := []struct { + name string + stored *bool + envs map[string]string + want bool + }{ + {"no preference, no env → default on", nil, nil, true}, + {"explicit opt-in, no env", boolPtr(true), nil, true}, + {"explicit opt-out, no env", boolPtr(false), nil, false}, + {"DO_NOT_TRACK overrides nil", nil, map[string]string{"DO_NOT_TRACK": "1"}, false}, + {"DO_NOT_TRACK overrides explicit opt-in", boolPtr(true), map[string]string{"DO_NOT_TRACK": "1"}, false}, + {"BREV_NO_ANALYTICS overrides explicit opt-in", boolPtr(true), map[string]string{"BREV_NO_ANALYTICS": "1"}, false}, + {"explicit opt-out stays opt-out under env override", boolPtr(false), map[string]string{"DO_NOT_TRACK": "1"}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("BREV_NO_ANALYTICS", "") + for k, v := range c.envs { + t.Setenv(k, v) + } + + if c.stored != nil { + if err := files.WritePersonalSettings(files.AppFs, tmp, &files.PersonalSettings{ + AnalyticsEnabled: c.stored, + }); err != nil { + t.Fatalf("write settings: %v", err) + } + } + + if got := IsAnalyticsEnabled(); got != c.want { + t.Errorf("IsAnalyticsEnabled() = %v, want %v", got, c.want) + } + }) + } +} + +// SetAnalyticsPreference must not lose other PersonalSettings fields. +func TestSetAnalyticsPreferencePreservesOtherFields(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + if err := files.WritePersonalSettings(files.AppFs, tmp, &files.PersonalSettings{ + DefaultEditor: "vim", + AnalyticsID: "preexisting-id", + }); err != nil { + t.Fatalf("write seed: %v", err) + } + + if err := SetAnalyticsPreference(false); err != nil { + t.Fatalf("SetAnalyticsPreference: %v", err) + } + + got, err := files.ReadPersonalSettings(files.AppFs, tmp) + if err != nil { + t.Fatalf("read back: %v", err) + } + if got.DefaultEditor != "vim" { + t.Errorf("DefaultEditor = %q, want %q (other fields must survive)", got.DefaultEditor, "vim") + } + if got.AnalyticsID != "preexisting-id" { + t.Errorf("AnalyticsID = %q, want %q", got.AnalyticsID, "preexisting-id") + } + if got.AnalyticsEnabled == nil || *got.AnalyticsEnabled != false { + t.Errorf("AnalyticsEnabled = %v, want pointer to false", got.AnalyticsEnabled) + } +} diff --git a/pkg/cmd/agentskill/agentskill.go b/pkg/cmd/agentskill/agentskill.go index 798a584cb..4153ff8dd 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" ) @@ -181,11 +177,14 @@ func GetSkillDir(homeDir string) string { return filepath.Join(homeDir, ".claude", "skills", skillName) } -// IsClaudeInstalled checks if Claude Code appears to be installed -func IsClaudeInstalled(homeDir string) bool { - claudeDir := filepath.Join(homeDir, ".claude") - _, err := os.Stat(claudeDir) - return err == nil +// IsAnyAgentInstalled returns true if any of installDirs exists under homeDir. +func IsAnyAgentInstalled(homeDir string) bool { + for _, dir := range installDirs { + if _, err := os.Stat(filepath.Join(homeDir, dir)); err == nil { + return true + } + } + return false } // IsSkillInstalled checks if the brev-cli skill is installed in any location @@ -199,45 +198,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 +282,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 { @@ -386,12 +334,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..a69f77df8 --- /dev/null +++ b/pkg/cmd/analytics/analytics.go @@ -0,0 +1,80 @@ +package analytics + +import ( + "github.com/brevdev/brev-cli/pkg/analytics" + "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/spf13/cobra" +) + +func NewCmdAnalytics(t *terminal.Terminal) *cobra.Command { + cmd := &cobra.Command{ + Annotations: map[string]string{"configuration": ""}, + Use: "analytics", + DisableFlagsInUseLine: true, + Short: "Manage usage analytics", + Long: "Show or change whether the Brev CLI sends usage analytics.", + Example: "brev analytics\nbrev analytics on\nbrev analytics off", + Args: cmderrors.TransformToValidationError(cobra.NoArgs), + RunE: func(cmd *cobra.Command, args []string) error { + printStatus(t) + return nil + }, + } + + cmd.AddCommand(newCmdOn(t)) + cmd.AddCommand(newCmdOff(t)) + + return cmd +} + +func newCmdOn(t *terminal.Terminal) *cobra.Command { + return &cobra.Command{ + Use: "on", + DisableFlagsInUseLine: true, + Short: "Enable usage analytics", + Args: cmderrors.TransformToValidationError(cobra.NoArgs), + RunE: func(cmd *cobra.Command, args []string) error { + if err := analytics.SetAnalyticsPreference(true); err != nil { + return breverrors.WrapAndTrace(err) + } + t.Vprintf("%s\n", t.Green("Analytics enabled.")) + if disabled, varName := analytics.IsDisabledByEnv(); disabled { + t.Vprintf("Note: still disabled at runtime by %s=1.\n", varName) + } + return nil + }, + } +} + +func newCmdOff(t *terminal.Terminal) *cobra.Command { + return &cobra.Command{ + Use: "off", + DisableFlagsInUseLine: true, + Short: "Disable usage analytics", + Args: cmderrors.TransformToValidationError(cobra.NoArgs), + RunE: func(cmd *cobra.Command, args []string) error { + if err := analytics.SetAnalyticsPreference(false); err != nil { + return breverrors.WrapAndTrace(err) + } + t.Vprintf("%s\n", t.Green("Analytics disabled.")) + return nil + }, + } +} + +func printStatus(t *terminal.Terminal) { + if disabled, varName := analytics.IsDisabledByEnv(); disabled { + t.Vprintf("Analytics: %s (%s=1)\n", t.Yellow("disabled"), varName) + return + } + + if analytics.IsAnalyticsEnabled() { + t.Vprintf("Analytics: %s\n", t.Green("enabled")) + t.Vprintf("Run %s to opt out.\n", t.Yellow("brev analytics off")) + } else { + t.Vprintf("Analytics: %s\n", t.Yellow("disabled")) + t.Vprintf("Run %s to opt in.\n", t.Yellow("brev analytics on")) + } +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 6912df3ac..e74b67c23 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,21 +154,8 @@ 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) + analytics.CaptureCommand("", cmd, args) return nil }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { @@ -232,6 +220,9 @@ func NewBrevCommand() *cobra.Command { //nolint:funlen,gocognit,gocyclo // defin if err != nil { return breverrors.WrapAndTrace(err) } + if hello.ShouldWeRunOnboarding(noLoginCmdStore) { + t.Vprintf("\n👋 New to Brev? Run %s for a guided walkthrough.\n", t.Yellow("brev hello")) + } return nil } }, @@ -330,6 +321,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)) @@ -499,6 +491,13 @@ Aliases: Examples: {{.Example}}{{end}}{{if .HasAvailableSubCommands}} +{{- if hasQuickstartCommands . }} + +Quick Start: +{{- range quickstartCommands . }} + {{rpad .Name .NamePadding }} {{.Short}} +{{- end}}{{- end}} + {{- if or (hasWorkspaceCommands .) (hasProviderDependentCommands .) }} Instance Commands: @@ -533,13 +532,6 @@ Configuration: {{rpad .Name .NamePadding }} {{.Short}} {{- end}}{{- end}} -{{- if hasQuickstartCommands . }} - -Quick Start: -{{- range quickstartCommands . }} - {{rpad .Name .NamePadding }} {{.Short}} -{{- end}}{{- end}} - {{- if hasDebugCommands . }} Debug Commands: diff --git a/pkg/cmd/hello/hello.go b/pkg/cmd/hello/hello.go index ee043bbd1..7a3389e8e 100644 --- a/pkg/cmd/hello/hello.go +++ b/pkg/cmd/hello/hello.go @@ -22,7 +22,7 @@ type HelloStore interface { func NewCmdHello(t *terminal.Terminal, store HelloStore) *cobra.Command { cmd := &cobra.Command{ - Annotations: map[string]string{"configuration": ""}, + Annotations: map[string]string{"quickstart": ""}, Use: "hello", DisableFlagsInUseLine: true, Long: "Get a quick onboarding of the Brev CLI", diff --git a/pkg/cmd/hello/hello_test.go b/pkg/cmd/hello/hello_test.go deleted file mode 100644 index 8a3fad622..000000000 --- a/pkg/cmd/hello/hello_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package hello - -import "testing" - -type shellOnboardingPollDoneCase struct { - name string - res *OnboardingObject - want bool -} - -var shellOnboardingPollDoneCases = []shellOnboardingPollDoneCase{ - { - name: "hasRunBrevShell", - res: &OnboardingObject{HasRunBrevShell: true}, - want: true, - }, - { - name: "brevOpenOnly", - res: &OnboardingObject{HasRunBrevOpen: true, HasRunBrevShell: false}, - want: false, - }, - { - name: "nil", - res: nil, - want: false, - }, -} - -func TestShellOnboardingPollDone(t *testing.T) { - t.Parallel() - for _, c := range shellOnboardingPollDoneCases { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - if got := shellOnboardingPollDone(c.res); got != c.want { - t.Fatalf("got %v, want %v", got, c.want) - } - }) - } -} diff --git a/pkg/cmd/hello/onboarding_utils.go b/pkg/cmd/hello/onboarding_utils.go index 5165b38df..7d8cfd292 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,43 +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 { @@ -79,35 +39,6 @@ func ShouldWeRunOnboarding(s HelloStore) bool { } } -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 { @@ -123,10 +54,6 @@ type OnboardingObject struct { HasRunBrevOpen bool `json:"hasRunBrevOpen"` } -func shellOnboardingPollDone(res *OnboardingObject) bool { - return res != nil && res.HasRunBrevShell -} - func SetupDefaultOnboardingFile() error { // get path path, err := GetOnboardingFilePath() diff --git a/pkg/cmd/hello/steps.go b/pkg/cmd/hello/steps.go deleted file mode 100644 index 4c1f7a493..000000000 --- a/pkg/cmd/hello/steps.go +++ /dev/null @@ -1,326 +0,0 @@ -package hello - -import ( - "fmt" - "time" - - "github.com/brevdev/brev-cli/pkg/config" - "github.com/brevdev/brev-cli/pkg/entity" - breverrors "github.com/brevdev/brev-cli/pkg/errors" - "github.com/brevdev/brev-cli/pkg/terminal" - "github.com/brevdev/brev-cli/pkg/util" - "github.com/briandowns/spinner" - "github.com/fatih/color" -) - -const DefaultDevEnvName = "first-workspace-react" - -const spinnerSuffix = "🎉 you did it!" - -func GetTextBasedONStatus(status string, t *terminal.Terminal) string { - s := "" - switch status { - case "RUNNING": - case "DEPLOYING": - s += t.Yellow("Your instance is deploying.") - s += "\nPlease wait for it to finish deploying then run " + t.Yellow("brev hello") + " to resume this walk through when your instance is ready\n" - case "UNHEALTHY": - s += t.Red("Your instance seems stuck. Can you reach out to support?") - s += "\nMessage us " - s += "\n\t in discord 👉 " + t.Yellow("https://discord.gg/RpszWaJFRA") - s += "\n\t via text or call 👉 " + t.Yellow("(415) 237-2247\n") - s += "\n\nRun " + t.Yellow("brev hello") + " to resume this walk through when your instance is ready\n" - case "STOPPED": - s += t.Yellow("Your instance is stopped.") - s += "\nRun this in your terminal to start it 👉 " + t.Yellow("brev start %s", DefaultDevEnvName) - s += "\n\nRun " + t.Yellow("brev hello") + " to resume this walk through when your instance is ready\n" - - case "STOPPING": - s += t.Yellow("Your instance is stopped.") - s += "\nRun this in your terminal to start it 👉 " + t.Yellow("brev start %s", DefaultDevEnvName) - s += "\n\nRun " + t.Yellow("brev hello") + " to resume this walk through when your instance is ready\n" - default: - s += t.Red("Please create a running instance for this walk through. ") - s += "\n\tYou can do that here: " + t.Yellow(fmt.Sprintf("%s/environments/new", config.GlobalConfig.GetConsoleURL())) - s += "\n\nRun " + t.Yellow("brev hello") + " to resume this walk through when your instance is ready\n" - } - return s -} - -/* -Return nil to exit the onboarding -*/ -func GetDevEnvOrStall(t *terminal.Terminal, workspaces []entity.Workspace) *entity.Workspace { - var runningDevEnvs []entity.Workspace - noneFound := true - for _, v := range workspaces { - if v.Status == "RUNNING" { - noneFound = false - runningDevEnvs = append(runningDevEnvs, v) - } - } - - if noneFound { - s := t.Red("Please create a running instance for this walk through. ") - s += "\n\tYou can do that here: " + t.Yellow(fmt.Sprintf("%s/environments/new", config.GlobalConfig.GetConsoleURL())) - s += "\n\nRun: " + t.Yellow("brev hello") + " to resume this walk through when your instance is ready\n" - TypeItToMe(s) - return nil - } - msg := GetTextBasedONStatus(runningDevEnvs[0].Status, t) - if msg != "" { - TypeItToMe(msg) - } - return &runningDevEnvs[0] -} - -func printLsIntroText(t *terminal.Terminal, _ entity.Workspace) { - s := "\nThe command " + t.Yellow("brev ls") + " shows your instances" - s += "\nIf the instance is " + t.Green("RUNNING") + ", you can open it." - TypeItToMe(s) -} - -func printBrevShellOnboarding(t *terminal.Terminal, firstWorkspace *entity.Workspace) { - s := "\n\nTry opening a terminal SSHed in your instance" - s += "\nIn a new terminal, run " + t.Green("brev shell %s", firstWorkspace.Name) + "\n" - TypeItToMe(s) -} - -func printAskInstallVsCode(t *terminal.Terminal) { - // The error here is most likely because code isn't in path and we depend on that - // TODO: remove the dependency on code being in path - s := t.Yellow("\n\nCould you please install the following VSCode extension? %s", t.Green("ms-vscode-remote.remote-ssh")) - s += "\nDo that then run " + t.Yellow("brev hello") + " to resume this walk-through\n" - // s += "Here's a video of me installing the VS Code extension 👉 " + "" - TypeItToMe(s) -} - -func printBrevOpen(t *terminal.Terminal, firstWorkspace entity.Workspace) { - s := "\n\nTry opening VS Code in your instance" - s += "\nIn a new terminal, run " + t.Green("brev open %s", firstWorkspace.Name) + "\n" - TypeItToMe(s) -} - -func printCompletedOnboarding(t *terminal.Terminal) { - s := "\n\nI think I'm done here. Now you know how to open an instance and start coding." - s += "\n\nUse the console " + t.Yellow(fmt.Sprintf("(%s)", config.GlobalConfig.GetConsoleURL())) + " to create a new instance or share it with people" - s += "\nand use this CLI to code the way you would normally 🤙" - s += "\n\nCheck out the docs at " + t.Yellow("https://brev.dev") + " and let us know if we can help!\n" - s += "\n\nIn case you missed it, my cell is " + t.Yellow("(415) 237-2247") + "\n\t-Nader\n" - TypeItToMe(s) -} - -// func waitSpinner(spinner *spinner.Spinner) error { -// // a while loop in golang -// sum := 0 -// spinner.Suffix = "👆 try that, I'll wait" -// spinner.Start() -// for sum > -1 { -// sum++ - -// res, err2 := GetOnboardingObject() -// if err2 != nil { -// return breverrors.WrapAndTrace(err2) -// } -// if res.HasRunBrevShell { -// spinner.Suffix = spinnerSuffix -// time.Sleep(250 * time.Millisecond) -// spinner.Stop() -// break -// } -// time.Sleep(1 * time.Second) - -// } -// return nil -// } - -/* -Step 1: - - The user just ran brev ls -*/ -func Step1(t *terminal.Terminal, workspaces []entity.Workspace, user *entity.User, store HelloStore) error { - err := CompletedOnboardingLs(user, store) - if err != nil { - return breverrors.WrapAndTrace(err) - } - spinner := t.NewSpinner() - bold := color.New(color.Bold).SprintFunc() - - firstWorkspace := GetDevEnvOrStall(t, workspaces) - if firstWorkspace == nil { - return nil - } - printLsIntroText(t, *firstWorkspace) - - // Check if VS Code is preferred editor - currentOnboardingStatus, err := user.GetOnboardingData() - if err != nil { - return breverrors.WrapAndTrace(err) - } - if currentOnboardingStatus.Editor == "VSCode" { - err = doVsCodeOnboarding(t, firstWorkspace, user, store, spinner, bold) - if err != nil { - return breverrors.WrapAndTrace(err) - } - } else { - err = doBrevShellOnboarding(t, firstWorkspace, user, store, spinner, bold) - if err != nil { - return breverrors.WrapAndTrace(err) - } - } - - // err = waitSpinner(spinner) - // if err != nil { - // return breverrors.WrapAndTrace(err) - // } - - // err = CompletedOnboardingShell(user, store) - // if err != nil { - // return breverrors.WrapAndTrace(err) - // } - - // TypeItToMe("\nHit " + t.Yellow("enter") + " to continue") - // fmt.Println() - // _ = terminal.PromptGetInput(terminal.PromptContent{ - // // Label: " " + bold("▸") + " Press " + bold("Enter") + " to continue", - // Label: " " + bold("▸"), - // ErrorMsg: "error", - // AllowEmpty: true, - // }) - - // Commenting out the below since public urls is gone - // handleLocalhostURLIfDefaultProject(*firstWorkspace, t) - printCompletedOnboarding(t) - err = CompletedOnboarding(user, store) - if err != nil { - return breverrors.WrapAndTrace(err) - } - return nil -} - -// func handleLocalhostURLIfDefaultProject(ws entity.Workspace, t *terminal.Terminal) { -// if ws.Name == DefaultDevEnvName { -// s := "\n\nOne last thing, since you're coding in the cloud, you can get a public URL to your localhost." -// s += "\nFrom within that Brev dev environment,\n\tRun " + t.Yellow("npm run start") + " to spin up the service" -// s += "\nThen instead of going to localhost:3000, \n\tGo to " + t.Yellow("https://3000-%s", ws.DNS) - -// // TODO: Give that a shot then press enter -// bold := color.New(color.Bold).SprintFunc() - -// s += "\n\nGive that a shot then press enter👆:" -// TypeItToMe(s) - -// fmt.Print("\n") -// _ = terminal.PromptGetInput(terminal.PromptContent{ -// // Label: " " + bold("▸") + " Press " + bold("Enter") + " to continue", -// Label: " " + bold("▸"), -// ErrorMsg: "error", -// AllowEmpty: true, -// }) - -// fmt.Print("\n") -// } -// } -func doBrevShellOnboarding( - t *terminal.Terminal, - firstWorkspace *entity.Workspace, - user *entity.User, - store HelloStore, - spinner *spinner.Spinner, - bold func(a ...interface{}) string, -) error { - printBrevShellOnboarding(t, firstWorkspace) - - // a while loop in golang - sum := 0 - spinner.Suffix = "☝️ try that, I'll wait" - spinner.Start() - for sum < 1 { - sum += sum - res, err1 := GetOnboardingObject() - if err1 != nil { - return breverrors.WrapAndTrace(err1) - } - if shellOnboardingPollDone(res) { - spinner.Suffix = spinnerSuffix - time.Sleep(250 * time.Millisecond) - spinner.Stop() - break - } - time.Sleep(1 * time.Second) - - } - - err := CompletedOnboardingShell(user, store) - if err != nil { - return breverrors.WrapAndTrace(err) - } - - TypeItToMe("\nHit " + t.Yellow("enter") + " to continue") - fmt.Println() - - _ = terminal.PromptGetInput(terminal.PromptContent{ - // Label: " " + bold("▸") + " Press " + bold("Enter") + " to continue", - Label: " " + bold("▸"), - ErrorMsg: "error", - AllowEmpty: true, - }) - return nil -} - -func doVsCodeOnboarding( - t *terminal.Terminal, - firstWorkspace *entity.Workspace, - user *entity.User, - store HelloStore, - spinner *spinner.Spinner, - bold func(a ...interface{}) string, -) error { - // TODO: check if ext is installed - isInstalled, err := util.IsVSCodeExtensionInstalled("ms-vscode-remote.remote-ssh") - if err != nil { - return breverrors.WrapAndTrace(err) - } - if !isInstalled { - printAskInstallVsCode(t) - return nil - } - - printBrevOpen(t, *firstWorkspace) - - sum := 0 - spinner.Suffix = "☝️ try that, I'll wait" - spinner.Start() - for sum < 1 { - sum += sum - res, err1 := GetOnboardingObject() - if err1 != nil { - return breverrors.WrapAndTrace(err1) - } - if res.HasRunBrevOpen { - spinner.Suffix = spinnerSuffix - time.Sleep(250 * time.Millisecond) - spinner.Stop() - break - } - time.Sleep(1 * time.Second) - - } - - err = CompletedOnboardingOpen(user, store) - if err != nil { - return breverrors.WrapAndTrace(err) - } - - TypeItToMe("\nHit " + t.Yellow("enter") + " to continue") - fmt.Println() - - _ = terminal.PromptGetInput(terminal.PromptContent{ - // Label: " " + bold("▸") + " Press " + bold("Enter") + " to continue", - Label: " " + bold("▸"), - ErrorMsg: "error", - AllowEmpty: true, - }) - return nil -} diff --git a/pkg/cmd/hello/updateUser.go b/pkg/cmd/hello/updateUser.go index 37da6589f..372c45024 100644 --- a/pkg/cmd/hello/updateUser.go +++ b/pkg/cmd/hello/updateUser.go @@ -6,29 +6,6 @@ import ( "github.com/brevdev/brev-cli/pkg/util" ) -// currentOnboardingStatus, err := user.GetOnboardingData() -// if err != nil { -// return breverrors.WrapAndTrace(err) -// } - -func SkippedOnboarding(user *entity.User, store HelloStore) error { - newOnboardingStatus := make(map[string]interface{}) - newOnboardingStatus["cliOnboardingSkipped"] = true - - _, err := store.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 { - return breverrors.WrapAndTrace(err) - } - - return nil -} - func CompletedOnboardingIntro(user *entity.User, store HelloStore) error { newOnboardingStatus := make(map[string]interface{}) newOnboardingStatus["cliOnboardingIntro"] = true @@ -46,75 +23,3 @@ func CompletedOnboardingIntro(user *entity.User, store HelloStore) error { return nil } - -func CompletedOnboardingLs(user *entity.User, store HelloStore) error { - newOnboardingStatus := make(map[string]interface{}) - newOnboardingStatus["cliOnboardingLs"] = true - - _, err := store.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 { - return breverrors.WrapAndTrace(err) - } - - return nil -} - -func CompletedOnboardingShell(user *entity.User, store HelloStore) error { - newOnboardingStatus := make(map[string]interface{}) - newOnboardingStatus["cliOnboardingBrevShell"] = true - - _, err := store.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 { - return breverrors.WrapAndTrace(err) - } - - return nil -} - -func CompletedOnboardingOpen(user *entity.User, store HelloStore) error { - newOnboardingStatus := make(map[string]interface{}) - newOnboardingStatus["cliOnboardingBrevOpen"] = true - - _, err := store.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 { - return breverrors.WrapAndTrace(err) - } - - return nil -} - -func CompletedOnboarding(user *entity.User, store HelloStore) error { - newOnboardingStatus := make(map[string]interface{}) - newOnboardingStatus["cliOnboardingCompleted"] = true - - _, err := store.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 { - return breverrors.WrapAndTrace(err) - } - - return nil -} diff --git a/pkg/cmd/login/login.go b/pkg/cmd/login/login.go index 205e7ed91..e96968cc9 100644 --- a/pkg/cmd/login/login.go +++ b/pkg/cmd/login/login.go @@ -68,21 +68,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,10 +83,12 @@ 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) + if homeErr == nil && agentskill.IsAnyAgentInstalled(homeDir) && !agentskill.IsSkillInstalled(homeDir) { + t.Vprintf("\n💡 Detected an AI coding agent. Run %s to enable natural-language commands.\n", t.Yellow("brev agent-skill install")) + } + if hello.ShouldWeRunOnboarding(loginStore) { + t.Vprintf("\n👋 New to Brev? Run %s for a guided walkthrough.\n", t.Yellow("brev hello")) } return nil }, @@ -232,18 +220,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{ diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index 208a515cc..b13ab1140 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -74,40 +74,7 @@ with other commands like stop, start, or delete.`, brev ls orgs 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) - }, + PersistentPostRunE: cmdcontext.InvokeParentPersistentPostRun, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { err := cmdcontext.InvokeParentPersistentPreRun(cmd, args) if err != nil { diff --git a/pkg/files/files.go b/pkg/files/files.go index a77d10bc1..ad25ae580 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -17,7 +17,7 @@ import ( type PersonalSettings struct { DefaultEditor string `json:"default_editor"` - AnalyticsEnabled *bool `json:"analytics_enabled,omitempty"` // nil = never asked, true = opted in, false = opted out + AnalyticsEnabled *bool `json:"analytics_enabled,omitempty"` // nil = default on (opt-out model), true = explicit opt-in, false = opted out AnalyticsID string `json:"analytics_id,omitempty"` // stable anonymous ID for analytics }