vortix
CLAUDE.md757 starsChanged 48 days agoMITView on GitHub
Vortix is a terminal VPN manager (TUI + CLI) for WireGuard and OpenVPN on macOS and Linux (see North Star). One Rust crate, crates/vortix, plus crates/xtask for boundary lints. It runs as root (sudo vortix); there is no helper or daemon. To fix a reported bug end to end (reproduce → fix → CI → PR → review → merge), run /fix-bug <issue number, URL or description>. Before a release, run /release-qa (the P0 gate, live on macOS and the…
- Installs packages
In this repository7 other files it gives its agents.
- AGENTS.mdAGENTS.md
- autopilotSkill
- docs-reviewSkill
- fix-bugSkill
- ideateSkill
- release-changelogSkill
- release-qaSkill
The file
We split this CLAUDE.md into the rules it gives an agent
11 rules. About 0 are common, 5 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 17
Before merging, the reviewer agent (.claude/agents/reviewer.md) reviews every change in a fresh context.
- Distinctive: line 28
One fixed experience: Every VPN gets the same sidebar, keys, panels, Security Guard, kill switch, CLI and JSON.
- Distinctive: line 39
Every proposal, issue and change is judged against this.
- Distinctive: line 51
Remove stale code: instead of working around it.
- Distinctive: line 53
Comments: default none; one short line when the *why* is not obvious.
- Distinctive: line 54
No plan IDs, ticket codes or phase names in code, help text or logs.
- Very specific: line 62
Errors: use the module's existing type (thiserror enum or String).
- Very specific: line 63
Never drop an error the user needs to see: an .ok().flatten() once made a refused credential file look like no credentials.
- Very specific: line 141
No aliases: auto/always are rejected with "Use: off, block-on-drop, vpn-only".
- Very specific: line 165
Test real behaviour and edge cases.
- Very specific: line 185
Run scripts/vpn-lab.sh ensure (the full command is in that doc) before anything connects a real tunnel.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Vortix — context for Claude Code
Vortix is a terminal VPN manager (TUI + CLI) for WireGuard and OpenVPN on macOS
and Linux (see North Star). One Rust crate, crates/vortix, plus crates/xtask for boundary
lints. It runs as root (sudo vortix); there is no helper or daemon.
To fix a reported bug end to end (reproduce → fix → CI → PR → review → merge),
run /fix-bug <issue number, URL or description>. Before a release, run
/release-qa (the P0 gate, live on macOS and the Linux lab) and
/release-changelog (user-facing notes, version and upgrade steps); the pipeline itself is in
RELEASING.md.
The project runs itself with /loop /autopilot (.claude/skills/autopilot/): triage, builds,
PR upkeep, release prep and post-release checks, one unit per tick. The maintainer makes only
two decisions: what gets built (/ideate, or labelling an issue approved) and what ships
(merging the release PR, which .claude/hooks/guard-merge.sh keeps Claude from doing).
Before merging, the reviewer agent (.claude/agents/reviewer.md) reviews every change in a
fresh context.
North Star
Vortix is the one keyboard interface for every VPN a person uses — their company's NetBird, their personal Mullvad, plain WireGuard and OpenVPN, and the next ones users ask for — all run by Vortix end to end, under one UI, one engine, one kill switch, with proof that nothing leaks. That is the moat: no vendor app combines other vendors' VPNs or works the same over SSH and headless.
- One fixed experience. Every VPN gets the same sidebar, keys, panels, Security Guard, kill switch, CLI and JSON. A product adds at most one thing behind one key (a server picker, a peer list, a login step); it never gets its own UI, panel or vocabulary.
- Vortix runs it. Vortix starts, supervises and stops every VPN through the native binaries
(
wg-quick,openvpn,netbird, …). It never just observes a VPN it has no profile for. - Generic by kind, specific only at the edge. Code is written once per kind of VPN (config
file, provider account, mesh network); a company is a thin driver plus labels, and nothing
else branches on it. Contract, kinds and release order:
docs/adapters.md. - Releases are features. Each minor release ships one feature a user can name (e.g. "NetBird"), only when complete and through release QA; patch releases carry fixes.
Every proposal, issue and change is judged against this. Work that does not serve it waits; changing it is the maintainer's decision alone.
How we work here
- Less code, fewer files, fewer bugs. Deleting is the best change. Before
adding a helper, search: it probably exists (
rg "fn name"). One owner per concept; a second copy of a rule is a future bug. - Fix the root, not the symptom. Grep every caller of what you touch and fix it once where all paths meet. No per-caller guards, no layered patches.
- User-visible behaviour does not change unless asked. Refactors keep every CLI flag, JSON field, TUI label and exit code.
- Remove stale code instead of working around it. Before calling something dead, read the full grep output — truncated output has fooled us twice.
- Comments: default none; one short line when the why is not obvious. No plan IDs, ticket codes or phase names in code, help text or logs.
- Search in this order before writing anything:
std, the crates already inCargo.toml, the owner in "Where things live". No new dependencies, Cargo features,unsafeor proc macros without asking. - Plain Rust. Concrete types and owned data; a
.clone()beats a lifetime orCow. No trait with one impl, generic with one instantiation, builder,Box<dyn>for a known type, or macro where a function works. Noasync, thread or channel where a direct call works. - Errors: use the module's existing type (
thiserrorenum orString). Never drop an error the user needs to see: an.ok().flatten()once made a refused credential file look like no credentials. No speculativeFromimpls. - Lints: fix them; never add an
#[allow], and delete dead code rather than allowing it. Do not reformat untouched files or bump dependencies on the side.
Commands
cargo build -p vortix # debug build → target/debug/vortix (use this for testing)
cargo test -p vortix <filter> # focused tests while iterating
scripts/ci-local.sh --quick # what CI runs (fmt, clippy, test, doc, xtask, Linux cross-clippy)
scripts/ci-local.sh # same + release build; run before every push
scripts/vpn-lab.sh ensure # the VPN servers every real tunnel uses (docs/live-testing.md)
sudo scripts/p0.sh # live smoke set against real tunnels (release QA)ci-local.sh is the only pre-push check that counts; docs/ci-parity.md
maps it to every CI job. On a Mac, Linux-only code (linux/, cfg(target_os = "linux")) is
compiled only by its Linux-target clippy and rustdoc steps; it runs in CI's Linux test and
integration jobs and on the lab. Test with debug builds; the full ci-local.sh builds
release itself.
Where things live
| Symptom / area | Look in |
| Connect, disconnect, switch, reconnect, retries | control/engine.rs (one thread), control/state.rs (tunnel state, route-conflict rule) |
| Which routes / DNS / firewall the host should have | control/plan.rs (pure), applied by control/net.rs |
| Kill switch modes and persisted state | control/killswitch.rs; firewalls in macos/firewall.rs (pf), linux/firewall.rs (nftables) |
| DNS | control/dns.rs (policy and its persisted receipt), macos/dns.rs, linux/dns.rs |
| Reading and writing kernel routes | macos/route_table.rs, linux/route_table.rs (route_interfaces_for answers many checks from one read) |
| Detecting running tunnels | control/scanner.rs |
| Starting protocol processes | control/tunnels.rs → wireguard/tunnel.rs, openvpn/tunnel.rs, mesh/mod.rs (mesh networks: daemon, login, peers; one driver file per vendor, mesh/netbird.rs); supervision in process/custodian.rs |
| Config parsing | wireguard/parser.rs, openvpn/parser.rs (the only readers of profile files); a profile becomes a tunnel spec (routes, DNS, servers) in control/specs.rs |
| Profiles on disk, import, rename, delete | config/profiles.rs, config/profile_store.rs, config/import.rs |
| Settings, config dir, file ownership under sudo | config/settings.rs, config/mod.rs, config/owned_file.rs, config/secret.rs; old profile storage upgraded by config/migration.rs |
| Saved OpenVPN credentials | config/openvpn_credentials.rs |
| Lifecycle hooks, session journal | hooks.rs, journal.rs |
| One-time upgrade notes (popup and CLI) | whats_new.rs (RELEASES, UPGRADE_URL) |
| TUI state and keys | app/ (input.rs keys, update.rs messages, connection.rs engine snapshot → render view, runtime.rs telemetry state) |
| TUI rendering | ui/dashboard/*, ui/overlays.rs (help, dialogs, upgrade popup), ui/theme.rs, ui/sigils.rs (status glyphs), ui/footer.rs, ui/helpers.rs (formatting) |
| CLI | cli/args.rs (clap), cli/commands.rs (dispatch), cli/tunnel.rs, cli/status.rs, cli/profiles.rs, cli/report.rs (vortix report), cli/output.rs (JSON envelope) |
| Public IP, ISP, latency | telemetry/ |
| Every subprocess | process/ (process::run, CommandSpec) |
| OS differences | platform.rs re-exports the per-OS type (platform::Firewall, platform::Dns, …) |
| Shared tunnel types | tunnel.rs; profile ids in profile.rs; CIDR math in cidr.rs |
How a connection works
control/ owns every VPN connection. One engine thread (engine.rs) holds the
tunnel list (state.rs), starts and stops protocol processes (tunnels.rs),
and after every change asks the pure planner (plan.rs) what routes, DNS and
firewall the host should carry, then applies the difference (net.rs). The plan
depends only on which tunnels are up and the kill switch mode — never on command
order — so every path to the same tunnel set lands on the same host state. The
newest full tunnel owns the default route and DNS. A switch between full
tunnels brings the new one up before stopping the old; tunnels that share
split routes stop first, because Linux refuses a second identical route.
TUI and CLI send control::Commands and read control::Snapshots; nothing else
touches routes, DNS or the firewall. The dashboard renders straight from the
snapshot (App::tunnels, App::tunnel, App::primary_id). New behaviour goes
into the planner or a state transition, with a test in plan.rs/state.rs —
not into a caller.
Kill switch vocabulary
One vocabulary on every surface (CLI verbs, CLI output, TUI, JSON, logs). Enum
variants never leak into output; route every string through the helpers on
control::killswitch — KillSwitchMode::display_name, cli_verb,
from_cli_verb, one_liner, behavior_lines, KillSwitchState::display_status.
| Enum | Slug | Behaviour |
Off | off | No firewall rules. Real IP exposed if the VPN drops. |
Auto | block-on-drop | Armed while a VPN is up; blocks egress only on an unexpected drop. |
AlwaysOn | vpn-only | Firewall always engaged: default-drop plus per-tunnel allow rules. State is always Blocking, never Armed. |
No aliases: auto/always are rejected with "Use: off, block-on-drop, vpn-only".
The header uses short forms (KS:Off / KS:Watch / KS:VPN-only / KS:DROPPED)
for the 80-column budget.
Boundaries (enforced by cargo xtask, run in CI)
cfg(target_os)only inmacos/,linux/,platform.rs(check-platform-leak).Command::newonly inprocess/(check-subprocess).wg/wg-quickonly inwireguard/,openvpnonly inopenvpn/, mesh vendors' binaries only inmesh/(check-protocol-leak).
Exceptions take // xtask:allow-*: <reason>. If you reach for one, move the
code instead.
TUI density
Density via signalling, not duplication. Never add a panel per tunnel; one-line
summaries and overflow ladders fit the existing layout at 80×24. Status glyphs come from
ui/sigils.rs, dialogs from overlays::confirm_dialog, scrolling lists from
helpers::render_scrolled: reuse them rather than drawing your own (P0-18b and P0-32 in
docs/manual-testing/P0.md).
Tests
Test real behaviour and edge cases. No mocks beyond process::MockRunner (canned subprocess
output), and no trait added only to make something mockable.
- Engine behaviour: unit tests in
control/plan.rsandcontrol/state.rs. - Rendering:
App::new_test(), seed tunnels withApp::set_tunnels_for_testandapp::connection::test_view, render into aTestBackend. - Integration tests live in
crates/vortix/tests/suite/behind onemain.rs. A new file there needs amodline or it silently never runs. Suites that mutate process-global state or time wall-clock stay top-leveltests/*.rs. - Things only a real kernel, terminal or VPN server can show go in
docs/manual-testing/P0.md, with a pass signal visible in a captured frame — only if no automated test can answer it.
Live testing
Machines, tmux sessions, sync and run commands and the test VPN servers are in
docs/live-testing.md; read it before any live check. Always:
- Claude never runs
sudoon the Mac; the root panes of tmuxvxrunare the only root there. - The Linux lab has passwordless sudo, allowed there only (and in its VMs).
- Run
scripts/vpn-lab.sh ensure(the full command is in that doc) before anything connects a real tunnel.
Docs
A change that alters what a user or contributor sees updates its doc in the same PR. Each fact has one owner; everything else links to it.
| Change | Owner |
| CLI command, flag, key binding, panel text, exit code, JSON field | docs/usage.md |
config.toml / settings.toml key, hooks, config dir, DNS behaviour | docs/configuration.md |
| A new error message or failure a user can hit | docs/troubleshooting.md |
| Something an upgrading user must do | docs/MIGRATION.md and whats_new.rs |
| Privilege, credentials, network calls, trust | SECURITY.md |
| CI job, local check | docs/ci-parity.md; release pipeline RELEASING.md |
| Live-only workflow | docs/manual-testing/P0.md |
| Test machines, tmux sessions, the VPN lab | docs/live-testing.md |
| Module ownership, a rule, a lesson | this file |
| How a VPN product plugs in (sources, transports) | docs/adapters.md |
README.md only summarises and links. A key change touches three places: app/input.rs, the
in-app help (HELP_TEXT in ui/overlays.rs) and docs/usage.md. cargo xtask check-docs fails on a broken link or anchor, and tests/suite/docs_match_code.rs
on an undocumented subcommand, flag or config key; CI runs both even on docs-only PRs. Everything else is caught by the
docs-review skill, which is mandatory before every commit alongside ponytail.
The tagline is the one description of Vortix: the line under the README title. Use it
verbatim wherever Vortix is described: Cargo.toml, vortix --help (cli/args.rs), the crate
docs, the npm and .deb/.rpm packages, flake.nix, funding.json, the GitHub repo
description, and external listings. Shorten it only where a field has a hard limit
(homebrew-core's desc is at most 80 characters). To change it, change every copy at once.
Secrets
Profiles contain private keys and passwords, on both machines: never print, copy or commit them. Credentials are typed by the user.
Git and PRs
- Branch from
main; one branch per fix; conventional commit subjects (fix:,refactor:,perf:,test:,docs:). Commit with the configured identity — never pass-c user.*. - The pre-commit hook (
scripts/install-hooks.sh) runs fmt, clippy, gitleaks (secret scan of staged changes) and tests..DS_Storeis ignored. - Before every commit, run the
ponytail:ponytail-reviewskill on the diff and apply its cuts (or note in the PR why one stays). It is a skill, so no git hook can run it; it is still mandatory. - Then run the
docs-reviewskill on the same diff and put its doc edits in the same commit. - PRs squash-merge into
main; a PR that fixes several issues (one commit each) rebase-merges so each fix stays its own commit.mainhas no branch protection, so the green check is ours to enforce: merge only when everygh pr checksrow ispassorskipping(the releaseplanjob runs on every PR; its publish jobs skip) and none is failing or pending. - Push only when asked, or as part of
/fix-bugor/release-qa.
Build budget
[profile.release] uses opt-level = "z", repeated in crates/vortix/Cargo.toml for
cargo install: keep the two identical. Never set panic = "abort": catch_unwind
isolates panics in tunnels, hooks and background tasks. The size budget and the other rules
are in docs/performance.md.
Removed on purpose — do not reintroduce
- Background mode, the privileged helper and the daemon (archived on branch
archive/background-mode; needs a product decision to revive). - The
vortix secretscommand,metadata.json, the iptables backend (nftables only; legacy chains are just cleaned up),core/,utils.rs, the TUI's separate tunnel registry and itsConnection/TunnelSnapshotrender model. - cargo-dist's npm installer (
"npm"ininstallers): its package downloads the binary inpostinstall, which npm 11+ global installs, pnpm and Bun skip, leaving users with EACCES.scripts/npm-package.shships the binaries instead.
Lessons that cost a CI cycle or a bug
- Blanket regex renames hit enum variants and foreign imports. Let the compiler find call sites and review the diff.
- Linux refuses a duplicate route (
RTNETLINK answers: File exists); macOS accepts it, so route-overlap bugs show only on the lab. print_error_and_exitends the process withprocess::exit, which skipsDrop: engine work that must finish goes throughControl::wait's settle, not onlyControl::drop.wg-quickparses its config in shell, seconds per kilobyte of one line on macOS; the staged copy splits longAllowedIPslines.- One macOS
route getcosts ~30 ms; check many routes from onenetstat -rnread (Routes::route_interfaces_for). cargo clippyskips rustdoc lints; broken intra-doc links fail only incargo docwith-D warnings.- pf rules for
vpn-onlyneedflags any, or existing flows lose connectivity. - Async results that arrive after the state they describe must be dropped: the telemetry worker tags results with an epoch so a pre-disconnect lookup cannot overwrite the real IP.
npm publish dir/file.tgztreats the path as a GitHubowner/repoand fails with a git error; write./dir/file.tgz. It cost the 0.5.3 npm publish.process::runreturnsOkfor a command that exits non-zero; checkCommandOutcome::success(). TreatingOkas success shipped a DNS flush that silently ignored failures.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

