Skip to content
Open
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
35 changes: 34 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,39 @@ on:
branches: [main]

jobs:
# The same lint and the same checks as the Helmcode CLI, so the two repos
# are held to one standard and a fix in either reads the same in both.
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: golangci-lint
uses: golangci/golangci-lint-action@v9.3.0
with:
# Pinned, not "latest": a release of the linter should not turn
# this repo's CI red on a morning nobody touched it.
version: v2.14.0
args: --timeout=5m

# `go fmt ./...` rewrites the files and exits 0 either way, so as a CI
# step it cannot fail. This one exits non-zero on a difference.
- name: Formatting
run: golangci-lint fmt --diff

# THIRD-PARTY-NOTICES.txt has to match what the binary actually links,
# or the file rots the first time somebody adds a dependency and the
# Apache-2.0 obligation quietly stops being met.
- name: Third-party notices
run: |
go run scripts/gen-notices.go
git diff --exit-code THIRD-PARTY-NOTICES.txt

test:
# Everything here ran on ubuntu only, so darwin was a platform we shipped
# binaries for and had never executed a line on: it cross-compiled, and
Expand Down Expand Up @@ -66,7 +99,7 @@ jobs:
os="${target%/*}"
arch="${target#*/}"
echo "building $os/$arch"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -o /dev/null .
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -o /dev/null ./cmd/nan
done

# The installers are the one thing here that is not Go and is not tested
Expand Down
19 changes: 11 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,17 @@ jobs:
echo "value=$tag" >> "$GITHUB_OUTPUT"

# The version shown by `nan --version` is a constant in the source
# (internal/tui.Version). A tag that disagrees with it ships a binary
# (internal/build.Version). A tag that disagrees with it ships a binary
# that lies about which build it is, and the first person to report a
# bug reports the wrong version.
- name: Check the tag matches internal/tui.Version
- name: Check the tag matches internal/build.Version
env:
TAG: ${{ steps.tag.outputs.value }}
run: |
declared="$(grep -oP 'const Version = "\K[^"]+' internal/tui/tui.go)"
declared="$(grep -oP '^var Version = "\K[^"]+' internal/build/build.go)"
tag="$TAG"
if [ "v$declared" != "$tag" ]; then
echo "tag $tag does not match internal/tui.Version ($declared)" >&2
echo "tag $tag does not match internal/build.Version ($declared)" >&2
echo "bump the constant or retag" >&2
exit 1
fi
Expand All @@ -119,16 +119,19 @@ jobs:
# -trimpath keeps build paths out of the binary; -s -w drops the
# symbol and DWARF tables, which is most of the size.
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags "-s -w" -o "dist/$binary" .
go build -trimpath -ldflags "-s -w" -o "dist/$binary" ./cmd/nan
# Windows gets a .zip, not a .tar.gz: nothing on a stock Windows
# unpacks a tarball by double-clicking, and scripts/install.sh is
# bash, so nobody reaches these through the one-liner anyway.
# THIRD-PARTY-NOTICES.txt travels with every copy: one dependency
# is Apache-2.0, and its notice has to go wherever the binary goes.
cp THIRD-PARTY-NOTICES.txt dist/
if [ "$os" = "windows" ]; then
(cd dist && zip -q "nan-cli_${VERSION}_${os}_${arch}.zip" "$binary")
(cd dist && zip -q "nan-cli_${VERSION}_${os}_${arch}.zip" "$binary" THIRD-PARTY-NOTICES.txt)
else
tar -czf "dist/nan-cli_${VERSION}_${os}_${arch}.tar.gz" -C dist "$binary"
tar -czf "dist/nan-cli_${VERSION}_${os}_${arch}.tar.gz" -C dist "$binary" THIRD-PARTY-NOTICES.txt
fi
rm "dist/$binary"
rm "dist/$binary" dist/THIRD-PARTY-NOTICES.txt
done
cd dist && sha256sum *.tar.gz *.zip > checksums.txt
cat checksums.txt
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/tag.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: tag

# Publishing a release was the one step nobody automated, and it showed: #26
# bumped internal/tui.Version to 0.1.20 on the 17th, and four days later
# bumped internal/build.Version to 0.1.20 on the 17th, and four days later
# /releases/latest still answered v0.1.19. Everything downstream was fine - the
# release workflow works, the installer works - but they only run once someone
# remembers to push a tag, and the fixes sat in main where nobody could install
Expand Down Expand Up @@ -44,19 +44,19 @@ jobs:
# The commit CI passed on, not whatever main has drifted to since.
ref: ${{ github.event.workflow_run.head_sha }}

- name: Tag the commit if internal/tui.Version is new
- name: Tag the commit if internal/build.Version is new
id: tag
env:
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
declared="$(grep -oP 'const Version = "\K[^"]+' internal/tui/tui.go)"
declared="$(grep -oP '^var Version = "\K[^"]+' internal/build/build.go)"
tag="v$declared"
# The same shape the release workflow insists on. A constant that is
# not a version we can tag is a mistake in the source, and finding
# out here beats finding out from a tag named v0.1.20-wip.
if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+[.][0-9]+[.][0-9]+([-][A-Za-z0-9.]+)?$'; then
echo "internal/tui.Version is not something we can tag: $declared" >&2
echo "internal/build.Version is not something we can tag: $declared" >&2
exit 1
fi
echo "value=$tag" >> "$GITHUB_OUTPUT"
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Binaries
nan
/nan
*.exe
*.exe~
*.dll
Expand Down Expand Up @@ -45,4 +45,4 @@ vendor/
tmp/
temp/
*.log
nan.exe
/nan.exe
13 changes: 13 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# gitleaks, as CI runs it: every default rule, and one exception.
#
# Test files carry fake keys on purpose (sk-hke_2b8d0000aaaa441e and friends):
# the tests are about how keys are matched, masked and written, and need
# something shaped like one. None is real, and every real credential stays
# out of the tree (session.json, .env). A secret in a non-test file still fails.

[extend]
useDefault = true

[allowlist]
description = "Fake keys in tests"
paths = ['''_test\.go$''']
48 changes: 48 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# The house base config. Same set in every Go repo so a finding here means
# the same thing as a finding there; the only per-repo line is the gci prefix.
version: "2"

# errcheck, govet, ineffassign, staticcheck and unused are on by default in v2.
linters:
enable:
# Correctness
- bodyclose # HTTP bodies left unclosed
- copyloopvar
- errorlint # %v instead of %w, == instead of errors.Is
- nilerr # returning nil when err != nil
- noctx # HTTP requests with no context
# Security
- gosec
# Style
- gocritic
- misspell
- revive
- unconvert
- unparam
- wastedassign
# Performance
- prealloc

exclusions:
rules:
# A doc comment is obligatory in pkg/, where it shows up on
# pkg.go.dev. Inside internal/ it would mostly restate the name.
# revive's wording is "exported const X should have comment (or a
# comment on this block) or be unexported", so the obvious text to
# exclude — "should have comment or be unexported" — never matches.
- path: internal/
text: "should have comment"
linters: [revive]
- path: _test\.go
linters: [gosec, unparam]

formatters:
enable:
- gofumpt
- gci
settings:
gci:
sections:
- standard
- default
- prefix(github.com/nxssie/nan-cli)
75 changes: 40 additions & 35 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ Thanks for your interest in contributing. This document covers how to get the pr

## Prerequisites

- **Go 1.26.9+** — the version in `go.mod`, which is where the standard
library carries the current security fixes. The project uses [mise](https://mise.jdx.dev/) to pin the version. Run `mise install` in the repo root and Go will be available automatically.
- A [nan.builders](https://nan.builders) account with an API key (needed to test the TUI at runtime).
- **Go 1.27.2+**, the version in `go.mod`, which is where the standard
library carries the current security fixes. The project uses [mise](https://mise.jdx.dev/) to pin it, with golangci-lint, lefthook and just: run `just setup` (or `mise install`) in the repo root.
- A [nan.builders](https://nan.builders) account with an API key (needed to try the dashboard at runtime).

## Getting started

```bash
git clone https://github.com/helmcode/nan-cli
cd nan-cli
go build -o nan .
just build # or: go build -o nan ./cmd/nan
./nan
```

The binary opens the Bubbletea TUI directly. Use the **Setup** tab to configure your API key and test tool auto-configuration.
The binary opens the dashboard. Its **Harnesses** area (6, or `e` from anywhere) sets your API key and configures the tools; `nan setup` does the same from the command line.

To run against a local stand-in instead of production, point `NAN_API_URL`,
`NAN_APP_URL` and `NAN_INFERENCE_URL` at it, or use `just local`.

To install a released binary instead of building from source:

Expand All @@ -33,38 +36,40 @@ INSTALL_DIR=~/.local/bin curl -fsSL https://nan.builders/install | bash

## Project structure

The same structure as the Helmcode CLI, on purpose: the two are maintained
the same way.

```
main.go Entry point — delegates to cmd.Execute()
cmd/ Cobra subcommands (auth, me, metrics, run, runs)
cmd/nan/main.go Entry point: wiring and exit codes, nothing else
internal/
api/client.go HTTP client for the nan.builders REST API
api/runs.go /v1/runs client and its SSE event stream
runs/render.go Run events to terminal lines, guest text sanitised
session/session.go Session persistence (~/.config/nan/session.json)
tui/tui.go Entire TUI — layout, renderers, and tool writers
build/ Version, name and repository of this binary
api/ nan.builders: the session client (auth, me, usage,
keys, models) and the /v1/runs client with its SSE stream
session/ Session persistence (~/.config/nan/session.json)
tools/ The coding tools: detection, writers and removers
models/ The model catalogue every writer reads
flow/ The sign-in and the setup, as Bubble Tea models
pulse/ The dashboard (Bubble Tea v2)
cli/ The cobra commands, run through fang
runs/ Run events to terminal lines, guest text sanitised
ui/ The NaN palette, wordmark, mascot and components
update/ Whether a newer release is out, and new models
```

All tool auto-configuration logic lives in `internal/tui/tui.go`. The relevant functions are:

| Function | Purpose |
|---|---|
| `detectTools()` | Checks which tools are installed on the system |
| `isNaNConfigured()` | Reads a tool's config file to check if NaN is already set up |
| `configureTools()` | Dispatches to the per-tool write functions |
| `writeXxxConfig()` | Writes or updates a specific tool's config file |
| `removeXxxConfig()` | Removes NaN entries from a tool's config file |
Every tool lives in `internal/tools`: one file per tool with its writer and
remover, and one entry in `Registry()` in `tools.go` that names its binary,
its config file, how to tell it is configured and what to do in it once it
is.

## Adding support for a new tool

1. **Add an entry to `detectTools()`** with the tool's binary name and config file path.

2. **Implement `writeXxxConfig(cfgPath, apiKey string) error`** — write the NaN provider block into the tool's config format. Always check if NaN is already present before writing, and build the model list by ranging over `catalog.All` from `internal/models`. Never write the ids by hand: three writers used to keep a list each, and all three had drifted to the same stale set of three models.
1. **Add an entry to `Registry()`** in `internal/tools/tools.go` with the tool's binary, config path, `Open` and `NextStep`, and its `configured` check (most live in `detect.go`).

3. **Implement `removeXxxConfig(cfgPath string) error`** — remove any NaN-related entries cleanly without touching the rest of the file.
2. **Implement the writer** in a file of its own: write the NaN provider block into the tool's config format, through `writeConfigFile` so the file ends up 0600. Always check if NaN is already present before writing, and build the model list by ranging over `catalog.ChatModels()` from `internal/models`. Never write the ids by hand: three writers used to keep a list each, and all three had drifted to the same stale set of three models.

4. **Implement the `isNaNConfigured` case** for the new tool name so the Setup tab shows the correct status.
3. **Implement the remover**: take out NaN's entries cleanly without touching the rest of the file.

5. **Wire it up in `configureTools()`** — add cases for the new tool in both the enable and disable branches.
4. **Test it** in `internal/tools/tools_test.go`, which writes each config into a temporary home and checks it against the catalogue.

## Adding a new NaN model

Expand All @@ -74,14 +79,14 @@ The window and the output budget are the ones the setup guides publish on nan.bu

The Codex config (`writeCodexConfig`) does not enumerate models; it points at `catalog.Coding`.

Then run `go test ./...`. `internal/tui/config_test.go` writes each config into a temp directory and checks it against the catalogue, which is what stops the lists from drifting again.
Then run `just test`. `internal/tools/tools_test.go` writes each config into a temp directory and checks it against the catalogue, which is what stops the lists from drifting again.

## Code style

- Follow standard Go conventions (`gofmt`, `go vet`).
- No comments unless the *why* is non-obvious — well-named identifiers are preferred.
- `just lint` must pass: golangci-lint with the repo's `.golangci.yml`, the same configuration as the Helmcode CLI. The git hooks (`just setup`) run it before each commit.
- No comments unless the *why* is non-obvious - well-named identifiers are preferred.
- Error messages are lowercase and don't end in punctuation (Go stdlib convention).
- No new dependencies without discussion — the dependency list is intentionally small.
- No new dependencies without discussion - the dependency list is intentionally small.

## Commit messages

Expand Down Expand Up @@ -125,15 +130,15 @@ BREAKING CHANGE: nan auth login no longer reads browser cookies automatically.
## Submitting changes

1. Fork the repo and create a branch from `main`.
2. Make your changes and verify the TUI builds and runs: `go build -o nan . && ./nan`.
3. Open a pull request — the title should follow the same Conventional Commits format as your commits.
2. Make your changes and verify the dashboard builds and runs: `just build && ./nan`, and that `just test` and `just lint` pass.
3. Open a pull request - the title should follow the same Conventional Commits format as your commits.

## Releases

Releases publish themselves from the version constant. There is no separate
tagging step to remember:

1. Bump `Version` in `internal/tui/tui.go` in a `chore: <version>` commit.
1. Bump `Version` in `internal/build/build.go` in a `chore: <version>` commit.
2. Merge it to `main`.
3. Once CI passes on `main`, `.github/workflows/tag.yml` creates the matching
`v<version>` tag and the release workflow builds, signs and publishes the
Expand All @@ -145,6 +150,6 @@ it. `scripts/install.sh` asks GitHub for the latest release, so whatever this
publishes is what `curl -fsSL https://nan.builders/install | bash` serves
minutes later.

Merges that do not change the constant tag nothing — the version you bump to
Merges that do not change the constant tag nothing - the version you bump to
is the release you get. To rebuild a release that failed halfway, run the
release workflow by hand from the Actions tab with an existing tag.
Loading
Loading