All writing

Opinion: The Ultimate Lean VS Code + AI Setup for Modern TypeScript Development

You're also free to read this article on Medium.

The most impressive development environments usually look terrible to work in.

You know the screenshots: six status-bar widgets, animated terminals, forty-seven extensions, three AI sidebars, eight colorized Git indicators, and a prompt that reports Kubernetes context, battery percentage, lunar phase, and probably the current price of Bitcoin. It looks like the bridge of a starship. Then you try to open a TypeScript monorepo and spend ten minutes figuring out which extension is responsible for the new red underline.

My ideal setup in 2026 is built around a fairly ordinary stack—macOS, VS Code, zsh, Next.js, React, strict TypeScript, Tailwind CSS, MongoDB, Vitest, pnpm, GitHub, and Vercel—but it treats AI as a first-class development tool. The goal is not to turn the editor into a casino. The goal is to make the entire workstation feel unusually fast, predictable, reproducible, and capable.

The core idea is simple: VS Code is the cockpit, the terminal is the engine room, and AI agents are tools with boundaries.

That last part matters. The best AI development setup is not the one that gives an agent permission to do everything. It is the one that gives you several good ways to delegate work while preserving visibility into what changed, why it changed, and whether it actually works.

VS Code Should Stay Boring in the Best Possible Way

I still like VS Code because it gets out of the way when configured properly. It understands TypeScript exceptionally well, has mature Git and debugging support, handles remote and container workflows, and now supports an increasingly capable agent ecosystem without forcing me to replace the editor itself.

That is important because I do not want to rebuild years of editor muscle memory every time a new AI product becomes fashionable.

The first rule is aggressively simple: keep the extension count low.

A developer can easily install fifty extensions because each one appears useful in isolation. The problem is cumulative. Extensions register language servers, file watchers, decorations, commands, background processes, source-control providers, completion engines, save hooks- the list goes on. Eventually the editor starts doing work you did not explicitly ask it to do.

For a modern React/Next.js/TypeScript setup, my permanent extension list is intentionally small:

  1. ESLint (dbaeumer.vscode-eslint) — workspace-aware lint feedback and explicit fix actions. The extension uses the ESLint package in the project when available, which is exactly what I want.
  2. Tailwind CSS IntelliSense (bradlc.vscode-tailwindcss) — completion, hover information, syntax support, and linting for Tailwind projects.
  3. GitHub Pull Requests and Issues (GitHub.vscode-pull-request-github) — useful when I want PR context without living in a browser.
  4. GitLens (eamodio.gitlens) — history, blame, worktrees, comparisons, and increasingly useful visibility into agent activity.
  5. Error Lens (usernamehw.errorlens) — puts diagnostics directly where I am already looking instead of making me repeatedly inspect the Problems panel.
  6. Pretty TypeScript Errors (yoavbls.pretty-ts-errors) — turns some of TypeScript’s more theatrical error messages into something a human can parse.
  7. MongoDB for VS Code (mongodb.mongodb-vscode) — schema inspection, collections, indexes, documents, and playgrounds without leaving the editor.
  8. Playwright Test for VS Code (ms-playwright.playwright) — excellent when end-to-end/browser testing is part of the project.
  9. EditorConfig (EditorConfig.EditorConfig) — boring, tiny, and useful for keeping editor behavior consistent across repositories.
  10. Container Tools (ms-azuretools.vscode-containers) — Microsoft’s current container-management extension, replacing the old Docker extension’s core functionality and adding Podman support.
  11. GitHub Copilot (GitHub.copilot) — still useful for inline completions and VS Code-native agent workflows. Installing Copilot also brings its conversational companion into the editor.
  12. Codex (openai.chatgpt) — OpenAI’s coding-agent extension.
  13. Claude Code (anthropic.claude-code) — Anthropic’s editor integration for Claude Code, including agentic workflows, subagents, commands, and MCP.

That is enough. I do not need a bracket-colorizer because VS Code already does it. I do not need five Git extensions. I do not install formatters globally just because everyone else does; the repository should determine formatting. I do not install an icon pack unless I genuinely enjoy looking at it.

Assuming you have the code shell command in your PATH, you can install the entire set from the terminal. If you don't, go into VSCode -> Comman + Shift + P -> Shell Command: Install 'code' command in PATH (mac).

Then:

code --install-extension dbaeumer.vscode-eslint
code --install-extension bradlc.vscode-tailwindcss
code --install-extension GitHub.vscode-pull-request-github
code --install-extension eamodio.gitlens
code --install-extension usernamehw.errorlens
code --install-extension yoavbls.pretty-ts-errors
code --install-extension mongodb.mongodb-vscode
code --install-extension ms-playwright.playwright
code --install-extension EditorConfig.EditorConfig
code --install-extension ms-azuretools.vscode-containers
code --install-extension GitHub.copilot
code --install-extension openai.chatgpt
code --install-extension anthropic.claude-code

The point is not that everyone needs all thirteen. If you never use MongoDB, do not install the MongoDB extension. If your project has no containers, skip Container Tools. A lean setup is defined as much by what you refuse to install as what you add.

Profiles Are Better Than One Giant Global Configuration

VS Code Profiles are one of the most useful features for developers who work across different kinds of projects. A profile can carry its own settings, extensions, UI layout, snippets, and related configuration. VS Code also lets you launch a folder directly into a named profile from the command line.

For example:

code ~/Development/my-app --profile "Product Engineering"

I like separate profiles for general engineering, web development, experimental AI tooling, and corporate work. This prevents a useful-but-noisy experimental extension from appearing everywhere, and it gives me a clean security boundary between personal and employer-controlled repositories.

It also makes experimentation cheap. Want to try some ridiculous new autonomous coding extension? Put it in an AI Lab profile. If it behaves badly, delete the profile. Your daily environment remains boring.

Boring is underrated.

Install the Workstation With Homebrew, Then Stop Thinking About It

On macOS, Homebrew is still the cleanest way I know to turn workstation setup into something reproducible. VS Code, iTerm2, Codex, Claude Code, JetBrains Mono Nerd Font, Atuin, Starship, ripgrep, fd, fzf, zoxide, bat, eza, lazygit, delta, jq, yq, and the Vercel CLI are all available through Homebrew. The current casks also expose codex and claude directly on the command line.

Here is the bulk install I would use on a new Mac:

brew install \
  atuin \
  bat \
  eza \
  fd \
  fzf \
  gh \
  git-delta \
  jq \
  lazygit \
  ripgrep \
  starship \
  yq \
  zoxide \
  vercel

brew install --cask \
  visual-studio-code \
  iterm2 \
  codex \
  claude-code \
  font-jetbrains-mono-nerd-font

Those package names are intentionally uninteresting. ripgrep is a fast search tool, fd is a friendlier file finder, bat is a nicer cat, eza is a maintained replacement for ls, git-delta makes diffs readable, and lazygit provides a very good terminal UI for the moments when a visual Git workflow is faster.

The Vercel CLI is currently available simply as:

brew install vercel

Homebrew exposes both vercel and vc.

For JavaScript projects, I prefer the repository to declare its expected Node and package-manager versions rather than allowing my workstation to silently dictate them. With pnpm, that means committing the packageManager field in package.json and letting the project remain authoritative.

Make zsh Useful Without Turning It Into Performance Art

I use zsh because it ships naturally with modern macOS and because its ecosystem is mature. I do not use an enormous shell framework. Shell startup should be fast enough that I never think about shell startup.

Starship gives me the useful prompt context I actually care about—directory, Git branch, dirty state, Node version, package version, and command duration—without forcing the rest of my shell configuration into a theme framework. Homebrew installs it with brew install starship.

Then:

eval "$(starship init zsh)"

Zoxide is the navigation tool I wish cd had always been. Install it with Homebrew and initialize it once:

brew install zoxide
eval "$(zoxide init zsh)"

After it learns where I work, this:

z personal-site

is often enough to jump directly into a deeply nested repository.

fzf fills the fuzzy-search role. Its official project supports Homebrew directly:

brew install fzf

On current fzf releases, I enable zsh integration with:

source <(fzf --zsh)

That gives me fuzzy file selection, command-history workflows, and a foundation for tiny custom project pickers.

Atuin Turns Shell History Into Actual Engineering Memory

Atuin may be my favorite part of this setup because it improves something developers use constantly and rarely question: shell history.

Traditional shell history is basically a text file containing old commands. Atuin stores richer history and gives it an excellent search interface. It can be installed via Homebrew with brew install atuin, and its documentation provides the zsh initialization command directly.

eval "$(atuin init zsh)"

Then import existing history:

atuin import auto

Now Ctrl-R stops feeling like archaeology.

The deeper reason this matters in an AI-oriented environment is that command history is operational knowledge. Which command successfully ran the integration suite? How did I launch the local database three weeks ago? What did I use to inspect that port conflict? What was the exact deployment command for this repository?

When your history is structured and searchable, both you and suitably constrained tooling have a much better record of what actually worked.

That feels much more useful to me than adding another AI chat window.

Codex and Claude Code Belong in the Terminal

The biggest change in my own development workflow is that terminal AI has become normal.

Codex installs with:

brew install --cask codex

and launches with:

codex

Homebrew describes the cask exactly as what it is: OpenAI’s coding agent running in your terminal.

Claude Code is equally simple:

brew install --cask claude-code

then:

claude

The Homebrew cask exposes the claude binary directly, while the VS Code extension can integrate Claude with your current editor context.

I like having both installed because I do not treat models as religions. They are engineering tools with overlapping capabilities and different behavior. For a difficult architecture question, I may ask one to analyze the design and the other to critique the proposed implementation. For a mechanical refactor, I might use whichever agent is already sitting in the correct working directory.

The key is to give them tasks, not vibes.

This is useful:

Run the failing tests, identify the root cause, fix only the defect,
and rerun the affected tests.

Do not modify unrelated behavior.
Do not commit or push anything.

This is much less useful:

Fix my app.

The quality of an agentic workflow is heavily influenced by the quality of its boundaries.

Put Those Boundaries in the Repository

Repeating the same instructions in every AI session is a waste of time. A repository should explain how it expects to be worked on.

For Codex and other agent-aware workflows, I like a small AGENTS.md at the repository root:

# Engineering instructions

## Package management
Use pnpm exclusively.

## Architecture
Use React Server Components by default.
Keep provider integrations behind service boundaries.
Do not bypass server-side authorization checks.

## Changes
Do not modify unrelated files.
Preserve existing visual behavior unless explicitly requested.

## Validation
Run typecheck, tests, and build before considering work complete.

## Git
Do not commit, push, merge, or deploy unless explicitly requested.

The exact file format matters less than the principle.

An agent entering a repository should not have to reverse-engineer every non-obvious rule from code. Humans get onboarding documentation, architecture decision records, lint rules, CI, and code review. Agents should get machine-readable working instructions too.

I expect these files to become as ordinary as .editorconfig.

Build Tiny Commands Around the Way You Actually Work

Dotfiles become valuable when they stop being a collection of visual preferences and start encoding repeated behavior.

For example, I use aliases like:

alias p='pnpm'
alias pi='pnpm install'
alias pif='pnpm install --frozen-lockfile'
alias pd='pnpm dev'
alias pt='pnpm test'
alias pb='pnpm build'

alias gs='git status --short --branch'
alias gd='git diff'
alias gds='git diff --staged'
alias lg='lazygit'

Nothing clever is happening there. That is exactly why they survive.

I also like a generic project quality function that reads the current package.json and runs only scripts that actually exist:

quality() {
    [[ -f package.json ]] || {
        echo "No package.json found."
        return 1
    }

    for script in typecheck lint test build; do
        if node -e \
          "const p=require('./package.json'); process.exit(p.scripts?.['$script'] ? 0 : 1)"
        then
            echo "==> pnpm run $script"
            pnpm run "$script" || return 1
        fi
    done
}

Now I can type:

quality

without needing every repository to expose exactly the same omnibus script.

The same idea works beautifully for AI:

ai() {
    if command -v codex >/dev/null 2>&1; then
        codex "$@"
        return
    fi

    claude "$@"
}

Or a narrowly scoped review command:

ai-review() {
    codex exec \
      "Review the current git diff as a principal engineer.
       Do not modify files. Identify correctness issues,
       regressions, security problems, unnecessary changes,
       and missing tests."
}

A good shell function should save thought, not demonstrate clever shell programming.

Git Worktrees Make Parallel Agents Much Less Chaotic

Once you start using multiple coding agents, git worktree suddenly becomes far more interesting.

Instead of letting two agents stomp around the same working directory, give each one its own checkout and branch:

git worktree add -b feature/auth-cleanup ../auth-cleanup
git worktree add -b feature/test-coverage ../test-coverage

Now one terminal can run Codex in the first worktree and another can run Claude in the second. The agents do not share uncommitted files. You can inspect each diff independently and throw away an experiment without disturbing your primary checkout.

GitLens has leaned further into worktree and agent visibility as well, which is one reason it remains on my short extension list.

This is the point where “AI pair programming” starts looking less like autocomplete and more like supervising parallel engineering work.

Keep the Editor Configuration Deliberately Plain

My VS Code settings are not interesting enough to deserve a YouTube thumbnail, which I consider a success.

A reasonable foundation looks like this:

{
    "editor.fontFamily": "JetBrainsMono Nerd Font, JetBrains Mono, monospace",
    "editor.fontLigatures": true,
    "editor.fontSize": 14,
    "editor.lineHeight": 22,
    "editor.minimap.enabled": false,
    "editor.stickyScroll.enabled": true,
    "editor.renderWhitespace": "selection",
    "editor.bracketPairColorization.enabled": true,
    "editor.guides.bracketPairs": true,
    "editor.formatOnSave": false,
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": "explicit"
    },
    "files.trimTrailingWhitespace": true,
    "files.insertFinalNewline": true,
    "git.autofetch": true,
    "git.confirmSync": false,
    "terminal.integrated.defaultProfile.osx": "zsh",
    "terminal.integrated.scrollback": 20000
}

I intentionally leave formatOnSave off globally. Some repositories use Prettier, some use ESLint, some use another formatter, and some contain generated or deliberately formatted files. The repository should decide. Global configuration should be conservative.

The JetBrains Mono Nerd Font is available as a Homebrew cask, which is convenient because it gives Starship and terminal tools access to Nerd Font glyphs without a separate manual font-installation ritual.

MCP Is the Difference Between an Agent and a Fancy Autocomplete Engine

Model Context Protocol is where this entire setup becomes more than “AI inside an editor.”

MCP is an open standard for connecting models to external tools, services, and data. In VS Code, MCP servers can expose tools for things such as file systems, databases, browsers, APIs, and documentation. That means an agent can work with structured capabilities instead of relying on copied-and-pasted context.

The important word is structured.

If I am working with a fast-moving API, I would rather give the agent access to the vendor’s current documentation than trust model memory. OpenAI, for example, publishes a read-only developer-documentation MCP server. Codex can add it directly from the terminal:

codex mcp add openaiDeveloperDocs \
  --url https://developers.openai.com/mcp

codex mcp list

The same server can be configured for VS Code through .vscode/mcp.json:

{
    "servers": {
        "openaiDeveloperDocs": {
            "type": "http",
            "url": "https://developers.openai.com/mcp"
        }
    }
}

OpenAI’s server provides documentation search and page content only; it does not call the API on your behalf. That is exactly the sort of MCP integration I like because the permission boundary is obvious.

That last point is worth keeping. MCP makes it technically possible to connect increasingly powerful services to agents, but “possible” is not the same thing as “good default.” I prefer documentation and read-only data access first. Write access to databases, deployment systems, billing providers, or production infrastructure should be deliberate, authenticated, and constrained.

AI tooling gets more useful when it can reach the right context. It gets more dangerous when it can reach everything.

Put the Whole Thing in a Dotfiles Repository

This is where the setup stops being a one-time afternoon project and becomes infrastructure.

A good dotfiles repository might contain:

dotfiles/
├── Brewfile
├── install.sh
├── doctor.sh
├── shell/
│   ├── .zprofile
│   ├── .zshrc
│   └── lib/
├── git/
│   ├── gitconfig
│   └── gitignore_global
├── starship/
│   └── starship.toml
├── atuin/
│   └── config.toml
├── vscode/
│   ├── settings.json
│   ├── keybindings.json
│   └── extensions.txt
└── agents/
    └── AGENTS.md

A Brewfile makes the workstation itself reproducible. A bootstrap script can back up existing files and create symlinks. A doctor.sh can report whether pnpm, codex, claude, gh, rg, fzf, and everything else are actually available.

There is one rule I consider non-negotiable: the dotfiles repository does not contain secrets.

No .env files. No API keys. No cookies. No authentication credentials. No database passwords. No SSH private keys. No production exports. Configuration belongs in Git; credentials belong somewhere designed to protect credentials.

A bootstrap script should also be defensive. It should back up existing files instead of silently deleting them, and it should never commit, push, create cloud resources, or modify production systems merely because somebody ran ./install.sh.

Reproducible should not mean reckless.

The Real Upgrade Is the Operating Model

None of these individual tools is the point.

VS Code is still VS Code. zsh is still zsh. Git is still Git. ripgrep still searches text. The interesting change is how the pieces cooperate.

The editor handles code navigation and review. The terminal handles the system. Structured shell history remembers what worked. Repository instructions explain the local rules. AI agents can investigate, execute, and validate tasks. Git worktrees isolate parallel experiments. CI remains the final authority. The human developer remains the person deciding what enters the codebase.

That is the model of AI-assisted development that makes sense to me.

I do not want a machine that enthusiastically changes everything it can reach. I want an environment where increasingly capable tools can safely do more because the boundaries around them are increasingly explicit.

The coolest development environment in 2026 is therefore surprisingly understated.

It opens fast, has very few extensions, knows how the repository works, remembers useful terminal history, gives me multiple capable AI agents without requiring multiple editors, makes experimentation cheap through profiles and worktrees.

And, if my laptop disappears tomorrow, I can rebuild almost the entire thing from a Git repository and a Homebrew command.

In my opinion, that is a much better definition of “ultimate setup” than another screenshot full of neon.