Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,12 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# Disable "Co-authored-by" line in git commits made by OpenClaude
# OPENCLAUDE_DISABLE_CO_AUTHORED_BY=1

# Enable PowerShell tool on Windows as the default shell for ! commands and bash-mode.
# Without this, OpenClaude uses bash (Git Bash / WSL / MSYS2) on Windows.
# Set to 1/true to activate; set to 0/false to explicitly disable.
# Only takes effect on Windows; ignored on macOS and Linux.
# OPENCLAUDE_USE_POWERSHELL_TOOL=1

# Disable strict tool schema normalization for non-Gemini providers
# Useful when MCP tools with complex optional params (e.g. list[dict])
# trigger "Extra required key ... supplied" errors from OpenAI-compatible endpoints
Expand Down
3 changes: 2 additions & 1 deletion src/services/tips/tipRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,11 @@ const externalTips: Tip[] = [
{
id: 'powershell-tool-env',
content: async () =>
'Set CLAUDE_CODE_USE_POWERSHELL_TOOL=1 to enable the PowerShell tool (preview)',
'Set OPENCLAUDE_USE_POWERSHELL_TOOL=1 to enable the PowerShell tool (preview)',
cooldownSessions: 10,
isRelevant: async () =>
getPlatform() === 'windows' &&
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL === undefined &&
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL === undefined,
},
{
Expand Down
18 changes: 14 additions & 4 deletions src/utils/shell/resolveDefaultShell.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { isEnvTruthy } from '../envUtils.js'
import { getPlatform } from '../platform.js'
import { getInitialSettings } from '../settings/settings.js'
import { getPowershellToolEnv } from './shellToolUtils.js'

/**
* Resolve the default shell for input-box `!` commands.
*
* Resolution order (docs/design/ps-shell-selection.md §4.2):
* settings.defaultShell 'bash'
* settings.defaultShell -> (Windows + OPENCLAUDE_USE_POWERSHELL_TOOL) -> 'bash'
*
* Platform default is 'bash' everywhere — we do NOT auto-flip Windows to
* PowerShell (would break existing Windows users with bash hooks).
* Platform default is 'bash' on all platforms, unless the user has explicitly
* opted into PowerShell via OPENCLAUDE_USE_POWERSHELL_TOOL=true (or the legacy
* CLAUDE_CODE_USE_POWERSHELL_TOOL) on Windows.
* This restores the upstream behavior where setting the env var also makes
* PowerShell the default for ! commands without requiring a separate
* settings.defaultShell change.
*/
export function resolveDefaultShell(): 'bash' | 'powershell' {
return getInitialSettings().defaultShell ?? 'bash'
return getInitialSettings().defaultShell ??
(getPlatform() === 'windows' && isEnvTruthy(getPowershellToolEnv())
? 'powershell'
: 'bash')
}
209 changes: 209 additions & 0 deletions src/utils/shell/shellToolUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'

// ── env hygiene ────────────────────────────────────────────────────────────
const originalEnv = {
OPENCLAUDE_USE_POWERSHELL_TOOL: process.env.OPENCLAUDE_USE_POWERSHELL_TOOL,
CLAUDE_CODE_USE_POWERSHELL_TOOL: process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL,
USER_TYPE: process.env.USER_TYPE,
}

beforeEach(() => {
delete process.env.OPENCLAUDE_USE_POWERSHELL_TOOL
delete process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL
delete process.env.USER_TYPE
})

afterEach(() => {
if (originalEnv.OPENCLAUDE_USE_POWERSHELL_TOOL === undefined) {
delete process.env.OPENCLAUDE_USE_POWERSHELL_TOOL
} else {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = originalEnv.OPENCLAUDE_USE_POWERSHELL_TOOL
}
if (originalEnv.CLAUDE_CODE_USE_POWERSHELL_TOOL === undefined) {
delete process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL
} else {
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = originalEnv.CLAUDE_CODE_USE_POWERSHELL_TOOL
}
if (originalEnv.USER_TYPE === undefined) {
delete process.env.USER_TYPE
} else {
process.env.USER_TYPE = originalEnv.USER_TYPE
}
})

// ═══════════════════════════════════════════════════════════════════════════
// Tests that do NOT use mock.module (run first — no mock interference)
// ═══════════════════════════════════════════════════════════════════════════

// ── getPowershellToolEnv ──────────────────────────────────────────────────

describe('getPowershellToolEnv', () => {
test('returns undefined when neither env var is set', async () => {
const { getPowershellToolEnv } = await import('./shellToolUtils.js')
expect(getPowershellToolEnv()).toBeUndefined()
})

test('returns preferred when OPENCLAUDE_USE_POWERSHELL_TOOL is set', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
const { getPowershellToolEnv } = await import('./shellToolUtils.js')
expect(getPowershellToolEnv()).toBe('1')
})

test('returns legacy fallback when only CLAUDE_CODE_USE_POWERSHELL_TOOL is set', async () => {
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = 'true'
const { getPowershellToolEnv } = await import('./shellToolUtils.js')
expect(getPowershellToolEnv()).toBe('true')
})

test('preferred wins when both env vars are set', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = '0'
const { getPowershellToolEnv } = await import('./shellToolUtils.js')
expect(getPowershellToolEnv()).toBe('1')
})
})

// ── isPowerShellToolEnabled (Windows — process.platform === 'win32') ──────

describe('isPowerShellToolEnabled (Windows)', () => {
test('enabled when preferred env var is truthy (external user)', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(true)

Check failure on line 72 in src/utils/shell/shellToolUtils.test.ts

View workflow job for this annotation

GitHub Actions / smoke-and-tests

error: expect(received).toBe(expected)

Expected: true Received: false at <anonymous> (/home/runner/work/openclaude/openclaude/src/utils/shell/shellToolUtils.test.ts:72:39)
})

test('enabled when only legacy env var is truthy (external user)', async () => {
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = 'true'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(true)

Check failure on line 78 in src/utils/shell/shellToolUtils.test.ts

View workflow job for this annotation

GitHub Actions / smoke-and-tests

error: expect(received).toBe(expected)

Expected: true Received: false at <anonymous> (/home/runner/work/openclaude/openclaude/src/utils/shell/shellToolUtils.test.ts:78:39)
})

test('disabled when neither env var is set (external user)', async () => {
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(false)
})

test('disabled when preferred is falsy and legacy is unset (external user)', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '0'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(false)
})

test('enabled for ant user when env var is unset (default-on)', async () => {
process.env.USER_TYPE = 'ant'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(true)

Check failure on line 95 in src/utils/shell/shellToolUtils.test.ts

View workflow job for this annotation

GitHub Actions / smoke-and-tests

error: expect(received).toBe(expected)

Expected: true Received: false at <anonymous> (/home/runner/work/openclaude/openclaude/src/utils/shell/shellToolUtils.test.ts:95:39)
})

test('disabled for ant user when preferred is explicitly falsy', async () => {
process.env.USER_TYPE = 'ant'
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '0'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(false)
})

test('disabled for ant user when legacy is explicitly falsy and preferred is unset', async () => {
process.env.USER_TYPE = 'ant'
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = 'false'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(false)
})
})

// ── resolveDefaultShell (Windows — real platform) ─────────────────────────

describe('resolveDefaultShell (Windows)', () => {
test('returns bash by default on Windows without env var', async () => {
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('bash')
})

test('returns powershell when preferred env var is truthy on Windows', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
mock.module('../settings/settings.js', () => ({
getInitialSettings: () => ({}),
}))
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('powershell')

Check failure on line 127 in src/utils/shell/shellToolUtils.test.ts

View workflow job for this annotation

GitHub Actions / smoke-and-tests

error: expect(received).toBe(expected)

Expected: "powershell" Received: "bash" at <anonymous> (/home/runner/work/openclaude/openclaude/src/utils/shell/shellToolUtils.test.ts:127:35)
mock.module('../settings/settings.js', () => ({}))
})

test('returns powershell when only legacy env var is truthy on Windows', async () => {
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = 'true'
mock.module('../settings/settings.js', () => ({
getInitialSettings: () => ({}),
}))
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('powershell')

Check failure on line 137 in src/utils/shell/shellToolUtils.test.ts

View workflow job for this annotation

GitHub Actions / smoke-and-tests

error: expect(received).toBe(expected)

Expected: "powershell" Received: "bash" at <anonymous> (/home/runner/work/openclaude/openclaude/src/utils/shell/shellToolUtils.test.ts:137:35)
mock.module('../settings/settings.js', () => ({}))
})

test('preferred env var wins over legacy for default shell', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = '0'
mock.module('../settings/settings.js', () => ({
getInitialSettings: () => ({}),
}))
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('powershell')

Check failure on line 148 in src/utils/shell/shellToolUtils.test.ts

View workflow job for this annotation

GitHub Actions / smoke-and-tests

error: expect(received).toBe(expected)

Expected: "powershell" Received: "bash" at <anonymous> (/home/runner/work/openclaude/openclaude/src/utils/shell/shellToolUtils.test.ts:148:35)
mock.module('../settings/settings.js', () => ({}))
})

test('settings.defaultShell overrides env var', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
mock.module('../settings/settings.js', () => ({
getInitialSettings: () => ({ defaultShell: 'bash' as const }),
}))
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('bash')
mock.module('../settings/settings.js', () => ({}))
})

test('settings.defaultShell=powershell wins regardless of env var', async () => {
mock.module('../settings/settings.js', () => ({
getInitialSettings: () => ({ defaultShell: 'powershell' as const }),
}))
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('powershell')
mock.module('../settings/settings.js', () => ({}))
})
})

// ═══════════════════════════════════════════════════════════════════════════
// Tests that use mock.module for platform (MUST RUN LAST)
// mock.module in Bun cannot be reliably restored — it leaks across tests
// ═══════════════════════════════════════════════════════════════════════════

describe('isPowerShellToolEnabled (non-Windows)', () => {
test('returns false on macOS even with env var set', async () => {
mock.module('../platform.js', () => ({
getPlatform: () => 'macos',
}))
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(false)
})

test('returns false on Linux even with legacy env var set', async () => {
mock.module('../platform.js', () => ({
getPlatform: () => 'linux',
}))
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL = '1'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(false)
})
})

describe('resolveDefaultShell (non-Windows)', () => {
test('returns bash on non-Windows even with env var set', async () => {
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
mock.module('../platform.js', () => ({
getPlatform: () => 'macos',
}))
mock.module('../settings/settings.js', () => ({
getInitialSettings: () => ({}),
}))
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('bash')
})
})
17 changes: 15 additions & 2 deletions src/utils/shell/shellToolUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import { getPlatform } from '../platform.js'

export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME]

/**
* Resolve the PowerShell-tool env var with backward-compatible fallback.
*
* explicit OPENCLAUDE_USE_POWERSHELL_TOOL
* else fall back to CLAUDE_CODE_USE_POWERSHELL_TOOL (legacy)
*/
export function getPowershellToolEnv(): string | undefined {
return (
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL ??
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL
)
}

/**
* Runtime gate for PowerShellTool. Windows-only (the permission engine uses
* Win32-specific path normalizations). Ant defaults on (opt-out via env=0);
Expand All @@ -17,6 +30,6 @@ export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME]
export function isPowerShellToolEnabled(): boolean {
if (getPlatform() !== 'windows') return false
return process.env.USER_TYPE === 'ant'
? !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
: isEnvTruthy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
? !isEnvDefinedFalsy(getPowershellToolEnv())
: isEnvTruthy(getPowershellToolEnv())
}
Loading