Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(go test *)",
"Bash(go build *)",
"Bash(./bumblebee.exe selftest *)",
"Bash(./bumblebee-test roots *)"
]
}
}
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down Expand Up @@ -47,6 +47,7 @@ jobs:
run: go build -buildvcs=false ./cmd/bumblebee

- name: bumblebee selftest
shell: bash
run: |
go build -buildvcs=false -o ./bumblebee ./cmd/bumblebee
./bumblebee selftest
Expand Down
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ builds:
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
Expand Down
10 changes: 10 additions & 0 deletions cmd/bumblebee/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func TestResolveDeviceIDEmptyEnv(t *testing.T) {
func TestIsBroadHomeRoot(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

broad := []string{
home,
Expand Down Expand Up @@ -95,6 +96,7 @@ func TestResolveRootsBaselineExcludesProjectTrees(t *testing.T) {
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
codeDir := filepath.Join(home, "code")
if err := os.MkdirAll(codeDir, 0o755); err != nil {
t.Fatal(err)
Expand All @@ -117,6 +119,7 @@ func TestResolveRootsBaselineExcludesProjectTrees(t *testing.T) {
func TestResolveRootsProjectIncludesCodeDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
codeDir := filepath.Join(home, "code")
if err := os.MkdirAll(codeDir, 0o755); err != nil {
t.Fatal(err)
Expand All @@ -139,6 +142,7 @@ func TestResolveRootsProjectIncludesCodeDir(t *testing.T) {
func TestResolveRootsBaselineIncludesUserLocalPython(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
pyRoot := filepath.Join(home, ".local", "lib", "python3.12")
if err := os.MkdirAll(filepath.Join(pyRoot, "site-packages"), 0o755); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -167,6 +171,7 @@ func TestResolveRootsBaselineIncludesClaudeAndCodexMCPRoots(t *testing.T) {
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
want := []string{
filepath.Join(home, ".claude"),
filepath.Join(home, ".codex"),
Expand Down Expand Up @@ -211,6 +216,7 @@ func TestResolveRootsBaselineSkipsAbsentClaudeCodexRoots(t *testing.T) {
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
// Provide one unrelated default so the baseline run does not fail
// with "no default roots". `~/go` is not one of the MCP candidates
// under test, so its presence cannot mask the assertion below.
Expand Down Expand Up @@ -263,6 +269,7 @@ func TestClassifyRootClaudeCodexMCP(t *testing.T) {
func TestResolveRootsBaselineRefusesBroadHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
_, _, err := resolveRoots(model.ProfileBaseline, []string{home}, rootsOpts{})
if err == nil {
t.Fatalf("expected refusal for baseline+%q", home)
Expand All @@ -275,6 +282,7 @@ func TestResolveRootsBaselineRefusesBroadHome(t *testing.T) {
func TestResolveRootsProjectRefusesBroadHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
_, _, err := resolveRoots(model.ProfileProject, []string{home}, rootsOpts{})
if err == nil {
t.Fatalf("expected refusal for project+%q", home)
Expand All @@ -284,6 +292,7 @@ func TestResolveRootsProjectRefusesBroadHome(t *testing.T) {
func TestResolveRootsDeepAllowsBroadHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
roots, _, err := resolveRoots(model.ProfileDeep, []string{home}, rootsOpts{})
if err != nil {
t.Fatalf("deep should accept broad home root: %v", err)
Expand All @@ -299,6 +308,7 @@ func TestResolveRootsDeepAllowsBroadHome(t *testing.T) {
func TestResolveRootsDeepRequiresExplicitRoot(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
_, _, err := resolveRoots(model.ProfileDeep, nil, rootsOpts{})
if err == nil {
t.Fatalf("deep with no roots should error")
Expand Down
81 changes: 75 additions & 6 deletions cmd/bumblebee/roots.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,26 +172,57 @@ func isBroadHomeRoot(path string) bool {
if path == "" {
return false
}

// Normalize path slashes for comparison
normalized := filepath.ToSlash(filepath.Clean(path))

// Check Unix/Linux patterns first (before filepath.Abs, which converts them on Windows)
switch normalized {
case "/", "/Users", "/home", "/root":
return true
}

// Unix user home patterns: /Users/<name> or /home/<name>
if strings.HasPrefix(normalized, "/Users/") || strings.HasPrefix(normalized, "/home/") {
parts := strings.Split(strings.TrimPrefix(normalized, "/"), "/")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
return true
}
}

// For absolute paths, use filepath.Abs which respects the OS
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
abs = filepath.Clean(abs)
if abs == "/" {
return true
}

// Check if path matches current user's home directory
if home, err := os.UserHomeDir(); err == nil && home != "" {
if abs == filepath.Clean(home) {
return true
}
}
switch abs {
case "/Users", "/home", "/root":

// Windows drive roots (C:\, D:\, etc.)
if len(abs) == 3 && abs[1] == ':' && (abs[2] == '\\' || abs[2] == '/') {
return true
}
if dir, _ := filepath.Split(abs); dir == "/Users/" || dir == "/home/" {

// Windows: C:\Users (case-insensitive)
upperAbs := strings.ToUpper(filepath.ToSlash(abs))
if upperAbs == "C:/USERS" {
return true
}

// Windows: C:\Users\someone (case-insensitive)
if strings.HasPrefix(upperAbs, "C:/USERS/") {
parts := strings.Split(strings.TrimPrefix(upperAbs, "C:/USERS/"), "/")
if len(parts) == 1 && parts[0] != "" {
return true
}
}

return false
}

Expand Down Expand Up @@ -254,6 +285,11 @@ func baselineHomeCandidates(home string) []scanner.Root {
add(filepath.Join(home, ".config", "Claude"), model.RootKindMCPConfig)
add(filepath.Join(home, ".config", "Claude Code"), model.RootKindMCPConfig)
add(filepath.Join(home, ".continue"), model.RootKindMCPConfig)
case "windows":
appData := os.Getenv("APPDATA")
add(filepath.Join(appData, "Claude"), model.RootKindMCPConfig)
default:
// Unknown platform: no platform-specific MCP config paths added
}

// Browser extension trees. We point directly at the per-profile
Expand Down Expand Up @@ -302,6 +338,21 @@ func systemRoots() []scanner.Root {
}
}
return roots
case "windows":
// Windows developers typically use per-project installs (venvs, node_modules).
// Global installs are less common, but include:
// - Python in %LOCALAPPDATA%\Programs\Python\*
// - NuGet packages in %USERPROFILE%\.nuget\packages
var roots []scanner.Root
localAppData := os.Getenv("LOCALAPPDATA")
for _, p := range globExisting(filepath.Join(localAppData, "Programs", "Python*")) {
roots = append(roots, scanner.Root{Path: p, Kind: model.RootKindGlobalPackage})
}
if userProfile := os.Getenv("USERPROFILE"); userProfile != "" {
nugetPath := filepath.Join(userProfile, ".nuget", "packages")
roots = append(roots, scanner.Root{Path: nugetPath, Kind: model.RootKindGlobalPackage})
}
return roots
}
return nil
}
Expand Down Expand Up @@ -525,6 +576,15 @@ func browserExtensionCandidateRoots(home string) []string {
filepath.Join(home, ".var", "app", "com.microsoft.Edge", "config", "microsoft-edge"),
}
chromiumBases["vivaldi"] = []string{filepath.Join(cfg, "vivaldi")}
case "windows":
localAppData := os.Getenv("LOCALAPPDATA")
chromiumBases["chrome"] = []string{filepath.Join(localAppData, "Google", "Chrome", "User Data")}
chromiumBases["chromium"] = []string{filepath.Join(localAppData, "Chromium", "User Data")}
chromiumBases["brave"] = []string{filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "User Data")}
chromiumBases["edge"] = []string{filepath.Join(localAppData, "Microsoft", "Edge", "User Data")}
chromiumBases["vivaldi"] = []string{filepath.Join(localAppData, "Vivaldi", "User Data")}
default:
// Unknown platform: no Chromium-family browser paths added
}
for _, bases := range chromiumBases {
for _, b := range bases {
Expand Down Expand Up @@ -555,6 +615,15 @@ func browserExtensionCandidateRoots(home string) []string {
filepath.Join(home, ".var", "app", "io.gitlab.librewolf-community", ".librewolf"),
filepath.Join(home, ".waterfox"),
)
case "windows":
appData := os.Getenv("APPDATA")
roots = append(roots,
filepath.Join(appData, "Mozilla", "Firefox", "Profiles"),
filepath.Join(appData, "LibreWolf", "Profiles"),
filepath.Join(appData, "Waterfox", "Profiles"),
)
default:
// Unknown platform: no Firefox-family browser paths added
}
return roots
}
Expand Down
4 changes: 2 additions & 2 deletions internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ func TestEndToEndScan(t *testing.T) {
var lockFromProj, lockFromDup, nmRec, pyRec bool
for _, r := range records {
switch {
case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && strings.Contains(r.SourceFile, "/proj/"):
case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && (strings.Contains(r.SourceFile, "/proj/") || strings.Contains(r.SourceFile, "\\proj\\")):
lockFromProj = true
case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && strings.Contains(r.SourceFile, "/dup/"):
case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && (strings.Contains(r.SourceFile, "/dup/") || strings.Contains(r.SourceFile, "\\dup\\")):
lockFromDup = true
case r.Ecosystem == "npm" && r.SourceType == "npm-node_modules":
nmRec = true
Expand Down
9 changes: 9 additions & 0 deletions internal/walk/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ var DefaultExcludes = []string{
".windsurf-server/bin",
".windsurf-server/cli",
".windsurf-server/logs",

// Windows-specific excludes for AppData directories and caches.
// AppData\Local\Temp accumulates temporary files from installers and
// app runtime. AppData\Local\Microsoft contains OS-managed state and
// Windows-specific caches. AppData\Local\Packages holds UWP app data.
"AppData/Local/Temp",
"AppData/Local/Microsoft",
"AppData/Local/Packages",
"AppData/LocalLow",
}

// Visitor is called for every directory entry the walker decides to surface.
Expand Down