How to set up a development environment, build, test, and debug Neru, and where to put new code.
This guide owns the local workflow. Neighbouring documents own the rest: contribution process and commit conventions in CONTRIBUTING.md, the architectural reference in ARCHITECTURE.md, per-platform support and platform file layout in CROSS_PLATFORM.md, and conventions in the root AGENTS.md. None of those are repeated here.
- Quick Start
- Development Setup
- Common Tasks
- Building
- Testing
- Debugging
- Adding Code
- Release Process
- Resources
git clone https://github.com/y3owk1n/neru.git
cd neru
devbox shell # or: brew install go just golangci-lint llvm
just build
./bin/neru launch # runs in the foregroundThen from a second terminal:
./bin/neru hints # should show hint overlaysThere is no just run recipe — build first, then launch the daemon directly.
The CLI talks to the running daemon over a socket, so both halves come from the
same ./bin/neru binary.
For end-user installation (Homebrew, Nix, prebuilt binaries) see INSTALLATION.md; for preparing a Linux host see LINUX_SETUP.md.
- Go 1.26+ — Install Go
- Xcode Command Line Tools (macOS) —
xcode-select --install - Just — command runner — install
- golangci-lint — linter — install
Devbox provides an isolated environment with every tool pre-configured:
curl -fsSL https://get.jetify.com/devbox | bash
devbox shell # enter the shell manuallyOr let direnv activate it automatically — install direnv,
add eval "$(direnv hook bash)" (or zsh/fish) to your shell, and the .envrc
in the repo root takes over whenever you cd in.
Devbox manages Go 1.26+, gopls, gotools, gofumpt, golines, golangci-lint, just, and clang-tools (for CGo).
brew install go just golangci-lint llvmllvm supplies clang-format for Objective-C formatting. Devbox's extra tools
are optional and installable on their own:
go install golang.org/x/tools/gopls@latest
go install mvdan.cc/gofumpt@latest
go install github.com/segmentio/golines@latestgo version # 1.26+
just --version
golangci-lint --version
just --list # all available recipesAn EditorConfig plugin is worth installing — .editorconfig carries the tab and
line-ending rules that CI enforces.
Every build, test, and lint entry point goes through just. This table is the
single reference for them; just --list shows the full set including the
Wayland protocol generation and icon recipes.
| Task | Command | Description |
|---|---|---|
| Build | just build |
Compile for the current platform |
| Build | just build-darwin |
Build a macOS binary (on macOS) |
| Build | just build-linux [ARCH] |
Build a Linux binary (defaults to amd64) |
| Build | just build-windows [ARCH] |
Build a Windows binary |
| Build | just build-version v1.0.0 |
Build with an explicit version string |
| Build | just release |
Optimized, stripped release build |
| Bundle | just bundle |
Release build + macOS Neru.app (ad-hoc signed) |
| Install | just install [-y] |
Install an already-built Neru; -y auto-accepts |
| Test | just test |
Unit + integration (desktop-safe: never drives your cursor) |
| Test | just test-unit |
Unit tests only |
| Test | just test-integration |
Integration tests only (desktop-safe) |
| Test | just test-desktop |
Integration incl. tests that drive the real cursor/keyboard/overlays |
| Test | just test-foundation |
Fast cross-platform-safe slice; CI runs it too |
| Test | just test-race |
Unit + integration with -race |
| Test | just test-race-unit |
Unit tests with -race |
| Test | just test-race-integration |
Integration tests with -race |
| Test | just test-all |
test and test-race, desktop tests included — the deepest sweep |
| Test | just test-ci |
What CI gates on: foundation, unit, race-unit, short-integration |
| Test | just coverage |
Unit tests with coverage; prints the total |
| Test | just coverage-html |
Coverage as a browsable coverage.html |
| Lint | just lint |
golangci-lint + clang-tidy on .m files (macOS) |
| Lint | just vet |
go vet |
| Lint | just vuln |
govulncheck — reachable CVEs in dependencies |
| Format | just fmt |
Format Go and Objective-C |
| Format | just fmt-check |
Check Objective-C formatting |
| Docs | just genman |
Generate man pages |
| Docs | just genflagref |
Rewrite the mode-flag reference in docs/CLI.md |
| Clean | just clean |
Remove build artifacts |
Targeting a single package or test:
go test ./internal/domain/hint/
go test -run TestHandler_HandleKey ./internal/app/modes/
go test -tags=integration ./internal/adapter/accessibility/Watch mode, if you have entr:
find . -name "*.go" | entr -r just testVERSION=$(git describe --tags --always --dirty)
go build \
-ldflags="-s -w -X github.com/y3owk1n/neru/internal/buildinfo.Version=$VERSION" \
-trimpath \
-o bin/neru \
./cmd/neru-ldflags="-s -w"— strip debug info and symbol table (smaller binary)-trimpath— remove filesystem paths from the binary-X pkg.Var=value— inject the version at build time
Starting Linux or Windows work? The minimum smoke test is:
just build
just test-foundationthen just build-linux / just build-windows / just build-darwin for your
target. Cross-compiled binaries build from any host, but only the target OS can
run just test meaningfully — integration tests are tagged per-OS, and
cross-compiling to Linux from macOS is not supported (CGO plus Linux headers).
Backend, CGO, and modifier expectations are not per-OS constants; start from profile.go and CROSS_PLATFORM.md.
Neru has four testing layers:
- Unit tests — shared Go logic with no native OS dependency, using mocks
from
internal/ports/mocks. - Contract tests — ports and adapters agreeing on error semantics such as
CodeNotSupported. - Integration tests — real OS/native behavior behind the
integrationbuild tag. - Architecture tests — guardrails protecting package boundaries and
platform isolation (
internal/architecture/).
When you add a stubbed platform feature, add or update a contract test so the unsupported behavior is explicit and stable until the real implementation lands.
| Type | File pattern | Build tag | Command |
|---|---|---|---|
| Unit | *_test.go |
— | just test-unit |
| Integration | *_integration_<os>_test.go |
integration && <os> |
just test-integration |
Tests are table-driven and named TestType_Method_EdgeCase. Naming, mocks, and
build-tag conventions are in the root AGENTS.md; the macOS
main-run-loop test harness is documented in
darwin/AGENTS.md.
Unit — hint generation, grid calculations, element filtering, action processing, mode transitions, config parsing/validation/defaults, and CLI argument handling. These run everywhere.
Integration — almost all of these are macOS: real Accessibility and
event tap APIs, global hotkey registration, overlay and window management, Unix
socket IPC, config file loading and reloading, and service-to-adapter
coordination. Linux has exactly one so far — the fontconfig font resolver
(internal/adapter/platform/linux/font_integration_linux_test.go, which reads
what is installed with fc-list and skips where fontconfig has nothing to
report) — and Windows has none, so behavior on both is otherwise pinned by unit
and contract tests alone. Adding real ones is one of the more valuable
contributions available.
No just recipe runs the Linux ones: just test-linux runs the container
without the integration tag, and just test-integration runs on the host.
Until there is a recipe, run them the way the container recipe does and add the
tag — docker run --rm -v "$PWD":/src -w /src -e CGO_ENABLED=1 neru-linux-ci go test -tags=integration ./... — on an image with fonts and fc-list installed
(fontconfig fonts-dejavu-core). CI covers them on ubuntu-latest, where
just test-ci runs the integration suite natively.
Integration tests exercise the real OS, and on macOS that has consequences worth knowing before your first run:
- They move your cursor and type keystrokes. Don't run them while you're typing in another window; the tests and you are sharing one physical input device.
- Your terminal needs Accessibility permission (System Settings → Privacy & Security → Accessibility). Without it, accessibility- and event-tap-backed tests fail with permission errors rather than skipping.
- Quit any running
nerudaemon first. A live daemon holds the IPC socket, which makes the IPC integration tests silently skip — a green run that tested less than you think. just test-integrationruns with-p 1(one package at a time — concurrent packages would fight over the one physical cursor) and-count=1(no test cache — Go's cache can't see whether Accessibility was granted or a daemon held the socket, so a cached pass may be from a run under different conditions).
Enable debug logging in ~/.config/neru/config.toml:
[logging]
log_level = "debug"Then follow the log:
tail -f ~/Library/Logs/neru/app.log # macOS
tail -f ~/.local/state/neru/log/app.log # LinuxFor a step debugger:
dlv debug ./cmd/neruWhat belongs at which log level — and what must never be logged — is in the root AGENTS.md under Conventions.
| Directory | Role |
|---|---|
internal/domain/ |
Pure business logic, entities, value objects |
internal/ports/ |
Interface contracts (Accessibility, Overlay, Font) |
internal/adapter/ |
Platform-specific adapter implementations |
internal/app/ |
Application orchestration, services, modes |
internal/app/components/ |
Mode-specific overlay rendering |
internal/app/modes/ |
Navigation mode implementations |
internal/cli/ |
Cobra CLI commands, IPC dispatch |
internal/config/ |
TOML parsing, validation, defaults |
Layer responsibilities and the boundaries between them are in ARCHITECTURE.md; platform file-slot naming is in CROSS_PLATFORM.md.
Configuration options — the full chain (schema → defaults → platform
overrides → validation → examples → docs) is documented in
internal/config/AGENTS.md; the
add-config-option skill in .agents/skills/ walks it step by step.
Actions
- Define the action in
internal/domain/action/action.go - Implement logic in
internal/app/services/action_service.go - Wire pending-action dispatch in
internal/app/modes/mode_handlers.go(the per-mode files set it viaContext.SetPendingAction) - Update config and documentation
UI components
- Create the component in
internal/app/components/ - Implement drawing in
internal/adapter/overlay/render/ - macOS Objective-C goes in
internal/adapter/platform/darwin/behind//go:build darwin, with a no-op stub elsewhere - Build the render overlay in
internal/adapter/overlay/manager/components.go— the overlay constructs what it draws — and assemble the app-side component ininternal/app/component_factory.go
CLI commands — cobra command in internal/cli/ (registered in an
init()), the matching IPC handler in internal/app/ipcctrl/, just genman,
and CLI.md; the add-cli-command skill walks it step by step.
Mode flags — one entry in the descriptor table in
internal/domain/modecmd, then just genflagref. The entry is what registers
the flag on every command that accepts it and what writes its row in
CLI.md; an architecture test fails while either is missing.
Wiring is manual and explicit — constructors take their dependencies, and
internal/app/new.go assembles everything in numbered phases
that unwind in reverse on failure.
app.New takes functional options (options.go),
which is how tests substitute doubles for the ports they need — WithSystemPort,
WithEventTap, WithIPCServer, WithOverlayPort, WithHotkeyService,
WithWatcher, plus WithConfig / WithConfigPath / WithLogger. An option
that is not supplied falls back to the real adapter built during initialization.
hintService := services.NewHintService(accAdapter, overlayAdapter, systemPort, hintGen, cfg.Hints, logger, visionPort)
gridService := services.NewGridService(overlayAdapter, systemPort, logger)
actionService := services.NewActionService(accAdapter, overlayAdapter, systemPort, logger)Every navigation mode implements Mode (Activate(modecmd.Activation) /
HandleKey(string) / Exit() / ModeType() /
RefreshForMonitorMove(context.Context, image.Rectangle)), defined in
handler.go. Each mode is its own type with
its own bodies for the four behavioural methods — the shape a new mode has to
follow is stated in
internal/app/modes/AGENTS.md.
A new CLI flag that varies a mode's activation means a new flag descriptor in
internal/domain/modecmd and the Activation
field it writes, not a new interface method.
All four run with the handler lock already held — the full locking contract lives in internal/app/modes/AGENTS.md; read it before touching anything that calls back into the handler.
Releases are automated by Release Please. Merging the release PR builds and publishes the binaries on GitHub.
Versioning is semantic — vMAJOR.MINOR.PATCH: breaking changes, backward-
compatible features, bug fixes. Because Release Please derives the changelog
from commit subjects, the conventional commit
format is what ships to users.
Note
The Homebrew version bump lives in a separate repo and is updated separately.