My Editor Stopped Being My Development Environment

My Editor Stopped Being My Development Environment

rustdevcontainersclicoding-agentsdeveloper-toolssquirrelsoft

Claude Code changed how I worked inside devcontainers. I still wanted isolated, reproducible development environments, but I no longer wanted VS Code to own them. So I built dev.

I didn't decide one day that I didn't need an editor anymore. I just kept making the terminal bigger.

Before AI coding agents, I used devcontainers from VS Code pretty regularly. I had a collection of .devcontainer configurations stored with my VS Code user settings and eventually built a scripted system for creating new environments from templates.

I liked devcontainers for fairly ordinary reasons. My Mac stayed clean. A TypeScript project could have the version of Node and npm it needed. A Rust project could have its own Rust toolchain. Different projects could use different versions of things without me having to manage all of that on the host.

Most importantly, I was developing inside Linux, which was usually much closer to the environment where the software would eventually run.

Then I started using Claude Code.

I Just Kept Making the Terminal Bigger

At first, nothing really changed.

I ran Claude Code inside VS Code's integrated terminal. I normally had the terminal on one side of the screen, the code I was working on in the middle, and the file explorer on the other side.

As I used Claude more, I noticed that I was spending less time looking at the code.

So I made the terminal bigger.

Then bigger again.

Eventually I realized I preferred running Claude Code directly from a normal terminal. I could give it the problem, watch what it was doing, and let automated tooling handle more of the mechanical review.

That last part was important.

I wasn't deciding that AI-generated code didn't need to be checked. I was changing how I checked it.

If the code needs to follow a particular style, a linter can check that.

If it needs to be formatted a certain way, a formatter can do that.

If an architectural rule can be checked automatically, a hook or static analysis tool can tell the agent when it gets it wrong.

Tests can verify behavior.

Over time, I became more interested in putting quality controls around the coding agent than personally watching every line appear in an editor.

At some point the code view disappeared from my normal workflow almost entirely.

That's when I discovered I had a strange problem.

I wasn't really using VS Code anymore, but I couldn't close it.

I Still Wanted the Container

VS Code owned the lifecycle of my devcontainer.

If I accidentally closed VS Code, the container shut down. If Claude Code was running inside it, that session disappeared too.

I tried working directly on my Mac for a while. I liked the terminal-first workflow, but I quickly remembered why I'd been using devcontainers in the first place.

Now Node, npm, Rust, Python, package managers, and whatever else a project needed were back on my host.

Different projects wanted different versions.

And I was no longer developing in the same kind of Linux environment I was deploying to.

There was also some isolation value in keeping a coding agent inside a container, although that wasn't my primary motivation at the time. Mostly, I wanted project-specific environments without turning my Mac into the union of every development environment I'd ever used.

I still wanted devcontainers.

I just didn't want VS Code to own them.

So I Built dev

I tried the existing devcontainer CLI and didn't particularly care for the workflow, so I had Claude start building the tool I wanted.

I called it dev.

The basic idea was simple: the development environment should have its own lifecycle. My shell, coding agents, editor, and other tools should be clients of that environment rather than the things responsible for keeping it alive.

My normal workflow today looks something like this:

dev status

# If it isn't already running:
dev up

# Enter the environment:
dev shell

That puts me in /workspace with my shell configured the way I like it and the tools I normally use available.

From there I might start Claude Code, Codex, or just work in the shell.

When I exit, the container keeps running.

The next time I come back:

dev shell

If I actually want VS Code for something:

dev open

It opens VS Code attached to the existing container.

That inversion ended up being one of my favorite parts of the workflow.

VS Code still works fine. It just isn't in charge anymore.

Then I Got Tired of Repeating My Configuration

The lifecycle problem was the reason I started dev, but once I was using it regularly, configuration became the next thing that bothered me.

I want almost all of my development environments set up basically the same way.

I want my shell configuration and dotfiles. I want Claude Code and Codex. I want GitHub tooling, cloud CLIs, SSH agent forwarding, 1Password integration, and a handful of other things I use regularly.

Copying that into every project's .devcontainer/devcontainer.json didn't make much sense.

So dev gained a base configuration:

~/.dev/base/devcontainer.json

That's essentially my answer to:

What should every development environment I use look like?

Then there are global templates.

If I want a Rust environment, dev can show me the templates from the official devcontainer ecosystem. I can select the Rust template and save it as something like:

~/.dev/global/rust/.devcontainer/devcontainer.json

I generally leave those pretty close to their defaults.

The base describes how I develop.

The global template describes how Rust development works.

That worked well until I ran into another problem.

Generated Configuration and Human Configuration Don't Mix Well

Suppose I'm working on dev itself.

It's a Rust project, so I start with my Rust template. But for some reason this particular project also needs Ruby.

I can add Ruby to the project's generated devcontainer.json.

Everything is fine until I change my base configuration.

Now dev needs to regenerate the project configuration so it picks up the new base settings.

If it regenerates the file, my project-specific Ruby configuration disappears.

I had accidentally created a file that was simultaneously owned by the generator and by me.

That wasn't going to work.

So I added another concept: recipe.json.

For dev itself, the recipe is roughly:

{
  "globalTemplate": "rust",
  "customizations": {
    "features": {
      "ghcr.io/devcontainers/features/ruby": {}
    }
  }
}

The recipe doesn't need to duplicate my normal environment or the Rust configuration.

It just describes what is different about this project.

When I run dev build, or rebuild the environment, dev composes several layers:

global template
      ↓
base configuration
      ↓
runtime configuration
      ↓
project recipe
      ↓
devcontainer.json

It also generates a devcontainer-lock.json containing the resolved features, versions, and integrity information.

The resulting devcontainer.json is disposable.

That's the part I care about.

I can change my base environment and rebuild every project without destroying the things that make an individual project different.

The recipe stores my intent. The generated devcontainer is just the materialized result.

Composition Turned Out to Be More Complicated Than Merging JSON

I originally thought layering configuration would mostly be a matter of merging JSON objects.

It isn't.

Some devcontainer fields should replace lower-level values. Some should merge by key. Arrays like mounts and forwarded ports need different behavior. Features need their own merge semantics. Lifecycle commands can be strings, arrays, or named command objects.

Then there are mutually exclusive definitions like image, build, and dockerComposeFile.

Relative paths also need to mean the same thing after a configuration has been moved through several layers.

dev has explicit rules for those cases now.

They're not perfect.

There are still edge cases around nested configuration, semantically equivalent mount definitions, and some feature precedence behavior. Other people have found combinations I never would have encountered in my own workflow.

Which leads to something else I didn't expect.

Apparently Other People Use This Thing

I built dev for myself.

A couple of friends saw me using it and asked what it was. I showed them, they started using it, and eventually they submitted a few pull requests fixing things that didn't work in their workflows.

For quite a while, I assumed that was basically the entire user base.

Then one day I looked at the GitHub repository and realized there was a screen full of issues and several pull requests from people I didn't know.

I checked again while revisiting this article.

There are more now.

As I'm writing this, the repository has 16 stars and 5 forks. That's obviously not taking GitHub by storm, but the number isn't really what I find interesting.

I built a tool because I had a problem.

I put it on GitHub because that's where I put my projects.

Apparently a few other people had the same problem.

That's enough to create an entirely new problem.

Now I Have to Maintain It

dev works well enough for the path I use that I don't spend much time thinking about it anymore.

That's good for me.

It's less good for the people opening issues and submitting pull requests.

They're trying to fit dev into workflows I don't use. I'm generally happy to tweak it to support those workflows as long as the changes don't break mine.

The problem is noticing the work.

I mostly ignore GitHub notification emails. To me they're noise, which means an issue can exist even though GitHub technically notified me about it.

Pull requests can sit there waiting for review because I simply didn't notice them.

Naturally, instead of developing a better habit of checking GitHub, I started working on an autonomous software factory that can watch my repositories, triage issues, work on them, review pull requests, and eventually handle more of the routine maintenance.

Apparently one tool I built for myself has now caused me to build another tool so I don't have to maintain the first one.

That seems about right.