agentleFS
Harry-kpHarry-kp/vortix/CLAUDE.md

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

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
  1. Very specific:

    Before merging, the reviewer agent (.claude/agents/reviewer.md) reviews every change in a fresh context.

    line 17
  2. Distinctive:

    One fixed experience: Every VPN gets the same sidebar, keys, panels, Security Guard, kill switch, CLI and JSON.

    line 28
  3. Distinctive:

    Every proposal, issue and change is judged against this.

    line 39
  4. Distinctive:

    Remove stale code: instead of working around it.

    line 51
  5. Distinctive:

    Comments: default none; one short line when the *why* is not obvious.

    line 53
  6. Distinctive:

    No plan IDs, ticket codes or phase names in code, help text or logs.

    line 54
  7. Very specific:

    Errors: use the module's existing type (thiserror enum or String).

    line 62
  8. Very specific:

    Never drop an error the user needs to see: an .ok().flatten() once made a refused credential file look like no credentials.

    line 63
  9. Very specific:

    No aliases: auto/always are rejected with "Use: off, block-on-drop, vpn-only".

    line 141
  10. Very specific:

    Test real behaviour and edge cases.

    line 165
  11. Very specific:

    Run scripts/vpn-lab.sh ensure (the full command is in that doc) before anything connects a real tunnel.

    line 185

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

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 in Cargo.toml, the owner in "Where things live". No new dependencies, Cargo features, unsafe or proc macros without asking.
  • Plain Rust. Concrete types and owned data; a .clone() beats a lifetime or Cow. No trait with one impl, generic with one instantiation, builder, Box<dyn> for a known type, or macro where a function works. No async, thread or channel where a direct call works.
  • Errors: use the module's existing type (thiserror enum or String). Never drop an error the user needs to see: an .ok().flatten() once made a refused credential file look like no credentials. No speculative From impls.
  • 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 / areaLook in
Connect, disconnect, switch, reconnect, retriescontrol/engine.rs (one thread), control/state.rs (tunnel state, route-conflict rule)
Which routes / DNS / firewall the host should havecontrol/plan.rs (pure), applied by control/net.rs
Kill switch modes and persisted statecontrol/killswitch.rs; firewalls in macos/firewall.rs (pf), linux/firewall.rs (nftables)
DNScontrol/dns.rs (policy and its persisted receipt), macos/dns.rs, linux/dns.rs
Reading and writing kernel routesmacos/route_table.rs, linux/route_table.rs (route_interfaces_for answers many checks from one read)
Detecting running tunnelscontrol/scanner.rs
Starting protocol processescontrol/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 parsingwireguard/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, deleteconfig/profiles.rs, config/profile_store.rs, config/import.rs
Settings, config dir, file ownership under sudoconfig/settings.rs, config/mod.rs, config/owned_file.rs, config/secret.rs; old profile storage upgraded by config/migration.rs
Saved OpenVPN credentialsconfig/openvpn_credentials.rs
Lifecycle hooks, session journalhooks.rs, journal.rs
One-time upgrade notes (popup and CLI)whats_new.rs (RELEASES, UPGRADE_URL)
TUI state and keysapp/ (input.rs keys, update.rs messages, connection.rs engine snapshot → render view, runtime.rs telemetry state)
TUI renderingui/dashboard/*, ui/overlays.rs (help, dialogs, upgrade popup), ui/theme.rs, ui/sigils.rs (status glyphs), ui/footer.rs, ui/helpers.rs (formatting)
CLIcli/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, latencytelemetry/
Every subprocessprocess/ (process::run, CommandSpec)
OS differencesplatform.rs re-exports the per-OS type (platform::Firewall, platform::Dns, …)
Shared tunnel typestunnel.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.

EnumSlugBehaviour
OffoffNo firewall rules. Real IP exposed if the VPN drops.
Autoblock-on-dropArmed while a VPN is up; blocks egress only on an unexpected drop.
AlwaysOnvpn-onlyFirewall 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 in macos/, linux/, platform.rs (check-platform-leak).
  • Command::new only in process/ (check-subprocess).
  • wg/wg-quick only in wireguard/, openvpn only in openvpn/, mesh vendors' binaries only in mesh/ (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.rs and control/state.rs.
  • Rendering: App::new_test(), seed tunnels with App::set_tunnels_for_test and app::connection::test_view, render into a TestBackend.
  • Integration tests live in crates/vortix/tests/suite/ behind one main.rs. A new file there needs a mod line or it silently never runs. Suites that mutate process-global state or time wall-clock stay top-level tests/*.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 sudo on the Mac; the root panes of tmux vxrun are 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.

ChangeOwner
CLI command, flag, key binding, panel text, exit code, JSON fielddocs/usage.md
config.toml / settings.toml key, hooks, config dir, DNS behaviourdocs/configuration.md
A new error message or failure a user can hitdocs/troubleshooting.md
Something an upgrading user must dodocs/MIGRATION.md and whats_new.rs
Privilege, credentials, network calls, trustSECURITY.md
CI job, local checkdocs/ci-parity.md; release pipeline RELEASING.md
Live-only workflowdocs/manual-testing/P0.md
Test machines, tmux sessions, the VPN labdocs/live-testing.md
Module ownership, a rule, a lessonthis 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_Store is ignored.
  • Before every commit, run the ponytail:ponytail-review skill 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-review skill 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. main has no branch protection, so the green check is ours to enforce: merge only when every gh pr checks row is pass or skipping (the release plan job runs on every PR; its publish jobs skip) and none is failing or pending.
  • Push only when asked, or as part of /fix-bug or /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 secrets command, metadata.json, the iptables backend (nftables only; legacy chains are just cleaned up), core/, utils.rs, the TUI's separate tunnel registry and its Connection/TunnelSnapshot render model.
  • cargo-dist's npm installer ("npm" in installers): its package downloads the binary in postinstall, which npm 11+ global installs, pnpm and Bun skip, leaving users with EACCES. scripts/npm-package.sh ships 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_exit ends the process with process::exit, which skips Drop: engine work that must finish goes through Control::wait's settle, not only Control::drop.
  • wg-quick parses its config in shell, seconds per kilobyte of one line on macOS; the staged copy splits long AllowedIPs lines.
  • One macOS route get costs ~30 ms; check many routes from one netstat -rn read (Routes::route_interfaces_for).
  • cargo clippy skips rustdoc lints; broken intra-doc links fail only in cargo doc with -D warnings.
  • pf rules for vpn-only need flags 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.tgz treats the path as a GitHub owner/repo and fails with a git error; write ./dir/file.tgz. It cost the 0.5.3 npm publish.
  • process::run returns Ok for a command that exits non-zero; check CommandOutcome::success(). Treating Ok as success shipped a DNS flush that silently ignored failures.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.