Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:
if: ${{ steps.version.outputs.skip != 'true' }}
uses: actions/setup-go@v7
with:
go-version: 1.25.x
go-version: 1.26.x
check-latest: true

- name: Run goreleaser
Expand Down
32 changes: 26 additions & 6 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: 1.25.x
go-version: 1.26.x
check-latest: true

- name: Test
Expand All @@ -37,6 +37,26 @@ jobs:
fi
"$install_dir/$binary" version

- name: Test uninstall
shell: bash
run: |
uninstall_dir="$RUNNER_TEMP/n2k-uninstall"
mkdir -p "$uninstall_dir"
binary=n2k
if [ "$RUNNER_OS" = Windows ]; then
binary=n2k.exe
fi
go build -trimpath -o "$uninstall_dir/$binary" ./cmd/n2k
HOME="$RUNNER_TEMP/n2k-uninstall-home" "$uninstall_dir/$binary" uninstall
for attempt in $(seq 1 100); do
if [ ! -e "$uninstall_dir/$binary" ]; then
exit 0
fi
sleep 0.1
done
echo "n2k uninstall did not remove $uninstall_dir/$binary" >&2
exit 1

race:
runs-on: ubuntu-latest

Expand All @@ -47,7 +67,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: 1.25.x
go-version: 1.26.x
check-latest: true

- name: Race detector
Expand All @@ -63,7 +83,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: 1.25.x
go-version: 1.26.x
check-latest: true

- name: golangci-lint
Expand All @@ -81,19 +101,19 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: 1.25.x
go-version: 1.26.x
check-latest: true

- name: Run govulncheck
env:
GOTOOLCHAIN: go1.25.12
GOTOOLCHAIN: go1.26.5
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.5.0
govulncheck ./...

- name: Run gosec
env:
GOTOOLCHAIN: go1.25.12
GOTOOLCHAIN: go1.26.5
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1
gosec -exclude-dir=.claude -exclude=G115 ./...
Expand Down
129 changes: 18 additions & 111 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,20 @@
# n2k-cli
# [NMEA 2000](https://www.nmea.org/nmea-2000.html) for Humans and Agents

[![CI](https://github.com/open-ships/n2k-cli/actions/workflows/test.yaml/badge.svg)](https://github.com/open-ships/n2k-cli/actions/workflows/test.yaml)
[![Release](https://img.shields.io/github/v/release/open-ships/n2k-cli)](https://github.com/open-ships/n2k-cli/releases)

**Turn raw NMEA 2000 traffic into typed, searchable, scriptable data.**

Marine-network problems often begin as opaque CAN frames: which device sent
this message, what does the payload mean, and can the failure be reproduced?
`n2k` makes that traffic useful without hiding the wire. It combines a guided
Bubble Tea command center with deterministic CLI commands for decoding,
recording, replaying, validating, filtering, and device discovery.
Decode, record, replay, validate, filter, and discover devices - all from a nicely packaged TUI.

The CLI is powered by the typed
[`open-ships/n2k`](https://github.com/open-ships/n2k) Go library.
Powered by the [`open-ships/n2k`](https://github.com/open-ships/n2k) Go library.

## Install
## Installation

Every installation provides the same `n2k` command.

### One-line installer

Copy and run:

```bash
curl -fsSL https://raw.githubusercontent.com/open-ships/n2k-cli/main/install.sh | sh
```

The installer detects the platform, downloads the matching release, verifies
its SHA-256 checksum, and installs `n2k` into `~/.local/bin` without `sudo`. If
that directory is not already on `PATH`, it prints the exact command to add it.

| Operating system | Architectures | Shell |
|------------------|---------------|-------|
| macOS | Intel (`amd64`), Apple Silicon (`arm64`) | `sh`, `bash`, or `zsh` |
| Linux | `amd64`, `arm64` | `sh`, `bash`, or `zsh` |
| Windows | `amd64`, `arm64` | Git Bash, MSYS2, or Cygwin |

WSL is detected as Linux. To choose another destination or pin a release:

```bash
curl -fsSL https://raw.githubusercontent.com/open-ships/n2k-cli/main/install.sh \
| N2K_INSTALL_DIR="$HOME/bin" N2K_VERSION=v1.0.2 sh
```

### Homebrew

Expand All @@ -58,85 +31,25 @@ Homebrew upgrades are also available directly:
brew upgrade --cask open-ships/tap/n2k
```

### Go

With Go 1.25.8 or newer:

```bash
go install github.com/open-ships/n2k-cli/cmd/n2k@latest
n2k version
```

Go writes the binary to `GOBIN`, which defaults to `$(go env GOPATH)/bin`;
make sure that directory is on `PATH`:

```bash
export PATH="$(go env GOPATH)/bin:$PATH"
```

If you set a custom `GOBIN`, `n2k update` preserves it and replaces that same
installation.

### Release binaries

Checksum-protected archives for macOS, Linux, and Windows are published on the
[`n2k-cli` releases page](https://github.com/open-ships/n2k-cli/releases).

### Build from source

For contributors or local development:

```bash
git clone https://github.com/open-ships/n2k-cli
cd n2k-cli
just build # writes bin/n2k
./bin/n2k --help
just install # optional: installs n2k into Go's bin directory
```

## Why `n2k`?

### Guided when you are exploring

Run `n2k` with no arguments. Search the workflow palette, choose a source, and
configure the operation with validated fields and autocomplete. Before
anything touches the network, `n2k` shows the equivalent copyable CLI command.

![The n2k Bubble Tea command center showing searchable decode, record, replay, validate, device, schema, and update workflows](.github/tui.svg)

### Precise when you are automating
![The n2k command center showing searchable decode, record, replay, validate, device, schema, and update workflows](.github/tui.svg)

The same workflows have stable flags, meaningful exit codes, dynamic shell
completion, CEL filters, and JSON-lines output. Typed values remain paired with
their exact wire representation, so pipelines are convenient without losing
diagnostic fidelity.

![The n2k CLI decoding a sailing-vessel capture into typed JSON lines with PGNs and exact wire values](.github/demo.svg)

### One tool for the whole debugging loop

| Need | What `n2k` provides |
| Use Case | Why use `n2k cli` |
|------|---------------------|
| Inspect traffic now | Decode SocketCAN, USB-CAN, TCP, UDP, candump, and gzip captures. |
| Reproduce a problem | Record owned observations, then replay with original or disabled timing. |
| Reduce the firehose | Filter messages with CEL and select typed, text, JSON, or unknown-PGN output. |
| Reproduce a problem | Record owned observations, replay with original or disabled timing. |
| Reduce the firehose | Filter messages using [Common Expression Language](https://cel.dev/) and select typed, text, JSON, or unknown-PGN output. |
| Find devices | Actively discover a writable network or passively inventory a saved capture. |
| Measure decoder coverage | Validate a source, count undecodable PGNs, and fail CI in strict mode. |
| Understand a PGN | Autocomplete every known PGN and inspect fields, units, ranges, and confidence. |
| Keep tooling current | Update through Homebrew, Go, or checksum-verified release binaries. |

## Quick Start — No Boat Required

The repository includes a privacy-scrubbed, six-second sailing-vessel capture,
so you can try the decoder without CAN hardware:

```bash
git clone --depth 1 https://github.com/open-ships/n2k-cli
cd n2k-cli
n2k sniff --file testdata/sample.log --output text
```

Frames containing vessel position, routes, or identity were removed.

## Rich terminal workflows

Expand All @@ -159,14 +72,6 @@ The command center provides:
- Terminal-aware colors, contextual key help, cancellation, and a full-screen
alternate buffer that leaves the shell clean.

For screen readers or terminals that cannot redraw reliably, use accessible
prompts:

```bash
n2k tui --accessible
# Or persist the preference:
export N2K_ACCESSIBLE=1
```

### Scriptable commands

Expand Down Expand Up @@ -211,6 +116,7 @@ n2k pgn list | jq 'select(.complete == true)'
| `n2k devices` | Actively discover a writable bus or passively inventory devices observed in a capture/UDP stream. |
| `n2k pgn` | Query or list typed PGN metadata, fields, ranges, and confidence. |
| `n2k update` | Check for a release and update through Homebrew, Go, or a verified release binary. |
| `n2k uninstall` | Remove n2k and its update-check cache from the machine. |

Run `n2k help <command>` for organized flags, defaults, allowed values, and
examples. The purpose-built parser enforces canonical `--long-flags`, supports
Expand All @@ -231,13 +137,6 @@ Check for and install the latest release:
n2k update
```

The updater preserves the installation method:

- Homebrew installations run `brew upgrade --cask open-ships/tap/n2k`.
- Go installations run `go install` against the exact latest release tag.
- Downloaded release binaries are replaced atomically after their archive
matches the release's `checksums.txt`.

Useful controls:

```bash
Expand All @@ -252,6 +151,14 @@ without the confirmation prompt, or `N2K_NO_UPDATE_CHECK=1` to disable the
automatic check. Scriptable commands never perform implicit network requests or
updates.

### Uninstall

Remove n2k and its update-check cache:

```bash
n2k uninstall
```

### Shell completion

Enable completion for the current shell with one of:
Expand Down Expand Up @@ -287,7 +194,7 @@ decode errors.

## Development

Go 1.25.8 or newer and `just` are required.
Go 1.26.5 or newer and `just` are required.

```bash
just setup # first checkout only
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0
1.1.0
27 changes: 19 additions & 8 deletions cmd/n2k/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,20 @@ func (parsed parsedCommand) durationValue(name string) (time.Duration, error) {
}

type cli struct {
in io.Reader
out io.Writer
errOut io.Writer
updater updaterService
in io.Reader
out io.Writer
errOut io.Writer
updater updaterService
uninstaller uninstallerService
}

func newCLI(in io.Reader, out, errOut io.Writer) *cli {
return &cli{
in: in,
out: out,
errOut: errOut,
updater: newUpdaterService(),
in: in,
out: out,
errOut: errOut,
updater: newUpdaterService(),
uninstaller: newUninstallerService(),
}
}

Expand Down Expand Up @@ -226,6 +228,8 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
return writeVersion(app.out)
case "update":
return app.runUpdate(ctx, parsed)
case "uninstall":
return app.runUninstall(ctx)
default:
return fmt.Errorf("command %q is not executable", parsed.spec.name)
}
Expand Down Expand Up @@ -539,6 +543,12 @@ func commandSpecs() []commandSpec {
},
},
},
{
name: "uninstall",
summary: "Remove n2k from this machine",
usage: "uninstall",
maxArgs: 0,
},
}
}

Expand Down Expand Up @@ -600,6 +610,7 @@ func writeRootHelp(out io.Writer) error {
_, _ = fmt.Fprintln(writer, " • Use n2k help <command> for flags, defaults, choices, and examples.")
_, _ = fmt.Fprintln(writer, " • Enable shell completion with n2k completion <shell>.")
_, _ = fmt.Fprintln(writer, " • Check and install releases with n2k update.")
_, _ = fmt.Fprintln(writer, " • Remove n2k and its update cache with n2k uninstall.")
_, _ = fmt.Fprintln(writer)
_, _ = fmt.Fprintln(writer, "Examples:")
_, _ = fmt.Fprintln(writer, " n2k sniff --tcp 192.168.4.1:1457")
Expand Down
2 changes: 2 additions & 0 deletions cmd/n2k/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func TestRootHelpIsDiscoverableWithoutATerminal(t *testing.T) {
require.Contains(t, stdout, "interactive command center")
require.Contains(t, stdout, "completion")
require.Contains(t, stdout, "devices")
require.Contains(t, stdout, "uninstall")
require.Contains(t, stdout, "validate")
}

Expand Down Expand Up @@ -172,6 +173,7 @@ func TestDynamicCompletionUnderstandsCommandsFlagsValuesAndPGNs(t *testing.T) {
{name: "pgn", args: []string{"__complete", "pgn", "12725"}, want: "127250\tVessel Heading"},
{name: "devices action", args: []string{"__complete", "devices", "li"}, want: "list\tInventory observed devices"},
{name: "update method", args: []string{"__complete", "update", "--method", "h"}, want: "homebrew\tupgrade the Homebrew cask"},
{name: "uninstall", args: []string{"__complete", "unin"}, want: "uninstall\tRemove n2k"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
Expand Down
Loading