diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index d65854673..1ab716fd2 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -11,11 +11,14 @@ import ( "os" "path/filepath" "regexp" + "strings" "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/platform" + "github.com/entireio/cli/cmd/entire/cli/stringutil" "github.com/entireio/cli/cmd/entire/cli/transcript" ) @@ -95,13 +98,29 @@ func (c *ClaudeCodeAgent) GetSessionDir(repoPath string) (string, error) { return override, nil } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) + homeDirs := platform.HomeDirCandidates() + if len(homeDirs) == 0 { + // fallback + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + homeDirs = []string{home} + } + + projectDirs := claudeProjectDirCandidates(repoPath) + // Try to find an existing directory first (important on WSL due to path-shape differences). + for _, home := range homeDirs { + for _, proj := range projectDirs { + p := filepath.Join(home, ".claude", "projects", proj) + if agent.DirExists(p) { + return p, nil + } + } } - projectDir := SanitizePathForClaude(repoPath) - return filepath.Join(homeDir, ".claude", "projects", projectDir), nil + // Default to the first candidate. + return filepath.Join(homeDirs[0], ".claude", "projects", projectDirs[0]), nil } // ReadSession reads a session from Claude's storage (JSONL transcript file). @@ -357,3 +376,18 @@ func (c *ClaudeCodeAgent) ChunkTranscript(_ context.Context, content []byte, max func (c *ClaudeCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { return agent.ReassembleJSONL(chunks), nil } + +func claudeProjectDirCandidates(repoPath string) []string { + cands := []string{SanitizePathForClaude(repoPath)} + if strings.HasPrefix(repoPath, "/") { + // Claude's sanitize keeps leading "/" (becomes leading "-"). + // Some environments may store without it, so try both. + cands = append(cands, SanitizePathForClaude(strings.TrimLeft(repoPath, "/"))) + } + if platform.IsWSL() { + for _, win := range platform.WindowsPathCandidatesFromWSLPath(repoPath) { + cands = append(cands, SanitizePathForClaude(win)) + } + } + return stringutil.UniqueStrings(cands) +} diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index c507bfc38..da9e2645b 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -14,6 +14,8 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/platform" + "github.com/entireio/cli/cmd/entire/cli/stringutil" ) //nolint:gochecknoinits // Agent self-registration is the intended pattern @@ -94,13 +96,26 @@ func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) { return override, nil } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) + homeDirs := platform.HomeDirCandidates() + if len(homeDirs) == 0 { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + homeDirs = []string{home} + } + + projectDirs := cursorProjectDirCandidates(repoPath) + for _, home := range homeDirs { + for _, proj := range projectDirs { + p := filepath.Join(home, ".cursor", "projects", proj, "agent-transcripts") + if agent.DirExists(p) { + return p, nil + } + } } - projectDir := sanitizePathForCursor(repoPath) - return filepath.Join(homeDir, ".cursor", "projects", projectDir, "agent-transcripts"), nil + return filepath.Join(homeDirs[0], ".cursor", "projects", projectDirs[0], "agent-transcripts"), nil } // ReadSession reads a session from Cursor's storage (JSONL transcript file). @@ -178,3 +193,13 @@ func (c *CursorAgent) ChunkTranscript(_ context.Context, content []byte, maxSize func (c *CursorAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { return agent.ReassembleJSONL(chunks), nil } + +func cursorProjectDirCandidates(repoPath string) []string { + cands := []string{sanitizePathForCursor(repoPath)} + if platform.IsWSL() { + for _, win := range platform.WindowsPathCandidatesFromWSLPath(repoPath) { + cands = append(cands, sanitizePathForCursor(win)) + } + } + return stringutil.UniqueStrings(cands) +} diff --git a/cmd/entire/cli/agent/fsutil.go b/cmd/entire/cli/agent/fsutil.go new file mode 100644 index 000000000..bc8a4860f --- /dev/null +++ b/cmd/entire/cli/agent/fsutil.go @@ -0,0 +1,9 @@ +package agent + +import "os" + +// DirExists reports whether the named directory exists. +func DirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index cae723c9d..99409fdce 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -11,6 +11,8 @@ import ( "sync" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/platform" + "github.com/entireio/cli/cmd/entire/cli/stringutil" ) // Directory constants @@ -156,13 +158,37 @@ func GetClaudeProjectDir(repoPath string) (string, error) { return override, nil } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) + homeDirs := platform.HomeDirCandidates() + if len(homeDirs) == 0 { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + homeDirs = []string{home} + } + + // Candidates matter on WSL because Windows-native Claude uses Windows path strings. + projectDirs := []string{SanitizePathForClaude(repoPath)} + if strings.HasPrefix(repoPath, "/") { + projectDirs = append(projectDirs, SanitizePathForClaude(strings.TrimLeft(repoPath, "/"))) + } + if platform.IsWSL() { + for _, win := range platform.WindowsPathCandidatesFromWSLPath(repoPath) { + projectDirs = append(projectDirs, SanitizePathForClaude(win)) + } + } + projectDirs = stringutil.UniqueStrings(projectDirs) + + for _, home := range homeDirs { + for _, proj := range projectDirs { + p := filepath.Join(home, ".claude", "projects", proj) + if info, err := os.Stat(p); err == nil && info.IsDir() { + return p, nil + } + } } - projectDir := SanitizePathForClaude(repoPath) - return filepath.Join(homeDir, ".claude", "projects", projectDir), nil + return filepath.Join(homeDirs[0], ".claude", "projects", projectDirs[0]), nil } // SessionMetadataDirFromSessionID returns the path to a session's metadata directory diff --git a/cmd/entire/cli/platform/wsl.go b/cmd/entire/cli/platform/wsl.go new file mode 100644 index 000000000..6fe7919cc --- /dev/null +++ b/cmd/entire/cli/platform/wsl.go @@ -0,0 +1,257 @@ +// Package platform provides platform detection utilities for the Entire CLI. +// It detects WSL (Windows Subsystem for Linux) environments and provides +// helpers for resolving Windows filesystem paths from within WSL. +package platform + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/entireio/cli/cmd/entire/cli/stringutil" +) + +var procVersionPath = "/proc/version" +var procOSReleasePath = "/proc/sys/kernel/osrelease" + +var ( + isWSLOnce sync.Once + isWSL bool +) + +// IsWSL returns true if the current process is running inside Windows Subsystem for Linux. +// The result is cached after the first call. +func IsWSL() bool { + isWSLOnce.Do(func() { + isWSL = detectWSL() + }) + + return isWSL +} + +// WSL runs as linux, so detect it via env vars + /proc markers. +func detectWSL() bool { + if runtime.GOOS != "linux" { + return false + } + + // Fast signals + if os.Getenv("WSL_INTEROP") != "" || os.Getenv("WSL_DISTRO_NAME") != "" || os.Getenv("WSLENV") != "" { + return true + } + + // Fallback heuristics + if b, err := os.ReadFile(procVersionPath); err == nil { + if strings.Contains(strings.ToLower(string(b)), "microsoft") { + return true + } + } + + if b, err := os.ReadFile(procOSReleasePath); err == nil { + if bytes.Contains(bytes.ToLower(b), []byte("microsoft")) { + return true + } + } + + return false +} + +// ResetWSLCache resets the cached WSL detection state. +// This is only intended for testing - do not use in production code. +func ResetWSLCache() { + isWSLOnce = sync.Once{} + isWSL = false +} + +func OSVariant() string { + if IsWSL() { + return "wsl" + } + + return runtime.GOOS +} + +// WslpathWindows converts a WSL/Linux path to a Windows path using `wslpath -w`. +func WslpathWindows(linuxPath string) (string, error) { + if linuxPath == "" { + return "", os.ErrNotExist + } + + cmd := exec.Command("wslpath", "-w", linuxPath) + out, err := cmd.Output() + if err != nil { + return "", err + } + s := strings.TrimSpace(string(out)) + if s == "" { + return "", os.ErrNotExist + } + + return s, nil +} + +// WslpathLinux converts a Windows path to a WSL/Linux path using `wslpath -u`. +func WslpathLinux(winPath string) (string, error) { + if winPath == "" { + return "", os.ErrNotExist + } + + cmd := exec.Command("wslpath", "-u", winPath) + out, err := cmd.Output() + if err != nil { + return "", err + } + + s := strings.TrimSpace(string(out)) + if s == "" { + return "", os.ErrNotExist + } + + return s, nil +} + +// WindowsPathCandidatesFromWSLPath returns likely Windows-side path strings that a +// Windows-native app might use to represent the same repo. +// +// Examples: +// - /mnt/c/Users/Alice/src/repo -> C:\Users\Alice\src\repo +// - /home/alice/repo -> \\wsl.localhost\Ubuntu\home\alice\repo (and \\wsl$\Ubuntu\home\alice\repo) +func WindowsPathCandidatesFromWSLPath(linuxPath string) []string { + if !IsWSL() { + return nil + } + + // Optional override for debugging / edge-cases. + if override := os.Getenv("ENTIRE_WSL_REPO_WIN_PATH"); override != "" { + return []string{override} + } + + win, err := WslpathWindows(linuxPath) + if err != nil || win == "" { + return nil + } + + cands := []string{win} + + lower := strings.ToLower(win) + if strings.HasPrefix(lower, `\\wsl.localhost\`) { + // swap prefix to \\wsl$\ + cands = append(cands, `\\wsl$`+win[len(`\\wsl.localhost`):]) + } + if strings.HasPrefix(lower, `\\wsl$\`) { + // swap prefix to \\wsl.localhost\ + cands = append(cands, `\\wsl.localhost`+win[len(`\\wsl$`):]) + } + + return stringutil.UniqueStrings(cands) +} + +// HomeDirCandidates returns a list of "home" directories to try, in priority order. +// On WSL we prefer Windows home first (because Windows-native agents store there), +// then fall back to Linux home. +func HomeDirCandidates() []string { + var out []string + + // On WSL, try Windows home first. + if IsWSL() { + if winHome, err := WindowsHomeDir(); err == nil && winHome != "" { + out = append(out, winHome) + } + } + + if linuxHome, err := os.UserHomeDir(); err == nil && linuxHome != "" { + out = append(out, linuxHome) + } + + return stringutil.UniqueStrings(out) +} + +// WindowsHomeDir returns the Windows user home directory accessible from WSL. +// This is needed because Windows-native agents (Claude Code, Cursor) store +// their session data under the Windows user directory, not the WSL Linux home. +// +// Resolution order: +// 1. ENTIRE_WSL_WIN_HOME environment variable (for testing/overrides) +// 2. cmd.exe /C "echo %USERPROFILE%" via wslpath +// 3. /mnt/c/Users/ fallback +// +// Returns an error if the Windows home cannot be determined. +func WindowsHomeDir() (string, error) { + if !IsWSL() { + return "", fmt.Errorf("WindowsHomeDir called outside WSL") + } + + // Allow override for testing and user customization + if override := os.Getenv("ENTIRE_WSL_WIN_HOME"); override != "" { + // Allow providing either Windows path or already-converted WSL path. + if strings.Contains(override, `:\`) || strings.HasPrefix(override, `\\`) { + if p, err := WslpathLinux(override); err == nil { + return p, nil + } + } + return override, nil + } + + // Try resolving via cmd.exe (most reliable) + if home, err := windowsHomeDirViaCmdExe(); err == nil { + return home, nil + } + + // Fallback: try /mnt/c/Users/ + return windowsHomeDirFallback() +} + +// windowsHomeDirViaCmdExe resolves the Windows home directory by invoking cmd.exe. +func windowsHomeDirViaCmdExe() (string, error) { + // Get Windows USERPROFILE path + cmd := exec.Command("cmd.exe", "/C", "echo", "%USERPROFILE%") + cmd.Stderr = nil + output, err := cmd.Output() + if err != nil { + return "", err + } + + winPath := strings.TrimSpace(string(output)) + if winPath == "" || winPath == "%USERPROFILE%" { + return "", os.ErrNotExist + } + + result, err := WslpathLinux(winPath) + if err != nil { + return "", err + } + + if result == "" { + return "", os.ErrNotExist + } + + if info, err := os.Stat(result); err == nil && info.IsDir() { + return result, nil + } + + return result, nil +} + +// windowsHomeDirFallback tries to resolve the Windows home via /mnt/c/Users/. +func windowsHomeDirFallback() (string, error) { + // Try the current user's name + username := os.Getenv("USER") + if username == "" { + username = os.Getenv("LOGNAME") + } + if username == "" { + return "", os.ErrNotExist + } + + candidate := filepath.Join("/mnt/c/Users", username) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate, nil + } + + return "", os.ErrNotExist +} diff --git a/cmd/entire/cli/platform/wsl_test.go b/cmd/entire/cli/platform/wsl_test.go new file mode 100644 index 000000000..b54d4f30f --- /dev/null +++ b/cmd/entire/cli/platform/wsl_test.go @@ -0,0 +1,183 @@ +package platform + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestIsWSL_NonWSL(t *testing.T) { + // NOTE: no t.Parallel() because this file mutates package globals (paths + cache). + + // On macOS, IsWSL should always return false regardless of /proc/version + if runtime.GOOS != "linux" { + ResetWSLCache() + defer ResetWSLCache() + + if IsWSL() { + t.Error("IsWSL() should return false on non-Linux OS") + } + return + } + + t.Setenv("WSL_INTEROP", "") + t.Setenv("WSL_DISTRO_NAME", "") + t.Setenv("WSLENV", "") + + // On Linux, test with mock /proc/version + tmpDir := t.TempDir() + mockProcVersion := filepath.Join(tmpDir, "version") + if err := os.WriteFile(mockProcVersion, []byte("Linux version 6.5.0-generic (builder@host)\n"), 0o644); err != nil { + t.Fatal(err) + } + + mockOSRelease := filepath.Join(tmpDir, "osrelease") + if err := os.WriteFile(mockOSRelease, []byte("6.5.0-generic\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Override the proc version path and reset cache + origPath := procVersionPath + origOSRel := procOSReleasePath + procVersionPath = mockProcVersion + procOSReleasePath = mockOSRelease + ResetWSLCache() + defer func() { + procVersionPath = origPath + procOSReleasePath = origOSRel + ResetWSLCache() + }() + + if IsWSL() { + t.Error("IsWSL() should return false when /proc/version does not contain 'microsoft'") + } +} + +func TestIsWSL_WSL2(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL detection only works on Linux") + } + + t.Setenv("WSL_INTEROP", "") + t.Setenv("WSL_DISTRO_NAME", "") + t.Setenv("WSLENV", "") + + tmpDir := t.TempDir() + mockProcVersion := filepath.Join(tmpDir, "version") + content := "Linux version 5.15.153.1-microsoft-standard-WSL2 " + + "(root@65c757a075e2) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) " + + "#1 SMP Fri Mar 29 23:14:13 UTC 2024\n" + if err := os.WriteFile(mockProcVersion, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + mockOSRelease := filepath.Join(tmpDir, "osrelease") + if err := os.WriteFile(mockOSRelease, []byte("5.15.153.1-microsoft-standard-WSL2\n"), 0o644); err != nil { + t.Fatal(err) + } + + origPath := procVersionPath + origOSRel := procOSReleasePath + procVersionPath = mockProcVersion + procOSReleasePath = mockOSRelease + ResetWSLCache() + defer func() { + procVersionPath = origPath + procOSReleasePath = origOSRel + ResetWSLCache() + }() + + if !IsWSL() { + t.Error("IsWSL() should return true when /proc/version contains 'microsoft'") + } +} + +func TestIsWSL_MissingProcVersion(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL detection only works on Linux") + } + + t.Setenv("WSL_INTEROP", "") + t.Setenv("WSL_DISTRO_NAME", "") + t.Setenv("WSLENV", "") + + origPath := procVersionPath + origOSRel := procOSReleasePath + procVersionPath = "/nonexistent/path/version" + procOSReleasePath = "/nonexistent/path/osrelease" + ResetWSLCache() + defer func() { + procVersionPath = origPath + procOSReleasePath = origOSRel + ResetWSLCache() + }() + + if IsWSL() { + t.Error("IsWSL() should return false when /proc/version is missing") + } +} + +func TestOSVariant_NonWSL(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + // On non-Linux, OSVariant should return runtime.GOOS + ResetWSLCache() + defer ResetWSLCache() + + got := OSVariant() + if got != runtime.GOOS { + t.Errorf("OSVariant() = %q, want %q", got, runtime.GOOS) + } + return + } + + // On Linux, mock a non-WSL /proc/version + tmpDir := t.TempDir() + mockProcVersion := filepath.Join(tmpDir, "version") + if err := os.WriteFile(mockProcVersion, []byte("Linux version 6.5.0-generic\n"), 0o644); err != nil { + t.Fatal(err) + } + + origPath := procVersionPath + procVersionPath = mockProcVersion + ResetWSLCache() + defer func() { + procVersionPath = origPath + ResetWSLCache() + }() + + got := OSVariant() + if got != "linux" { + t.Errorf("OSVariant() = %q, want %q", got, "linux") + } +} + +func TestWindowsHomeDir_EnvOverride(t *testing.T) { + t.Setenv("ENTIRE_WSL_WIN_HOME", "/mnt/c/Users/testuser") + + got, err := WindowsHomeDir() + if err != nil { + t.Fatalf("WindowsHomeDir() error = %v", err) + } + if got != "/mnt/c/Users/testuser" { + t.Errorf("WindowsHomeDir() = %q, want %q", got, "/mnt/c/Users/testuser") + } +} + +func TestWindowsHomeDir_EmptyEnv(t *testing.T) { + t.Setenv("ENTIRE_WSL_WIN_HOME", "") + + if runtime.GOOS != "linux" { + // On non-Linux, cmd.exe and wslpath won't be available, + // so this just verifies we don't panic + _, _ = WindowsHomeDir() + return + } + + // On actual Linux (non-WSL), it's expected to fail gracefully + _, err := WindowsHomeDir() + // We just verify it doesn't panic; error is expected on non-WSL Linux + _ = err +} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 87fd1c1eb..345ac1a77 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -4,6 +4,7 @@ import ( "fmt" "runtime" + "github.com/entireio/cli/cmd/entire/cli/platform" "github.com/entireio/cli/cmd/entire/cli/telemetry" "github.com/entireio/cli/cmd/entire/cli/versioncheck" "github.com/entireio/cli/cmd/entire/cli/versioninfo" @@ -96,8 +97,15 @@ func NewRootCmd() *cobra.Command { } func versionString() string { - return fmt.Sprintf("Entire CLI %s (%s)\nGo version: %s\nOS/Arch: %s/%s\n", - versioninfo.Version, versioninfo.Commit, runtime.Version(), runtime.GOOS, runtime.GOARCH) + variant := platform.OSVariant() + osArch := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) + + if variant != runtime.GOOS { + osArch = fmt.Sprintf("%s (%s)", osArch, variant) + } + + return fmt.Sprintf("Entire CLI %s (%s)\nGo version: %s\nOS/Arch: %s\n", + versioninfo.Version, versioninfo.Commit, runtime.Version(), osArch) } func newVersionCmd() *cobra.Command { diff --git a/cmd/entire/cli/root_test.go b/cmd/entire/cli/root_test.go index 85720dbc9..86e9db7df 100644 --- a/cmd/entire/cli/root_test.go +++ b/cmd/entire/cli/root_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/entireio/cli/cmd/entire/cli/platform" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/spf13/cobra" ) @@ -58,7 +59,7 @@ func TestVersionFlag_ContainsExpectedInfo(t *testing.T) { }{ {"version number", versioninfo.Version}, {"go version", runtime.Version()}, - {"os", runtime.GOOS}, + {"os", platform.OSVariant()}, {"arch", runtime.GOARCH}, } for _, c := range checks { diff --git a/cmd/entire/cli/stringutil/stringutil.go b/cmd/entire/cli/stringutil/stringutil.go index dcd737b01..49bdd84ba 100644 --- a/cmd/entire/cli/stringutil/stringutil.go +++ b/cmd/entire/cli/stringutil/stringutil.go @@ -45,3 +45,21 @@ func CapitalizeFirst(s string) string { } return string(unicode.ToUpper(r)) + s[size:] } + +// UniqueStrings deduplicates a string slice, preserving order and skipping empty strings. +func UniqueStrings(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + + return out +} diff --git a/cmd/entire/cli/telemetry/detached.go b/cmd/entire/cli/telemetry/detached.go index 303a1fef1..a1b77921d 100644 --- a/cmd/entire/cli/telemetry/detached.go +++ b/cmd/entire/cli/telemetry/detached.go @@ -8,6 +8,7 @@ import ( "time" "github.com/denisbrodbeck/machineid" + "github.com/entireio/cli/cmd/entire/cli/platform" "github.com/posthog/posthog-go" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -69,6 +70,7 @@ func BuildEventPayload(cmd *cobra.Command, agent string, isEntireEnabled bool, v "cli_version": version, "os": runtime.GOOS, "arch": runtime.GOARCH, + "os_variant": platform.OSVariant(), } if len(flags) > 0 { diff --git a/scripts/install.sh b/scripts/install.sh index 10ed0cf08..6de84a104 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -55,6 +55,21 @@ detect_os() { esac } +is_wsl() { + # Fast env-based checks + if [[ -n "${WSL_INTEROP:-}" || -n "${WSL_DISTRO_NAME:-}" || -n "${WSLENV:-}" ]]; then + return 0 + fi + # /proc markers + if [[ -f /proc/version ]] && grep -qi microsoft /proc/version 2>/dev/null; then + return 0 + fi + if [[ -f /proc/sys/kernel/osrelease ]] && grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; then + return 0 + fi + return 1 +} + detect_arch() { local arch arch="$(uname -m)" @@ -125,7 +140,11 @@ main() { local os arch os=$(detect_os) arch=$(detect_arch) - info "Detected platform: ${os}/${arch}" + if is_wsl; then + info "Detected platform: ${os}/${arch} (WSL)" + else + info "Detected platform: ${os}/${arch}" + fi info "Fetching latest version..." local version