Skip to content

Commit 8e255d8

Browse files
authored
Got rid of forced tutorial and added auto opt in for CLI analytics (#370)
1 parent 2d876de commit 8e255d8

14 files changed

Lines changed: 256 additions & 729 deletions

File tree

pkg/analytics/analytics.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ type EventData struct {
1717
}
1818

1919
func TrackEvent(data EventData) error {
20+
if !IsAnalyticsEnabled() {
21+
return nil
22+
}
23+
2024
conf := config.NewConstants()
2125

2226
url := conf.GetBrevAPIURl() + "/api/brevent"

pkg/analytics/posthog.go

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ func getClient() (posthog.Client, error) {
4848
return client, clientErr
4949
}
5050

51-
// IsAnalyticsFeatureEnabled checks the PostHog feature flag to determine
52-
// whether to prompt the user about analytics opt-in.
51+
// 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.
5352
func IsAnalyticsFeatureEnabled() bool {
5453
anonID := GetOrCreateAnalyticsID()
5554
if anonID == "" {
@@ -81,13 +80,26 @@ func RecordCommandStart(cmd *cobra.Command, args []string) {
8180
storedArgs = args
8281
}
8382

84-
// IsAnalyticsEnabled returns whether analytics is enabled and whether the user has been asked.
85-
func IsAnalyticsEnabled() (enabled bool, hasBeenAsked bool) {
83+
// IsAnalyticsEnabled defaults to true; DO_NOT_TRACK and BREV_NO_ANALYTICS override.
84+
func IsAnalyticsEnabled() bool {
85+
if disabled, _ := IsDisabledByEnv(); disabled {
86+
return false
87+
}
8688
settings := readSettings()
8789
if settings.AnalyticsEnabled == nil {
88-
return false, false
90+
return true
91+
}
92+
return *settings.AnalyticsEnabled
93+
}
94+
95+
func IsDisabledByEnv() (disabled bool, varName string) {
96+
if os.Getenv("DO_NOT_TRACK") == "1" {
97+
return true, "DO_NOT_TRACK"
98+
}
99+
if os.Getenv("BREV_NO_ANALYTICS") == "1" {
100+
return true, "BREV_NO_ANALYTICS"
89101
}
90-
return *settings.AnalyticsEnabled, true
102+
return false, ""
91103
}
92104

93105
// SetAnalyticsPreference persists the user's analytics preference.
@@ -139,34 +151,15 @@ func GetOrCreateAnalyticsID() string {
139151
return settings.AnalyticsID
140152
}
141153

142-
// CaptureAnalyticsOptIn sends an event recording the user's analytics consent choice.
143-
// This is sent regardless of the user's choice so we can measure opt-in rates.
144-
func CaptureAnalyticsOptIn(optedIn bool) {
145-
anonID := GetOrCreateAnalyticsID()
146-
if anonID == "" {
147-
return
148-
}
149-
150-
c, err := getClient()
151-
if err != nil {
152-
return
153-
}
154-
155-
_ = c.Enqueue(posthog.Capture{
156-
DistinctId: anonID,
157-
Event: "analytics_opt_in",
158-
Properties: posthog.NewProperties().
159-
Set("opted_in", optedIn).
160-
Set("os", runtime.GOOS).
161-
Set("arch", runtime.GOARCH).
162-
Set("cli_version", version.Version),
163-
})
154+
// shouldCapturePostHog returns true only when both the local opt-out and
155+
// the remote PostHog kill switch agree.
156+
func shouldCapturePostHog() bool {
157+
return IsAnalyticsEnabled() && IsAnalyticsFeatureEnabled()
164158
}
165159

166160
// IdentifyUser links the anonymous analytics ID to a real user ID using PostHog Alias.
167161
func IdentifyUser(userID string) {
168-
enabled, asked := IsAnalyticsEnabled()
169-
if !asked || !enabled {
162+
if !shouldCapturePostHog() {
170163
return
171164
}
172165

@@ -202,22 +195,19 @@ func CaptureCommandError() {
202195
if storedCmd == nil {
203196
return
204197
}
205-
// If CaptureCommand already ran (success path), don't double-capture.
206-
// storedUser being set means PersistentPostRunE ran.
207-
// We only get here on error, so PersistentPostRunE didn't run.
208-
userID := storedUser
209-
if userID == "" {
210-
userID = GetOrCreateAnalyticsID()
211-
}
212-
captureEvent(userID, storedCmd, storedArgs, false)
198+
captureEvent(storedUser, storedCmd, storedArgs, false)
213199
}
214200

215201
func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bool) {
216-
enabled, asked := IsAnalyticsEnabled()
217-
if !asked || !enabled {
202+
if !shouldCapturePostHog() {
218203
return
219204
}
220205

206+
// Resolve the analytics ID lazily, only after gates pass — avoids writing a
207+
// persistent UUID to ~/.brev/personal_settings.json for opted-out users.
208+
if userID == "" {
209+
userID = GetOrCreateAnalyticsID()
210+
}
221211
if userID == "" {
222212
return
223213
}

pkg/analytics/posthog_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package analytics
2+
3+
import (
4+
"testing"
5+
6+
"github.com/brevdev/brev-cli/pkg/files"
7+
)
8+
9+
func boolPtr(b bool) *bool { return &b }
10+
11+
func TestIsDisabledByEnv(t *testing.T) {
12+
cases := []struct {
13+
name string
14+
envs map[string]string
15+
wantDisabled bool
16+
wantVar string
17+
}{
18+
{"no env vars set", nil, false, ""},
19+
{"DO_NOT_TRACK=1", map[string]string{"DO_NOT_TRACK": "1"}, true, "DO_NOT_TRACK"},
20+
{"BREV_NO_ANALYTICS=1", map[string]string{"BREV_NO_ANALYTICS": "1"}, true, "BREV_NO_ANALYTICS"},
21+
{"DO_NOT_TRACK=0 (only \"1\" disables)", map[string]string{"DO_NOT_TRACK": "0"}, false, ""},
22+
{"DO_NOT_TRACK=true (only \"1\" disables)", map[string]string{"DO_NOT_TRACK": "true"}, false, ""},
23+
{"both set — DO_NOT_TRACK reported first", map[string]string{"DO_NOT_TRACK": "1", "BREV_NO_ANALYTICS": "1"}, true, "DO_NOT_TRACK"},
24+
}
25+
for _, c := range cases {
26+
t.Run(c.name, func(t *testing.T) {
27+
t.Setenv("DO_NOT_TRACK", "")
28+
t.Setenv("BREV_NO_ANALYTICS", "")
29+
for k, v := range c.envs {
30+
t.Setenv(k, v)
31+
}
32+
disabled, varName := IsDisabledByEnv()
33+
if disabled != c.wantDisabled {
34+
t.Errorf("disabled = %v, want %v", disabled, c.wantDisabled)
35+
}
36+
if varName != c.wantVar {
37+
t.Errorf("varName = %q, want %q", varName, c.wantVar)
38+
}
39+
})
40+
}
41+
}
42+
43+
func TestIsAnalyticsEnabled(t *testing.T) {
44+
cases := []struct {
45+
name string
46+
stored *bool
47+
envs map[string]string
48+
want bool
49+
}{
50+
{"no preference, no env → default on", nil, nil, true},
51+
{"explicit opt-in, no env", boolPtr(true), nil, true},
52+
{"explicit opt-out, no env", boolPtr(false), nil, false},
53+
{"DO_NOT_TRACK overrides nil", nil, map[string]string{"DO_NOT_TRACK": "1"}, false},
54+
{"DO_NOT_TRACK overrides explicit opt-in", boolPtr(true), map[string]string{"DO_NOT_TRACK": "1"}, false},
55+
{"BREV_NO_ANALYTICS overrides explicit opt-in", boolPtr(true), map[string]string{"BREV_NO_ANALYTICS": "1"}, false},
56+
{"explicit opt-out stays opt-out under env override", boolPtr(false), map[string]string{"DO_NOT_TRACK": "1"}, false},
57+
}
58+
for _, c := range cases {
59+
t.Run(c.name, func(t *testing.T) {
60+
tmp := t.TempDir()
61+
t.Setenv("HOME", tmp)
62+
t.Setenv("DO_NOT_TRACK", "")
63+
t.Setenv("BREV_NO_ANALYTICS", "")
64+
for k, v := range c.envs {
65+
t.Setenv(k, v)
66+
}
67+
68+
if c.stored != nil {
69+
if err := files.WritePersonalSettings(files.AppFs, tmp, &files.PersonalSettings{
70+
AnalyticsEnabled: c.stored,
71+
}); err != nil {
72+
t.Fatalf("write settings: %v", err)
73+
}
74+
}
75+
76+
if got := IsAnalyticsEnabled(); got != c.want {
77+
t.Errorf("IsAnalyticsEnabled() = %v, want %v", got, c.want)
78+
}
79+
})
80+
}
81+
}
82+
83+
// SetAnalyticsPreference must not lose other PersonalSettings fields.
84+
func TestSetAnalyticsPreferencePreservesOtherFields(t *testing.T) {
85+
tmp := t.TempDir()
86+
t.Setenv("HOME", tmp)
87+
88+
if err := files.WritePersonalSettings(files.AppFs, tmp, &files.PersonalSettings{
89+
DefaultEditor: "vim",
90+
AnalyticsID: "preexisting-id",
91+
}); err != nil {
92+
t.Fatalf("write seed: %v", err)
93+
}
94+
95+
if err := SetAnalyticsPreference(false); err != nil {
96+
t.Fatalf("SetAnalyticsPreference: %v", err)
97+
}
98+
99+
got, err := files.ReadPersonalSettings(files.AppFs, tmp)
100+
if err != nil {
101+
t.Fatalf("read back: %v", err)
102+
}
103+
if got.DefaultEditor != "vim" {
104+
t.Errorf("DefaultEditor = %q, want %q (other fields must survive)", got.DefaultEditor, "vim")
105+
}
106+
if got.AnalyticsID != "preexisting-id" {
107+
t.Errorf("AnalyticsID = %q, want %q", got.AnalyticsID, "preexisting-id")
108+
}
109+
if got.AnalyticsEnabled == nil || *got.AnalyticsEnabled != false {
110+
t.Errorf("AnalyticsEnabled = %v, want pointer to false", got.AnalyticsEnabled)
111+
}
112+
}

pkg/cmd/agentskill/agentskill.go

Lines changed: 8 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,16 @@
22
package agentskill
33

44
import (
5-
"bufio"
65
"encoding/json"
76
"fmt"
87
"io"
98
"net/http"
109
"os"
1110
"path/filepath"
12-
"strings"
1311
"time"
1412

1513
breverrors "github.com/brevdev/brev-cli/pkg/errors"
1614
"github.com/brevdev/brev-cli/pkg/terminal"
17-
"github.com/fatih/color"
18-
"github.com/manifoldco/promptui"
1915
"github.com/spf13/cobra"
2016
)
2117

@@ -181,11 +177,14 @@ func GetSkillDir(homeDir string) string {
181177
return filepath.Join(homeDir, ".claude", "skills", skillName)
182178
}
183179

184-
// IsClaudeInstalled checks if Claude Code appears to be installed
185-
func IsClaudeInstalled(homeDir string) bool {
186-
claudeDir := filepath.Join(homeDir, ".claude")
187-
_, err := os.Stat(claudeDir)
188-
return err == nil
180+
// IsAnyAgentInstalled returns true if any of installDirs exists under homeDir.
181+
func IsAnyAgentInstalled(homeDir string) bool {
182+
for _, dir := range installDirs {
183+
if _, err := os.Stat(filepath.Join(homeDir, dir)); err == nil {
184+
return true
185+
}
186+
}
187+
return false
189188
}
190189

191190
// IsSkillInstalled checks if the brev-cli skill is installed in any location
@@ -199,45 +198,6 @@ func IsSkillInstalled(homeDir string) bool {
199198
return false
200199
}
201200

202-
// PromptInstallSkill asks the user if they want to install the agent skill
203-
// Returns true if they want to install, false otherwise
204-
func PromptInstallSkill(t *terminal.Terminal, homeDir string) bool {
205-
// Skip if skill is already installed
206-
if IsSkillInstalled(homeDir) {
207-
return false
208-
}
209-
210-
// Check if Claude Code appears to be installed
211-
if !IsClaudeInstalled(homeDir) {
212-
return false
213-
}
214-
215-
fmt.Println()
216-
caretType := color.New(color.FgCyan, color.Bold).SprintFunc()
217-
fmt.Println(" ", caretType("▸"), " AI Agent Integration")
218-
fmt.Println()
219-
fmt.Println(" We detected an AI coding agent on your system.")
220-
fmt.Println(" Would you like to install the Brev CLI skill?")
221-
fmt.Println()
222-
fmt.Println(" This enables natural language commands like:")
223-
fmt.Println(t.Yellow(" \"Create an A100 instance for ML training\""))
224-
fmt.Println(t.Yellow(" \"Search for GPUs with 40GB VRAM\""))
225-
fmt.Println(t.Yellow(" \"Stop all my running instances\""))
226-
fmt.Println()
227-
228-
prompt := promptui.Select{
229-
Label: "Install agent skill",
230-
Items: []string{"Yes, install it", "No, skip for now"},
231-
}
232-
233-
idx, _, err := prompt.Run()
234-
if err != nil {
235-
return false
236-
}
237-
238-
return idx == 0
239-
}
240-
241201
// InstallSkill downloads and installs the agent skill to all install paths
242202
func InstallSkill(t *terminal.Terminal, homeDir string, quiet bool) error {
243203
skillDirs := GetSkillDirs(homeDir)
@@ -322,18 +282,6 @@ func UninstallSkill(t *terminal.Terminal, homeDir string) error {
322282
return nil
323283
}
324284

325-
// RunInstallSkillIfWanted prompts and installs if user wants it
326-
// This is called from the login flow
327-
func RunInstallSkillIfWanted(t *terminal.Terminal, homeDir string) {
328-
if PromptInstallSkill(t, homeDir) {
329-
err := InstallSkill(t, homeDir, false)
330-
if err != nil {
331-
// Don't fail login for skill install errors
332-
fmt.Printf(" %s Failed to install skill: %v\n", t.Yellow("Warning:"), err)
333-
}
334-
}
335-
}
336-
337285
// downloadAndInstallFile downloads a single file and writes it to all skill dirs.
338286
// Returns true on success, false if the download or any write failed.
339287
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) {
386334

387335
return body, nil
388336
}
389-
390-
// PromptInstallSkillSimple is a simpler yes/no prompt for the login flow
391-
func PromptInstallSkillSimple() bool {
392-
reader := bufio.NewReader(os.Stdin)
393-
fmt.Print("Install agent skill? [y/N]: ")
394-
response, _ := reader.ReadString('\n')
395-
response = strings.ToLower(strings.TrimSpace(response))
396-
return response == "y" || response == "yes"
397-
}

0 commit comments

Comments
 (0)