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
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')
}
217 changes: 217 additions & 0 deletions src/utils/shell/shellToolUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
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
}
})

// ═══════════════════════════════════════════════════════════════════════════
// getPowershellToolEnv — platform-agnostic, no mocks needed
// ═══════════════════════════════════════════════════════════════════════════

// ── 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) ────────────────────────────────────

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

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

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

test('disabled when preferred is falsy and legacy is unset (external user)', async () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'windows' }))
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 () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'windows' }))
process.env.USER_TYPE = 'ant'
const { isPowerShellToolEnabled } = await import('./shellToolUtils.js')
expect(isPowerShellToolEnabled()).toBe(true)
})

test('disabled for ant user when preferred is explicitly falsy', async () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'windows' }))
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 () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'windows' }))
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) ─────────────────────────────────────────

describe('resolveDefaultShell (Windows)', () => {
test('returns bash by default on Windows without env var', async () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'windows' }))
mock.module('../settings/settings.js', () => ({
getInitialSettings: () => ({}),
}))
const { resolveDefaultShell } = await import('./resolveDefaultShell.js')
expect(resolveDefaultShell()).toBe('bash')
mock.module('../settings/settings.js', () => ({}))
})

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

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

test('preferred env var wins over legacy for default shell', async () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'windows' }))
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')
mock.module('../settings/settings.js', () => ({}))
})

test('settings.defaultShell overrides env var', async () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'windows' }))
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('../platform.js', () => ({ getPlatform: () => 'windows' }))
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', () => ({}))
})
})

// ── non-Windows 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 () => {
mock.module('../platform.js', () => ({ getPlatform: () => 'macos' }))
process.env.OPENCLAUDE_USE_POWERSHELL_TOOL = '1'
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())
}