Getting Started

Pick your platform. Run the commands. You'll have a working shell in a few minutes.

Windows, via WSL

Most people reading this are on Windows. WSL runs a real Linux system next to your normal Windows apps. Everything below happens inside that Linux system, except step 1.

Steps

  1. Open PowerShell as Administrator. Run:
    wsl --install -d Debian
    This may ask you to restart your computer.
  2. Open "Debian" from the Start menu. The first time it opens, pick a Linux username and password (different from your Windows login).
  3. From inside that Debian window, run this one line:
    $ cd && sudo apt-get update && sudo apt-get install -y curl git && sh -c "$(curl -fsSL https://raw.githubusercontent.com/intellectual-frontiers/workspaces-host-v3/main/install.sh)"
    sudo asks for the password from step 2. That's the only password prompt in the whole process. It takes a few minutes the first time; safe to run again later.
  4. Fill in your credentials and apply them:
    $ nano ~/.config/workspaces-host/credentials
    $ workspaces-host-update
  5. Close this window and open a new one. Look for the new prompt and autosuggestions as you type. That's your confirmation it worked.

New window looks the same as the old one? Run doctor. It names the exact problem and the exact fix. See Checking your environment.

Using VS Code with WSL

  1. Install VS Code on Windows (not inside Debian): code.visualstudio.com.
  2. Install the "WSL" extension in that Windows VS Code (published by Microsoft).
  3. From your Debian window, in any project folder, run code . The first time, this installs a small VS Code Server inside WSL and opens a normal VS Code window running entirely inside Linux.

Linux

Open a regular terminal. Most VM/cloud images already have curl/git/xz; a bare-bones one won't.

$ sudo apt-get install -y curl git xz-utils   # Debian/Ubuntu
$ sudo dnf install -y curl git xz             # RHEL/Fedora/CentOS
$ sudo pacman -Sy --noconfirm curl git xz     # Arch

Then run:

$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/intellectual-frontiers/workspaces-host-v3/main/install.sh)"

install.sh auto-detects which of those three families you're on for anything else it needs. Want to build or run this repo's container images too?

$ sudo apt install -y docker.io   # Debian/Ubuntu; see docs.docker.com for other distros

macOS

curl, git, and xz are already there. Apple Silicon or Intel, run the same one-liner:

$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/intellectual-frontiers/workspaces-host-v3/main/install.sh)"

Container sandboxing (the network-restricted image) doesn't run on macOS. Everything else does.

Manual, step by step

If you'd rather run each step yourself instead of the one-liner:

$ sudo apt update && sudo apt install -y curl git xz-utils   # skip on macOS
$ sh <(curl -L https://nixos.org/nix/install) --no-daemon
$ mkdir -p ~/.config/nix
$ echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
$ git clone https://github.com/intellectual-frontiers/workspaces-host-v3.git ~/.workspaces-host-v3
$ cd ~/.workspaces-host-v3
$ nix build ".#homeConfigurations.current.activationPackage" --impure
$ export HOME_MANAGER_BACKUP_EXT=pre-workspaces-host-backup
$ ./result/activate

install.sh in the repository is the real source of truth for these steps.

Verify it worked

$ doctor

One PASS/WARN/FAIL line per check. Exits non-zero only if something's actually broken. A WARN for anything you haven't configured yet (like a blank credential) is normal.

Autocomplete UX

This repository uses bash by default. Bash here gets fish-like real-time syntax highlighting and history autosuggestions from blesh, layered on top of plain bash so every tutorial and copy-pasted snippet still works unchanged. If typing feels slower than you'd expect, here's why, and two ways to fix it.

blesh's default configuration auto-triggers full completion, not just the lightweight grey suggestion, on almost every keystroke. That's the expensive part: it can shell out to real completion scripts (git, docker, and so on) before you've even pressed Tab.

Option (a): turn off just that one setting. Tab-completion itself is unaffected; you only lose the auto-popup that runs before you press Tab. Try it in your current shell first:

$ bleopt complete_auto_complete=

That change lasts for the current shell only. To make it permanent, add it to your personal, machine-specific override file (never touched by workspaces-host-update, so it survives every sync):

$ nano ~/.config/workspaces-host/local.nix
{ ... }:
{
  programs.bash.initExtra = ''
    bleopt complete_auto_complete=
  '';
}

Then run workspaces-host-update to apply it.

Option (b): switch to the fish persona. If you want the real thing instead of a workaround, fish's own line editor (fish 4.x is written in Rust) does highlighting and suggestions natively, with nothing layered on top. It also gives you a few things blesh doesn't try to replicate: richer tab-completion generated straight from a command's own man page (no separate bash-completion package to install), simpler scripting syntax (real arrays, no [[ ]]), and a visual settings tool (fish_config) for tweaking colors and prompt options in a browser tab instead of editing config by hand. See Personas below for the exact command.

Personas

The base profile stays small: your shell, git, credentials, ws-repos, and AI coding agent tooling. A persona adds one more specialized set of tools on top, when you actually need it. (Curious why the base profile stays small instead of including all of this? See Why the base profile stays small.)

PersonaAdds
backendJava+Maven, PostgreSQL client/pgpass, Redis, docker-compose, httpie
datapython3, uv, duckdb
mobileandroid-tools (adb/fastboot), watchman
agent-opsact, semtag/git-standup/git-extras, gopass, deno, llm
complianceosquery, cnquery, steampipe, openobserve, surveilr
networkingTailscale, Nebula
fishfish shell, with the same aliases and prompt bash gets

Personas are additive and they combine: everything the base profile gives you is still there, plus every persona's extras, plus each other's. Activate none and nothing changes. Activate backend and fish both, and every future workspaces-host-update builds both together, automatically, for as long as you keep them activated.

The fish persona only makes fish and its config available. It never changes your login shell, the same way activating networking never joins a Tailscale mesh for you. To actually use it day to day, run these two commands (the same way on Linux generally, WSL included, and macOS - anywhere chsh validates against /etc/shells):

$ echo "$(which fish)" | sudo tee -a /etc/shells
$ chsh -s $(which fish)

The first line is required: a Nix-installed shell isn't in /etc/shells by default, and chsh refuses any shell that isn't. Then close this window and open a new one. doctor checks for either bash or fish as your login shell, so switching never shows up as a problem.

Finding and checking personas

Don't want to remember the table above? ws-persona prints it for you, along with the exact command to turn each one on:

$ ws-persona list

Not sure what you already have active?

$ ws-persona current

This shows two things, and they can briefly disagree: what's activated (recorded in ~/.config/workspaces-host/personas, applied on your next workspaces-host-update) and what's detected (one marker tool per persona, like mvn for backend, actually on your PATH right now). Activate a persona and it shows as activated immediately, but not detected until you actually rebuild.

Detection is a quick signal, not a certainty - it can occasionally miss or over-report if you've installed something similar yourself, outside this flake. Run doctor --all for the full, authoritative picture.

Activating one (or several)

Record it, then update:

$ ws-persona activate backend
$ workspaces-host-update

workspaces-host-update runs doctor right after, so you see immediately whether it worked. Activate as many as you want the same way; each one adds to the others rather than replacing them, and the choice sticks - no environment variable to remember on your next update, next week or next year.

Changed your mind about one?

$ ws-persona deactivate backend
$ workspaces-host-update

Want to try a persona once without committing to it - build it in isolation, see if you like it, without touching your activated list?

$ WORKSPACES_HOST_PROFILE=current-backend workspaces-host-update

That one-off build ignores your activated list entirely (it's just backend, nothing else), and doesn't change it either. Prefer the plain Nix commands instead of workspaces-host-update for that one-off?

$ nix build ".#homeConfigurations.current-backend.activationPackage" --impure
$ ./result/activate

What's next

Head to Using Your Sandbox for credentials, your shell, managing repos, and everything else you'll touch day to day. New to the command line? Try with AI has copy/paste prompts that hand the typing to an AI agent instead. Want to understand how this is built, or point an AI agent at the repository? Go to Contributing. Curious why any of this is built the way it is, or what it grew out of? See FAQ.

Using Your Sandbox

Already installed? Here's what you'll actually reach for, day to day.

Setting up credentials

Your git name/email and your GitHub/GitLab tokens go in one plain text file, outside the repository entirely: ~/.config/workspaces-host/credentials. Just KEY=value lines. No Nix syntax, no encryption tool to learn first. (Curious why a plain file? See Why a plain credentials file.)

$ nano ~/.config/workspaces-host/credentials
GIT_NAME=Your Name
GIT_EMAIL=you@example.com

GITHUB_TOKEN=ghp_yourToken
GITLAB_TOKEN=

ANTHROPIC_API_KEY=sk-ant-yourRealKey
$ workspaces-host-update

That one command applies your changes and runs doctor right after, so you see immediately whether it worked. Rotating a token later is the same two steps: edit the line, run workspaces-host-update again.

Need a credential this file doesn't have a line for (a cloud token, a project API key)? Add it with whatever name makes sense. workspaces-host-update writes anything that isn't GIT_NAME/GIT_EMAIL into ~/.local/state/workspaces-host/secrets/env/<NAME>, ready for a project's own .envrc.

Before committing anything, check what's staged (git diff --staged) and scan for secrets with the tool that's already installed:

$ gitleaks detect --source . -v

Authenticating gh and glab

A GITHUB_TOKEN/GITLAB_TOKEN in your credentials file is enough for gh/glab commands themselves, but cloning a private repo (what ws-repos ensure does under the hood) needs git's own credentials set up too. Do that once, per host:

$ gh auth login       # answer yes when it asks to authenticate git
$ glab auth login     # same prompt, for gitlab.com

Using a private, self-hosted GitLab instead of gitlab.com? Point glab at it by hostname:

$ glab auth login --hostname gitlab.mycompany.com

Add that host's repos to ws-repos.json the same way, using the hostname instead of gitlab.com:

{ "repo": "gitlab.mycompany.com/team/project" }

doctor reports whether gh/glab are authenticated (see Checking your environment).

Your shell

Bash, on purpose. It's what every tutorial already assumes. A few things are added on top:

  • Syntax highlighting and autosuggestions as you type. Press or End to accept a suggestion.
  • Fuzzy history and file search: Ctrl+R searches history, Ctrl+T finds a file, Alt+C finds and jumps into a directory.
  • Smarter cd: z <part of a path> jumps there by frecency.
  • Modern replacements, all optional: eza for ls, bat for cat, rg for grep, fd for find. ll/ls/cat are already aliased; everything else keeps its own name.

Typing feels slower than you'd expect? See Autocomplete UX in Getting Started for why, and the two fixes (one of which is switching to the fish persona).

Managing your repos

ws-repos keeps every project you work on under one predictable layout. (Wondering about the name? See Why ws-repos.)

$ nano ~/workspaces/ws-repos.json   # { "repos": [{ "repo": "github.com/org/repo" }] }
$ ws-repos ensure                    # clone-or-pull everything listed
$ ws-repos status                    # dirty/untracked/ahead/behind/locked/stash/clean
$ ws-repos inspect                   # list git hosts and repos referenced by workspace files

Every repo lands at ~/workspaces/<git-host>/<org>/<repo>, the same path segments as its HTTPS clone URL, so it stays predictable and greppable no matter how many hosts you work across.

On WSL: keep repos under ~/workspaces, not /mnt/c/Users/.... Crossing the Windows/Linux filesystem boundary is slow, git especially. doctor checks for this too.

Checking your environment

$ doctor

By default you get the essentials: Nix, your shell, git, credentials, GitHub/GitLab authentication, and ws-repos, plus a one-line summary. Run doctor --all for everything else: every ported tool, and every persona-specific check reported as an informational WARN when that persona isn't active. A real FAIL always prints, in either mode.

If an update ever breaks something, roll back with home-manager's own generations. No separate tooling needed:

$ home-manager generations
2026-09-13 14:02 : id 6 -> /nix/store/i9k2x...-home-manager-generation   # the broken one
2026-09-13 09:47 : id 5 -> /nix/store/7fa31...-home-manager-generation   # the one before it
$ /nix/store/7fa31...-home-manager-generation/activate

Keeping in sync

New features land on main as small, merged changes. Your machine doesn't pick those up on its own:

$ cd ~/.workspaces-host-v3
$ workspaces-host-update

Every shell also checks once a day, in the background, whether origin/main has moved, and nudges you if so. It never runs the update itself.

Workspace profiles (personas)

Need a specialized set of extra tools (Java, Python, Postgres, Tailscale, and so on)? See Personas in Getting Started: the full list, ws-persona list/ws-persona current to find and check them, and the exact command to turn one on.

Setting up AI coding agents

Every profile installs nodejs and aider-chat, ready to go. The fast-moving hosted CLIs install with their own npm install -g:

$ npm install -g @anthropic-ai/claude-code   # provides: claude
$ npm install -g @openai/codex               # provides: codex
$ npm install -g @google/gemini-cli          # provides: gemini

Give one its key by adding it to your credentials file (above) and running workspaces-host-update. Each CLI's key is scoped to that one invocation, never exported to your whole shell: echo $ANTHROPIC_API_KEY stays empty even after claude has used it (see Why secrets are scoped per invocation). doctor checks whether each CLI is installed and whether it has a key.

Everyday tools

  • wget, rclone: a plain fetcher and a directly-runnable rclone.
  • git-chglog: generate a CHANGELOG.md from your commit history.
  • SSH agent auto-start: a new login shell loads your key automatically.
  • cdp: jumps to the current git repo's top-level directory.
  • lefthook: git hooks manager, ready to adopt in any project: cp templates/lefthook.yml.example ./lefthook.yml && lefthook install.
  • sensitivectl: back up a sensitive local directory to any rclone-supported remote.

Try with AI

Don't know the command? You don't have to. Copy one of these prompts into your AI coding agent and let it do the typing.

Add a repo to your workspace

Every prompt below assumes you already have an AI coding agent running in this sandbox (see Setting up AI coding agents). Open it in a terminal and paste.

Add github.com/<org>/<repo> to my ws-repos.json in ~/workspaces, then run ws-repos ensure to clone it.

These prompts are starting points, not magic words. Your agent may ask a clarifying question or do something slightly different depending on what it finds on your machine. That's normal: answer it and keep going.

Diagnose a problem

Something looks wrong, but you don't know which command tells you why.

Run doctor --all in my sandbox, explain any FAIL or WARN lines in plain language, and tell me exactly what to do to fix each one.

Add or rotate a credential

You have a new token or key and don't want to hand-edit a config file wrong.

Open my ~/.config/workspaces-host/credentials file, add a line for GITHUB_TOKEN with the value I give you next, then run workspaces-host-update and show me the doctor output.

Undo a broken update

An update made things worse, and you want the previous state back.

My last workspaces-host-update broke something. List my home-manager generations, tell me which one was the last good one, and roll back to it.

Turn on a persona

You need a specialized tool a persona provides (Java, Python, Tailscale, and so on) but you don't remember the exact build command.

Activate the backend persona for my workspaces-host-v3 sandbox and confirm it worked.

Start a new project

You're starting something new and want it set up the way this repository expects: an agent-ready starter kit, not a blank folder.

Create a new project folder in ~/workspaces called my-project, run scaffold-agent-harness in it, and show me what files it created.

Contributing

How this thing is actually built, and what to know before you change it or point an AI agent at it.

Nix, in plain terms

I run this whole environment on Nix, a package manager that installs exact, pinned versions of every tool, bash and git included, into its own store, keyed by a hash of everything that went into building it. Two machines that build the same input get byte-identical output. That's the whole guarantee this repository exists to give you: no "works on my machine," because there's only one machine's worth of software here, reproduced everywhere.

A flake is Nix's own unit of "here's a reproducible thing, pinned." flake.nix at the repository root declares the inputs (nixpkgs, home-manager, both pinned by commit in flake.lock) and the outputs: home-manager configurations, packages, and checks.

home-manager takes a Nix module describing your user environment (packages, dotfiles, shell config) and applies it declaratively to your account. Every time it runs, it builds a new generation, a complete, numbered snapshot, and switches your profile symlink to point at it. Nothing gets edited in place, which is why rolling back is just pointing that symlink at an older generation (see Checking your environment).

Two identities matter in this flake: default is a fixed test identity (home.username = "workspace") used by nix flake check and CI, evaluated purely, no reading the real environment. current reads your actual $USER/$HOME via builtins.getEnv, which needs --impure. Every real install uses current; every persona also has a current-<persona> counterpart.

Repository layout

flake.nix                # inputs, systems, homeConfigurations, packages, checks
home/
  default.nix             # the base profile's module list
  shell.nix               # bash, oh-my-posh, zoxide, fzf
  git.nix                 # git identity + delta + aliases
  tools.nix                # pinned everyday CLI tools
  ai-harness.nix            # per-invocation credential wrappers for AI CLIs
  workspaces.nix             # ~/workspaces + ws-repos.json bootstrap
  secrets.nix                # opt-in sops-based secrets
  profiles/
    backend.nix               # persona: Java/Postgres/Redis/docker-compose
    data.nix                   # persona: python3/uv/duckdb
    mobile.nix                  # persona: android-tools/watchman
    agent-ops.nix                 # persona: act/semtag/git-standup/gopass/deno/llm
    compliance.nix                 # persona: osquery/cnquery/steampipe/openobserve/surveilr
    networking.nix                  # persona: tailscale/nebula
pkgs/                        # this repo's own custom-built tools (ws-repos, doctor, ...)
oci/                          # container image definitions, same closure as the host
docs/                          # this site (one self-contained index.html)
specs/<NNN-name>/               # one spec.md + plan.md per feature
.specify/memory/
  constitution.md                # the non-negotiable principles
  writing-style.md                 # the prose voice every doc follows

How personas work

Every persona is a small home-manager module under home/profiles/ that adds its own home.packages (and sometimes imports a shared module the base profile doesn't, like backend importing home/java.nix). flake.nix maps each entry in personaModules to a homeConfigurations.<persona> (fixed test identity, for CI) and a homeConfigurations.current-<persona> (real identity, that one persona only, ignoring anything activated) - both built from the same base home/ module set plus that one extra module. Personas are purely additive: nothing a persona module does can remove or override what the base profile already configures, which is exactly what lets several of them stack safely in one build.

homeConfigurations.current is where that stacking actually happens: it reads ~/.config/workspaces-host/personas (one name per line, # comments allowed, an unrecognized name dropped with a builtins.trace warning rather than failing the whole build) and folds every named persona's module into the same build alongside ./home - ws-persona activate/deactivate just add or remove a line in that file, nothing more; they never run nix build or home-manager switch themselves. Since this state lives outside the repository and is only read impurely, it's invisible to nix flake check's pure evaluation, same as local.nix.

Deciding where a new tool belongs is a single question: does every engineer need this on day one, or only someone doing a specific kind of work? The base profile answers the first question; a persona (existing or new) answers the second. See Why the base profile stays small for the full reasoning.

The spec-driven workflow

Every feature in this repository follows GitHub Spec Kit's lifecycle: a spec (what and why, in testable requirements), a plan (how, mapped onto this repository's actual files), and only then code. specs/<NNN-name>/spec.md and plan.md exist for every feature, core and backlog alike. A spec that no longer matches the code is worse than no spec at all. A change that breaks a spec's claim updates that spec in the same commit, not as a followup that may never happen.

Specs are tiered: core (001–005) is the minimum that has to exist for the "same environment everywhere" promise to hold at all; everything else is backlog, specified in full but only implemented once actually scheduled. A backlog spec has to stand on its own: implementable without rewriting a core spec.

The constitution

.specify/memory/constitution.md is the actual governance document, and it supersedes every other practice in this repository. Six principles, worth knowing before you change anything:

  1. Reproducible by lockfile, not by drift. Nothing gets installed imperatively; if it can't be declared in the flake, it doesn't belong here yet.
  2. Ephemeral and disposable by default. Rebuild, don't patch. No feature can depend on state surviving a wipe.
  3. Secrets never touch the agent's shell unscoped. Every credential is resolved at the point of use, never as an ambient shell variable. Non-negotiable, since this repository explicitly provisions environments AI agents run inside.
  4. Container and cloud-harness parity is required, not optional. A feature that only works on a persistent host is incomplete.
  5. Simplicity over completeness. Build to what the spec needs today. A prior version's tool or workaround needs its own justification, not "that's how it was done before."
  6. Documentation voice. Prose documentation (this site, the README, a spec's own narrative sections) follows writing-style.md. A spec's testable requirements stay in SpecKit's own precise language instead.

Managing this repository with an AI coding agent

This repository is built to be handed to an agent, and mostly has been: large parts of it were. Point Claude Code, Codex, or another agent at a checkout and it already has everything it needs to work correctly, as long as it actually reads what's here first:

  1. Read the constitution before changing anything. It's short, and it answers most "should this go here" questions before you have to ask them.
  2. Follow the spec-first lifecycle. A real feature gets a spec and a plan before code. A bug fix or a small doc change doesn't need the full ceremony, but still needs the spec it touches updated in the same change if it makes that spec's claims wrong.
  3. Ask "does everyone need this, or only some engineers?" before adding a package. The answer decides base profile vs. a persona. See How personas work above.
  4. Never export a secret to the ambient shell. Every credential-consuming tool gets a per-invocation wrapper (home/ai-harness.nix has the pattern). This is Principle III, and it's the one principle this repository will not compromise on for convenience.
  5. Validate before claiming done. nix flake check --all-systems at minimum; a real change to home/ or a persona deserves an actual scratch-$HOME activation and a doctor --all run with zero unexpected FAILs. "The Nix expression evaluates" is not the same claim as "activation actually works."
  6. Write prose in this repository's voice. writing-style.md is specific and has a built-in audit pass; run it before calling documentation done.
  7. Keep this site current, not the README. The README stays a short pointer; this page is the comprehensive, always-current documentation. A change that affects usage updates this file in the same commit, not the README (see Why the docs live here, not in the README).

scaffold-agent-harness (installed in every profile) drops a starter AGENTS.md, an .mcp.json, and a .claude/settings.json into any project, not just this one. The same idea, applied to whatever you're building next.

Building & testing

$ nix flake check --all-systems              # evaluates + builds the base activation, every system
$ nix build ".#homeConfigurations.default.activationPackage"
$ nix build ".#homeConfigurations.current-backend.activationPackage" --impure

A change to a shared module (anything under home/ outside profiles/) affects every persona; rebuild at least one persona alongside the base profile before calling it done. A change scoped to one persona module only needs that persona rebuilt.

Container & cloud-harness parity

$ nix build .#oci-image             # same shell/tools/dotfiles as the host profile
$ docker load < result
$ docker run -it workspaces-host:latest

$ nix build .#oci-image-sandboxed   # + default-deny network egress allowlist, non-root user

Both images build from the exact same evaluated home-manager configuration as the host profile, not a hand-maintained Dockerfile that could drift. The sandboxed variant runs init-firewall as root, verifies its own allowlist, then drops to a non-root user before handing off to the workload.

Where to go deeper

  • The constitution: every non-negotiable principle, in full.
  • Every spec: the exact requirements behind every feature, core and backlog.
  • FAQ: every design decision explained, and the earlier repositories this one grew out of.

FAQ

Why this repository works the way it does, and what it grew out of.

Why this exists

I didn't build this for "a nice shell." I built it so a human engineer, a teammate who's new to Linux, a CI/CD pipeline, and an AI coding agent (Claude Code, Codex, an autonomous CI bot, whatever) can all open a terminal on completely different machines and find the exact same structure. The same place repos land (~/workspaces, managed by ws-repos), the same command to check the environment (doctor), the same command to update it (workspaces-host-update), the same shell, the same tools, at the same versions. When everyone and everything on a project, engineering, DevOps, an agent working overnight, shares one predictable layout, nobody has to relearn "how this particular machine happens to be set up" before they can do anything useful on it.

That matters more now, not less, because AI put a real command line in front of people who never expected to need one. Just about everyone is an engineer some of the time now, and everyone doing that work deserves the same consistent, capable Linux environment, not a stripped-down one just because they're newer to it. WSL already gives Windows users the desktop, files, and apps they know; this is what gives them the same ready-to-go setup on the Linux side. "New to Linux" stays the only unfamiliar part, not the tooling.

It's also why AI CLIs ship as part of the standard environment (see Setting up AI coding agents). Once someone has an API key configured the safe way this repo documents, they can point an AI harness at their own sandbox and ask it to help fix or improve the setup, the same way it would help with application code. I want that barrier low on purpose. Lowering it is most of the point of building this at all.

Here's the thing worth protecting: that consistency. If an AI harness or a person finds a real improvement while working in one sandbox (a new tool, a better default, an extra doctor check, a smarter install step), it belongs in this repo, via a pull request. Not buried in one person's credentials file, not a one-off tweak that only exists on their machine. Your credentials file exists for what's actually personal, your name, your keys, precisely so everything else stays shared. A good idea stuck in one sandbox helps one person. The same idea merged here helps everyone, and every CI run, and every agent, who uses this setup afterward.

Why Nix and home-manager, not Homebrew/pkgx/mise/chezmoi

Every one of those tools solves part of this problem. None solves all of it the way a single sandbox needs. A version manager (mise, SDKMAN!) pins one language's runtime, not your shell, your prompt, or your git config. A dotfile templater (chezmoi) manages files, not packages, so you still need something else to install the tools those files configure. Homebrew and pkgx install packages, but not reproducibly enough: "brew install" today and "brew install" in six months can silently resolve to different versions unless you separately pin and audit that yourself.

Nix does the whole job with one mechanism: a lockfile that pins every input, and a package manager that builds from that lockfile into a content-addressed store, so the same commit produces the same closure everywhere, forever. home-manager is the layer that applies that same guarantee to your actual user environment (shell, prompt, dotfiles), not just system packages. Once you have that, you don't need four different tools each solving a slice of the problem with a different reproducibility story. That's Constitution Principle I, and it's the reason nothing else is allowed to sneak into this repository's toolchain.

Why a plain credentials file, instead of Nix or an encrypted store

Three reasons.

It never leaves your machine. It lives outside this repo, so git pull/workspaces-host-update can't touch it, overwrite it, or conflict with it, and there's nothing here you could accidentally commit.

It's protected the way ~/.ssh or ~/.aws/credentials are. workspaces-host-update sets it to mode 600 every time it runs and fixes the permissions if anything loosens them. doctor checks this too.

It's read by a plain script, not by Nix. workspaces-host-update parses it as KEY=value text. It never sources it as a shell script, so a stray backtick or $(...) in a token can't run as a command. It writes your git name/email into a file git itself reads automatically, and drops every other token into a per-command-scoped mechanism: a key is only ever visible to the one command that needs it, gh/glab, or an AI harness CLI, never the rest of your shell.

This is the simplest thing that satisfies Constitution Principle III, secrets never touch the agent's shell unscoped, for the common case. No age/sops steps just to set your name and email. An opt-in workspacesHost.secrets option (home/secrets.nix) covers the cases this doesn't: field-level encryption at rest for a genuinely shared team secret.

One more piece of this: a project's own .envrc should check the ambient environment variable first and only fall back to this sandbox's file, never the other way around. The same project runs in more than one place, your machine, a teammate's, CI/CD, a container, and each gets its credentials differently. In CI/CD, the platform injects the secret directly; there's no credentials file there at all. Get the order backwards and you'd silently overwrite a value CI/CD already injected with the wrong one. ${VAR:-fallback} does exactly this in one line: in CI/CD the fallback never runs, since the pipeline's own secret already won; on your machine, nothing set it yet, so it falls through to the file.

Why secrets are scoped per invocation, not exported

claude/codex/gemini/aider/gh/glab each get their own bash function of the same name (home/ai-harness.nix) that looks for a matching key and sets it only for that one call, never a shell-wide export. That's Constitution Principle III, applied literally: a secret gets resolved at the point of use, never as an ambient variable available to the whole shell session and everything running in it, an AI coding agent included. Run claude and its wrapper finds the value, sets $ANTHROPIC_API_KEY for that one call, and touches nothing else. echo $ANTHROPIC_API_KEY in the same window comes back empty. If a CLI has its own browser-based login instead (Claude Code and Gemini CLI both do), that works too. The wrapper is a no-op when nothing's configured, and doctor only warns if neither a configured credential nor an existing login shows up.

This is non-negotiable given what this repository is for: provisioning environments an AI coding agent operates inside. An agent with an ambient API key in its shell can leak that key into anything it touches, a log line, a generated script, a subprocess it spawns. Scoping the key to the one command that actually needs it closes that door structurally, instead of trusting every tool and every prompt to behave.

Why Claude Code/Codex/Gemini CLI aren't Nix-packaged like everything else

They ship near-weekly. Hand-vendoring each one as a Nix derivation means either pinning to a stale version forever or re-deriving a hash on every release, a maintenance job that isn't worth taking on for tools whose entire value is being current. nodejs, their shared runtime, gets the declarative treatment instead. Installing the actual CLI stays a single npm install -g, the same command upstream already documents.

Why the base profile stays small, and specialized tools moved to personas

The base profile stays small on purpose: just what every engineer needs on day one. Compliance tooling, the Java/Postgres toolchain, bulk git tooling, and zero-trust networking clients all live in personas instead, since only some engineers use any given one of them, and a base-profile install is what every other engineer carries around unused. Deciding where a new tool belongs is a single question: does every engineer need this, or only someone doing a specific kind of work? That question is Constitution Principle V, simplicity over completeness, applied directly.

Why fish is a persona, not the default

Bash stays the base profile's shell because every tutorial, every copy-pasted snippet, and most engineers' existing muscle memory already assume it. blesh gets bash most of the way to fish's own feel (see Autocomplete UX) without asking anyone to give that up.

Fish is the honest alternative for someone who wants the real thing: its own native, Rust-implemented (fish 4.x) line editor, nothing layered on top. It's a persona, not a second default, for the same reason Tailscale is a persona and not something this repo joins a mesh for automatically: switching your actual login shell is a consequential, per-engineer choice, not something a package list should decide on your behalf. Activating the fish persona only makes fish and a matching config available; a separate, deliberate chsh step (see Personas for the exact commands) is what actually makes it yours.

Why ws-repos, not mgit, and why the file suffix still says .mgit

Other, unrelated tools are also named mgit. Naming this repository's tool ws-repos instead avoids that collision entirely; the directory convention and file matching it implements aren't affected by the rename. (Curious about the tool this one was inspired by? See Inspiration.)

The workspace file suffix stays *.mgit.code-workspace on purpose: keeping that exact string means a *.mgit.code-workspace file written by any tool that recognizes that suffix keeps working here, unchanged.

ws-repos status reports a stuck index.lock ("locked") and stash count, and separates "untracked" from "dirty." Parsing a *.mgit.code-workspace file tolerates the comments VS Code itself allows there, a best-effort filter for // and /* */, not a full JSONC parser (spec 003's Assumptions document the one edge case that misses).

Why Deno, Lefthook, and Tailscale/Nebula are scoped the way they are

These three tools trace back to questions the repositories in this project's own lineage raised (see Inspiration); here's how this repository answers each on its own terms.

Deno is provisioned as a plain scripting runtime, available to anyone who wants deno+dax over make for custom project tasks, independent of what ws-repos/doctor happen to be written in. It lives in the agent-ops persona, not the base profile, since it's a specialized enough choice that most engineers won't reach for it on day one.

Lefthook and the zero-trust networking clients (Tailscale, Nebula) are each installed declaratively via Nix, and nothing more. For Lefthook, that means a documented, copy-in example config instead of forcing hooks on every repo. For Tailscale/Nebula, it means installing the client and stopping there: joining an actual mesh (a Tailscale account and its login flow, or a Nebula certificate a network admin issues) is unavoidably a human, out-of-band action, the same category as a git identity or an API key. Constitution Principle III's "provisioning a key is a deliberate, separate, human action" applies just as much to network trust material. Running a server-side control plane is infrastructure someone chooses to operate separately; it's not part of one engineer's sandbox.

Why not oh-my-posh enable autoupgrade

Looks like the obvious fix for the "a new release is available" message. It isn't. oh-my-posh's binary lives in the read-only Nix store, so a self-upgrade either fails outright or, worse, succeeds by writing a binary Nix has no record of, which breaks the one guarantee this repository exists to give you: every tool pinned by a lockfile, never resolved against a moving upstream at runtime. The message is silenced correctly instead (disable_notice in home/shell.nix); that's a cosmetic setting, not a version change. Want a newer oh-my-posh? Bump this flake's nixpkgs pin, the same as updating anything else here.

Why not SDKMAN! or another Java version manager

Nix already pins a reproducible version for every tool in this setup, Java included. A second version manager on top of that would just duplicate the job. Want a different JDK version or vendor? Override home/java.nix's pkgs.jdk/pkgs.maven in a fork.

Why a spec for everything

A spec-driven workflow feels like ceremony until the alternative shows up: a feature nobody can explain the intent of six months later, or a change that quietly breaks a requirement nobody wrote down in the first place. Every feature here, core and backlog alike, gets a spec (what and why, in testable requirements) and a plan (how, mapped onto this repository's actual files) before code. A spec that no longer matches the code is worse than no spec at all, so a change that breaks a spec's claim updates that spec in the same commit. This isn't process for its own sake: it's what makes it possible to hand this repository to an AI agent and trust that "the spec says X" is still true.

Why the docs live here, not in the README

Two documents claiming to explain the same thing drift apart. One of them updates when a feature changes; the other one, inevitably, doesn't, and now there are two contradictory answers to "how do I set up credentials." Rather than accept that as a cost of having a README and a docs site, the README stays a short pointer: what this is, the core concepts, and a link here. This page is the one place installation steps, day-to-day usage, the technical architecture, and every "why" get written down, kept current in the same commit as the change that makes them true. One canonical answer, not two that can disagree.

v1: strategy-coach/workspaces-host

The first version of this idea: a chezmoi-based dotfiles repository that called Deno "a core requirement" and carried an unfinished roadmap of its own, including lines for a Git hooks manager and zero-trust networking clients that never got built there. This repository doesn't reuse its code or its chezmoi templates; it takes the idea that a whole engineering sandbox deserves to be one reproducible artifact, and rebuilds it on Nix and home-manager instead.

mgit: strategy-coach/workspaces

A separate tool for keeping many git repositories under one governed directory layout: clone-or-pull idempotently, report status across all of them, and compose a multi-root VS Code workspace from the result. This repository's ws-repos follows the same pattern in POSIX shell, deliberately renamed to avoid colliding with the several other, unrelated tools also called mgit (see Why ws-repos, not mgit).

v2: intellectual-frontiers/workspaces-host-v2

A second iteration that continued refining the same goal. This repository builds on what v2 established, on its own foundation, rather than carrying its implementation forward line by line.

This repository: a spiritual successor, not a port

workspaces-host-v3 is written from its own spec-driven foundation (see The spec-driven workflow), on Nix and home-manager, with its own constitution governing every decision. It carries forward the goal its predecessors were reaching for, one reproducible sandbox, everywhere, without carrying forward their code, their naming, or an obligation to behave identically to any of them. Using this repository never requires knowing any of the above; every real design decision it makes has its own answer elsewhere in this FAQ, on its own terms.