Install Neovim in the web sandbox
This is the missing half of the neovim RPC skill: that skill drives a running
Neovim via $NVIM, but the sandbox ships neither the binary nor a parent
editor. This skill installs Neovim, launches it headless on a listen socket,
and exports $NVIM so the neovim skill's commands work verbatim afterwards.
Scope: web sandbox only (CLAUDE_CODE_REMOTE=true). Do not run on a real
machine — there Neovim comes from nixpkgs-unstable via home-manager.
Why Nix
Nix installs Neovim from *.nixos.org (allow-listed by default), so the binary
needs no GitHub access — and nixpkgs-unstable ships a current Neovim with
vim.pack, which nvim-fredrik requires. Same source as the real machines.
Step 0 — Network prerequisites
Two independent mechanisms gate traffic; they bite at different points.
The binary (this skill): substituted from the Nix cache (
*.nixos.org), reachable under the default Trusted network policy. No GitHub, no action. Verify:curl -sSI -o /dev/null -w "cache: %{http_code}\n" https://cache.nixos.org curl -sSI -o /dev/null -w "channels: %{http_code}\n" https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xzThe plugins (a full
nvim-fredrikrun): at startupvim.packclones ~50 plugins. These split three ways:- Public GitHub clones — already work under the default Trusted policy
(
github.comis allow-listed). No action. - codeberg.org plugins (nvim-lint, nvim-dap, nvim-dap-python) —
required and blocked by default. Edit the environment → Network
access selector → Custom → in Allowed domains add the bare
codeberg.org→ tick "Also include default list of common package managers" → save. This rebuilds the environment. See https://code.claude.com/docs/en/claude-code-on-the-web. Gotcha: use the apexcodeberg.org, not*.codeberg.org— the wildcard matches only subdomains, but the pluginsrcURLs arehttps://codeberg.org/..., so a*.codeberg.org-only allowlist still 403s the clones. (Fullnetwork access also works but is broader than needed.) - GitHub release assets (codediff's prebuilt lib, Mason LSP servers,
treesitter parsers) — gated by the GitHub proxy to attached repos
regardless of network level, so they may 403. Optional for observing
most config behavior; attach a specific repo with
add_repoonly if the change under test needs it.
- Public GitHub clones — already work under the default Trusted policy
(
Step 1 — Ensure Nix is installed
Follow the nix-install skill (apt nix-bin + single-user config). In
short:
apt-get update
apt-get install -y --no-install-recommends nix-bin
mkdir -p /etc/nix
grep -q '^build-users-group' /etc/nix/nix.conf 2>/dev/null \
|| echo 'build-users-group =' >> /etc/nix/nix.conf
Step 2 — Install Neovim from nixpkgs-unstable
Everything is substituted from cache.nixos.org; nothing is compiled and no
GitHub is touched.
nix-env -iA neovim -f https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz
export PATH="$HOME/.nix-profile/bin:$PATH"
nvim --version | head -1 # NVIM v0.12.x
nvim --headless -c 'lua io.write(tostring(vim.pack ~= nil))' -c 'qa' # expect: true
Step 3 — Wire the config into place and persist the environment
The config lives in the cloned repo. Two env vars point Neovim at it:
NVIM_APPNAME=nvim-fredrikmakes Neovim read~/.config/nvim-fredrik(the symlink created below).$DOTFILESis the dotfiles repo root. The config builds paths from it (Mason lockfile, lint configs, snippets); left unset it falls back to~/.dotfiles, which doesn't exist in the sandbox, so those lookups silently resolve to missing files. Point it at the clone root.
Each Bash call is a fresh shell whose environment is snapshotted at session
start — ~/.bashrc is not re-run per call (its non-interactive return guard
exits early), so appending an export or source line there does not reach
later calls. The filesystem does persist, though: write the env to a file and
source it at the top of every later call that needs it — including every
neovim-skill call, which keys off $NVIM.
repo="/home/user/dotfiles" # adjust if the repo is cloned elsewhere
mkdir -p ~/.config
ln -sfn "$repo/nvim-fredrik" ~/.config/nvim-fredrik
cat > ~/.nvim-sandbox.env <<EOF
export PATH="\$HOME/.nix-profile/bin:\$PATH"
export DOTFILES="$repo"
export NVIM_APPNAME=nvim-fredrik
export NVIM=/tmp/nvim-fredrik.sock
EOF
source ~/.nvim-sandbox.env
Step 4 — Launch Neovim headless with a listen socket
This is the bridge to the neovim skill. Start a backgrounded headless
instance listening on $NVIM, so the RPC skill's commands — which all key off
$NVIM — work unchanged. The first launch clones all plugins via vim.pack
(needs Step 0 item 2) and can take a few minutes.
source ~/.nvim-sandbox.env # env doesn't persist across calls; re-source
rm -f "$NVIM"
# --headless keeps it alive as long as the socket is served; run detached.
nohup nvim --headless --listen "$NVIM" >/tmp/nvim-headless.log 2>&1 &
echo $! > /tmp/nvim-fredrik.pid # record PID so a later restart can kill it
# Wait for the socket to accept RPC (plugin cloning happens during startup).
for i in $(seq 1 120); do
[ -S "$NVIM" ] && nvim --server "$NVIM" --remote-expr '1' >/dev/null 2>&1 && break
sleep 2
done
echo "NVIM socket: $NVIM"; tail -8 /tmp/nvim-headless.log
403 / CONNECT tunnel failed lines in the log point at the Step 0 item 2
gaps (usually codeberg.org if it isn't allowed yet, or a GitHub release
asset). The editor still runs; only the plugins that failed to clone are
missing.
Step 5 — Verify and hand off to the neovim skill
source ~/.nvim-sandbox.env
nvim --server "$NVIM" --remote-expr 'v:version'
nvim --server "$NVIM" --remote-expr 'luaeval("vim.fn.stdpath(\"config\")")'
With ~/.nvim-sandbox.env sourced, $NVIM is set exactly as if Claude Code
were running inside a Neovim terminal, so switch to the neovim skill for
all further interaction (buffer state, running Lua, inspecting diagnostics, LSP,
plugins). Start each of those Bash calls with source ~/.nvim-sandbox.env too —
the env doesn't carry over on its own.
One-shot checks (skip the persistent server)
Steps 4–5 stand up a persistent --listen server so the neovim skill can
drive it across turns. You only need that for interactive, multi-step work. For
an observe-once question — does this config change load cleanly? what does the
formatter do to this file? — skip the socket entirely and use a
self-terminating headless run that loads the real config, does its thing,
prints, and quits with qa!. It exits cleanly and sidesteps all of the
launch/restart/socket handling (and its footguns) above. Prefer it whenever you
don't actually need cross-turn RPC.
source ~/.nvim-sandbox.env
# "does my config change load without error?"
nvim --headless \
-c 'lua io.write("config="..vim.fn.stdpath("config").."\n")' \
-c 'qa!'
Exercising behaviour follows the same shape — open a file so its filetype plugins load, run the operation, print, quit:
source ~/.nvim-sandbox.env
nvim --headless path/to/file.go \
-c '<command that performs the operation, e.g. runs the formatter>' \
-c 'lua io.write(table.concat(vim.fn.getline(1, "$"), "\n").."\n")' \
-c 'qa!'
-c commands run in order after startup. Opening a file fires FileType
loading, but a plugin that lazy-loads on its own event or command only
activates once you invoke that command — so trigger the real operation rather
than assuming the plugin is already loaded.
Testing a config-change PR
The nvim-fredrik config is part of this repo, so a PR that changes it is
just a branch checkout — no separate config repo:
git -C /home/user/dotfiles fetch origin <pr-branch>
git -C /home/user/dotfiles checkout <pr-branch>
Then restart the headless instance so the new config is sourced. Kill the old instance by the PID recorded at launch and wait until it is actually gone before relaunching. Two traps make this fiddlier than it looks:
- Don't
pkill -f 'nvim --headless --listen'. Each Bash call runs asbash -c '<the whole script>', so that string is in the script shell's own arguments;pkill -fmatches and kills the script itself, which dies with a128 + signalexit (e.g.143/144) before it ever relaunches. Killing the recorded PID sidesteps the pattern entirely. - Let the old process exit before reusing
$NVIM. A dying nvim unlinks its own socket on the way out. Relaunch on the same path too soon and the old instance's cleanup deletes the new socket — RPC then fails with "connection refused" even though the new process is alive. So confirm it's gone (SIGKILL as a fallback) beforerm-ing the path and starting fresh.
source ~/.nvim-sandbox.env
oldpid="$(cat /tmp/nvim-fredrik.pid 2>/dev/null)"
if [ -n "$oldpid" ]; then
kill "$oldpid" 2>/dev/null || true
for i in $(seq 1 50); do kill -0 "$oldpid" 2>/dev/null || break; sleep 0.2; done
kill -9 "$oldpid" 2>/dev/null || true # force if it ignored SIGTERM
fi
rm -f "$NVIM"
nohup nvim --headless --listen "$NVIM" >/tmp/nvim-headless.log 2>&1 &
echo $! > /tmp/nvim-fredrik.pid
for i in $(seq 1 120); do
[ -S "$NVIM" ] && nvim --server "$NVIM" --remote-expr '1' >/dev/null 2>&1 && break
sleep 2
done
Now drive the behavior under test via the neovim skill — e.g. for a
neotest/diagnostics change, open a test file, run the command, then read the
diagnostics:
nvim --server "$NVIM" --remote-expr 'luaeval("vim.json.encode(vim.diagnostic.get(0))")'
Caveats
- First launch is slow — plugin cloning is network-bound and serial.
- Mason / LSP servers and treesitter parsers are best-effort. They pull
extra tools from GitHub release assets and compile locally; if a change under
test doesn't depend on a language server, you don't need them. Failures are
logged in
/tmp/nvim-headless.logand don't prevent the rest of the config loading. - Headless has no UI. Inspect state via RPC (
vim.diagnostic.get,vim.lsp.get_clients, buffer APIs), not by looking at a screen. - Ephemeral. The install and config live only for this environment's lifetime; nothing is committed. Re-run the skill in a fresh environment.