Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,7 @@ This is an early-stage product.

**Requirements:**

* ✅ **OS:** macOS (Fully supported). ⚠️ Linux/Windows are untested.
* ✅ **OS:** macOS (fully supported), Linux (GUI terminals + tmux for headless), WSL (Windows Terminal via [setup guide](docs/windows-wsl-guide.md)). ⚠️ Native Windows is unsupported.

* ✅ **Runtime:** Node.js 16+, Git 2.5+.

Expand Down Expand Up @@ -726,6 +726,8 @@ Acknowledgments
----------------

- [@NoahCardoza](https://github.com/NoahCardoza) — Jira Cloud integration (PR [#588](https://github.com/iloom-ai/iloom-cli/pull/588)): JiraApiClient, JiraIssueTracker, ADF/Markdown conversion, MCP provider, sprint/mine filtering, and `il issues` Jira support.
- [@TickTockBent](https://github.com/TickTockBent) — Linux, WSL, and tmux terminal support (PR [#796](https://github.com/iloom-ai/iloom-cli/pull/796)): strategy-pattern terminal backends, GUI-to-tmux fallback for headless environments, WSL detection, and cross-platform terminal launching.
- [@rexsilex](https://github.com/rexsilex) — Original Linux/WSL terminal support design (PR [#649](https://github.com/iloom-ai/iloom-cli/pull/649)): pioneered the strategy pattern and backend interface that inspired the final implementation.

License & Name
--------------
Expand Down
169 changes: 169 additions & 0 deletions docs/windows-wsl-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# iloom on Windows (WSL)

iloom runs on Windows through **Windows Subsystem for Linux (WSL)**. It does **not** run natively in PowerShell or Command Prompt. All iloom commands must be run from inside a WSL distribution.

## Installing iloom

Before installing iloom, you'll need these prerequisites set up inside WSL:

- [WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install) with a Linux distribution (Ubuntu recommended)
- [Windows Terminal](https://aka.ms/terminal) (pre-installed on Windows 11)
- [Node.js 22+](https://github.com/nvm-sh/nvm#installing-and-updating) installed inside WSL (not the Windows version)
- [Git 2.5+](https://git-scm.com/) installed inside WSL
- [GitHub CLI (`gh`)](https://cli.github.com/) installed and authenticated inside WSL
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/overview) installed inside WSL

Then install iloom from inside your WSL terminal:

```bash
npm install -g @iloom/cli
```

If you need help setting up the prerequisites, see [Setting up WSL](#setting-up-wsl) below.

## Using VS Code with WSL

You must open VS Code **from WSL**, not from Windows. This ensures VS Code's integrated terminal runs inside WSL where iloom is installed.

```bash
# Navigate to your project inside WSL
cd ~/projects/my-app

# Open VS Code from WSL — this launches VS Code with the WSL remote extension
code .
```

When you do this, VS Code will:
- Install the **WSL extension** automatically (first time only)
- Show "WSL: Ubuntu" (or your distro name) in the bottom-left corner
- Run its integrated terminal inside WSL
- Have access to all your WSL-installed tools (Node.js, Git, iloom, Claude CLI)

### Do NOT open from Windows Explorer

If you open VS Code from the Windows Start menu or by double-clicking a folder in Windows Explorer, it runs in Windows mode. The integrated terminal will be PowerShell, and iloom won't work. Always use `code .` from your WSL terminal.

### iloom VS Code extension

Once VS Code is open in WSL mode (via `code .` from your WSL terminal), install the iloom extension from the Extensions panel. Because VS Code is running in WSL, the extension runs inside WSL too — it has full access to iloom, Claude CLI, and your development tools.

### Verifying your setup

In VS Code's integrated terminal, run:

```bash
# Should show a Linux path like /home/username/projects/my-app
pwd

# Should show "Linux"
uname -s

# Should work without errors
il --version
```

If `pwd` shows a Windows path (like `/mnt/c/Users/...`), you're accessing Windows files through WSL. While this works, it's significantly slower than using files stored natively in WSL (like `~/projects/`). For best performance, keep your projects in your WSL home directory.

## How iloom uses Windows Terminal

When you run `il start <issue>`, iloom detects that you're in WSL and:

1. Launches **Windows Terminal** (`wt.exe`) to open new tabs
2. Each tab runs inside your WSL distribution automatically
3. Terminal tabs get titled with the task context (e.g., "Dev Server", "Claude")

Your development terminals appear as native Windows Terminal tabs alongside your other terminal sessions.

## Setting up WSL

If you don't have the prerequisites yet, follow these steps.

### 1. Install WSL

Open PowerShell as Administrator and run:

```powershell
wsl --install
```

This installs WSL 2 with Ubuntu by default. Restart your computer when prompted.

### 2. Install Windows Terminal

Install from the [Microsoft Store](https://aka.ms/terminal) if you don't have it already. On Windows 11, it's pre-installed.

### 3. Set up your WSL environment

Open your WSL terminal (Ubuntu) and install the tools:

```bash
# Update packages
sudo apt update && sudo apt upgrade -y

# Install Node.js (using nvm)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
source ~/.bashrc
nvm install 22

# Install GitHub CLI
(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y

# Authenticate with GitHub
gh auth login

# Install Claude CLI
npm install -g @anthropic-ai/claude-code

# Verify everything
node --version # Should be 22+
git --version # Should be 2.5+
gh --version # Should show gh version
claude --version # Should show Claude CLI version
```

## Troubleshooting

### "Windows Terminal (wt.exe) is not available"

Install Windows Terminal from the [Microsoft Store](https://aka.ms/terminal). It's required for iloom to open terminal tabs from WSL.

### iloom commands not found

Make sure you installed iloom inside WSL, not in Windows PowerShell:

```bash
# Run this inside WSL
which il
# Should show something like /home/username/.nvm/versions/node/v22.x.x/bin/il
```

### VS Code not detecting WSL

Install the [WSL extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) for VS Code. Then always open projects with `code .` from your WSL terminal.

### Slow file access

If you're working with files on the Windows filesystem (paths starting with `/mnt/c/`), file operations will be slow due to the WSL-Windows filesystem bridge. Move your projects to your WSL home directory for much better performance:

```bash
# Move project to WSL native filesystem
cp -r /mnt/c/Users/you/projects/my-app ~/projects/my-app
cd ~/projects/my-app
```

### tmux fallback

If Windows Terminal is unavailable for some reason, iloom can fall back to **tmux** (a terminal multiplexer). Install it with:

```bash
sudo apt install tmux
```

iloom will automatically use tmux when no GUI terminal is available (e.g., in SSH sessions or Docker containers).
125 changes: 125 additions & 0 deletions src/utils/platform-detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { isWSL, detectTerminalEnvironment, detectWSLDistro, _resetWSLCache } from './platform-detect.js'

vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
}))

import { readFileSync } from 'node:fs'

describe('platform-detect', () => {
const originalPlatform = process.platform
const originalEnv = { ...process.env }

beforeEach(() => {
vi.clearAllMocks()
_resetWSLCache()
process.env = { ...originalEnv }
})

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true })
process.env = originalEnv
})

describe('isWSL', () => {
it('should return false on non-linux platforms', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', writable: true })
expect(isWSL()).toBe(false)
})

it('should return true when WSL_DISTRO_NAME is set', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
process.env.WSL_DISTRO_NAME = 'Ubuntu'
expect(isWSL()).toBe(true)
})

it('should fall back to /proc/version check', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
delete process.env.WSL_DISTRO_NAME
vi.mocked(readFileSync).mockReturnValue('Linux version 5.15.0 (microsoft-standard-WSL2)')
expect(isWSL()).toBe(true)
})

it('should return false when not WSL', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
delete process.env.WSL_DISTRO_NAME
vi.mocked(readFileSync).mockReturnValue('Linux version 6.8.0-90-generic')
expect(isWSL()).toBe(false)
})

it('should return false when /proc/version is not found (ENOENT)', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
delete process.env.WSL_DISTRO_NAME
const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException
err.code = 'ENOENT'
vi.mocked(readFileSync).mockImplementation(() => { throw err })
expect(isWSL()).toBe(false)
})

it('should return false when /proc/version throws an unexpected error', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
delete process.env.WSL_DISTRO_NAME
vi.mocked(readFileSync).mockImplementation(() => { throw new Error('unexpected failure') })
expect(isWSL()).toBe(false)
})

it('should cache the result', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
delete process.env.WSL_DISTRO_NAME
vi.mocked(readFileSync).mockReturnValue('Linux version 6.8.0-90-generic')

isWSL()
isWSL()

expect(readFileSync).toHaveBeenCalledTimes(1)
})
})

describe('detectTerminalEnvironment', () => {
it('should return darwin on macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', writable: true })
expect(detectTerminalEnvironment()).toBe('darwin')
})

it('should return win32 on Windows', () => {
Object.defineProperty(process, 'platform', { value: 'win32', writable: true })
expect(detectTerminalEnvironment()).toBe('win32')
})

it('should return wsl on WSL', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
process.env.WSL_DISTRO_NAME = 'Ubuntu'
expect(detectTerminalEnvironment()).toBe('wsl')
})

it('should return linux on native Linux', () => {
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
delete process.env.WSL_DISTRO_NAME
vi.mocked(readFileSync).mockReturnValue('Linux version 6.8.0-90-generic')
expect(detectTerminalEnvironment()).toBe('linux')
})

it('should return unsupported for unknown platforms', () => {
Object.defineProperty(process, 'platform', { value: 'freebsd', writable: true })
expect(detectTerminalEnvironment()).toBe('unsupported')
})
})

describe('detectWSLDistro', () => {
it('should return the distro name from env', () => {
process.env.WSL_DISTRO_NAME = 'Ubuntu-22.04'
expect(detectWSLDistro()).toBe('Ubuntu-22.04')
})

it('should return undefined when not set', () => {
delete process.env.WSL_DISTRO_NAME
expect(detectWSLDistro()).toBeUndefined()
})

it('should return undefined for empty string', () => {
process.env.WSL_DISTRO_NAME = ''
expect(detectWSLDistro()).toBeUndefined()
})
})
})
83 changes: 83 additions & 0 deletions src/utils/platform-detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { readFileSync } from 'node:fs'

/**
* Terminal environment types.
* 'darwin' = macOS, 'wsl' = Windows Subsystem for Linux, 'linux' = native Linux, 'win32' = native Windows
*/
export type TerminalEnvironment = 'darwin' | 'wsl' | 'linux' | 'win32' | 'unsupported'

let cachedIsWSL: boolean | undefined

/**
* Detect if running inside Windows Subsystem for Linux.
*
* Detection strategy (in order):
* 1. Check WSL_DISTRO_NAME env var (always set in WSL2, most reliable)
* 2. Fallback: read /proc/version for "microsoft" or "WSL" signature
*
* Result is cached to avoid repeated /proc reads.
*/
export function isWSL(): boolean {
if (cachedIsWSL !== undefined) {
return cachedIsWSL
}

if (process.platform !== 'linux') {
cachedIsWSL = false
return false
}

// Most reliable: WSL_DISTRO_NAME is always set in WSL2
if (process.env.WSL_DISTRO_NAME) {
cachedIsWSL = true
return true
}

// Fallback: check /proc/version for WSL signature
try {
const procVersion = readFileSync('/proc/version', 'utf-8')
cachedIsWSL = /microsoft|wsl/i.test(procVersion)
return cachedIsWSL
} catch (error: unknown) {
// /proc/version not found — not WSL
if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
cachedIsWSL = false
return false
}
// Unexpected error — assume not WSL
cachedIsWSL = false
return false
}
}

/**
* Detect the terminal environment, distinguishing WSL from plain Linux.
*/
export function detectTerminalEnvironment(): TerminalEnvironment {
const platform = process.platform
if (platform === 'darwin') return 'darwin'
if (platform === 'win32') return 'win32'
if (platform === 'linux') {
return isWSL() ? 'wsl' : 'linux'
}
return 'unsupported'
}

/**
* Get the WSL distribution name from the environment.
* Returns undefined when not running in WSL or when the variable is not set.
*/
export function detectWSLDistro(): string | undefined {
const distro = process.env.WSL_DISTRO_NAME
// Empty string means unset; nullish coalescing won't catch it
if (!distro) return undefined
return distro
}

/**
* Reset the cached WSL detection result.
* Exposed for testing only.
*/
export function _resetWSLCache(): void {
cachedIsWSL = undefined
}
Loading