Skip to content

Commit 27be676

Browse files
committed
Support for Claude in Docker.
1 parent 4257c3d commit 27be676

20 files changed

+564
-229
lines changed

.claude/settings.local.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
"Bash(dotnet build:*)",
1010
"Bash(winget search:*)",
1111
"Bash(git -C \"X:\\src\\PostSharp.Engineering\" diff --stat)",
12-
"Bash(git -C \"X:\\src\\PostSharp.Engineering\" log --oneline -20)"
12+
"Bash(git -C \"X:\\src\\PostSharp.Engineering\" log --oneline -20)",
13+
"WebSearch",
14+
"Bash(ForEach-Object { docker rmi $_ -f })",
15+
"Bash(docker images:*)",
16+
"Bash(findstr:*)",
17+
"Bash(docker run:*)",
18+
"Bash(.DockerBuild.ps1 -Claude -SkipBuild)",
19+
"Bash(dotnet restore:*)"
1320
],
1421
"deny": [],
1522
"ask": []

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ nuget.config
1515
global.json
1616
*.g.json
1717
*.g.ps1
18+
eng/docker-context/claude.json
1819
scripts/build-agents/last-execution.txt
1920
*.log
2021
copilot.data.*

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ The `postsharp-engineering` plugin provides skills and slash commands. To discov
88

99
Before any work in this repo, read these skills:
1010
- `$env:USERPROFILE\.claude\plugins\cache\postsharp-engineering\**\skills\*.md` - Engineering workflows (git, builds, CI/CD)
11-
11+
- Never update DockerBuild.ps1, Dockerfile. Dockerfile.claude, eng/RunClaude.ps1. These files are generated by `Build.ps1`. Their source code is in the Resources directory.

DockerBuild.ps1

Lines changed: 170 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ param(
99
[switch]$NoClean, # Does not clean up.
1010
[switch]$NoNuGetCache, # Does not mount the host nuget cache in the container.
1111
[switch]$KeepEnv, # Does not override the env.g.json file.
12-
[switch]$Claude, # Run Claude CLI instead of Build.ps1.
13-
[string]$ClaudePrompt, # Optional prompt for Claude (non-interactive mode).
12+
[switch]$Claude, # Run Claude CLI instead of Build.ps1. Use -Claude for interactive, -Claude "prompt" for non-interactive.
1413
[string]$ImageName, # Image name (defaults to a name based on the directory).
1514
[string]$BuildAgentPath = 'C:\BuildAgent',
1615
[switch]$LoadEnvFromKeyVault, # Forces loading environment variables form the key vault.
1716
[switch]$StartVsmon, # Enable the remote debugger.
1817
[string]$Script = 'Build.ps1', # The build script to be executed inside Docker.
1918
[Parameter(ValueFromRemainingArguments)]
20-
[string[]]$BuildArgs # Arguments passed to `Build.ps1` within the container.
19+
[string[]]$BuildArgs # Arguments passed to `Build.ps1` within the container (or Claude prompt if -Claude is specified).
2120
)
2221

2322
####
@@ -235,9 +234,9 @@ if (-not (Test-Path $dockerContextDirectory))
235234

236235

237236
# Prepare volume mappings
238-
$VolumeMappings = @("-v", "${SourceDirName}:${SourceDirName}")
239-
$MountPoints = @($SourceDirName, "c:\packages")
240-
$GitDirectories = @($SourceDirName)
237+
$VolumeMappings = @("-v", "${SourceDirName}:${SourceDirName}")
238+
$MountPoints = @($SourceDirName, "c:\packages")
239+
$GitDirectories = @($SourceDirName)
241240

242241
# Define static Git system directory for mapping. This used by Teamcity as an LFS parent repo.
243242
$gitSystemDir = "$BuildAgentPath\system\git"
@@ -321,6 +320,68 @@ if (Test-Path $dockerMountsScript)
321320
. $dockerMountsScript
322321
}
323322

323+
# Handle non-C: drive letters for Docker (Windows containers only have C: by default)
324+
# We mount X:\foo to C:\X\foo in the container, then use subst to create the X: drive
325+
$driveLetters = @{}
326+
327+
function Get-ContainerPath($hostPath)
328+
{
329+
if ($hostPath -match '^([A-Za-z]):(.*)$')
330+
{
331+
$driveLetter = $Matches[1].ToUpper()
332+
$pathWithoutDrive = $Matches[2]
333+
if ($driveLetter -ne 'C')
334+
{
335+
$driveLetters[$driveLetter] = $true
336+
return "C:\$driveLetter$pathWithoutDrive"
337+
}
338+
}
339+
return $hostPath
340+
}
341+
342+
# Transform all volume mappings to use container paths
343+
$transformedVolumeMappings = @()
344+
for ($i = 0; $i -lt $VolumeMappings.Count; $i += 2)
345+
{
346+
$flag = $VolumeMappings[$i]
347+
$mapping = $VolumeMappings[$i + 1]
348+
349+
# Parse volume mapping: hostPath:containerPath[:options]
350+
if ($mapping -match '^([A-Za-z]:\\[^:]*):([A-Za-z]:\\[^:]*)(:.+)?$')
351+
{
352+
$hostPath = $Matches[1]
353+
$containerPath = $Matches[2]
354+
$options = $Matches[3]
355+
$newContainerPath = Get-ContainerPath $containerPath
356+
$transformedVolumeMappings += @($flag, "${hostPath}:${newContainerPath}${options}")
357+
}
358+
else
359+
{
360+
$transformedVolumeMappings += @($flag, $mapping)
361+
}
362+
}
363+
$VolumeMappings = $transformedVolumeMappings
364+
365+
# Transform MountPoints, GitDirectories, and SourceDirName for the container
366+
$MountPoints = $MountPoints | ForEach-Object { Get-ContainerPath $_ }
367+
$GitDirectories = $GitDirectories | ForEach-Object { Get-ContainerPath $_ }
368+
$ContainerSourceDir = Get-ContainerPath $SourceDirName
369+
370+
# Build subst commands string for inline execution in docker run
371+
$substCommandsInline = ""
372+
foreach ($letter in $driveLetters.Keys | Sort-Object)
373+
{
374+
$substCommandsInline += "C:\Windows\System32\subst.exe ${letter}: C:\$letter; "
375+
}
376+
if ($driveLetters.Count -gt 0)
377+
{
378+
Write-Host "Drive letter mappings for container: $($driveLetters.Keys -join ', ')" -ForegroundColor Cyan
379+
}
380+
381+
# Create empty Init.g.ps1 for Dockerfile COPY (required by Dockerfile but not used for drive mapping)
382+
$initScript = Join-Path $dockerContextDirectory "Init.g.ps1"
383+
"# Drive mappings are handled inline in docker run command" | Set-Content -Path $initScript -Encoding UTF8
384+
324385
$mountPointsAsString = $MountPoints -Join ";"
325386
$gitDirectoriesAsString = $GitDirectories -Join ";"
326387

@@ -337,35 +398,35 @@ docker ps -q --filter "ancestor=$ImageTag" | ForEach-Object {
337398
# Building the image.
338399
if (-not $NoBuildImage)
339400
{
340-
Write-Host "Building the base image with tag: $ImageTag" -ForegroundColor Green
341-
Get-Content -Raw Dockerfile | docker build -t $ImageTag --build-arg GITDIRS="$gitDirectoriesAsString" --build-arg MOUNTPOINTS="$mountPointsAsString" -f - $dockerContextDirectory
342-
if ($LASTEXITCODE -ne 0)
343-
{
344-
Write-Host "Docker build failed with exit code $LASTEXITCODE" -ForegroundColor Red
345-
exit $LASTEXITCODE
346-
}
347-
348-
# Build Claude image if requested
349401
if ($Claude)
350402
{
351-
$ClaudeImageTag = "$ImageTag-claude"
352-
Write-Host "Building the Claude image with tag: $ClaudeImageTag" -ForegroundColor Green
403+
# Build Claude image directly from standalone Dockerfile.claude
404+
$ImageTag = "$ImageTag-claude"
405+
Write-Host "Building the Claude image with tag: $ImageTag" -ForegroundColor Green
353406

354407
if (-not (Test-Path "Dockerfile.claude"))
355408
{
356-
Write-Error "Dockerfile.claude not found. Make sure generate-scripts was run with a NodeJs component."
409+
Write-Error "Dockerfile.claude not found. Make sure generate-scripts was run with Claude support."
357410
exit 1
358411
}
359412

360-
Get-Content -Raw Dockerfile.claude | docker build -t $ClaudeImageTag --build-arg BASE_IMAGE="$ImageTag" -f - $dockerContextDirectory
413+
Get-Content -Raw Dockerfile.claude | docker build -t $ImageTag --build-arg GITDIRS="$gitDirectoriesAsString" --build-arg MOUNTPOINTS="$mountPointsAsString" -f - $dockerContextDirectory
361414
if ($LASTEXITCODE -ne 0)
362415
{
363416
Write-Host "Docker build (Claude) failed with exit code $LASTEXITCODE" -ForegroundColor Red
364417
exit $LASTEXITCODE
365418
}
366-
367-
# Use Claude image for the run
368-
$ImageTag = $ClaudeImageTag
419+
}
420+
else
421+
{
422+
# Build base image
423+
Write-Host "Building the base image with tag: $ImageTag" -ForegroundColor Green
424+
Get-Content -Raw Dockerfile | docker build -t $ImageTag --build-arg GITDIRS="$gitDirectoriesAsString" --build-arg MOUNTPOINTS="$mountPointsAsString" -f - $dockerContextDirectory
425+
if ($LASTEXITCODE -ne 0)
426+
{
427+
Write-Host "Docker build failed with exit code $LASTEXITCODE" -ForegroundColor Red
428+
exit $LASTEXITCODE
429+
}
369430
}
370431
}
371432
else
@@ -383,7 +444,87 @@ else
383444
# Run the build within the container
384445
if (-not $BuildImage)
385446
{
447+
if ($Claude)
448+
{
449+
# Run Claude mode
450+
Write-Host "Running Claude in the container." -ForegroundColor Green
386451

452+
# Add Claude-specific volume mounts for auth and settings
453+
$hostUserProfile = $env:USERPROFILE
454+
$containerUserProfile = "C:\Users\ContainerUser"
455+
456+
# Mount .claude directory (settings and credentials)
457+
if (Test-Path "$hostUserProfile\.claude")
458+
{
459+
$VolumeMappings += @("-v", "${hostUserProfile}\.claude:${containerUserProfile}\.claude")
460+
}
461+
462+
# Copy .claude.json to docker-context (cannot mount files on Windows Docker)
463+
# Also fix installMethod to match container's npm installation
464+
$claudeJsonSource = "$hostUserProfile\.claude.json"
465+
$claudeJsonDest = Join-Path $dockerContextDirectory "claude.json"
466+
$copyClaudeJsonScript = ""
467+
if (Test-Path $claudeJsonSource)
468+
{
469+
$claudeConfig = Get-Content $claudeJsonSource -Raw | ConvertFrom-Json
470+
# Change installMethod to npm since that's how Claude is installed in container
471+
if ($claudeConfig.installMethod)
472+
{
473+
$claudeConfig.installMethod = "npm"
474+
}
475+
$claudeConfig | ConvertTo-Json -Depth 10 | Set-Content $claudeJsonDest -Encoding UTF8
476+
# Will copy from mounted source dir to user profile in container
477+
$copyClaudeJsonScript = "Copy-Item '$ContainerSourceDir\eng\docker-context\claude.json' '$containerUserProfile\.claude.json' -Force; "
478+
}
479+
480+
# Mount .cache\claude (cache)
481+
if (Test-Path "$hostUserProfile\.cache\claude")
482+
{
483+
$VolumeMappings += @("-v", "${hostUserProfile}\.cache\claude:${containerUserProfile}\.cache\claude")
484+
}
485+
486+
$VolumeMappingsAsString = $VolumeMappings -join " "
487+
488+
# Extract Claude prompt from remaining arguments if present
489+
# Usage: -Claude for interactive, -Claude "prompt" for non-interactive
490+
$ClaudePrompt = $null
491+
if ($BuildArgs -and $BuildArgs.Count -gt 0 -and $BuildArgs[0] -and -not $BuildArgs[0].StartsWith('-'))
492+
{
493+
$ClaudePrompt = $BuildArgs[0]
494+
}
495+
496+
# Build inline script: subst drives, copy claude.json, cd to source, run Claude
497+
if ($ClaudePrompt)
498+
{
499+
# Non-interactive mode with prompt - no -it flags
500+
$dockerArgs = @()
501+
$inlineScript = "${substCommandsInline}${copyClaudeJsonScript}cd '$SourceDirName'; & .\eng\RunClaude.g.ps1 -Prompt `"$ClaudePrompt`""
502+
}
503+
else
504+
{
505+
# Interactive mode - requires TTY
506+
$dockerArgs = @("-it")
507+
$inlineScript = "${substCommandsInline}${copyClaudeJsonScript}cd '$SourceDirName'; & .\eng\RunClaude.g.ps1"
508+
}
509+
510+
$dockerArgsAsString = $dockerArgs -join " "
511+
$pwshPath = 'C:\Program Files\PowerShell\7\pwsh.exe'
512+
513+
# Set HOME/USERPROFILE so Claude finds its config in the mounted location
514+
$envArgs = @("-e", "HOME=$containerUserProfile", "-e", "USERPROFILE=$containerUserProfile")
515+
516+
Write-Host "Executing: ``docker run --rm --memory=12g $dockerArgsAsString $VolumeMappingsAsString -e HOME=$containerUserProfile -e USERPROFILE=$containerUserProfile -w $ContainerSourceDir $ImageTag `"$pwshPath`" -Command `"$inlineScript`"" -ForegroundColor Cyan
517+
docker run --rm --memory=12g $dockerArgs @VolumeMappings @envArgs -w $ContainerSourceDir $ImageTag $pwshPath -Command $inlineScript
518+
519+
if ($LASTEXITCODE -ne 0)
520+
{
521+
Write-Host "Docker run (Claude) failed with exit code $LASTEXITCODE" -ForegroundColor Red
522+
exit $LASTEXITCODE
523+
}
524+
}
525+
else
526+
{
527+
# Run standard build mode
387528
# Delete now and not in the container because it's much faster and lock error messages are more relevant.
388529
Write-Host "Building the product in the container." -ForegroundColor Green
389530

@@ -404,23 +545,26 @@ if (-not $BuildImage)
404545
{
405546
$pwshArgs = "-NonInteractive"
406547
$dockerArgs = @()
407-
$pwshExitCommand = "exit `$LASTEXITCODE`;"
548+
$pwshExitCommand = "exit `$LASTEXITCODE`;"
408549
}
409550

410551
$buildArgsString = $BuildArgs -join " "
411552
$VolumeMappingsAsString = $VolumeMappings -join " "
412553
$dockerArgsAsString = $dockerArgs -join " "
413554

555+
# Build inline script: subst drives, cd to source, run build
556+
$inlineScript = "${substCommandsInline}cd '$SourceDirName'; & .\$Script $buildArgsString; $pwshExitCommand"
414557

415-
Write-Host "Executing: ``docker run --rm --memory=12g $dockerArgsAsString $VolumeMappingsAsString -w $SourceDirName $ImageTag pwsh $pwshArgs -Command `"& .\$Script $buildArgsString`; $pwshExitCommand`"" -ForegroundColor Cyan
558+
$pwshPath = 'C:\Program Files\PowerShell\7\pwsh.exe'
559+
Write-Host "Executing: ``docker run --rm --memory=12g $dockerArgsAsString $VolumeMappingsAsString -w $ContainerSourceDir $ImageTag `"$pwshPath`" $pwshArgs -Command `"$inlineScript`"" -ForegroundColor Cyan
416560

417-
docker run --rm --memory=12g $dockerArgs @VolumeMappings -w $SourceDirName @dockerArgs $ImageTag pwsh $pwshArgs -Command "& .\$Script $buildArgsString`; $pwshExitCommand; "
561+
docker run --rm --memory=12g $dockerArgs @VolumeMappings -w $ContainerSourceDir $ImageTag $pwshPath $pwshArgs -Command $inlineScript
418562
if ($LASTEXITCODE -ne 0)
419563
{
420564
Write-Host "Docker run (build) failed with exit code $LASTEXITCODE" -ForegroundColor Red
421565
exit $LASTEXITCODE
422566
}
423-
567+
}
424568
}
425569
else
426570
{

Dockerfile

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
FROM mcr.microsoft.com/windows/servercore:ltsc2025
66

7-
# The initial shell is PowerShell Desktop.
8-
SHELL ["powershell", "-Command"]
7+
# The initial shell is Windows PowerShell (use full path to avoid HCS issues)
8+
SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Command"]
99

1010
# Prepare environment
1111
ENV PSExecutionPolicyPreference=Bypass
@@ -14,41 +14,47 @@ ENV TEMP=C:\Temp
1414
ENV TMP=C:\Temp
1515
ENV RUNNING_IN_DOCKER=TRUE
1616

17+
# Add Windows PowerShell to PATH (pwsh added later by PowershellComponent)
18+
ENV PATH="C:\Windows\System32\WindowsPowerShell\v1.0;${PATH}"
19+
1720
# Enable long path support
1821
RUN Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1
1922

2023

2124

2225
# Install Git
23-
RUN Invoke-WebRequest -Uri https://github.com/git-for-windows/git/releases/download/v2.50.0.windows.1/MinGit-2.50.0-64-bit.zip -OutFile MinGit.zip; `
24-
Expand-Archive c:\\MinGit.zip -DestinationPath C:\\git; `
25-
Remove-Item C:\\MinGit.zip; `
26-
$pathsToAdd = @('C:\git\cmd', 'C:\git\bin', 'C:\git\usr\bin'); `
27-
$newPath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + ($pathsToAdd -join ';'); `
28-
[Environment]::SetEnvironmentVariable('PATH', $newPath, 'Machine');
29-
30-
RUN "C:\Git\cmd\git.exe" config --system core.longpaths true
26+
RUN Invoke-WebRequest -Uri https://github.com/git-for-windows/git/releases/download/v2.50.0.windows.1/PortableGit-2.50.0-64-bit.7z.exe -OutFile PortableGit.exe; `
27+
Start-Process -FilePath .\PortableGit.exe -ArgumentList '-o"C:\git"', '-y' -Wait; `
28+
Remove-Item PortableGit.exe
29+
30+
# Add git to PATH using ENV directive (persists across shell switches)
31+
ENV PATH="C:\git\cmd;C:\git\bin;C:\git\usr\bin;${PATH}"
32+
33+
RUN git config --system core.longpaths true
34+
35+
# Set CLAUDE_CODE_GIT_BASH_PATH for Claude Code
36+
ENV CLAUDE_CODE_GIT_BASH_PATH=C:\git\bin\bash.exe
3137

3238

3339
# Install PowerShell 7
3440
RUN Invoke-WebRequest -Uri https://github.com/PowerShell/PowerShell/releases/download/v7.5.2/PowerShell-7.5.2-win-x64.msi -OutFile PowerShell.msi; `
3541
$process = Start-Process msiexec.exe -Wait -PassThru -ArgumentList '/I PowerShell.msi /quiet'; `
3642
if ($process.ExitCode -ne 0) { exit $process.ExitCode }; `
37-
Remove-Item PowerShell.msi; `
38-
$pathsToAdd = @('C:\Program Files\PowerShell\7'); `
39-
$newPath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + ($pathsToAdd -join ';'); `
40-
[Environment]::SetEnvironmentVariable('PATH', $newPath, 'Machine');
43+
Remove-Item PowerShell.msi
44+
45+
# Add PowerShell 7 to PATH using ENV directive (persists across shell switches)
46+
ENV PATH="C:\Program Files\PowerShell\7;${PATH}"
4147

4248

4349
# Download .NET Installer
44-
RUN Invoke-WebRequest -Uri https://dot.net/v1/dotnet-install.ps1 -OutFile dotnet-install.ps1; `
45-
$pathsToAdd = @('C:\Program Files\dotnet'); `
46-
$newPath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + ($pathsToAdd -join ';'); `
47-
[Environment]::SetEnvironmentVariable('PATH', $newPath, 'Machine');
50+
RUN Invoke-WebRequest -Uri https://dot.net/v1/dotnet-install.ps1 -OutFile dotnet-install.ps1
51+
52+
# Add .NET to PATH using ENV directive (persists across shell switches)
53+
ENV PATH="C:\Program Files\dotnet;${PATH}"
4854

4955

5056
# Install .NET Sdk 9.0.305
51-
RUN powershell -ExecutionPolicy Bypass -File dotnet-install.ps1 -Version 9.0.305 -InstallDir 'C:\Program Files\dotnet';
57+
RUN & .\dotnet-install.ps1 -Version 9.0.305 -InstallDir 'C:\Program Files\dotnet'
5258

5359

5460
# Epilogue
@@ -65,9 +71,12 @@ RUN if ($env:MOUNTPOINTS) { `
6571
}
6672

6773
# Import environment variables
68-
COPY ReadEnvironmentVariables.ps1 c:\ReadEnvironmentVariables.ps1
74+
COPY ReadEnvironmentVariables.ps1 c:\ReadEnvironmentVariables.ps1
6975
COPY env.g.json c:\env.g.json
70-
RUN c:\ReadEnvironmentVariables.ps1 c:\env.g.json
76+
RUN c:\ReadEnvironmentVariables.ps1 c:\env.g.json
77+
78+
# Copy Init.g.ps1 placeholder (drive mappings handled inline in docker run)
79+
COPY Init.g.ps1 c:\Init.g.ps1
7180

7281
# Configure NuGet
7382
ENV NUGET_PACKAGES=c:\packages

0 commit comments

Comments
 (0)