This documents the full setup sequence for a professional development environment inside WSL2 Ubuntu, starting from a bare shell. It covers shell customization, Claude Code and its rule configuration, Git and the GitHub CLI, and the language and infrastructure toolchain.
Updated 2026-08-18: this walkthrough was revised after rebuilding the environment on a fresh machine. The Terraform install, AWS SSO configuration, pyenv toolchain, and jq were added or corrected to reflect what the setup actually requires.
The work happened in several distinct phases:
- Shell environment (ZSH + oh-my-zsh)
- System dependencies
- Claude Code installation
- Claude Code configuration and rules
- Git global config and GitHub CLI
- Version-controlling the
.claudeconfiguration - Toolchain additions (Terraform, Node.js, markdownlint)
- AWS CLI v2 and multi-org SSO
- Python toolchain (pyenv)
Phase 1 - Shell Environment (ZSH + oh-my-zsh)
The first thing to do on a fresh WSL Ubuntu install is replace bash with ZSH and layer oh-my-zsh on top for productivity. Syntax highlighting, git-aware prompts, plugin support, and tab completion that bash can’t match for daily work.
Install ZSH:
sudo apt update && sudo apt upgrade -y
sudo apt install zsh -y
Install oh-my-zsh:
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
Set ZSH as the default shell:
chsh -s $(which zsh)
After running
chsh, close and reopen your WSL terminal for the change to take effect. From this point forward, all shell configuration lives in~/.zshrc.
Phase 2 - System Dependencies
Install build dependencies:
sudo apt update
sudo apt install -y make build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \
libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev
Install jq, which this list missed:
sudo apt-get install -y jq
jqwent unnoticed until Claude Code’sUserPromptSubmithooks began printingjq: command not foundon every single prompt. Anything that pipes JSON through a shell hook assumesjqis on PATH, and a bare WSL image does not ship it. The failure was non-blocking, which is why it took a while to chase down rather than announcing itself immediately.
Phase 3 - Claude Code Installation
Claude Code is Anthropic’s agentic coding CLI. It runs in the terminal and can read, write, and reason about code in your project with full filesystem context. The install is a single curl command, but in a WSL environment there can be path and shell sourcing quirks that require a reload after install.
Install Claude Code:
curl -fsSL https://claude.ai/install.sh | bash
If
claudeis not found immediately after install, the installer added it to your PATH in~/.zshrcbut the current session hasn’t picked it up yet.
Reload your shell and verify:
source ~/.zshrc
which claude
claude
Phase 4 - Claude Code Configuration and Rules
Out of the box, Claude Code works, but its real power comes from custom rules - markdown files in ~/.claude/rules/ that provide persistent behavioral context: code standards, IAM review checklists, Git workflow expectations, WSL-specific notes, and more. These are global rules that apply across all projects.
Because this WSL environment is a secondary machine (the primary .claude config lives on the Windows side), the existing config was copied from the Windows profile into the WSL home directory rather than starting from scratch.
Copy existing .claude config from Windows profile into WSL:
cp -r /mnt/c/Users/<windows-user>/.claude ~/
Inspect the directory layout:
tree ~/.claude
Create and manage rule files:
# IAM review checklist
vim ~/.claude/rules/iam-review.md
# Copyright / licensing reminder
vim ~/.claude/rules/copywrite.md
# Code review standards
vim ~/.claude/rules/code-review.md
# WSL-specific bridge notes (Windows paths, interop behavior, etc.)
vim ~/.claude/rules/wsl-bridge.md
Housekeeping - rename and remove rules:
# Remove redundant Windows-specific rule now covered by wsl-bridge.md
rm ~/.claude/rules/windows.md
# Normalize filenames
mv ~/.claude/rules/git-workflow.md ~/.claude/rules/git.md
mv ~/.claude/rules/code-quality.md ~/.claude/rules/engineering.md
Move a project-local rule from global to repo-local:
mv ~/.claude/rules/code-review.md .claude/rules/
Verify configuration:
claude config list
claude mcp list
Phase 5 - Git Global Config and GitHub CLI
Before committing anything, Git needs a global identity. The GitHub CLI (gh) enables authenticated GitHub operations - repo creation, secret management, auth status - without managing personal access tokens manually.
Configure Git identity:
git config --global user.name "Brad Duhon"
git config --global user.email "your@email.com"
Install GitHub CLI (gh):
sudo apt update && sudo apt install curl gpg -y
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/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:
gh auth login
gh auth status
Phase 6 - Version-Control the .claude Configuration
Claude Code rules and config are valuable - they encode your workflow, standards, and context preferences. Versioning ~/.claude in GitHub means it’s portable, recoverable, and auditable. A .gitignore was set up first to ensure no credentials, tokens, or sensitive auth files are accidentally committed.
Initialize the repo:
cd ~/.claude
git init
git branch -M main
Create a security-conscious .gitignore:
cat > ~/.claude/.gitignore << 'EOF'
# Credentials and secrets
*credentials*
*secret*
*token*
*.key
*.pem
*.env
.env*
# Any JSON that might be auth-related
*auth*.json
*service-account*.json
# OS noise
.DS_Store
Thumbs.db
EOF
Add the GitHub remote and sync:
git remote add origin https://github.com/<your-username>/.claude.git
git branch --set-upstream-to=origin/main main
git pull --rebase origin main
Security note: A config repo is a tempting place for a stray token to hide. Keep the
.gitignoreahead of the first commit rather than adding it later, and treat anything that slipped in before it as exposed.
Phase 7 - Toolchain: Terraform, Node.js, markdownlint
Three additions: Terraform for infrastructure-as-code work, Node.js for JavaScript tooling and static-site builds, and markdownlint-cli to enforce markdown quality in documentation and Claude rules.
Terraform did not come from the HashiCorp apt source in the end. The repo pins its version in .terraform-version, so the install pulls that exact release and the local toolchain matches the pin instead of whatever apt happens to ship.
Create a user-local bin directory and put it on PATH:
mkdir -p ~/.local/bin
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
Download the pinned release:
curl -fsSLo /tmp/tf.zip https://releases.hashicorp.com/terraform/1.14.8/terraform_1.14.8_linux_amd64.zip
Extract it without unzip:
python3 -m zipfile -e /tmp/tf.zip ~/.local/bin/
chmod +x ~/.local/bin/terraform
A bare Ubuntu WSL image ships without
unzip, and Python’szipfilemodule extracts the archive with no package install and no sudo. It does not preserve the executable bit, so thechmod +xis required rather than optional.
Verify the binary matches the pin:
terraform version
cat .terraform-version
Install Node.js 22.x via NodeSource (avoids the outdated apt default):
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version
Install pnpm (workspace-aware package manager):
sudo npm install -g pnpm
Install markdownlint-cli:
sudo npm install -g markdownlint-cli
markdownlint --version
Phase 8 - AWS CLI v2 and Multi-Org SSO
Two AWS Organizations get reached from this shell, one personal and one corporate, and each runs its own IAM Identity Center. That shapes the whole config: the sso-session block is the organization, and profiles hang off it.
Install unzip, the one step that needs sudo:
sudo apt-get install -y unzip
The
python3 -m zipfilefallback from the Terraform install does not work here. The AWS CLI archive carries symlinks and executable bits that Python’szipfiledrops, sounzipis genuinely required rather than a convenience.
Download and extract the installer:
curl -fsSLo /tmp/awscliv2.zip https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip
unzip -q /tmp/awscliv2.zip -d /tmp
Install it user-local, no sudo:
/tmp/aws/install --install-dir ~/.local/aws-cli --bin-dir ~/.local/bin
aws --version
~/.local/bin went on PATH during the Terraform install, so aws resolves with no further shell changes.
Give each organization its own session block in ~/.aws/config:
[sso-session <org-a>]
sso_start_url = https://<org-a-subdomain>.awsapps.com/start
sso_region = <region>
sso_registration_scopes = sso:account:access
[sso-session <org-b>]
sso_start_url = https://<org-b-subdomain>.awsapps.com/start
sso_region = <region>
sso_registration_scopes = sso:account:access
[profile <org-a>-admin]
sso_session = <org-a>
sso_account_id = <ACCOUNT_ID>
sso_role_name = <PermissionSetName>
region = <region>
output = json
[profile <org-b>-admin]
sso_session = <org-b>
sso_account_id = <ACCOUNT_ID>
sso_role_name = <PermissionSetName>
region = <region>
output = json
Sessions are the organization switch: each Identity Center issues its own token, and the profile picks the account and role inside that organization. Add one profile block per account-and-role pair you actually use.
A start URL subdomain is chosen once and does not follow a later company rename, so an outdated name sitting in the corporate URL is expected rather than a mistake.
Where each value comes from:
sso_start_urlandsso_region: the IAM Identity Center settings page in the console, read once per organization.sso_account_idandsso_role_name: the access portal, which lists every account you can reach and the permission set names available under each.
Log in to one organization:
aws sso login --sso-session <org-a>
The command prints a device-authorization URL and waits. WSL has no browser of its own, so that URL gets pasted into the Windows browser and approved there. The CLI notices the approval and caches the token under ~/.aws/sso/cache.
Verify which identity a profile resolves to:
aws sts get-caller-identity --profile <org-a>-admin
Each session logs in separately and expires separately. A live token for one organization says nothing about the other, so aws sso login gets run once per sso-session name.
Phase 9 - Python Toolchain: pyenv
Ubuntu’s system Python is externally managed under PEP 668, so pip install against it is refused outright. Rather than fight that with --break-system-packages, pyenv supplies a user-owned interpreter where tooling dependencies install freely, and it pins a Python version per project on the way.
Install the build dependencies, the one step that needs sudo:
sudo apt-get install -y build-essential libssl-dev zlib1g-dev libbz2-dev \
libreadline-dev libsqlite3-dev libncursesw5-dev xz-utils tk-dev \
libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev git curl
pyenv compiles CPython from source rather than downloading a binary, which is where the header packages come from. Phase 2 already installed most of this list, so on this machine the command was mostly a no-op.
Install pyenv:
curl -fsSL https://pyenv.run | bash
That clones pyenv and the virtualenv plugin under ~/.pyenv. It does not touch the shell config, which is the next step.
Wire it into ~/.zshrc:
printf '\nexport PYENV_ROOT="$HOME/.pyenv"\nexport PATH="$PYENV_ROOT/bin:$PATH"\neval "$(pyenv init - zsh)"\neval "$(pyenv virtualenv-init -)"\n' >> ~/.zshrc
tail -5 ~/.zshrc
exec zsh
A heredoc is the natural tool for appending a block like this, and it failed twice here. A
<<'EOF'terminator has to sit at column 0, and paste handling in an interactive zsh (bracketed paste, prompt frameworks) can indent it, which leaves the shell sitting at aheredoc>prompt waiting for a terminator that never arrives. The printf one-liner has no terminator to break, andtail -5shows what actually landed before the shell restarts.
Install a Python, cut a working virtualenv, and make it the default:
pyenv install 3.14
pyenv virtualenv 3.14 aws-dev
pyenv global aws-dev
A bare 3.14 resolves to the newest 3.14 patch release pyenv knows about, so the exact patch comes from the version list rather than the command. Setting aws-dev as the global default means every new shell opens inside it already.
Install a package, no sudo and no managed-environment fight:
pip install boto3
python --version
The base 3.14 install stays clean. Project and tooling dependencies land in the aws-dev environment instead. pyenv activate aws-dev is the explicit form for when you have switched away from the default, and pyenv local aws-dev inside a project directory overrides the global for that directory and everything under it. boto3 is what the SSO profile generator needs.
ZSH maintenance
During setup, stale ZSH completion cache files caused warnings. These can be safely removed:
rm ~/.zcompdump-<hostname>-<version>.zwc
rm ~/.zcompdump-<hostname>-<version>
exec zsh
Tools installed
| Tool | Install method | Purpose |
|---|---|---|
| ZSH | apt | Default shell |
| oh-my-zsh | curl installer | Shell framework |
| Claude Code | claude.ai installer | Agentic coding CLI |
GitHub CLI (gh) | apt (GitHub source) | GitHub auth + repo operations |
| Terraform | pinned zip (releases.hashicorp.com) | Infrastructure as Code |
| Node.js 22.x | NodeSource | JS runtime for front-end tooling |
| pnpm | npm global | Workspace package manager |
| markdownlint-cli | npm global | Markdown linting |
| AWS CLI v2 | official zip installer (user-local) | AWS API access and SSO login |
| unzip | apt | Required by the AWS CLI installer |
| pyenv | curl installer | Python version manager |
| jq | apt | JSON processor for shell hooks |
| tree | apt | Directory visualization |
At this point the environment is ready to work in: a configured shell, an agentic CLI carrying its own versioned rules, authenticated Git and GitHub access, and the language and IaC toolchains on PATH. Everything past here is project work, which starts from a clean checkout rather than from a fresh machine.