From 1070c11d5c352f18a00a71c6a97b57778322dd98 Mon Sep 17 00:00:00 2001 From: crstian19 Date: Tue, 13 Oct 2026 13:42:22 +0200 Subject: [PATCH 1/3] refactor!: rebuild on the Helmcode CLI structure The same layout, Charm stack and tooling as the Helmcode CLI, so the two are maintained the same way. Every feature of nan stays. Structure - cmd/nan for the entry point; internal/{build,api,session,tools,models, flow,pulse,cli,runs,ui,update}, as in the Helmcode CLI - Bubble Tea v2, Bubbles v2, Lip Gloss v2, huh forms and fang for the command tree, replacing Bubble Tea v1 and the single internal/tui/tui.go - golangci-lint with the shared config, lefthook, mise and a justfile - the version lives in internal/build; tag.yml and release.yml read it there Dashboard - seven numbered areas always on screen: Home, Profile, Usage, Models, Costs, Harnesses and About, with lazygit-style verbs, an enter menu per row, / to find anything and a short guided tour - Home says what needs you (sign in, a missing or refused key, a tool not pointed at NaN, a newer release) and shows models new to your key - the NaN identity stays: violet palette, wordmark and the mascot, whose face follows what the dashboard is doing - sign-in, key and tool setup are huh forms; s signs in, o signs out twice, e opens the setup from anywhere Commands - run, runs, automations, approvals, secrets, me, metrics and auth keep their flags, output and exit codes (0-4, 10, 64, 65, 69, 75) - new: nan setup, nan setup status and nan setup remove - errors are framed by fang but printed as written: its default title-cased the first word, which turned a run id into a different one - a newer release is said after a command, once a day at most Compatibility - session.json stays at ~/.config/nan with the same fields - every tool writer is moved as it was, and its tests pass unchanged - a key the cluster refuses is no longer saved; one that cannot be checked because the cluster is unreachable still is, and that is said Also: THIRD-PARTY-NOTICES.txt ships in every archive (cobra is Apache-2.0), and the version moves to 0.2.0. --- .github/workflows/ci.yml | 35 +- .github/workflows/release.yml | 19 +- .github/workflows/tag.yml | 8 +- .gitignore | 4 +- .gitleaks.toml | 13 + .golangci.yml | 48 + CONTRIBUTING.md | 75 +- README.md | 57 +- THIRD-PARTY-NOTICES.txt | 1199 ++++++ cmd/nan/main.go | 46 + cmd/root.go | 77 - cmd/root_test.go | 33 - docs/PROJECT_OVERVIEW.md | 255 -- go.mod | 48 +- go.sum | 118 +- internal/api/account.go | 148 + internal/api/approvals.go | 2 +- internal/api/auth.go | 213 + internal/{auth => api}/auth_test.go | 59 +- internal/api/automations_test.go | 4 +- internal/api/client.go | 276 +- internal/api/client_test.go | 27 +- internal/api/errors.go | 53 + internal/api/logout.go | 67 - internal/api/logout_test.go | 33 +- internal/api/models.go | 68 + internal/api/release.go | 57 + internal/api/runs.go | 16 +- internal/api/runs_test.go | 44 +- internal/auth/auth.go | 169 - internal/build/build.go | 40 + internal/cli/app.go | 28 + {cmd => internal/cli}/approvals.go | 20 +- {cmd => internal/cli}/auth.go | 220 +- {cmd => internal/cli}/auth_test.go | 6 +- {cmd => internal/cli}/automations.go | 42 +- {cmd => internal/cli}/automations_test.go | 19 +- internal/cli/confirm.go | 9 + {cmd => internal/cli}/credentials.go | 37 +- {cmd => internal/cli}/credentials_test.go | 8 +- internal/cli/errors.go | 37 + internal/cli/errors_test.go | 28 + {cmd => internal/cli}/exit.go | 22 +- {cmd => internal/cli}/golden_test.go | 8 +- {cmd => internal/cli}/help_test.go | 4 +- {cmd => internal/cli}/logout_test.go | 6 +- {cmd => internal/cli}/me.go | 13 +- {cmd => internal/cli}/metrics.go | 10 +- {cmd => internal/cli}/names.go | 2 +- {cmd => internal/cli}/names_test.go | 42 +- internal/cli/output.go | 171 + {cmd => internal/cli}/review_test.go | 6 +- internal/cli/root.go | 132 + internal/cli/root_test.go | 53 + {cmd => internal/cli}/run.go | 52 +- {cmd => internal/cli}/run_test.go | 4 +- {cmd => internal/cli}/runs.go | 54 +- {cmd => internal/cli}/runs_test.go | 2 +- {cmd => internal/cli}/secrets.go | 16 +- internal/cli/setup.go | 292 ++ internal/cli/theme.go | 54 + internal/cli/update.go | 54 + internal/flow/setup.go | 493 +++ internal/flow/setup_test.go | 89 + internal/flow/signin.go | 290 ++ internal/flow/signin_test.go | 127 + internal/flow/validate_test.go | 248 ++ internal/models/models.go | 2 +- internal/pulse/areas.go | 220 + internal/pulse/cards.go | 132 + internal/pulse/costs.go | 200 + internal/pulse/data.go | 241 ++ internal/pulse/keys.go | 64 + internal/pulse/modal.go | 101 + internal/pulse/motion.go | 67 + internal/pulse/motion_test.go | 65 + internal/pulse/palette.go | 146 + internal/pulse/profile.go | 67 + internal/pulse/pulse.go | 805 ++++ internal/pulse/pulse_test.go | 577 +++ internal/pulse/signals.go | 92 + internal/pulse/tour.go | 72 + internal/pulse/view.go | 695 +++ internal/pulse/whatsnew.go | 52 + internal/runs/render.go | 46 +- internal/runs/render_test.go | 8 +- internal/session/session.go | 139 +- internal/session/session_test.go | 28 +- internal/tools/codex.go | 417 ++ internal/tools/detect.go | 65 + internal/tools/factory.go | 140 + internal/tools/file.go | 104 + internal/tools/hermes.go | 231 + internal/tools/opencode.go | 137 + internal/tools/pi.go | 171 + internal/tools/tools.go | 321 ++ .../config_test.go => tools/tools_test.go} | 1294 +----- internal/tui/banner.go | 301 -- internal/tui/security_test.go | 191 - internal/tui/session_test.go | 147 - internal/tui/signout_server_test.go | 163 - internal/tui/tui.go | 3732 ----------------- internal/ui/badges.go | 52 + internal/ui/banner.go | 152 + internal/ui/banner_test.go | 68 + internal/ui/components.go | 398 ++ internal/ui/dashes_test.go | 73 + internal/ui/eyebrow_motion.go | 43 + internal/ui/eyebrow_motion_test.go | 41 + internal/ui/form.go | 187 + internal/ui/form_test.go | 161 + internal/ui/format.go | 225 + internal/ui/format_test.go | 155 + internal/ui/loader.go | 106 + internal/{tui => ui}/mascot.go | 75 +- internal/ui/sanitize.go | 58 + internal/ui/sanitize_test.go | 144 + internal/ui/sort.go | 144 + internal/ui/sort_test.go | 129 + internal/ui/table.go | 223 + internal/ui/table_test.go | 137 + internal/ui/theme.go | 101 + internal/ui/width.go | 84 + internal/update/update.go | 233 + internal/update/update_test.go | 127 + justfile | 56 + lefthook.yml | 27 + main.go | 7 - mise.toml | 7 + scripts/gen-notices.go | 207 + 130 files changed, 13718 insertions(+), 6944 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 .golangci.yml create mode 100644 THIRD-PARTY-NOTICES.txt create mode 100644 cmd/nan/main.go delete mode 100644 cmd/root.go delete mode 100644 cmd/root_test.go delete mode 100644 docs/PROJECT_OVERVIEW.md create mode 100644 internal/api/account.go create mode 100644 internal/api/auth.go rename internal/{auth => api}/auth_test.go (68%) create mode 100644 internal/api/errors.go delete mode 100644 internal/api/logout.go create mode 100644 internal/api/models.go create mode 100644 internal/api/release.go delete mode 100644 internal/auth/auth.go create mode 100644 internal/build/build.go create mode 100644 internal/cli/app.go rename {cmd => internal/cli}/approvals.go (89%) rename {cmd => internal/cli}/auth.go (59%) rename {cmd => internal/cli}/auth_test.go (99%) rename {cmd => internal/cli}/automations.go (92%) rename {cmd => internal/cli}/automations_test.go (98%) create mode 100644 internal/cli/confirm.go rename {cmd => internal/cli}/credentials.go (88%) rename {cmd => internal/cli}/credentials_test.go (97%) create mode 100644 internal/cli/errors.go create mode 100644 internal/cli/errors_test.go rename {cmd => internal/cli}/exit.go (93%) rename {cmd => internal/cli}/golden_test.go (97%) rename {cmd => internal/cli}/help_test.go (97%) rename {cmd => internal/cli}/logout_test.go (95%) rename {cmd => internal/cli}/me.go (62%) rename {cmd => internal/cli}/metrics.go (70%) rename {cmd => internal/cli}/names.go (99%) rename {cmd => internal/cli}/names_test.go (96%) create mode 100644 internal/cli/output.go rename {cmd => internal/cli}/review_test.go (99%) create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go rename {cmd => internal/cli}/run.go (88%) rename {cmd => internal/cli}/run_test.go (99%) rename {cmd => internal/cli}/runs.go (90%) rename {cmd => internal/cli}/runs_test.go (99%) rename {cmd => internal/cli}/secrets.go (91%) create mode 100644 internal/cli/setup.go create mode 100644 internal/cli/theme.go create mode 100644 internal/cli/update.go create mode 100644 internal/flow/setup.go create mode 100644 internal/flow/setup_test.go create mode 100644 internal/flow/signin.go create mode 100644 internal/flow/signin_test.go create mode 100644 internal/flow/validate_test.go create mode 100644 internal/pulse/areas.go create mode 100644 internal/pulse/cards.go create mode 100644 internal/pulse/costs.go create mode 100644 internal/pulse/data.go create mode 100644 internal/pulse/keys.go create mode 100644 internal/pulse/modal.go create mode 100644 internal/pulse/motion.go create mode 100644 internal/pulse/motion_test.go create mode 100644 internal/pulse/palette.go create mode 100644 internal/pulse/profile.go create mode 100644 internal/pulse/pulse.go create mode 100644 internal/pulse/pulse_test.go create mode 100644 internal/pulse/signals.go create mode 100644 internal/pulse/tour.go create mode 100644 internal/pulse/view.go create mode 100644 internal/pulse/whatsnew.go create mode 100644 internal/tools/codex.go create mode 100644 internal/tools/detect.go create mode 100644 internal/tools/factory.go create mode 100644 internal/tools/file.go create mode 100644 internal/tools/hermes.go create mode 100644 internal/tools/opencode.go create mode 100644 internal/tools/pi.go create mode 100644 internal/tools/tools.go rename internal/{tui/config_test.go => tools/tools_test.go} (52%) delete mode 100644 internal/tui/banner.go delete mode 100644 internal/tui/security_test.go delete mode 100644 internal/tui/session_test.go delete mode 100644 internal/tui/signout_server_test.go delete mode 100644 internal/tui/tui.go create mode 100644 internal/ui/badges.go create mode 100644 internal/ui/banner.go create mode 100644 internal/ui/banner_test.go create mode 100644 internal/ui/components.go create mode 100644 internal/ui/dashes_test.go create mode 100644 internal/ui/eyebrow_motion.go create mode 100644 internal/ui/eyebrow_motion_test.go create mode 100644 internal/ui/form.go create mode 100644 internal/ui/form_test.go create mode 100644 internal/ui/format.go create mode 100644 internal/ui/format_test.go create mode 100644 internal/ui/loader.go rename internal/{tui => ui}/mascot.go (81%) create mode 100644 internal/ui/sanitize.go create mode 100644 internal/ui/sanitize_test.go create mode 100644 internal/ui/sort.go create mode 100644 internal/ui/sort_test.go create mode 100644 internal/ui/table.go create mode 100644 internal/ui/table_test.go create mode 100644 internal/ui/theme.go create mode 100644 internal/ui/width.go create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go create mode 100644 justfile create mode 100644 lefthook.yml delete mode 100644 main.go create mode 100644 mise.toml create mode 100644 scripts/gen-notices.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5887242..1ee44ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baed825..955ec6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index d00f354..3e063ac 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -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 @@ -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" diff --git a/.gitignore b/.gitignore index f5448ec..8059af7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -nan +/nan *.exe *.exe~ *.dll @@ -45,4 +45,4 @@ vendor/ tmp/ temp/ *.log -nan.exe +/nan.exe diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..6d6004b --- /dev/null +++ b/.gitleaks.toml @@ -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$'''] diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b12b1bf --- /dev/null +++ b/.golangci.yml @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 609f35e..e82a0db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: @@ -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 @@ -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 @@ -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: ` commit. +1. Bump `Version` in `internal/build/build.go` in a `chore: ` commit. 2. Merge it to `main`. 3. Once CI passes on `main`, `.github/workflows/tag.yml` creates the matching `v` tag and the release workflow builds, signs and publishes the @@ -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. diff --git a/README.md b/README.md index 336eb7b..4796c8e 100644 --- a/README.md +++ b/README.md @@ -49,28 +49,38 @@ built from. ## Usage -Run `nan` to open the TUI dashboard: +Run `nan` to open the dashboard: ``` nan ``` -Navigate with `←/→` between tabs, `↑/↓` to scroll, `r` to refresh, `?` for help, `q` to quit. +On a machine nobody has signed in on, it opens on the sign-in: your email, then +the link from it, then your API key for this machine (skippable). -### Tabs +Seven areas run down the left, always on screen and numbered. `1`-`7` or `tab` +move between them, `↑/↓` and `enter` pick a row and show what can be done with +it, `/` finds anything by name, `e` opens the setup from anywhere, `s` signs in, +`o` signs out (twice, to confirm), `r` reloads, `t` takes a short tour, `?` +shows every key and `q` quits. -| Tab | Description | +| Area | What it shows | |---|---| -| **Profile** | Your nan.builders account details | -| **Usage** | Token usage across the last 24h, 30 days, and all time | -| **Models** | Available models and your usage per model | -| **Costs** | Estimated cost comparison against other providers | -| **Setup** | API key management and tool auto-configuration | -| **About** | Version and links | +| **Home** | What needs you (sign in, a missing key, a tool not pointed at NaN, a newer release), new models, and shortcuts | +| **Profile** | Your nan.builders account details, identifiers shortened | +| **Usage** | Token usage over the last 24 hours, 30 days and all time, by model | +| **Models** | What your key can call, and your usage of each | +| **Costs** | Estimated cost of that usage on other providers | +| **Harnesses** | Your API key and the coding tools on this machine | +| **About** | Version, links and where the session is kept | -### Setup tab +When a newer release is out, the header says so and Home has the command that +updates your copy; after any other command, one line on stderr says the same. -The Setup tab lets you configure AI coding tools to use the NaN API automatically. Supported tools: +### Setting up your tools + +`e` in the dashboard, or `nan setup` on the command line, configures the AI +coding tools on this machine to use the NaN API. Supported tools: - [OpenCode](https://opencode.ai) - [Factory AI](https://factory.ai) (`droid`) @@ -78,13 +88,18 @@ The Setup tab lets you configure AI coding tools to use the NaN API automaticall - [Codex](https://github.com/openai/codex) - [Hermes](https://hermes-agent.nousresearch.com/) -Press `e` to set your API key, `space` to toggle tools, and `c` to apply the configuration. +Paste your key, tick the tools, and it writes them. Only installed tools are +listed, and only the NaN part of each config is touched: everything else in +those files is left as it was, and unticking a tool takes ours back out. The +key is checked against the cluster before anything is written, so a mistyped +one is caught here rather than as a 401 inside each tool later. -Only installed tools are listed, and only the NaN part of each config is -touched: everything else in those files is left as it was, and unticking a -tool takes ours back out. The key you paste is checked against the cluster -before you apply it, so a mistyped one is caught here rather than as a 401 -inside each tool later. +```bash +nan setup # asks, and shows what is installed +echo "$NAN_API_KEY" | nan setup --key - --all # for a machine with nobody at it +nan setup status # what is configured right now +nan setup remove # take NaN back out of every tool +``` ## Agent runs @@ -216,7 +231,7 @@ JSON object per line): . refused. 3. **A saved token**, from `nan auth login --api-token`. 4. **Your session**, from `nan auth login` (the emailed sign-in link). -5. **A saved API key**, from the Setup tab (or an `sk-...` key saved with +5. **A saved API key**, from `nan setup` or the dashboard (or an `sk-...` key saved with `nan auth login --api-token`). Create platform tokens in **Settings > Tokens** at @@ -251,14 +266,14 @@ history and in `ps`. ## Build from source -Requires Go 1.26.9+ ([mise](https://mise.jdx.dev/) recommended) — the version +Requires Go 1.27.2+ ([mise](https://mise.jdx.dev/) recommended), the version in `go.mod`, which is where the standard library carries the current security fixes: ```bash git clone https://github.com/helmcode/nan-cli cd nan-cli -go build -o nan . +go build -o nan ./cmd/nan ./nan ``` diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..8c00e8e --- /dev/null +++ b/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,1199 @@ +Third-party notices for nan +=========================== + +The distributed binary statically links the Go modules listed below, and +their licences are reproduced here in full. + +One of them is Apache-2.0, whose section 4 requires this of any +redistribution. The rest are reproduced alongside it rather than +sorting the obligation per module. + +This is the set actually linked into ./cmd/nan, which is narrower than +`go list -m all`: that reports the whole module graph, test-only and +build-time dependencies included, and those never reach a user. + +Generated by scripts/gen-notices.go; run `just notices` after changing +dependencies. + +Modules: 36 +1 Apache-2.0, 5 BSD-3-Clause, 30 MIT + + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.6 + github.com/atotto/clipboard v0.1.4 + github.com/catppuccin/go v0.2.0 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/fang v1.0.0 + github.com/charmbracelet/harmonica v0.2.0 + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 + github.com/charmbracelet/x/exp/ordered v0.1.0 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 + github.com/charmbracelet/x/term v0.2.2 + github.com/charmbracelet/x/termios v0.1.1 + github.com/charmbracelet/x/windows v0.2.2 + github.com/clipperhouse/displaywidth v0.11.0 + github.com/clipperhouse/uax29/v2 v2.7.0 + github.com/dustin/go-humanize v1.0.1 + github.com/lucasb-eyer/go-colorful v1.4.1 + github.com/mattn/go-runewidth v0.0.27 + github.com/mitchellh/hashstructure/v2 v2.0.2 + github.com/muesli/cancelreader v0.2.2 + github.com/muesli/mango v0.1.0 + github.com/muesli/mango-cobra v1.2.0 + github.com/muesli/mango-pflag v0.1.0 + github.com/muesli/roff v0.1.0 + github.com/rivo/uniseg v0.4.7 + github.com/sahilm/fuzzy v0.1.3 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/text v0.39.0 + +============================================================================== +charm.land/bubbles/v2 +v2.2.1 +============================================================================== + +MIT License + +Copyright (c) 2020-2026 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +charm.land/bubbletea/v2 +v2.0.9 +============================================================================== + +MIT License + +Copyright (c) 2020-2026 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +charm.land/huh/v2 +v2.0.3 +============================================================================== + +MIT License + +Copyright (c) 2023–2026 Charm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +charm.land/lipgloss/v2 +v2.0.6 +============================================================================== + +MIT License + +Copyright (c) 2021-2026 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/atotto/clipboard +v0.1.4 +============================================================================== + +Copyright (c) 2013 Ato Araki. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of @atotto. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +============================================================================== +github.com/catppuccin/go +v0.2.0 +============================================================================== + +MIT License + +Copyright (c) 2021 Catppuccin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/colorprofile +v0.4.3 +============================================================================== + +MIT License + +Copyright (c) 2020-2024 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/fang +v1.0.0 +============================================================================== + +MIT License + +Copyright (c) 2024-2025 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/harmonica +v0.2.0 +============================================================================== + +MIT License + +Copyright (c) 2021 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/ultraviolet +v0.0.0-20260811164956-006e29f97886 +============================================================================== + +MIT License + +Copyright (c) 2025 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/x/ansi +v0.11.8 +============================================================================== + +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/x/exp/charmtone +v0.0.0-20250603201427-c31516f43444 +============================================================================== + +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/x/exp/ordered +v0.1.0 +============================================================================== + +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/x/exp/strings +v0.0.0-20240722160745-212f7b056ed0 +============================================================================== + +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/x/term +v0.2.2 +============================================================================== + +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/x/termios +v0.1.1 +============================================================================== + +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/charmbracelet/x/windows +v0.2.2 +============================================================================== + +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/clipperhouse/displaywidth +v0.11.0 +============================================================================== + +MIT License + +Copyright (c) 2025 Matt Sherman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/clipperhouse/uax29/v2 +v2.7.0 +============================================================================== + +MIT License + +Copyright (c) 2020 Matt Sherman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/dustin/go-humanize +v1.0.1 +============================================================================== + +Copyright (c) 2005-2008 Dustin Sallings + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +============================================================================== +github.com/lucasb-eyer/go-colorful +v1.4.1 +============================================================================== + +Copyright (c) 2013 Lucas Beyer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +============================================================================== +github.com/mattn/go-runewidth +v0.0.27 +============================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 Yasuhiro Matsumoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/mitchellh/hashstructure/v2 +v2.0.2 +============================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================================== +github.com/muesli/cancelreader +v0.2.2 +============================================================================== + +MIT License + +Copyright (c) 2022 Erik Geiser and Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/muesli/mango +v0.1.0 +============================================================================== + +MIT License + +Copyright (c) 2022 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/muesli/mango-cobra +v1.2.0 +============================================================================== + +MIT License + +Copyright (c) 2022 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/muesli/mango-pflag +v0.1.0 +============================================================================== + +MIT License + +Copyright (c) 2022 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/muesli/roff +v0.1.0 +============================================================================== + +MIT License + +Copyright (c) 2022 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/rivo/uniseg +v0.4.7 +============================================================================== + +MIT License + +Copyright (c) 2019 Oliver Kuederle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/sahilm/fuzzy +v0.1.3 +============================================================================== + +The MIT License (MIT) + +Copyright (c) 2017 Sahil Muthoo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +github.com/spf13/cobra +v1.10.2 +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +============================================================================== +github.com/spf13/pflag +v1.0.9 +============================================================================== + +Copyright (c) 2012 Alex Ogier. All rights reserved. +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +============================================================================== +github.com/xo/terminfo +v0.0.0-20220910002029-abceb7e1c41e +============================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 Anmol Sethi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +golang.org/x/sync +v0.22.0 +============================================================================== + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +============================================================================== +golang.org/x/sys +v0.47.0 +============================================================================== + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +============================================================================== +golang.org/x/text +v0.39.0 +============================================================================== + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/cmd/nan/main.go b/cmd/nan/main.go new file mode 100644 index 0000000..7c23579 --- /dev/null +++ b/cmd/nan/main.go @@ -0,0 +1,46 @@ +// Command nan is the nan.builders CLI. +// +// Thin on purpose: wiring and exit codes live here, and everything else lives +// in internal/ where it can be tested without a terminal. +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/charmbracelet/fang" + + "github.com/nxssie/nan-cli/internal/build" + "github.com/nxssie/nan-cli/internal/cli" +) + +func main() { + // os.Exit does not run deferred functions, so the exit code is decided + // in run() and spent here. + os.Exit(run()) +} + +func run() int { + // SIGTERM cancels whatever is in flight. Ctrl-C is deliberately NOT + // taken here: `nan run` owns it while it follows a run - the first one + // detaches and leaves the run going, a second cancels it - and a context + // cancelled underneath it would cut the stream before it could decide. + // Every other command keeps Ctrl-C's ordinary meaning, and the dashboard + // reads it as a key. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) + defer stop() + + root := cli.NewRootCmd() + err := fang.Execute(ctx, root, + fang.WithVersion(build.Version), + fang.WithColorSchemeFunc(cli.ColorScheme), + fang.WithErrorHandler(cli.ErrorHandler), + ) + if err == nil { + return 0 + } + cmd, _, _ := root.Find(os.Args[1:]) + return cli.ExitCode(cmd, err) +} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 1eba075..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,77 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "os" - - "github.com/nxssie/nan-cli/internal/tui" - "github.com/spf13/cobra" -) - -var rootCmd = &cobra.Command{ - Use: "nan", - Short: "nan.builders cloud CLI", - Version: tui.Version, - // A sign-in link that did not work is not a usage mistake: printing the - // flag list under it buries the one line that says what happened. Execute() - // below prints the error itself, so cobra printing it too showed every - // failure twice. - SilenceUsage: true, - SilenceErrors: true, - CompletionOptions: cobra.CompletionOptions{ - DisableDefaultCmd: true, - }, - RunE: func(cmd *cobra.Command, args []string) error { - return openTUI() - }, -} - -// openTUI is the dashboard. A variable so a test can tell whether a command -// line reached it. -var openTUI = tui.Run - -func init() { - // SilenceUsage covers runtime failures, but a mistyped flag IS a usage - // mistake and the flag list is the answer to it. - rootCmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error { - c.Println(c.UsageString()) - return err - }) - - // One line a script can parse (`nan --version | cut -d' ' -f2`). The - // framed wordmark this used to print filled the screen like the dashboard - // does, and read as the dashboard having opened. - rootCmd.SetVersionTemplate("nan {{.Version}}\n") -} - -func Execute() { - cmd, err := rootCmd.ExecuteC() - if err == nil { - return - } - code, msg := exitCodeFor(cmd, err) - if msg != "" { - fmt.Fprintln(os.Stderr, msg) - } - os.Exit(code) -} - -// exitCodeFor is how a failed command ends. `nan run` and `nan runs` have a -// documented table, and a script reads their codes: there anything that is -// not already an ExitError is cobra refusing the arguments - an unknown -// flag, a missing id, two flags that exclude each other - which is a usage -// error. Every other command keeps exiting 1. -func exitCodeFor(cmd *cobra.Command, err error) (int, string) { - var exit *ExitError - if errors.As(err, &exit) { - if exit.Err == nil { - return exit.Code, "" - } - return exit.Code, exit.Err.Error() - } - if cmd != nil && cmd.Annotations[sysexitsAnnotation] != "" { - return exitUsage, err.Error() - } - return 1, err.Error() -} diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index 29b4d15..0000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package cmd - -import ( - "bytes" - "testing" - - "github.com/nxssie/nan-cli/internal/tui" -) - -// `nan --version` and `nan -v` print one line and exit 0, without opening -// the dashboard. -func TestVersionPrintsOneLine(t *testing.T) { - old := openTUI - defer func() { openTUI = old }() - openTUI = func() error { - t.Error("--version opened the dashboard") - return nil - } - - for _, flag := range []string{"--version", "-v"} { - var out bytes.Buffer - rootCmd.SetArgs([]string{flag}) - rootCmd.SetOut(&out) - rootCmd.SetErr(&out) - if err := rootCmd.Execute(); err != nil { - t.Fatalf("%s: %v", flag, err) - } - if got, want := out.String(), "nan "+tui.Version+"\n"; got != want { - t.Errorf("%s printed %q, want %q", flag, got, want) - } - _ = rootCmd.Flags().Set("version", "false") - } -} diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md deleted file mode 100644 index 053353f..0000000 --- a/docs/PROJECT_OVERVIEW.md +++ /dev/null @@ -1,255 +0,0 @@ -# nan CLI — Project Overview - -> A terminal-based CLI for interacting with the [nan.builders](https://nan.builders) cloud platform. -> Authentication via Discord OAuth; API calls use cookie-based session tokens. - ---- - -## Quick Reference (Java Developer's Cheat Sheet) - -| Go concept | Java equivalent | -|---|---| -| `package` | `package` (directory = module) | -| `import` | `import` | -| `struct` | `class` (data carrier, no methods) | -| `func (c *Client) Method()` | `class Client { void method() }` (receiver = implicit `this`) | -| `interface{}` | `Object` / `var` / `generic` | -| `map[string]any` | `Map` | -| `defer resp.Body.Close()` | try-with-resources (`try (var r = resp) {}`) | -| `errors.New()` / `%w` | `throw new RuntimeException()` / `Exception e` (wrapped) | -| `init()` function | static initializer block | -| `go.mod` | `pom.xml` / `build.gradle` | -| `go run .` | `mvn exec:java` / `java Main` | -| `go build -o nan .` | `mvn package` (produces fat JAR or binary) | - ---- - -## Architecture - -``` -main.go ← Entry point. Calls cmd.Execute(). -│ -└── cmd/ ← "Controllers" — CLI subcommands (Cobra) - ├── root.go Root command: "nan" - ├── auth.go "nan auth login" / "nan auth logout" - ├── me.go "nan me" — fetch current user profile - └── metrics.go "nan metrics usage" — fetch usage stats - │ - └── internal/ ← "Service layer" (private to this module) - ├── api/client.go HTTP client — thin wrapper around nan.builders REST API - ├── browser/cookies.go Browser cookie reader — reads Firefox/Chromium cookie DBs - └── session/ Session persistence — reads/writes ~/.config/nan/session.json -``` - -### Directory tree - -``` -. -├── main.go # Entry point -├── go.mod # Module + dependency manifest (like pom.xml) -├── go.sum # Dependency checksums -├── nan # Compiled binary (after `go build`) -├── cmd/ # CLI commands ("controllers") -│ ├── root.go # Root command: "nan" -│ ├── auth.go # "auth login" / "auth logout" -│ ├── me.go # "me" — profile endpoint -│ └── metrics.go # "metrics usage" — usage stats -└── internal/ # Private packages - ├── api/ - │ └── client.go # REST client (HTTP + JSON) - ├── browser/ - │ └── cookies.go # Firefox & Chromium cookie reader - └── session/ - └── session.go # Token persistence (~/.config/nan/session.json) -``` - ---- - -## 1. Entry Point & CLI Framework - -### `main.go` - -Minimal — just delegates to Cobra's `Execute()`. - -```go -func main() { cmd.Execute() } -``` - -**Think of it as:** a Spring Boot `@SpringBootApplication` class with a single `public static void main` that delegates to a framework bootstrap. Cobra handles argument parsing, help text, and command routing — similar to Spring Shell or Picocli. - -### `cmd/root.go` - -Defines the root command `nan` and registers all subcommands. The `init()` function is like a static initializer — Cobra's command tree is built at package load time. - ---- - -## 2. Authentication Flow - -### `cmd/auth.go` — "Auth Controller" - -| Subcommand | Usage | What it does | -|---|---|---| -| `nan auth login` | Opens Discord OAuth URL in browser, then waits for user to paste `nan_session` cookie value | Saves token to `~/.config/nan/session.json` | -| `nan auth login --token ` | Skips browser, saves token directly | Same persistence | -| `nan auth logout` | Deletes `session.json` | Clears local session | - -**Login flow (step by step):** - -1. `openBrowser(apiAuthURL)` — spawns `xdg-open` / `open` / `rundll32` to open Discord login in the default browser. -2. Prints instructions to manually copy the `nan_session` cookie from DevTools. -3. User pastes the token into the terminal. -4. `saveToken()` writes it to `~/.config/nan/session.json` (file permissions `0600`, dir `0700` — like a `.env` file with restrictive perms). - -**No browser cookie polling anymore** — the older version auto-read Firefox/Chromium DBs; the current version uses manual paste for simplicity and security. The `browser/` package is still present but unused by the login flow. - ---- - -## 3. Session Persistence - -### `internal/session/session.go` — "Session Repository" - -Analogous to a `JdbcTemplate` or `@Repository` that reads/writes a single JSON file: - -``` -~/.config/nan/ -└── session.json ← {"token": "abc123..."} (mode 0600) -``` - -| Function | Behavior | -|---|---| -| `Load()` | Reads `session.json`; returns `ErrNotLoggedIn` if file missing | -| `Save(s *Session)` | Creates directory (if needed, `0700`) and writes `session.json` (`0600`) | -| `Delete()` | Removes `session.json` (used by `logout`) | - -**Java equivalent:** a class with `load()`, `save(Session)`, and `delete()` backed by `ObjectMapper` reading a file on disk. - ---- - -## 4. API Client - -### `internal/api/client.go` — "REST Client" - -A thin wrapper around `net/http` with cookie-based auth. - -``` -Base URL: https://cloud-api.nan.builders/api -Auth: Cookie header → "nan_session=" -``` - -| Method | Endpoint | Returns | -|---|---|---| -| `GetMe()` | `GET /auth/me` | `map[string]any` (user profile JSON) | -| `GetMetricsUsage()` | `GET /metrics/usage` | `map[string]any` (usage stats JSON) | -| `GetAgentsModels()` | `GET /agents/models` | `any` (agents/models list — not yet used) | - -**How `get()` works (the HTTP layer):** - -1. Builds a `GET` request to `BaseURL + path`. -2. Attaches `Cookie: nan_session=` header if a token exists. -3. Reads the full response body. -4. On HTTP ≥ 400, tries to parse `{"error":"..."}` from the body; falls back to `"HTTP "`. -5. Returns raw bytes on success (caller does the `json.Unmarshal`). - -**Java equivalent:** a `@Component` using `RestTemplate` or `WebClient` — the `get()` method is like a private `executeGet(path)` helper that handles auth header injection and error parsing. The caller methods (`GetMe`, `GetMetricsUsage`) are like service-layer methods that deserialize the JSON into typed objects (currently `map[string]any` / `Map` because the response schemas are unknown or dynamic). - ---- - -## 5. Browser Cookie Reader (Legacy / Unused) - -### `internal/browser/cookies.go` - -This package reads the `nan_session` cookie from local browser databases. It supports two engines: - -#### Firefox -- Reads `~/.mozilla/firefox//cookies.sqlite` -- Queries the `moz_cookies` table for `nan_session` where `host LIKE '%nan.builders%'` -- No decryption needed — Firefox stores cookie values in plaintext in the DB. - -#### Chromium / Chrome -- Reads `~/.config/chromium/Default/Cookies` or `~/.config/google-chrome/Default/Cookies` -- Queries the `cookies` table for `encrypted_value` -- **Decryption:** Chromium on Linux encrypts cookie values with AES-128-CBC: - - Key derived via PBKDF2: `PBKDF2("peanuts", "saltysalt", 1, 16)` - - IV: 16 space characters - - Supports `v10` and `v11` prefix formats - - Strips PKCS#7 padding after decryption - -**Why it exists:** The original login flow auto-pollled browser cookies every 2 seconds to detect when the user completed Discord login. It was replaced by manual paste in the current version, but the package is kept for potential future use. - -**Java equivalent:** a `@Service` using an embedded SQLite driver (like `org.xerial:sqlite-jdbc`) to query browser cookie DBs on disk. The decryption logic maps to `javax.crypto.Cipher` with `AES/CBC/PKCS5Padding`. - ---- - -## 6. Commands & Endpoints - -### Full command tree - -``` -nan # Root command -├── auth # Authentication -│ ├── login # Discord login (paste session token) -│ │ └── --token # Skip browser, save token directly -│ └── logout # Delete local session -├── me # Show current user profile (JSON) -└── metrics # Usage metrics - └── usage # Show usage stats (JSON) -``` - -### Example output - -```bash -$ nan me -{ - "id": "123456", - "username": "devuser", - "email": "dev@example.com" -} - -$ nan metrics usage -{ - "requests_used": 42, - "requests_limit": 1000 -} -``` - ---- - -## 7. Dependencies - -| Package | Role | -|---|---| -| `github.com/spf13/cobra` | CLI framework (arg parsing, command tree, help generation) | -| `charmbracelet/bubbletea` | TUI framework (installed but not yet used in code) | -| `modernc.org/sqlite` | Pure-Go SQLite (no CGO) — for reading browser cookie DBs | -| `golang.org/x/crypto` | PBKDF2 key derivation (for Chromium cookie decryption) | -| `dustin/go-humanize` | Human-readable numbers (installed, unused) | - ---- - -## 8. Build & Run - -```bash -# Build -go build -o nan . - -# Run -./nan auth login -./nan me -./nan metrics usage -./nan auth logout -``` - -```bash -# Single-command run (no build needed) -go run . auth login -``` - ---- - -## 9. Key Design Decisions - -1. **Cookie-based auth, not JWT.** The API expects `Cookie: nan_session=` — no `Authorization: Bearer` header. -2. **Dynamic response types.** All API responses are `map[string]any` — no typed DTOs. The response schemas are treated as opaque JSON for now. -3. **No persistence layer beyond a single file.** Session state is one JSON file on disk. No database, no cache. -4. **Embedded SQLite.** `modernc.org/sqlite` is a pure-Go SQLite implementation — no native libraries or CGO required. This means the binary is fully static and cross-compiles easily. -5. **Manual login flow.** The cookie-polling approach was replaced with a manual paste step for simplicity and to avoid needing `database/sql` during login. diff --git a/go.mod b/go.mod index 2cf9520..389a879 100644 --- a/go.mod +++ b/go.mod @@ -1,36 +1,46 @@ module github.com/nxssie/nan-cli -go 1.26.9 +go 1.27.2 require ( - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.6 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/fang v1.0.0 + github.com/charmbracelet/harmonica v0.2.0 + github.com/charmbracelet/x/ansi v0.11.8 github.com/charmbracelet/x/term v0.2.2 + github.com/sahilm/fuzzy v0.1.3 github.com/spf13/cobra v1.10.2 ) require ( github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index c144769..30067da 100644 --- a/go.sum +++ b/go.sum @@ -1,62 +1,104 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/account.go b/internal/api/account.go new file mode 100644 index 0000000..a40f0e3 --- /dev/null +++ b/internal/api/account.go @@ -0,0 +1,148 @@ +package api + +import ( + "context" + "encoding/json" + "strconv" +) + +// Me is the member as the platform describes them. It stays a map because +// the dashboard's Profile shows whatever the platform sends, and `nan me` +// prints it unchanged: a field the platform adds tomorrow appears in both +// without a release of this CLI. +type Me map[string]any + +// Me fetches the signed-in member. +func (c *Client) Me(ctx context.Context) (Me, error) { + var out Me + if err := c.get(ctx, "/api/auth/me", &out); err != nil { + return nil, err + } + return out, nil +} + +// Count is a token count off the wire. The platform sends numbers, and a +// field that arrives as something else reads as zero rather than failing +// the whole answer: one odd figure should not blank the Usage area. +type Count float64 + +// UnmarshalJSON takes a number, a numeric string, or anything else as zero. +func (n *Count) UnmarshalJSON(b []byte) error { + var f float64 + if err := json.Unmarshal(b, &f); err == nil { + *n = Count(f) + return nil + } + var s string + if err := json.Unmarshal(b, &s); err == nil { + if f, err := strconv.ParseFloat(s, 64); err == nil { + *n = Count(f) + return nil + } + } + *n = 0 + return nil +} + +// ModelUsage is what one model took in a period. +type ModelUsage struct { + Model string `json:"model"` + InputTokens Count `json:"inputTokens"` + OutputTokens Count `json:"outputTokens"` +} + +// Total is input and output together. +func (m ModelUsage) Total() float64 { return float64(m.InputTokens + m.OutputTokens) } + +// Period is one window of usage: its total, and the models behind it in the +// order the platform sends them. +type Period struct { + TotalTokens Count `json:"totalTokens"` + ByModel []ModelUsage `json:"byModel"` +} + +// Sum is input and output summed over the models, which is what a price is +// applied to; TotalTokens is the platform's own figure for the headline. +func (p Period) Sum() (in, out float64) { + for _, m := range p.ByModel { + in += float64(m.InputTokens) + out += float64(m.OutputTokens) + } + return in, out +} + +// Usage is the member's spend over the three windows the platform keeps. +type Usage struct { + Last24h Period `json:"last24h"` + Last30d Period `json:"last30d"` + AllTime Period `json:"allTime"` +} + +// Usage fetches the member's usage. +func (c *Client) Usage(ctx context.Context) (*Usage, error) { + var out Usage + if err := c.get(ctx, "/api/metrics/usage", &out); err != nil { + return nil, err + } + return &out, nil +} + +// UsageRaw is the same answer untouched, for `nan metrics usage`, which +// prints what the platform sent rather than what this version understood of +// it. +func (c *Client) UsageRaw(ctx context.Context) (map[string]any, error) { + var out map[string]any + if err := c.get(ctx, "/api/metrics/usage", &out); err != nil { + return nil, err + } + return out, nil +} + +// KeyStatus is what the platform will say about a member's API key. Note +// what is not in it: the key. The secret is handed over once, when it is +// created, and never again, so Setup cannot fetch a key on a member's +// behalf, only tell them whether they have one and where it lives. +type KeyStatus struct { + Exists bool `json:"exists"` + Alias string `json:"keyAlias"` + Name string `json:"keyName"` + Region string `json:"region"` + Synced bool `json:"secretSynced"` +} + +// KeyStatus fetches it. +func (c *Client) KeyStatus(ctx context.Context) (*KeyStatus, error) { + var out KeyStatus + if err := c.get(ctx, "/api/keys", &out); err != nil { + return nil, err + } + return &out, nil +} + +// AgentModel is one entry of the platform's own model list: a deployment +// name and what it does, which is what Models falls back to when there is +// no API key to ask the inference API with. +type AgentModel struct { + Name string `json:"name"` + Mode string `json:"mode"` +} + +// AgentModels fetches the platform's list. It answers either `{"models": +// [...]}` or the bare array, and both are read. +func (c *Client) AgentModels(ctx context.Context) ([]AgentModel, error) { + var raw json.RawMessage + if err := c.get(ctx, "/api/agents/models", &raw); err != nil { + return nil, err + } + var wrapped struct { + Models []AgentModel `json:"models"` + } + if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Models != nil { + return wrapped.Models, nil + } + var bare []AgentModel + if err := json.Unmarshal(raw, &bare); err == nil { + return bare, nil + } + return nil, nil +} diff --git a/internal/api/approvals.go b/internal/api/approvals.go index c529d39..e21fbc8 100644 --- a/internal/api/approvals.go +++ b/internal/api/approvals.go @@ -226,7 +226,7 @@ func (c *RunsClient) ProposalDiff(ctx context.Context, id string) (string, error if err != nil { return "", fmt.Errorf("could not reach nan.builders: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiff+1)) if err != nil { return "", fmt.Errorf("could not read the answer from nan.builders: %w", err) diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..0257866 --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,213 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/mail" + "net/url" + "strings" + "time" +) + +// Signing in is a magic link: the platform emails one, and the member pastes +// it back. Nothing here opens a browser or listens on a port. A loopback +// callback would need the platform to know about it, and pasting works over +// ssh, in a container, and on a machine with no browser at all. + +// linkDomain is the domain the platform sends its links from. Subdomains +// count: the link lands on the web app, the API lives next door. +const linkDomain = "nan.builders" + +// LinkValidity is how long a sign-in link works for, in the platform's own +// words on its login page: "It expires in 15 minutes and can only be used +// once." Worth saying, because an email that turns up late turns up dead. +const LinkValidity = "15 minutes" + +// clientKind is what this CLI declares on the verify POST, so the platform +// records the session it hands back as a CLI session. A CLI session can +// read, start runs and reject proposals; approving a proposal, writing a +// secret and editing an automation need a session signed in through the +// browser. The session file this CLI keeps is readable by anything running +// as the member, an agent in a workspace included, and must never be able to +// do those. Declaring "cli" here only ever narrows what the session can do. +const clientKind = "cli" + +// RequestMagicLink asks the platform to email a sign-in link. +// +// It answers 202 whether or not the address belongs to a member, so success +// here is not proof that an email is on the way, and nothing should say it +// is. +func (c *Client) RequestMagicLink(ctx context.Context, email string) error { + err := c.do(ctx, http.MethodPost, "/api/auth/login/request", map[string]string{"email": email}, nil) + var refused *Error + if errors.As(err, &refused) { + if refused.Status == http.StatusTooManyRequests { + return errors.New("too many sign-in attempts, wait a few minutes") + } + return fmt.Errorf("could not send the sign-in link (HTTP %d)", refused.Status) + } + return err +} + +// TokenFromLink accepts the whole link, or just the token if the mail client +// mangled it. +func (c *Client) TokenFromLink(pasted string) (string, error) { + pasted = strings.TrimSpace(pasted) + if pasted == "" { + return "", errors.New("nothing pasted") + } + if !strings.Contains(pasted, "://") { + if strings.ContainsAny(pasted, " \t") { + return "", errors.New("that is neither a link nor a token") + } + return pasted, nil + } + + u, err := url.Parse(pasted) + if err != nil { + return "", fmt.Errorf("that does not parse as a link: %w", err) + } + // Ours, or nothing. The token in a link from somewhere else is not one + // this exchange can spend, and a member who has been sent a link that + // only looks like ours is better told that here. The refusal names the + // host, never the link: it is shown on screen and pasted into issues. + if !c.isOurs(u.Hostname()) { + return "", fmt.Errorf("that link points at %s, not %s", u.Hostname(), linkDomain) + } + token := u.Query().Get("token") + if token == "" { + return "", fmt.Errorf("that %s link carries no token", u.Hostname()) + } + return token, nil +} + +// isOurs accepts nan.builders and its subdomains, and whatever portal this +// client was pointed at, so a local stand-in's links work too. +func (c *Client) isOurs(host string) bool { + host = strings.ToLower(host) + if host == linkDomain || strings.HasSuffix(host, "."+linkDomain) { + return true + } + if c.appURL == defaultAppURL { + return false + } + if u, err := url.Parse(c.appURL); err == nil && u.Hostname() != "" { + return host == strings.ToLower(u.Hostname()) + } + return false +} + +// Verify spends a link's token for a session. +// +// The browser flow ends on a page that POSTs the token and gets the session +// cookie back. This does the same POST and keeps the cookie instead of +// following the redirect. +func (c *Client) Verify(ctx context.Context, token string) (string, error) { + form := url.Values{"token": {token}, "client": {clientKind}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/auth/login/verify", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", userAgent()) + + resp, err := c.http.Do(req) + if err != nil { + return "", unreachable(err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + + for _, cookie := range resp.Cookies() { + if cookie.Name == SessionCookie && cookie.Value != "" { + return cookie.Value, nil + } + } + // A spent or expired link redirects to the platform's access-denied + // page with the reason in the query, which says more than the status. + if location, err := resp.Location(); err == nil { + if reason := location.Query().Get("reason"); reason != "" { + return "", errors.New(linkReasonMessage(reason)) + } + } + return "", fmt.Errorf("no session came back (HTTP %d)", resp.StatusCode) +} + +// The platform's reasons, in the query of the page it redirects a refused +// link to. `invalid_link` covers a link that expired and one somebody else +// already spent; the member this happens to is the one whose email turned up +// late, so it names the clock and what to do about it. +var linkReasons = map[string]string{ + "invalid_link": "that link expired or was already used - a link works once and " + + "expires " + LinkValidity + " after it is sent, so ask for another one", + "missing_token": "that link is incomplete - copy the whole link out of the most recent email", +} + +func linkReasonMessage(reason string) string { + if message, ok := linkReasons[reason]; ok { + return message + } + return "the link did not work: " + strings.ReplaceAll(reason, "_", " ") +} + +// logoutTimeout is short: signing out must not hang the dashboard on a +// platform that accepts the connection and never answers. +var logoutTimeout = 10 * time.Second + +// Logout ends the session on the platform, not only here. A session token +// copied off the machine before signing out otherwise keeps working until +// it expires. +// +// The session goes as a bearer and as the cookie, so an older platform that +// only reads the cookie still revokes it. A 401 is success: a session the +// platform already refuses is already over. +func (c *Client) Logout(ctx context.Context) error { + if c.token == "" { + return nil + } + ctx, cancel := context.WithTimeout(ctx, logoutTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/auth/logout", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.token}) //nolint:gosec // G124: an outgoing request cookie; the attributes are for the server to set + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent()) + + resp, err := c.http.Do(req) + if err != nil { + return unreachable(err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode/100 == 2 { + return nil + } + return fmt.Errorf("HTTP %d", resp.StatusCode) +} + +// ValidateEmail is the check before a link is asked for: obvious typos are +// caught here instead of as an email that never arrives. +func ValidateEmail(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return errors.New("an email address is needed") + } + addr, err := mail.ParseAddress(s) + if err != nil || addr.Address != s { + return errors.New("that is not an email address") + } + at := strings.LastIndex(s, "@") + domain := s[at+1:] + if !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") { + return fmt.Errorf("%q is not a domain this could reach", domain) + } + return nil +} diff --git a/internal/auth/auth_test.go b/internal/api/auth_test.go similarity index 68% rename from internal/auth/auth_test.go rename to internal/api/auth_test.go index 3edb67b..2a2fea3 100644 --- a/internal/auth/auth_test.go +++ b/internal/api/auth_test.go @@ -1,6 +1,8 @@ -package auth +package api import ( + "context" + "io" "net/http" "net/http/httptest" "net/url" @@ -25,7 +27,7 @@ func TestOnlyNanLinksAreAccepted(t *testing.T) { {"a bare token", "abc", "abc"}, } { t.Run(c.name, func(t *testing.T) { - got, err := TokenFromLink(c.pasted) + got, err := New("").TokenFromLink(c.pasted) if err != nil { t.Fatalf("refused one of ours: %v", err) } @@ -45,7 +47,7 @@ func TestOnlyNanLinksAreAccepted(t *testing.T) { {"plain http on an unrelated host", "http://localhost:8080/verify?token=abc"}, } { t.Run(c.name, func(t *testing.T) { - token, err := TokenFromLink(c.pasted) + token, err := New("").TokenFromLink(c.pasted) if err == nil { t.Fatalf("took a token from %s", c.pasted) } @@ -65,7 +67,7 @@ func TestLinkErrorsDoNotEchoTheLink(t *testing.T) { "https://nan.builders/auth/verify#token=" + secret, "https://elsewhere.example/verify?token=" + secret, } { - if _, err := TokenFromLink(pasted); err == nil { + if _, err := New("").TokenFromLink(pasted); err == nil { t.Fatalf("accepted %s", pasted) } else if strings.Contains(err.Error(), secret) { t.Errorf("the error repeats the token: %v", err) @@ -100,24 +102,63 @@ func TestRefusedLinkSaysWhatToDoAboutIt(t *testing.T) { // The verify POST declares this client as the CLI (AGENT-AUTOMATIONS-PHASE2 // §11.3), so the session is recorded as a CLI session, never as a browser // one. -func TestExchangeTokenDeclaresTheCLI(t *testing.T) { +func TestVerifyDeclaresTheCLI(t *testing.T) { + var path string var form url.Values srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path _ = r.ParseForm() form = r.PostForm http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: "sess"}) http.Redirect(w, r, "/dashboard", http.StatusSeeOther) })) defer srv.Close() - old := loginVerifyURL - loginVerifyURL = srv.URL - defer func() { loginVerifyURL = old }() - got, err := ExchangeToken("tok") + got, err := New("", WithBaseURL(srv.URL)).Verify(context.Background(), "tok") if err != nil || got != "sess" { t.Fatalf("%q %v", got, err) } + if path != "/api/auth/login/verify" { + t.Errorf("posted to %s", path) + } if form.Get("token") != "tok" || form.Get("client") != "cli" || form.Has("flow") { t.Errorf("form = %v", form) } } + +// A refused link is followed by the platform's reason, not by a status code. +func TestVerifyReadsTheReasonOffTheRedirect(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/access-denied?reason=invalid_link", http.StatusSeeOther) + })) + defer srv.Close() + + _, err := New("", WithBaseURL(srv.URL)).Verify(context.Background(), "tok") + if err == nil || !strings.Contains(err.Error(), "15 minutes") { + t.Errorf("a spent link reads as %v", err) + } +} + +// Asking for a link: what is sent, and the one refusal worth its own words. +func TestRequestingALink(t *testing.T) { + var body string + status := http.StatusAccepted + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + body = r.URL.Path + " " + string(raw) + w.WriteHeader(status) + })) + defer srv.Close() + c := New("", WithBaseURL(srv.URL)) + + if err := c.RequestMagicLink(context.Background(), "ana@acme.com"); err != nil { + t.Fatal(err) + } + if body != `/api/auth/login/request {"email":"ana@acme.com"}` { + t.Errorf("sent %s", body) + } + status = http.StatusTooManyRequests + if err := c.RequestMagicLink(context.Background(), "ana@acme.com"); err == nil || !strings.Contains(err.Error(), "too many") { + t.Errorf("a 429 reads as %v", err) + } +} diff --git a/internal/api/automations_test.go b/internal/api/automations_test.go index 9e1d4d0..8e8c589 100644 --- a/internal/api/automations_test.go +++ b/internal/api/automations_test.go @@ -114,7 +114,7 @@ func TestRejectRunSendsTheReason(t *testing.T) { } func TestRejectRunNotAwaitingIsAnAPIError(t *testing.T) { - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusConflict) _, _ = io.WriteString(w, `{"error":{"message":"the run is not awaiting approval","type":"invalid_request_error","code":"not_awaiting_approval"}}`) }), "sess", "") @@ -242,7 +242,7 @@ func TestListSecretsUsesTheSession(t *testing.T) { // /api's newer envelope carries the code in "error" and the text in // "message"; the code is what callers branch on. func TestBareEnvelopeWithCodeAndMessage(t *testing.T) { - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = io.WriteString(w, `{"error":"browser_session_required","message":"sign in in the browser","param":"x"}`) }), "sess", "") diff --git a/internal/api/client.go b/internal/api/client.go index 02c8a41..d3f1e51 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1,186 +1,214 @@ +// Package api speaks to nan.builders: the platform behind the web portal +// (cloud-api.nan.builders), and the inference API a member points their +// tools at (api.nan.builders/v1). +// +// Two clients, because the two halves authenticate differently. Client is +// the dashboard's: the member's session, as the cookie the browser flow +// sets. RunsClient is the agent runs': a session, a platform token or an API +// key, and a stream that must not time out. package api import ( + "bytes" + "context" "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" + "os" "strings" "time" ) -const BaseURL = "https://cloud-api.nan.builders/api" +// Where the platform and the web portal live. Both are overridable so the +// CLI can be pointed at a local stand-in without a rebuild, and the tests +// can run against a server of their own. +const ( + defaultAPIURL = "https://cloud-api.nan.builders" + defaultAppURL = "https://cloud.nan.builders" + + envAPIURL = "NAN_API_URL" + envAppURL = "NAN_APP_URL" + envInferenceURL = "NAN_INFERENCE_URL" +) + +// SessionCookie is the cookie the platform keeps the session in. +const SessionCookie = "nan_session" // ErrSessionExpired is the platform refusing the session a request carried. -// The session token expires on the server, and nothing on this side knows -// when: session.json still holds it, so everything reading that file goes on -// believing the member is signed in. What the platform says instead is -// `invalid session`, which names what it decided and not what to do about it, -// so a 401 comes back as this and the caller can tell it apart from a -// platform that is down. +// The session expires on the server, and nothing on this side knows when: +// session.json still holds it. What the platform says instead is `invalid +// session`, which names what it decided and not what to do about it, so a +// 401 comes back as this and the caller can tell it apart from a platform +// that is down. var ErrSessionExpired = errors.New("your session has expired") // A client with no timeout waits forever on a connection that is accepted and -// then never answered, which in the panel is a spinner that never stops. +// then never answered, which in the dashboard is a spinner that never stops. const requestTimeout = 30 * time.Second +// Client is the dashboard's client: one member's session. type Client struct { - token string - http *http.Client - // Defaults to BaseURL. A field rather than the constant so the tests can - // point the client at a server of their own. baseURL string + appURL string + token string + http *http.Client } -func New(token string) *Client { - return &Client{token: token, http: &http.Client{Timeout: requestTimeout}, baseURL: BaseURL} -} +// Option configures a Client. +type Option func(*Client) + +// WithBaseURL points the client at another platform, for tests. +func WithBaseURL(u string) Option { return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") } } + +// WithAppURL overrides the web portal, whose links the sign-in accepts. +func WithAppURL(u string) Option { return func(c *Client) { c.appURL = strings.TrimRight(u, "/") } } -// WithBaseURL points the client somewhere else, for tests. -func (c *Client) WithBaseURL(u string) *Client { - c.baseURL = strings.TrimRight(u, "/") +// New builds a client for a session token, which may be empty. +func New(token string, opts ...Option) *Client { + c := &Client{ + baseURL: resolveBase(envAPIURL, defaultAPIURL), + appURL: resolveBase(envAppURL, defaultAppURL), + token: token, + // No redirects: Go's default policy re-sends the cookie on the host + // alone, scheme not considered, so a redirect to plain http on the + // same host would put the session on the wire in the clear. + http: &http.Client{Timeout: requestTimeout, CheckRedirect: noRedirects}, + } + for _, opt := range opts { + opt(c) + } return c } -// Token is what this client sends. The panel builds a client once, at start, -// and has to build another when a member signs in from inside it - a client -// still holding the token it started with sends an empty cookie and the -// platform answers `unauthorized`, which reads exactly like a failed login. +// Token is what this client sends. The dashboard builds a client once, at +// start, and has to build another when a member signs in from inside it: a +// client still holding the token it started with sends an empty cookie and +// the platform answers `unauthorized`, which reads exactly like a failed +// sign-in. func (c *Client) Token() string { return c.token } -func (c *Client) get(path string) ([]byte, error) { - base := c.baseURL - if base == "" { - base = BaseURL +// AppURL is the web portal this client is paired with, for printing links. +func (c *Client) AppURL() string { return c.appURL } + +// APIHost names the platform when it is not the real one, so a screen can +// say which one it is talking to. Empty for nan.builders itself. +func (c *Client) APIHost() string { + if c.baseURL == defaultAPIURL { + return "" } - req, err := http.NewRequest(http.MethodGet, base+path, nil) - if err != nil { - return nil, err + if u, err := url.Parse(c.baseURL); err == nil && u.Host != "" { + return u.Host } - if c.token != "" { - req.Header.Set("Cookie", "nan_session="+c.token) + return c.baseURL +} + +// resolveBase reads an override, keeping only its scheme and host, and +// refuses anything that would send the session in the clear to somewhere +// that is not this machine. +func resolveBase(env, fallback string) string { + u, ok := override(env, fallback) + if !ok { + return fallback } - resp, err := c.http.Do(req) - if err != nil { - return nil, err + return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String() +} + +// resolveEndpoint is resolveBase for a URL whose path matters, such as the +// inference API's /v1. +func resolveEndpoint(env, fallback string) string { + u, ok := override(env, fallback) + if !ok { + return fallback } - defer resp.Body.Close() + return (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: strings.TrimRight(u.Path, "/")}).String() +} - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err +func override(env, fallback string) (*url.URL, bool) { + raw := strings.TrimSpace(os.Getenv(env)) + if raw == "" { + return nil, false } - if resp.StatusCode == http.StatusUnauthorized && c.token != "" { - return nil, ErrSessionExpired + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + fmt.Fprintf(os.Stderr, "%s is not a URL, using %s\n", env, fallback) + return nil, false } - if resp.StatusCode >= 400 { - var e struct { - Error string `json:"error"` - } - if json.Unmarshal(body, &e) == nil && e.Error != "" { - return nil, fmt.Errorf("%s", e.Error) - } - return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + if u.Scheme != "https" && (u.Scheme != "http" || !isLoopback(u.Hostname())) { + fmt.Fprintf(os.Stderr, "%s must be https (or http on a local address), using %s\n", env, fallback) + return nil, false } - return body, nil + return u, true } -func (c *Client) GetMe() (map[string]any, error) { - body, err := c.get("/auth/me") - if err != nil { - return nil, err - } - var result map[string]any - return result, json.Unmarshal(body, &result) +func isLoopback(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" } -func (c *Client) GetMetricsUsage() (map[string]any, error) { - body, err := c.get("/metrics/usage") - if err != nil { - return nil, err - } - var result map[string]any - return result, json.Unmarshal(body, &result) +func (c *Client) get(ctx context.Context, path string, out any) error { + return c.do(ctx, http.MethodGet, path, nil, out) } -// InferenceBaseURL is the API a member points their tools at, and the only -// place that knows which ids their key can actually name in a request. -const InferenceBaseURL = "https://api.nan.builders/v1" +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + var payload io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return err + } + payload = bytes.NewReader(encoded) + } -// ListModels returns the ids from GET /v1/models, which is the list the docs -// call definitive: `/agents/models` on the platform answers with deployment -// names instead, so it carries routing aliases (`-fallback`) and models that -// are on their way out. -func ListModels(apiKey string) ([]string, error) { - req, err := http.NewRequest(http.MethodGet, InferenceBaseURL+"/models", nil) + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, payload) if err != nil { - return nil, err + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent()) + if c.token != "" { + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.token}) //nolint:gosec // G124: an outgoing request cookie; the attributes are for the server to set } - req.Header.Set("Authorization", "Bearer "+apiKey) - resp, err := (&http.Client{Timeout: requestTimeout}).Do(req) + resp, err := c.http.Do(req) if err != nil { - return nil, err + return unreachable(err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { - return nil, err + return err } - if resp.StatusCode == http.StatusUnauthorized { - return nil, fmt.Errorf("the API key in Setup is not valid") + if resp.StatusCode == http.StatusUnauthorized && c.token != "" { + return ErrSessionExpired } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - var parsed struct { - Data []struct { - ID string `json:"id"` - } `json:"data"` + return parseError(resp.StatusCode, raw) } - if err := json.Unmarshal(body, &parsed); err != nil { - return nil, err + if out == nil { + return nil } - ids := make([]string, 0, len(parsed.Data)) - for _, m := range parsed.Data { - ids = append(ids, m.ID) + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("nan.builders sent something this version cannot read: %w", err) } - return ids, nil -} - -// KeyStatus is what the platform will say about a member's API key. Note what -// is not in it: the key. GET /api/keys answers with metadata only - the secret -// is handed over once, when it is created, and never again - so the Setup tab -// cannot fetch a key on a member's behalf, only tell them whether they have -// one and where it lives. -type KeyStatus struct { - Exists bool `json:"exists"` - Alias string `json:"keyAlias"` - Name string `json:"keyName"` - Region string `json:"region"` - Synced bool `json:"secretSynced"` + return nil } -func (c *Client) GetKeyStatus() (*KeyStatus, error) { - body, err := c.get("/keys") - if err != nil { - return nil, err - } - var status KeyStatus - if err := json.Unmarshal(body, &status); err != nil { - return nil, err - } - return &status, nil -} +// maxResponseBytes caps what one answer may make this process hold. The +// largest the dashboard asks for is a usage summary; anything near this is +// a misbehaving server, not data. +const maxResponseBytes = 8 << 20 -func (c *Client) GetAgentsModels() (any, error) { - body, err := c.get("/agents/models") - if err != nil { - return nil, err +// unreachable says the network failed, and leaves a cancellation alone: a +// ctrl-c is not a connectivity problem. +func unreachable(err error) error { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err } - var result any - return result, json.Unmarshal(body, &result) + return fmt.Errorf("could not reach nan.builders: %w", err) } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index b75cda8..5a749f3 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "net/http" "net/http/httptest" @@ -21,13 +22,13 @@ func TestKeyStatusIsMetadataAndCarriesNoKey(t *testing.T) { })) defer srv.Close() - c := &Client{token: "session-token", http: srv.Client(), baseURL: srv.URL} - status, err := c.GetKeyStatus() + c := New("session-token", WithBaseURL(srv.URL)) + status, err := c.KeyStatus(context.Background()) if err != nil { t.Fatal(err) } - if gotPath != "/keys" { - t.Errorf("asked for %q, want /keys", gotPath) + if gotPath != "/api/keys" { + t.Errorf("asked for %q, want /api/keys", gotPath) } if !strings.Contains(gotCookie, "nan_session=session-token") { t.Errorf("cookie %q does not carry the session", gotCookie) @@ -41,13 +42,13 @@ func TestKeyStatusIsMetadataAndCarriesNoKey(t *testing.T) { // something useful about, so it must survive the parse rather than read as // "yes" by accident. func TestKeyStatusSaysWhenThereIsNoKey(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"exists":false}`)) })) defer srv.Close() - c := &Client{token: "t", http: srv.Client(), baseURL: srv.URL} - status, err := c.GetKeyStatus() + c := New("t", WithBaseURL(srv.URL)) + status, err := c.KeyStatus(context.Background()) if err != nil { t.Fatal(err) } @@ -60,29 +61,29 @@ func TestKeyStatusSaysWhenThereIsNoKey(t *testing.T) { // `invalid session`, and the panel has to tell that apart from a platform that // is down: one means sign in again, the other means try later. func TestAnExpiredSessionIsNamedAsOne(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"invalid session"}`)) })) defer srv.Close() - c := &Client{token: "stale", http: srv.Client(), baseURL: srv.URL} - if _, err := c.GetMe(); !errors.Is(err, ErrSessionExpired) { + c := New("stale", WithBaseURL(srv.URL)) + if _, err := c.Me(context.Background()); !errors.Is(err, ErrSessionExpired) { t.Errorf("a refused session comes back as %v", err) } // With no token there was no session to expire, and saying so would send // someone who never signed in to sign in "again". c.token = "" - if _, err := c.GetMe(); errors.Is(err, ErrSessionExpired) { + if _, err := c.Me(context.Background()); errors.Is(err, ErrSessionExpired) { t.Error("a request with no session reads as an expired one") } - srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }) c.token = "stale" - if _, err := c.GetMe(); errors.Is(err, ErrSessionExpired) { + if _, err := c.Me(context.Background()); errors.Is(err, ErrSessionExpired) { t.Error("a platform that is down reads as an expired session") } } diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..2a3cb04 --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,53 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/nxssie/nan-cli/internal/build" +) + +// Error is a refusal from the dashboard's half of the platform. +// +// The platform answers with `{"error": "..."}` in words already meant for a +// person, so the message is passed on as it came; only an answer without one +// is described by its status. +type Error struct { + Status int + Message string +} + +func (e *Error) Error() string { + if e.Message != "" { + return e.Message + } + switch e.Status { + case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return fmt.Sprintf("nan.builders is having trouble (HTTP %d) - try again in a moment", e.Status) + } + return fmt.Sprintf("HTTP %d", e.Status) +} + +// maxMessageLength keeps a hostile or broken server from filling the screen +// with whatever it put in the error field. +const maxMessageLength = 200 + +func parseError(status int, body []byte) error { + var wire struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &wire); err == nil && wire.Error != "" { + msg := wire.Error + if len(msg) > maxMessageLength { + msg = msg[:maxMessageLength] + "…" + } + return &Error{Status: status, Message: msg} + } + return &Error{Status: status} +} + +// userAgent says which CLI, and which version of it, is asking. +func userAgent() string { + return build.Name + "-cli/" + build.Version +} diff --git a/internal/api/logout.go b/internal/api/logout.go deleted file mode 100644 index 2ec31db..0000000 --- a/internal/api/logout.go +++ /dev/null @@ -1,67 +0,0 @@ -package api - -import ( - "context" - "fmt" - "io" - "net/http" - "time" -) - -// logoutTimeout bounds the sign-out call. Signing out must not hang on a -// platform that accepts the connection and never answers: the local sign-out -// goes ahead either way, this only decides how long it waits to say whether -// the platform got the message. -// -// A variable only so the tests can make a hanging platform time out quickly. -var logoutTimeout = 10 * time.Second - -// Logout ends the session on the platform (POST /auth/logout), so the token -// stops working everywhere and not only on this machine. Deleting -// session.json alone leaves any copy of the token valid until it expires. -// -// The session rides as a bearer, which is how the platform recognises a CLI -// session on logout, and also as the nan_session cookie, which is how every -// other call of this client presents it: a platform that predates bearer -// logout still revokes it. Both name the same session; it is revoked once. -// -// A 401 means the platform already refuses the session, which is the outcome -// wanted, so it is not an error. This assumes cloud-api answers 401 here only -// for a session it rejects, which is true today: /auth/logout itself never -// 401s (it is public and answers 200), and the edge in front of it does not -// authenticate. Redirects are not followed: a 3xx did not revoke anything. Anything else that is not 2xx is: nothing was -// revoked, and the caller should say so. -func (c *Client) Logout() error { - if c.token == "" { - return nil - } - base := c.baseURL - if base == "" { - base = BaseURL - } - ctx, cancel := context.WithTimeout(context.Background(), logoutTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/auth/logout", nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Cookie", "nan_session="+c.token) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "nan-cli") - - hc := *c.http - hc.CheckRedirect = noRedirects - resp, err := hc.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) - - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode/100 == 2 { - return nil - } - return fmt.Errorf("HTTP %d", resp.StatusCode) -} diff --git a/internal/api/logout_test.go b/internal/api/logout_test.go index 30d250c..6101b8e 100644 --- a/internal/api/logout_test.go +++ b/internal/api/logout_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/http" "net/http/httptest" "testing" @@ -11,7 +12,7 @@ import ( // the platform, not only forget it locally. A session token copied off the // machine before `nan auth logout` otherwise keeps working until it expires. // -// Logout POSTs /auth/logout carrying the session as a bearer (and as the +// Logout POSTs /api/auth/logout carrying the session as a bearer (and as the // nan_session cookie, which is how every other call of this client presents // it, so an older platform that only reads the cookie still revokes it). @@ -26,12 +27,12 @@ func TestLogoutPostsTheSessionToThePlatform(t *testing.T) { })) defer srv.Close() - c := New("session-jwt").WithBaseURL(srv.URL) - if err := c.Logout(); err != nil { + c := New("session-jwt", WithBaseURL(srv.URL)) + if err := c.Logout(context.Background()); err != nil { t.Fatalf("Logout: %v", err) } - if method != http.MethodPost || path != "/auth/logout" { - t.Errorf("sent %s %s, want POST /auth/logout", method, path) + if method != http.MethodPost || path != "/api/auth/logout" { + t.Errorf("sent %s %s, want POST /api/auth/logout", method, path) } if authz != "Bearer session-jwt" { t.Errorf("Authorization = %q, want the session as a bearer", authz) @@ -43,12 +44,12 @@ func TestLogoutPostsTheSessionToThePlatform(t *testing.T) { // A session the platform already refuses is already over: nothing to report. func TestLogoutOfAnExpiredSessionIsNotAnError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() - if err := New("stale").WithBaseURL(srv.URL).Logout(); err != nil { + if err := New("stale", WithBaseURL(srv.URL)).Logout(context.Background()); err != nil { t.Errorf("an already-dead session reads as a failed logout: %v", err) } } @@ -56,14 +57,14 @@ func TestLogoutOfAnExpiredSessionIsNotAnError(t *testing.T) { // A platform that is down did not revoke anything, and the caller has to be // able to say so. func TestLogoutReportsAPlatformThatDidNotAnswer(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) })) - if err := New("t").WithBaseURL(srv.URL).Logout(); err == nil { + if err := New("t", WithBaseURL(srv.URL)).Logout(context.Background()); err == nil { t.Error("a 502 reads as a revoked session") } srv.Close() - if err := New("t").WithBaseURL(srv.URL).Logout(); err == nil { + if err := New("t", WithBaseURL(srv.URL)).Logout(context.Background()); err == nil { t.Error("an unreachable platform reads as a revoked session") } } @@ -71,10 +72,10 @@ func TestLogoutReportsAPlatformThatDidNotAnswer(t *testing.T) { // No session, no request: there is nothing on the platform to end. func TestLogoutWithoutASessionSendsNothing(t *testing.T) { calls := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })) + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { calls++ })) defer srv.Close() - if err := New("").WithBaseURL(srv.URL).Logout(); err != nil { + if err := New("", WithBaseURL(srv.URL)).Logout(context.Background()); err != nil { t.Errorf("Logout with no session: %v", err) } if calls != 0 { @@ -86,7 +87,7 @@ func TestLogoutWithoutASessionSendsNothing(t *testing.T) { // sign-out hostage: Logout gives up and says it was not confirmed. func TestLogoutGivesUpOnAHangingPlatform(t *testing.T) { release := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { select { case <-release: case <-r.Context().Done(): @@ -100,7 +101,7 @@ func TestLogoutGivesUpOnAHangingPlatform(t *testing.T) { defer func() { logoutTimeout = orig }() start := time.Now() - if err := New("t").WithBaseURL(srv.URL).Logout(); err == nil { + if err := New("t", WithBaseURL(srv.URL)).Logout(context.Background()); err == nil { t.Error("a platform that never answered reads as a revoked session") } if took := time.Since(start); took > 5*time.Second { @@ -112,14 +113,14 @@ func TestLogoutGivesUpOnAHangingPlatform(t *testing.T) { // it points. func TestLogoutDoesNotFollowRedirects(t *testing.T) { followed := false - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { followed = true })) + target := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { followed = true })) defer target.Close() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL+"/auth/logout", http.StatusTemporaryRedirect) })) defer srv.Close() - if err := New("t").WithBaseURL(srv.URL + "/").Logout(); err == nil { + if err := New("t", WithBaseURL(srv.URL+"/")).Logout(context.Background()); err == nil { t.Error("a 307 reads as a revoked session") } if followed { diff --git a/internal/api/models.go b/internal/api/models.go new file mode 100644 index 0000000..d14bce3 --- /dev/null +++ b/internal/api/models.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +// InferenceBaseURL is the API a member points their tools at, and the only +// place that knows which ids their key can actually name in a request. +const InferenceBaseURL = "https://api.nan.builders/v1" + +// InferenceURL is InferenceBaseURL, or the override for a local stand-in. +func InferenceURL() string { + return resolveEndpoint(envInferenceURL, InferenceBaseURL) +} + +// ErrKeyRefused is the inference API turning a key down. +var ErrKeyRefused = errors.New("the API key in Setup is not valid") + +// ListModels returns the ids from GET /v1/models, which is the list the docs +// call definitive: the platform's /agents/models answers with deployment +// names instead, so it carries routing aliases (`-fallback`) and models that +// are on their way out. It is also how a pasted key is checked: an answer +// here is the cluster accepting it. +func ListModels(ctx context.Context, apiKey string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, InferenceURL()+"/models", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent()) + + resp, err := (&http.Client{Timeout: requestTimeout, CheckRedirect: noRedirects}).Do(req) + if err != nil { + return nil, unreachable(err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, ErrKeyRefused + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("the inference API sent something this version cannot read: %w", err) + } + ids := make([]string, 0, len(parsed.Data)) + for _, m := range parsed.Data { + ids = append(ids, m.ID) + } + return ids, nil +} diff --git a/internal/api/release.go b/internal/api/release.go new file mode 100644 index 0000000..088b93e --- /dev/null +++ b/internal/api/release.go @@ -0,0 +1,57 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Release is the newest published version of this CLI. +type Release struct { + Version string + PublishedAt string + NotesURL string +} + +// releasesURL is GitHub's answer for the repository's latest release. The +// repository is public, so this needs no credential, and it is the same +// place the installers download from: a version said to exist here is one +// they can install. +var releasesURL = "https://api.github.com/repos/helmcode/nan-cli/releases/latest" + +// LatestRelease asks which version is the newest. A failure is just "do not +// know": nothing depends on it but a line that says an update exists. +func LatestRelease(ctx context.Context) (*Release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", userAgent()) + + resp, err := (&http.Client{Timeout: requestTimeout, CheckRedirect: noRedirects}).Do(req) + if err != nil { + return nil, unreachable(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var wire struct { + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` + HTMLURL string `json:"html_url"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&wire); err != nil { + return nil, err + } + return &Release{ + Version: strings.TrimPrefix(wire.TagName, "v"), + PublishedAt: wire.PublishedAt, + NotesURL: wire.HTMLURL, + }, nil +} diff --git a/internal/api/runs.go b/internal/api/runs.go index d9016ea..234edd1 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -209,6 +209,8 @@ type Workspace struct { // APIError is a refusal from the platform, in the OpenAI envelope /v1/runs // answers with. Code is what callers branch on; Message is for people. +// +//nolint:revive // the name the run commands have always matched on type APIError struct { Status int Type string @@ -252,7 +254,7 @@ type RunsClient struct { // OnRetry, when set, hears about each reconnect before its backoff, so // a caller can say why nothing is arriving. - OnRetry func(attempt, max int, err error) + OnRetry func(attempt, limit int, err error) } // NewRunsClient builds a client for the session token or, when there is none, @@ -260,7 +262,7 @@ type RunsClient struct { // command uses; the key is the fallback for a machine that only ran Setup. func NewRunsClient(token, apiKey string) *RunsClient { return &RunsClient{ - baseURL: RunsBaseURL, + baseURL: resolveBase(envAPIURL, RunsBaseURL), token: token, apiKey: apiKey, http: &http.Client{Timeout: requestTimeout, CheckRedirect: noRedirects}, @@ -319,7 +321,7 @@ func (c *RunsClient) newRequest(ctx context.Context, method, path string, body a req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "nan-cli") + req.Header.Set("User-Agent", userAgent()) c.authorize(req) return req, nil } @@ -335,7 +337,7 @@ func (c *RunsClient) do(req *http.Request, out any) (int, error) { if err != nil { return 0, fmt.Errorf("could not reach nan.builders: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse)) if err != nil { return resp.StatusCode, fmt.Errorf("could not read the answer from nan.builders: %w", err) @@ -569,7 +571,9 @@ func (c *RunsClient) Follow(ctx context.Context, id string, after int64, onFrame if lastErr == nil { lastErr = errors.New("the platform closed the stream") } - return nil, fmt.Errorf("%w: %v", ErrStreamLost, lastErr) + // %v, not %w: what lost the stream is said, never matched on - + // a refused session inside it must not read as this run's outcome. + return nil, fmt.Errorf("%w: %v", ErrStreamLost, lastErr) //nolint:errorlint // see above } if c.OnRetry != nil { c.OnRetry(failures, c.StreamRetries, lastErr) @@ -633,7 +637,7 @@ func (c *RunsClient) followOnce(ctx context.Context, id string, lastSeq *int64, } return nil, false, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) diff --git a/internal/api/runs_test.go b/internal/api/runs_test.go index 7f80913..486cf42 100644 --- a/internal/api/runs_test.go +++ b/internal/api/runs_test.go @@ -63,7 +63,7 @@ func TestRunsClientFallsBackToTheAPIKey(t *testing.T) { } func TestRunsClient401IsAnExpiredSession(t *testing.T) { - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"error":{"message":"invalid session","type":"auth_error","code":"invalid_api_key"}}`) }), "stale", "") @@ -80,7 +80,7 @@ func TestRunsClientParsesBothErrorEnvelopes(t *testing.T) { {`{"error":{"message":"bad timeout","type":"invalid_request_error","param":"timeout_seconds","code":"invalid_request"}}`, "invalid_request", "bad timeout", "timeout_seconds"}, {`{"error":"workspaces are not enabled"}`, "", "workspaces are not enabled", ""}, } { - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusConflict) _, _ = io.WriteString(w, tc.body) }), "t", "") @@ -289,7 +289,7 @@ func TestFollowReconnectsWithLastEventID(t *testing.T) { if run.State != StateFailed { t.Errorf("state = %s", run.State) } - var seqs []int64 + seqs := make([]int64, 0, len(frames)) for _, f := range frames { seqs = append(seqs, f.Event.Seq) } @@ -304,7 +304,7 @@ func TestFollowReconnectsWithLastEventID(t *testing.T) { func TestFollowGivesUpAfterFiveFailuresInARow(t *testing.T) { var mu sync.Mutex calls := 0 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { mu.Lock() calls++ mu.Unlock() @@ -376,7 +376,7 @@ func TestFollowDoesNotRetryAuthOrNotFound(t *testing.T) { {http.StatusNotFound, func(err error) bool { var e *APIError; return errors.As(err, &e) && e.Status == 404 }}, } { calls := 0 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls++ w.WriteHeader(tc.status) }), "t", "") @@ -404,7 +404,7 @@ func TestFollowStopsWhenTheContextIsCancelled(t *testing.T) { // A connection that is accepted and never answered must not hang the CLI: // the idle limit covers the wait for the headers as well. func TestFollowTimesOutWaitingForHeaders(t *testing.T) { - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { <-r.Context().Done() }), "t", "") c.IdleTimeout = 30 * time.Millisecond @@ -425,11 +425,11 @@ func TestFollowTimesOutWaitingForHeaders(t *testing.T) { // hang-up - must count as a failure, or the client reconnects forever. func TestFollowGivesUpOnStreamsThatNeverWork(t *testing.T) { for name, h := range map[string]http.HandlerFunc{ - "not an event stream": func(w http.ResponseWriter, r *http.Request) { + "not an event stream": func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"data":[]}`) }, - "heartbeat and hang up": func(w http.ResponseWriter, r *http.Request) { + "heartbeat and hang up": func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, ": hb\n\n") }, @@ -464,16 +464,16 @@ func TestFollowRetryBudgetResetsOnWorkingConnections(t *testing.T) { t.Run("frames", func(t *testing.T) { var mu sync.Mutex calls := 0 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { mu.Lock() calls++ n := calls mu.Unlock() - switch { - case n == 4: + switch n { + case 4: w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, sseEvent(1, "log", `{}`)) - case n == 8: + case 8: w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, sseEnd("succeeded")) default: @@ -487,7 +487,7 @@ func TestFollowRetryBudgetResetsOnWorkingConnections(t *testing.T) { t.Run("long-lived heartbeats", func(t *testing.T) { var mu sync.Mutex calls := 0 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { mu.Lock() calls++ n := calls @@ -510,7 +510,7 @@ func TestFollowRetryBudgetResetsOnWorkingConnections(t *testing.T) { func TestFollowRetriesTooManyRequests(t *testing.T) { var calls atomic.Int32 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if calls.Add(1) == 1 { w.WriteHeader(http.StatusTooManyRequests) return @@ -525,7 +525,7 @@ func TestFollowRetriesTooManyRequests(t *testing.T) { func TestFollowSkipsAMalformedEnd(t *testing.T) { var calls atomic.Int32 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { n := calls.Add(1) w.Header().Set("Content-Type", "text/event-stream") if n == 1 { @@ -547,7 +547,7 @@ func TestFollowRefusesOversizedLinesAndFrames(t *testing.T) { "frame": strings.Repeat("data: "+strings.Repeat("a", maxSSELine/2)+"\n", 10) + "\n", } { var calls atomic.Int32 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1) w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, body) @@ -575,7 +575,7 @@ func TestFollowBusyConsumerIsNotIdle(t *testing.T) { "frame": strings.Repeat("data: "+strings.Repeat("a", maxSSELine/2)+"\n", 10) + "\n", } { var calls atomic.Int32 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1) w.Header().Set("Content-Type", "text/event-stream") for seq := int64(1); seq <= events; seq++ { @@ -639,7 +639,7 @@ func TestFollowOversizedLineArrivingSlowlyIsNotIdle(t *testing.T) { // wherever it points. func TestRunsClientDoesNotFollowRedirects(t *testing.T) { hit := false - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = true })) + target := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { hit = true })) defer target.Close() c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound) @@ -663,7 +663,7 @@ func TestTerminal(t *testing.T) { // up has shown nothing new: that is not a working connection. func TestFollowReplaysAloneDoNotRefillTheBudget(t *testing.T) { var calls atomic.Int32 - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1) w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, sseEvent(1, "log", `{}`)) @@ -683,7 +683,7 @@ func TestFollowReplaysAloneDoNotRefillTheBudget(t *testing.T) { } func TestFollowRefusesAnEndForAnotherRun(t *testing.T) { - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, sseEnd("succeeded")) }), "t", "") @@ -693,11 +693,11 @@ func TestFollowRefusesAnEndForAnotherRun(t *testing.T) { } func TestOnRetryHearsEachReconnect(t *testing.T) { - c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }), "t", "") var attempts []int - c.OnRetry = func(attempt, max int, err error) { attempts = append(attempts, attempt) } + c.OnRetry = func(attempt, _ int, _ error) { attempts = append(attempts, attempt) } _, _ = c.Follow(context.Background(), runID, 0, func(Frame) {}) if fmt.Sprint(attempts) != "[1 2 3 4 5]" { t.Errorf("attempts = %v", attempts) diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index b4b61e7..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,169 +0,0 @@ -// Package auth is the sign-in flow, shared by the `nan auth login` command and -// by the panel. -// -// It lived inside cmd/ until the panel learned to sign a member in on its own. -// Leaving it there would have meant the TUI importing a cobra command package -// to make three HTTP calls, or a second copy of the flow that drifts from the -// first - and the whole point of doing it in the panel is that it is the same -// flow, reached without having to quit and come back. -package auth - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" -) - -const ( - loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request" - sessionCookie = "nan_session" - // The domain the platform sends its sign-in links from. Subdomains count: - // the link lands on the web app, the API lives next door. - linkDomain = "nan.builders" - - // How long a sign-in link works for, in the platform's own words on its - // login page: "It expires in 15 minutes and can only be used once." This - // flow spends long enough waiting for a member to fetch a link out of an - // inbox that the number is worth saying: an email that turns up late turns - // up dead, and the paste prompt cannot tell anybody that. - LinkValidity = "15 minutes" -) - -// loginVerifyURL is a variable so a test can point the exchange at a server -// of its own. -var loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify" - -// clientKind is what this CLI declares on the verify POST, so the platform -// records the session it hands back as a CLI session. A CLI session can -// read, start runs and reject proposals; approving a proposal, writing a -// secret and editing an automation need a session signed in through the -// browser. The session file this CLI keeps is readable by anything running -// as the member - an agent in a workspace included - and must never be able -// to do those. The platform never takes a client's word for "browser": that -// kind is proven by the browser flow itself, so declaring "cli" here only -// ever narrows what the session can do. -const clientKind = "cli" - -// A timeout, because http.DefaultClient has none: a connection that is -// accepted and then never answered - a captive portal, a hotel network, a -// firewall that drops instead of refusing - hangs the panel on a spinner with -// no way out but ctrl-c. -const requestTimeout = 30 * time.Second - -func RequestSignInLink(email string) error { - body, err := json.Marshal(map[string]string{"email": email}) - if err != nil { - return err - } - client := &http.Client{Timeout: requestTimeout} - resp, err := client.Post(loginRequestURL, "application/json", bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("could not reach nan.builders: %w", err) - } - defer resp.Body.Close() - - switch { - case resp.StatusCode == http.StatusTooManyRequests: - return fmt.Errorf("too many sign-in attempts, wait a few minutes") - case resp.StatusCode >= 400: - return fmt.Errorf("could not send the sign-in link (HTTP %d)", resp.StatusCode) - } - // 202 comes back whether or not the address belongs to a member, so a - // successful call here is not proof that an email is on the way. - return nil -} - -// Accepts the whole link, or just the token if the mail client mangled it. -func TokenFromLink(pasted string) (string, error) { - if pasted == "" { - return "", fmt.Errorf("nothing pasted") - } - if strings.Contains(pasted, "://") { - u, err := url.Parse(pasted) - if err != nil { - return "", fmt.Errorf("that does not parse as a link: %w", err) - } - // Ours, or nothing. The token in a link from somewhere else is not one - // this exchange can spend, and a member who has been sent a link that - // only looks like ours is better told that here than after we have - // posted whatever was in it and read back an error about it. - if !isLinkHost(u.Hostname()) { - return "", fmt.Errorf("that link points at %s, not %s", u.Hostname(), linkDomain) - } - token := u.Query().Get("token") - if token == "" { - // The host and not the link: a link with no `token` in its query - // can still be carrying one somewhere this does not read, and the - // message is rendered in the panel and copied into issues. - return "", fmt.Errorf("that %s link carries no token", u.Hostname()) - } - return token, nil - } - if strings.ContainsAny(pasted, " \t") { - return "", fmt.Errorf("that is neither a link nor a token") - } - return pasted, nil -} - -func isLinkHost(host string) bool { - host = strings.ToLower(host) - return host == linkDomain || strings.HasSuffix(host, "."+linkDomain) -} - -// The browser flow ends on a page that POSTs the token and gets the session -// cookie back. This does the same POST and keeps the cookie instead of -// following the redirect, which is the whole reason the old flow had to send -// people into DevTools. -func ExchangeToken(token string) (string, error) { - client := &http.Client{ - Timeout: requestTimeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } - form := url.Values{"token": {token}, "client": {clientKind}} - resp, err := client.PostForm(loginVerifyURL, form) - if err != nil { - return "", fmt.Errorf("could not reach nan.builders: %w", err) - } - defer resp.Body.Close() - - for _, c := range resp.Cookies() { - if c.Name == sessionCookie && c.Value != "" { - return c.Value, nil - } - } - - // A spent or expired link redirects to the platform's access-denied page - // with the reason in the query, which is more useful than the status code. - if location, err := resp.Location(); err == nil { - if reason := location.Query().Get("reason"); reason != "" { - return "", errors.New(linkReasonMessage(reason)) - } - } - return "", fmt.Errorf("no session came back (HTTP %d)", resp.StatusCode) -} - -// The platform's reasons, in the query of the page it redirects a refused link -// to. Two of them have something the member can do about it, and both were -// arriving here as a slug with the underscores taken out: `invalid_link` covers -// a link that expired and a link somebody else already spent, and named -// neither, which left the person this happens to - the one whose email turned -// up late - with nothing to do but paste the same dead link again. -var linkReasons = map[string]string{ - "invalid_link": "that link expired or was already used — a link works once and " + - "expires " + LinkValidity + " after it is sent, so ask for another one", - "missing_token": "that link is incomplete — copy the whole link out of the most recent email", -} - -func linkReasonMessage(reason string) string { - if message, ok := linkReasons[reason]; ok { - return message - } - return "the link did not work: " + strings.ReplaceAll(reason, "_", " ") -} diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 0000000..55dc013 --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,40 @@ +// Package build carries what the binary knows about itself. +package build + +import "runtime" + +// Version is the release this binary was cut from. +// +// Bumping it is the act of publishing: once a commit that changes it passes +// CI on main, .github/workflows/tag.yml tags it and the release follows. The +// workflow reads it from this line, so it stays a plain string literal. +var Version = "0.2.0" + +// Name is the binary as it is invoked, used wherever a message has to spell +// out a command for someone to type. +const Name = "nan" + +// Repo is where this comes from, shown in About and named by the installer +// and the release workflow. One constant because the three of them going out +// of step is the kind of thing nobody notices until somebody follows the +// wrong one. +const Repo = "https://github.com/helmcode/nan-cli" + +// Line is what `--version` prints: one line, parseable, the version in the +// second field. +// +// A framed banner is the wrong answer here however good it looks. `--version` +// is read by scripts, pasted into issues and diffed between machines, and all +// three of those want `nan 0.2.0`, not a box. The banner still exists - it +// is what the dashboard's Home and About draw - but it is not what a version +// flag is for. +func Line() string { + return Name + " " + Version +} + +// Detail is the second line: the toolchain and platform this binary was built +// for. It costs nothing and it is the first thing anybody asks for in a bug +// report. +func Detail() string { + return runtime.Version() + " " + runtime.GOOS + "/" + runtime.GOARCH +} diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..9aba5e0 --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,28 @@ +package cli + +import ( + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/session" +) + +// app is what the dashboard-side commands are handed: somewhere to print, a +// session, and a client that carries it. +// +// It is built once per invocation, which is what keeps each command file down +// to the thing it actually does rather than five lines of the same wiring. +// The run commands authenticate their own way (see credentials.go) and do +// not use it. +type app struct { + p *Printer + session *session.Session + client *api.Client +} + +func newApp(asJSON, noColor bool) *app { + s := session.LoadOrEmpty() + return &app{ + p: NewPrinter(asJSON, noColor), + session: s, + client: newAPIClient(s.Token), + } +} diff --git a/cmd/approvals.go b/internal/cli/approvals.go similarity index 89% rename from cmd/approvals.go rename to internal/cli/approvals.go index de96d36..41e3fa2 100644 --- a/cmd/approvals.go +++ b/internal/cli/approvals.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "context" @@ -7,9 +7,10 @@ import ( "strings" "text/tabwriter" + "github.com/spf13/cobra" + "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/runs" - "github.com/spf13/cobra" ) var ( @@ -57,7 +58,7 @@ var approvalsLsCmd = &cobra.Command{ Short: "List approvals, pending ones by default", Args: cobra.NoArgs, Annotations: sysexits, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { state, err := approvalsStateParam(approvalsState) if err != nil { return err @@ -82,7 +83,6 @@ var approvalsShowCmd = &cobra.Command{ } func init() { - rootCmd.AddCommand(approvalsCmd) approvalsCmd.AddCommand(approvalsLsCmd, approvalsShowCmd) approvalsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) @@ -121,9 +121,9 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { } if len(list.Data) == 0 { if state == api.ApprovalPending { - fmt.Fprintln(env.stderr, "Nothing is waiting for your approval.") + _, _ = fmt.Fprintln(env.stderr, "Nothing is waiting for your approval.") } else { - fmt.Fprintln(env.stderr, "No approvals match.") + _, _ = fmt.Fprintln(env.stderr, "No approvals match.") } return nil } @@ -131,7 +131,7 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { env.nameAutomation(ctx, list.Data[i].Automation) } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "RUN\tSTATE\tAUTOMATION\tWAITING FOR APPROVAL\tEXPIRES") + _, _ = fmt.Fprintln(tw, "RUN\tSTATE\tAUTOMATION\tWAITING FOR APPROVAL\tEXPIRES") for _, a := range list.Data { expires := "-" if a.ExpiresAt != nil && a.State == api.ApprovalPending { @@ -141,7 +141,7 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { if automation == "" { automation = "-" } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", runs.SanitizeLine(a.RunID), runs.SanitizeLine(a.State), runs.Truncate(automation, 40), @@ -152,10 +152,10 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { return err } if list.NextCursor != nil { - fmt.Fprintf(env.stderr, "showing the %d most recent; raise --limit (up to 100) for more\n", len(list.Data)) + _, _ = fmt.Fprintf(env.stderr, "showing the %d most recent; raise --limit (up to 100) for more\n", len(list.Data)) } if state == api.ApprovalPending { - fmt.Fprintf(env.stderr, "\nreview one: nan runs proposal \napprove: in the portal, %s (approving needs a browser sign-in)\nreject: nan runs reject \n", runs.ApprovalsURL) + _, _ = fmt.Fprintf(env.stderr, "\nreview one: nan runs proposal \napprove: in the portal, %s (approving needs a browser sign-in)\nreject: nan runs reject \n", runs.ApprovalsURL) } return nil } diff --git a/cmd/auth.go b/internal/cli/auth.go similarity index 59% rename from cmd/auth.go rename to internal/cli/auth.go index a9f0e10..4a3f4be 100644 --- a/cmd/auth.go +++ b/internal/cli/auth.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bufio" @@ -9,12 +9,14 @@ import ( "os" "strings" + "charm.land/huh/v2" "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" + "github.com/nxssie/nan-cli/internal/api" - "github.com/nxssie/nan-cli/internal/auth" "github.com/nxssie/nan-cli/internal/session" - "github.com/nxssie/nan-cli/internal/tui" - "github.com/spf13/cobra" + "github.com/nxssie/nan-cli/internal/tools" + "github.com/nxssie/nan-cli/internal/ui" ) var ( @@ -56,7 +58,6 @@ var logoutCmd = &cobra.Command{ } func init() { - rootCmd.AddCommand(authCmd) authCmd.AddCommand(loginCmd) authCmd.AddCommand(logoutCmd) loginCmd.Flags().StringVar(&emailFlag, "email", "", "Email to send the sign-in link to") @@ -70,7 +71,7 @@ func init() { // command opened https://cloud-api.nan.builders/api/auth/discord, which has // answered 404 since that flow was retired: the browser landed on an error page // and the command then asked for a cookie that no longer existed. -func runLogin(cmd *cobra.Command, args []string) error { +func runLogin(cmd *cobra.Command, _ []string) error { if apiTokenFlag { if tokenFlag != "" || linkFlag != "" || emailFlag != "" { return fmt.Errorf("--api-token cannot be combined with --email, --link or --token") @@ -85,91 +86,139 @@ func runLogin(cmd *cobra.Command, args []string) error { return saveToken(token) } + client := newAPIClient("") + // `--link` picks the flow up at its second half, for a shell that cannot // answer a prompt: a script, a CI step, or a terminal that runs one command - // at a time. + // at a time. It goes through TokenFromLink exactly as the prompt does, so + // the host check is never skipped. if linkFlag != "" { pasted, err := flagValue(linkFlag) if err != nil { return err } - token, err := auth.TokenFromLink(pasted) - if err != nil { - return err - } - sessionToken, err := auth.ExchangeToken(token) - if err != nil { - return err - } - return saveToken(sessionToken) + return finishLogin(cmd.Context(), client, pasted) } - in := bufio.NewScanner(os.Stdin) - - // Both dead ends below name the flag that skips the prompt. They used to - // say only that the email was missing or malformed, which is the whole - // story when someone typed it wrong and none of it when the prompt itself - // never reached them - a terminal that renders a command's output as a - // block, a shell running one command at a time, anything piping into this. - // The `--link` half of this flow has said so for a while; this half did - // not, so the first of the two prompts was the one you could get stuck on. - const emailHint = "pass it instead:\n\n nan auth login --email you@example.com" - + p := NewPrinter(false, false) email := strings.TrimSpace(emailFlag) if email == "" { - fmt.Print("Email: ") - if !in.Scan() { - fmt.Println() - return fmt.Errorf("nothing to read from here — %s", emailHint) + var err error + if email, err = askEmail(cmd.Context(), p); err != nil { + return err } - email = strings.TrimSpace(in.Text()) } - if !strings.Contains(email, "@") { + if err := api.ValidateEmail(email); err != nil { if email == "" { - return fmt.Errorf("no email — %s", emailHint) + return fmt.Errorf("no email - %s", emailHint) } - return fmt.Errorf("not an email address: %q — %s", email, emailHint) + return fmt.Errorf("not an email address: %q - %s", email, emailHint) } - if err := auth.RequestSignInLink(email); err != nil { + if err := client.RequestMagicLink(cmd.Context(), email); err != nil { return err } + announceLink(p, client, email) - fmt.Println() - fmt.Printf("A sign-in link is on its way to %s.\n", email) - fmt.Println() - fmt.Println("Copy the link out of the email. Don't open it in your browser first:") - fmt.Println("the link works once, and the browser would spend it.") - fmt.Printf("It also expires %s after it is sent — an email that turns up late\n", auth.LinkValidity) - // Saying "run this command again" and stopping there sent people at a - // blocking prompt looking for a way out of it. The empty line they reach - // for is the one exit that is not one: it comes back as `nothing pasted` - // and exit 1, which reads like the login failed rather than like they - // left it. - fmt.Println("turns up dead. If it has not arrived, leave with ctrl-c and run") - fmt.Println("this command again.") - fmt.Println() - - fmt.Print("Paste the link: ") - if !in.Scan() { - // Nobody at the keyboard: a pipe, a CI step, a shell that runs one - // command at a time. The email has already gone out, so this is not a - // failure to report - it is the second half of the flow, as a command. - // (Asking the OS whether stdin is a terminal does not settle it on - // Windows, where NUL is a character device too.) - fmt.Println() - fmt.Println("Nothing to read from here. Finish the login with:") - fmt.Println() - fmt.Println(" nan auth login --link \"\"") - return nil + pasted, err := askLink(cmd.Context(), p, client) + if err != nil || pasted == "" { + return err // an empty paste is the CI dead end, already explained } + return finishLogin(cmd.Context(), client, pasted) +} + +// Both dead ends name the flag that skips the prompt. Saying only that the +// email is missing is the whole story when somebody typed it wrong, and none +// of it when the prompt never reached them: a terminal that renders a +// command's output as a block, a shell running one command at a time, +// anything piping into this. +const emailHint = "pass it instead:\n\n nan auth login --email you@example.com" - token, err := auth.TokenFromLink(strings.TrimSpace(in.Text())) +// askEmail is the first half of the flow. +func askEmail(ctx context.Context, p *Printer) (string, error) { + if !p.Interactive() { + return "", fmt.Errorf("nothing to read from here - %s", emailHint) + } + var email string + form := ui.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Sign in to nan.builders"). + Description("Where should the sign-in link go?"). + Placeholder("you@example.com"). + // Validated in the form, so a typo is caught before an email is + // spent on it rather than after. + Validate(api.ValidateEmail). + Value(&email), + )) + if err := form.RunWithContext(ctx); err != nil { + if errorsIsUserAbort(err) { + return "", fmt.Errorf("cancelled") + } + return "", err + } + return strings.TrimSpace(email), nil +} + +// announceLink says what just happened, and what to do with the email. +func announceLink(p *Printer, client *api.Client, email string) { + p.Blank() + p.Line(indent + ui.Success(fmt.Sprintf("A sign-in link is on its way to %s.", email))) + p.Blank() + p.Note("Copy the link out of the email. Do not open it in your browser first:") + p.Note("the link works once, and the browser would spend it. It also expires") + p.Note("%s after it is sent, so an email that turns up late turns up dead.", api.LinkValidity) + // Which platform, when it is not the real one: production answers 202 to + // any address, so a shell that has lost NAN_API_URL gives the identical + // line and no email. + if host := client.APIHost(); host != "" { + p.Note("Sent to %s - not the real nan.builders.", host) + } + p.Blank() +} + +// askLink is the second half: the link out of the email, pasted back. +// +// An empty return with no error is the CI dead end: the email has already +// gone, so there is nothing to report, only a second command to run. +func askLink(ctx context.Context, p *Printer, client *api.Client) (string, error) { + if !p.Interactive() { + p.Line(indent + ui.Value("Nothing to read from here. Finish the login with:")) + p.Blank() + p.Line(indent + ui.Mono(` nan auth login --link ""`)) + return "", nil + } + var pasted string + form := ui.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Paste the link"). + Description("If it has not arrived, leave with ctrl-c and run this command again."). + Placeholder("https://nan.builders/...?token=..."). + // Dots: the link carries a credential. + EchoMode(huh.EchoModePassword). + // The host check runs here, so a link that is not ours is + // refused in the field rather than after its token is posted. + Validate(func(s string) error { + _, err := client.TokenFromLink(s) + return err + }). + Value(&pasted), + )) + if err := form.RunWithContext(ctx); err != nil { + if errorsIsUserAbort(err) { + return "", fmt.Errorf("cancelled") + } + return "", err + } + return strings.TrimSpace(pasted), nil +} + +// finishLogin spends the link's token and saves the session it buys. +func finishLogin(ctx context.Context, client *api.Client, pasted string) error { + token, err := client.TokenFromLink(pasted) if err != nil { return err } - - sessionToken, err := auth.ExchangeToken(token) + sessionToken, err := client.Verify(ctx, token) if err != nil { return err } @@ -204,14 +253,14 @@ func flagValue(value string) (string, error) { // the sign-out. A variable so the tests can read it. var logoutWarnings io.Writer = os.Stderr -func runLogout(cmd *cobra.Command, args []string) error { +func runLogout(cmd *cobra.Command, _ []string) error { // End the session on the platform first, while session.json still holds // the token: deleting the file alone leaves any copy of the token working // until it expires. A platform that cannot be reached does not keep the // member signed in here; the file still goes, and they are told. if current, err := session.Load(); err == nil && current.Token != "" { - if err := api.New(current.Token).WithBaseURL(apiBaseURL).Logout(); err != nil { - fmt.Fprintf(logoutWarnings, "Could not end the session on the platform (%v); it stays valid until it expires. To end it now: cloud.nan.builders, Settings > Account > Sign out other sessions.\n", err) + if err := newAPIClient(current.Token).Logout(contextOf(cmd)); err != nil { + _, _ = fmt.Fprintf(logoutWarnings, "Could not end the session on the platform (%v); it stays valid until it expires. To end it now: cloud.nan.builders, Settings > Account > Sign out other sessions.\n", err) } } if err := session.Delete(); err != nil { @@ -225,15 +274,19 @@ func runLogout(cmd *cobra.Command, args []string) error { // giving back would have been logged out of everything except the cluster // their key bills. if keepToolsFlag { - fmt.Println("Your API key is still in the tools — run it again without --keep-tools to take it out.") + fmt.Println("Your API key is still in the tools - run it again without --keep-tools to take it out.") return nil } - removed, failed := tui.RemoveNanFromTools() + removed, failed := tools.RemoveAll() if len(removed) > 0 { fmt.Println("Removed your API key from " + strings.Join(removed, ", ") + ".") } if len(failed) > 0 { - return fmt.Errorf("your API key is still in %s", strings.Join(failed, ", ")) + names := make([]string, len(failed)) + for i, f := range failed { + names[i] = f.Tool + " (" + f.FirstLine() + ")" + } + return fmt.Errorf("your API key is still in %s", strings.Join(names, ", ")) } return nil } @@ -272,9 +325,9 @@ func newTokenInput(f *os.File) tokenInput { func (in tokenInput) read(prompt io.Writer) (string, error) { if in.tty { - fmt.Fprint(prompt, "Paste the token (it is not shown): ") + _, _ = fmt.Fprint(prompt, "Paste the token (it is not shown): ") raw, err := in.readHidden() - fmt.Fprintln(prompt) + _, _ = fmt.Fprintln(prompt) if err != nil { return "", fmt.Errorf("could not read the token: %w", err) } @@ -302,12 +355,12 @@ func loginWithAPIToken(ctx context.Context, in tokenInput, out, prompt io.Writer return fmt.Errorf("that is not a valid token, nothing was saved: %s", tokenShapeHint) } - client := api.NewRunsClient("", tok).WithBaseURL(runsBaseURL) + client := newRunsClient("", tok) if _, err := client.ListRuns(ctx, api.ListRunsParams{Limit: 1}); err != nil { if errors.Is(err, api.ErrSessionExpired) { return fmt.Errorf("nan.builders refused that token (revoked, expired or mistyped), nothing was saved: create one in Settings > Tokens at https://cloud.nan.builders") } - return fmt.Errorf("could not check the token, nothing was saved: %v", apiExit(err, credential{kind: credStoredToken}).Err) + return fmt.Errorf("could not check the token, nothing was saved: %v", apiExit(err, credential{kind: credStoredToken}).Err) //nolint:errorlint // printed, never matched on } current, err := session.Load() @@ -328,14 +381,23 @@ func loginWithAPIToken(ctx context.Context, in tokenInput, out, prompt io.Writer return fmt.Errorf("could not save the token: %w", err) } - fmt.Fprintf(out, "Token saved to %s. nan run and nan runs will use it on this machine.\n", session.Path()) + _, _ = fmt.Fprintf(out, "Token saved to %s. nan run and nan runs will use it on this machine.\n", session.Path()) if replacedKey { - fmt.Fprintln(out, "It replaces the API key saved before. Tools the Setup tab configured keep the old key until you apply Setup again.") + _, _ = fmt.Fprintln(out, "It replaces the API key saved before. Tools the Setup tab configured keep the old key until you apply Setup again.") } if isKey && current.Token != "" { // The session goes first and nothing here knows when it expires, so // saying "while it lasts" would promise a fallback that never happens. - fmt.Fprintln(out, "You are also signed in by email, and nan run uses that session first, even after it expires. To use only this key: nan auth logout, then save it again.") + _, _ = fmt.Fprintln(out, "You are also signed in by email, and nan run uses that session first, even after it expires. To use only this key: nan auth logout, then save it again.") } return nil } + +// contextOf is the command's context, or a background one when it is run +// outside Execute, as the tests do. +func contextOf(cmd *cobra.Command) context.Context { + if ctx := cmd.Context(); ctx != nil { + return ctx + } + return context.Background() +} diff --git a/cmd/auth_test.go b/internal/cli/auth_test.go similarity index 99% rename from cmd/auth_test.go rename to internal/cli/auth_test.go index 93cd141..1b8c9ef 100644 --- a/cmd/auth_test.go +++ b/internal/cli/auth_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -68,7 +68,7 @@ func withStdin(t *testing.T, content string) { os.Stdin = f t.Cleanup(func() { os.Stdin = original - f.Close() + _ = f.Close() }) } @@ -257,7 +257,7 @@ func TestAPITokenLoginWithStdinFromDevNull(t *testing.T) { if err != nil { t.Fatal(err) } - defer f.Close() + defer func() { _ = f.Close() }() in := newTokenInput(f) if in.tty { t.Fatal(os.DevNull + " taken for a terminal") diff --git a/cmd/automations.go b/internal/cli/automations.go similarity index 92% rename from cmd/automations.go rename to internal/cli/automations.go index e57a62c..910b020 100644 --- a/cmd/automations.go +++ b/internal/cli/automations.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -13,9 +13,10 @@ import ( "time" "unicode/utf8" + "github.com/spf13/cobra" + "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/runs" - "github.com/spf13/cobra" ) // maxInputBytes is the most input an automation run takes (the platform's @@ -88,7 +89,7 @@ var automationsLsCmd = &cobra.Command{ Short: "List your automations", Args: cobra.NoArgs, Annotations: sysexits, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { return withRunsEnv(func(env *runsEnv) error { return doAutomationsLs(cmd.Context(), env) }) }, } @@ -165,7 +166,6 @@ Docs: https://nan.builders/docs/runs`, } func init() { - rootCmd.AddCommand(automationsCmd) automationsCmd.AddCommand(automationsLsCmd, automationsShowCmd, automationsRunCmd, automationsDeliveriesCmd) automationsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) @@ -184,7 +184,7 @@ func init() { // oneAutomation is cobra.ExactArgs(1) with a message that says what is // missing. -func oneAutomation(cmd *cobra.Command, args []string) error { +func oneAutomation(_ *cobra.Command, args []string) error { switch { case len(args) == 0: return usageErrorf("missing automation id or name; see: nan automations ls") @@ -244,16 +244,16 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error { return printJSON(env.stdout, list, true) } if len(list.Data) == 0 { - fmt.Fprintf(env.stderr, "No automations yet. Create one in the portal: %s\n", runs.NewAutomationURL) + _, _ = fmt.Fprintf(env.stderr, "No automations yet. Create one in the portal: %s\n", runs.NewAutomationURL) return nil } for i := range list.Data { env.nameWorkspace(ctx, list.Data[i].Workspace) } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tNAME\tWORKSPACE\tAGENT\tSTATUS\tAPPROVAL") + _, _ = fmt.Fprintln(tw, "ID\tNAME\tWORKSPACE\tAGENT\tSTATUS\tAPPROVAL") for _, a := range list.Data { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", runs.SanitizeLine(a.ID), runs.Truncate(runs.SanitizeLine(a.Name), 40), runs.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40), @@ -305,7 +305,7 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error { tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) row := func(k, v string) { if v != "" { - fmt.Fprintf(tw, "%s\t%s\n", k, v) + _, _ = fmt.Fprintf(tw, "%s\t%s\n", k, v) } } row("id", runs.SanitizeLine(a.ID)) @@ -358,11 +358,11 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error { return err } if trigErr != nil { - fmt.Fprintf(env.stderr, "could not read the triggers: %s\n", runs.SanitizeLine(apiExit(trigErr, env.cred).Error())) + _, _ = fmt.Fprintf(env.stderr, "could not read the triggers: %s\n", runs.SanitizeLine(apiExit(trigErr, env.cred).Error())) } printBlock(env, "instructions", a.Instructions) - fmt.Fprintf(env.stdout, "\nrun it: nan automations run %s\nedit it in the portal: %s\n", runs.SanitizeLine(a.ID), runs.AutomationURL(a.ID)) + _, _ = fmt.Fprintf(env.stdout, "\nrun it: nan automations run %s\nedit it in the portal: %s\n", runs.SanitizeLine(a.ID), runs.AutomationURL(a.ID)) return nil } @@ -417,7 +417,7 @@ func readAutomationInput(in promptInput, flag string) (json.RawMessage, error) { switch { case flag == "-": if in.tty && in.notice != nil { - fmt.Fprintln(in.notice, "reading the input from stdin; end it with Ctrl-D") + _, _ = fmt.Fprintln(in.notice, "reading the input from stdin; end it with Ctrl-D") } if in.r == nil { return nil, usageErrorf("no stdin to read the input from") @@ -428,12 +428,12 @@ func readAutomationInput(in promptInput, flag string) (json.RawMessage, error) { } case strings.HasPrefix(flag, "@"): path := flag[1:] - f, openErr := os.Open(path) + f, openErr := os.Open(path) //nolint:gosec // G304: a config file at a path this program chose if openErr != nil { return nil, usageErrorf("could not read the input: %v", unwrapPath(openErr)) } raw, err = io.ReadAll(io.LimitReader(f, 4*maxInputBytes+1)) - f.Close() + _ = f.Close() if err != nil { return nil, usageErrorf("could not read the input: %v", unwrapPath(err)) } @@ -478,7 +478,7 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json. return exitf(exitUnavailable, "nan.builders answered with something that is not a run id") } if replayed { - fmt.Fprintf(env.stderr, "run %s already exists for this idempotency key (%s)\n", run.ID, runs.SanitizeLine(run.State)) + _, _ = fmt.Fprintf(env.stderr, "run %s already exists for this idempotency key (%s)\n", run.ID, runs.SanitizeLine(run.State)) } if autoRunDetach { if autoRunJSON { @@ -486,8 +486,8 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json. return err } } else { - fmt.Fprintln(env.stdout, run.ID) - fmt.Fprintf(env.stderr, "run %s %s\n", run.ID, strings.ReplaceAll(runs.SanitizeLine(run.State), "_", " ")) + _, _ = fmt.Fprintln(env.stdout, run.ID) + _, _ = fmt.Fprintf(env.stderr, "run %s %s\n", run.ID, strings.ReplaceAll(runs.SanitizeLine(run.State), "_", " ")) printFollowHints(env.stderr, run.ID) } return runExit(run) @@ -502,7 +502,7 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json. if name == "" { name = runs.Truncate(runs.SanitizeLine(ref), 64) } - fmt.Fprintf(env.stderr, "run %s of %s queued on %s; Ctrl-C to detach\n", run.ID, name, runs.SanitizeLine(run.Workspace.Name)) + _, _ = fmt.Fprintf(env.stderr, "run %s of %s queued on %s; Ctrl-C to detach\n", run.ID, name, runs.SanitizeLine(run.Workspace.Name)) } return follow(ctx, env, run.ID, 0, autoRunJSON, true) } @@ -520,17 +520,17 @@ func doAutomationsDeliveries(ctx context.Context, env *runsEnv, ref string) erro return printJSON(env.stdout, list, true) } if len(list.Data) == 0 { - fmt.Fprintln(env.stderr, "No webhook deliveries in the last 30 days.") + _, _ = fmt.Fprintln(env.stderr, "No webhook deliveries in the last 30 days.") return nil } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "RECEIVED\tOUTCOME\tRUN\tDELIVERY") + _, _ = fmt.Fprintln(tw, "RECEIVED\tOUTCOME\tRUN\tDELIVERY") for _, d := range list.Data { run := "-" if d.RunID != nil { run = runs.SanitizeLine(*d.RunID) } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", localTime(d.ReceivedAt), runs.SanitizeLine(d.Outcome), run, diff --git a/cmd/automations_test.go b/internal/cli/automations_test.go similarity index 98% rename from cmd/automations_test.go rename to internal/cli/automations_test.go index 0a688fc..7aaf736 100644 --- a/cmd/automations_test.go +++ b/internal/cli/automations_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -15,8 +15,9 @@ import ( "testing" "time" - "github.com/nxssie/nan-cli/internal/api" "github.com/spf13/cobra" + + "github.com/nxssie/nan-cli/internal/api" ) // The Phase 2 contract (AGENT-AUTOMATIONS-PHASE2 §3.4, §4.4, §7.6, §11) as a @@ -294,8 +295,10 @@ func TestAutomationsRunDetach(t *testing.T) { func TestAutomationsRunWithoutTheScope(t *testing.T) { resetPhase2Flags() defer resetPhase2Flags() - h := newPhase2Harness(t, &fakePhase2{status: http.StatusForbidden, - errBody: `{"error":{"message":"this token lacks the automations:run scope","type":"permission_error","code":"token_scope_denied"}}`}) + h := newPhase2Harness(t, &fakePhase2{ + status: http.StatusForbidden, + errBody: `{"error":{"message":"this token lacks the automations:run scope","type":"permission_error","code":"token_scope_denied"}}`, + }) h.env.cred = credential{kind: credEnv, bearer: testPAT} err := doAutomationsRun(context.Background(), h.env, testAutoID, nil) if exitCode(err) != exitAuth { @@ -502,8 +505,10 @@ func TestRunsShowPhase2(t *testing.T) { t.Fatal(err) } out := h.stdout.String() - for _, want := range []string{"phase", "propose", "automation", "PR review", "approval", "pending", "1 actions (nan runs proposal", "apply run", testRunID[:35] + "2", "awaiting your approval", "nan runs reject " + testRunID, - "repository", "acme/api", "GITHUB_TOKEN (the agent)", "1. github_pr_comment ok · https://github.com/acme/api/pull/4#c1"} { + for _, want := range []string{ + "phase", "propose", "automation", "PR review", "approval", "pending", "1 actions (nan runs proposal", "apply run", testRunID[:35] + "2", "awaiting your approval", "nan runs reject " + testRunID, + "repository", "acme/api", "GITHUB_TOKEN (the agent)", "1. github_pr_comment ok · https://github.com/acme/api/pull/4#c1", + } { if !strings.Contains(out, want) { t.Errorf("show lacks %q:\n%s", want, out) } @@ -695,7 +700,7 @@ func TestPhase2Help(t *testing.T) { if !strings.Contains(text, runsDocs) { t.Errorf("nan %s --help does not link %s", name, runsDocs) } - if strings.Contains(text, "—") { + if strings.Contains(text, string(rune(0x2014))) { t.Errorf("nan %s --help has an em-dash", name) } } diff --git a/internal/cli/confirm.go b/internal/cli/confirm.go new file mode 100644 index 0000000..64545a2 --- /dev/null +++ b/internal/cli/confirm.go @@ -0,0 +1,9 @@ +package cli + +import "strings" + +// errorsIsUserAbort reports whether huh stopped because the person stopped it. +func errorsIsUserAbort(err error) bool { + return err != nil && (strings.Contains(err.Error(), "user aborted") || + strings.Contains(err.Error(), "aborted")) +} diff --git a/cmd/credentials.go b/internal/cli/credentials.go similarity index 88% rename from cmd/credentials.go rename to internal/cli/credentials.go index 00c23a9..27a6ece 100644 --- a/cmd/credentials.go +++ b/internal/cli/credentials.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bufio" @@ -22,13 +22,29 @@ const tokenEnvVar = session.TokenEnvVar // short name for the many messages that // anything longer is a file that is not a token. const maxTokenLen = 512 -// runsBaseURL is where the run commands send requests. A variable so the -// tests can point it at a server of their own. -var runsBaseURL = api.RunsBaseURL +// runsBaseURL and apiBaseURL point the commands at a server of the tests' +// own. Empty is nan.builders, or NAN_API_URL when it is set. +var ( + runsBaseURL string + apiBaseURL string +) + +// newRunsClient is the run commands' client, pointed where runsBaseURL says. +func newRunsClient(token, apiKey string) *api.RunsClient { + c := api.NewRunsClient(token, apiKey) + if runsBaseURL != "" { + c = c.WithBaseURL(runsBaseURL) + } + return c +} -// apiBaseURL is where the session endpoints (logout) are sent. A variable so -// the tests can point it at a server of their own. -var apiBaseURL = api.BaseURL +// newAPIClient is the session's client, pointed where apiBaseURL says. +func newAPIClient(token string) *api.Client { + if apiBaseURL != "" { + return api.New(token, api.WithBaseURL(apiBaseURL)) + } + return api.New(token) +} // tokenFileFlag is --token-file on `nan run` and `nan runs`. var tokenFileFlag string @@ -71,7 +87,7 @@ func (c credential) describe() string { } func (c credential) client() *api.RunsClient { - return api.NewRunsClient(c.session, c.bearer).WithBaseURL(runsBaseURL) + return newRunsClient(c.session, c.bearer) } // validTokenShape is the check done before a token is sent anywhere: a @@ -98,6 +114,7 @@ func validTokenShape(tok string) bool { return true } +//nolint:gosec // G101: a hint that names the shape of a token, not one const tokenShapeHint = "expected a platform token (nan_pat_...) from Settings > Tokens at https://cloud.nan.builders, or an API key (sk-...)" // resolveCredential picks what the run commands authenticate with, first @@ -161,7 +178,7 @@ func notSignedIn() *ExitError { // that made it and has no mode worth reading. func readTokenFile(path string) (string, error) { shown := runs.SanitizeLine(path) - f, err := os.Open(path) + f, err := os.Open(path) //nolint:gosec // G304: a config file at a path this program chose if errors.Is(err, os.ErrNotExist) { return "", exitf(exitAuth, "--token-file %s does not exist", shown) } @@ -169,7 +186,7 @@ func readTokenFile(path string) (string, error) { if err != nil { return "", exitf(exitAuth, "could not open --token-file %s: %v", shown, unwrapPath(err)) } - defer f.Close() + defer func() { _ = f.Close() }() info, err := f.Stat() if err != nil { return "", exitf(exitAuth, "could not read --token-file %s: %v", shown, unwrapPath(err)) diff --git a/cmd/credentials_test.go b/internal/cli/credentials_test.go similarity index 97% rename from cmd/credentials_test.go rename to internal/cli/credentials_test.go index f0b215b..dc847ab 100644 --- a/cmd/credentials_test.go +++ b/internal/cli/credentials_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "context" @@ -435,15 +435,15 @@ func TestRunsLsWithTokenFileFlag(t *testing.T) { func TestUnwrapPathDropsOnlyThePath(t *testing.T) { cause := errors.New("not a directory") - if got := unwrapPath(&os.PathError{Op: "open", Path: "/secret\x1b/path", Err: cause}); got != cause { + if got := unwrapPath(&os.PathError{Op: "open", Path: "/secret\x1b/path", Err: cause}); got != cause { //nolint:errorlint // identity: exactly the cause comes back t.Errorf("PathError: got %v", got) } wrapped := fmt.Errorf("ctx: %w", &os.PathError{Op: "read", Path: "/p", Err: cause}) - if got := unwrapPath(wrapped); got != cause { + if got := unwrapPath(wrapped); got != cause { //nolint:errorlint // identity: exactly the cause comes back t.Errorf("wrapped PathError: got %v", got) } other := errors.New("other") - if got := unwrapPath(other); got != other { + if got := unwrapPath(other); got != other { //nolint:errorlint // identity: the same error comes back t.Errorf("other error changed: %v", got) } } diff --git a/internal/cli/errors.go b/internal/cli/errors.go new file mode 100644 index 0000000..106fc56 --- /dev/null +++ b/internal/cli/errors.go @@ -0,0 +1,37 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/fang" + "github.com/charmbracelet/x/term" +) + +// ErrorHandler prints a failed command's error, framed like the rest of +// fang's output but with the message exactly as it was written. +// +// fang's own handler title-cases the first word and adds a full stop. The +// first word is often a value - a run id, a flag, a tool's name - and +// "not-an-id" printed as "Not-An-Id" is a different id from the one the +// member typed. `--detach` and a Ctrl-C that left a run going end with a +// code and nothing to say, so those print nothing at all. +func ErrorHandler(w io.Writer, styles fang.Styles, err error) { + if Silent(err) { + return + } + msg := err.Error() + // To a pipe or a file: the message and nothing else, for whatever is + // reading it. + if f, ok := w.(term.File); ok && !term.IsTerminal(f.Fd()) { + _, _ = fmt.Fprintln(w, msg) + return + } + if !strings.HasSuffix(msg, ".") && !strings.HasSuffix(msg, "?") && !strings.HasSuffix(msg, "!") { + msg += "." + } + _, _ = fmt.Fprintln(w, styles.ErrorHeader.String()) + _, _ = fmt.Fprintln(w, styles.ErrorText.UnsetTransform().Render(msg)) + _, _ = fmt.Fprintln(w) +} diff --git a/internal/cli/errors_test.go b/internal/cli/errors_test.go new file mode 100644 index 0000000..7126a59 --- /dev/null +++ b/internal/cli/errors_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/charmbracelet/fang" + "github.com/charmbracelet/x/ansi" +) + +// The message is printed as written: its first word is often a value, and +// a run id title-cased is a different id. +func TestErrorsArePrintedAsWritten(t *testing.T) { + var out bytes.Buffer + styles := fang.Styles{} + ErrorHandler(&out, styles, errors.New(`"not-an-id" is not a run id`)) + if got := ansi.Strip(out.String()); !strings.Contains(got, `"not-an-id" is not a run id.`) { + t.Errorf("printed %q", got) + } + + out.Reset() + ErrorHandler(&out, styles, &ExitError{Code: exitDetached}) + if out.Len() != 0 { + t.Errorf("a silent exit printed %q", out.String()) + } +} diff --git a/cmd/exit.go b/internal/cli/exit.go similarity index 93% rename from cmd/exit.go rename to internal/cli/exit.go index e194c9f..9304910 100644 --- a/cmd/exit.go +++ b/internal/cli/exit.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "errors" @@ -61,7 +61,7 @@ func authError(cred credential) *ExitError { case credStoredKey: return exitf(exitAuth, "nan.builders refused %s: run: nan auth login", cred.describe()) } - return exitf(exitAuth, "%v — run: nan auth login", api.ErrSessionExpired) + return exitf(exitAuth, "%v - run: nan auth login", api.ErrSessionExpired) } // apiExit maps a failed request onto an exit code and a message that says @@ -85,17 +85,17 @@ func apiExit(err error, cred credential) *ExitError { msg := runs.SanitizeLine(apiErr.Error()) switch apiErr.Code { case "tier_restricted": - return exitf(exitAuth, "your membership does not include runs — see https://cloud.nan.builders/membership") + return exitf(exitAuth, "your membership does not include runs - see https://cloud.nan.builders/membership") case "workspace_key_not_allowed": - return exitf(exitAuth, "a workspace key cannot start or read runs — sign in with: nan auth login") + return exitf(exitAuth, "a workspace key cannot start or read runs - sign in with: nan auth login") case "agent_not_installed", "no_inference_key", "config_error": return exitf(exitConfig, "%s", msg) case "idempotency_conflict": return usageErrorf("that idempotency key was already used for a different run") case "run_queue_full": - return exitf(exitUnavailable, "too many runs are queued — wait for some to finish (%s)", msg) + return exitf(exitUnavailable, "too many runs are queued - wait for some to finish (%s)", msg) case "rate_limited": - return exitf(exitUnavailable, "too many runs started in the last hour — try again later") + return exitf(exitUnavailable, "too many runs started in the last hour - try again later") case "browser_session_required": return exitf(exitAuth, "%s", browserOnly) case "token_scope_denied": @@ -111,17 +111,17 @@ func apiExit(err error, cred credential) *ExitError { case "secrets_unavailable": return exitf(exitUnavailable, "the secrets store is unavailable right now; try again in a few minutes") } - switch { - case apiErr.Status == 400: + switch apiErr.Status { + case 400: if apiErr.Param != "" { return usageErrorf("%s: %s", runs.SanitizeLine(apiErr.Param), msg) } return usageErrorf("%s", msg) - case apiErr.Status == 401: + case 401: return exitf(exitAuth, "%v", session.ErrNotLoggedIn) - case apiErr.Status == 403: + case 403: return exitf(exitAuth, "%s", msg) - case apiErr.Status == 404: + case 404: return usageErrorf("%s", msg) } return exitf(exitUnavailable, "nan.builders could not do that right now: %s", msg) diff --git a/cmd/golden_test.go b/internal/cli/golden_test.go similarity index 97% rename from cmd/golden_test.go rename to internal/cli/golden_test.go index 96ca8c3..44a7f05 100644 --- a/cmd/golden_test.go +++ b/internal/cli/golden_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -53,7 +53,7 @@ func goldenEnvWith(t *testing.T, workspaces string) (*runsEnv, *bytes.Buffer, *b http.NotFound(w, r) return } - b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "phase2", name)) + b, err := os.ReadFile(filepath.Join("..", "api", "testdata", "phase2", name)) if err != nil { t.Error(err) } @@ -126,14 +126,14 @@ func TestGoldenRender(t *testing.T) { // With workspaces:read the workspace is named, from one lookup. const workspaces = `[{"workspace_uuid":"` + ws + `","name":"develop","status":"running"}]` - env, out, _, seen := goldenEnvWith(t, workspaces) + env, out, _, _ = goldenEnvWith(t, workspaces) if err := doAutomationsLs(ctx, env); err != nil { t.Fatal(err) } if lines := strings.Split(out.String(), "\n"); len(lines) < 2 || !strings.Contains(lines[1], "develop") || strings.Contains(lines[1], ws) { t.Errorf("automations ls does not name the workspace:\n%s", out.String()) } - env, out, _, seen = goldenEnvWith(t, workspaces) + env, out, _, seen := goldenEnvWith(t, workspaces) if err := doAutomationsShow(ctx, env, "PR review api"); err != nil { t.Fatal(err) } diff --git a/cmd/help_test.go b/internal/cli/help_test.go similarity index 97% rename from cmd/help_test.go rename to internal/cli/help_test.go index 7aab40c..c4df71d 100644 --- a/cmd/help_test.go +++ b/internal/cli/help_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "strings" @@ -50,7 +50,7 @@ func TestNewHelpHasNoEmDashes(t *testing.T) { "auth login": loginCmd.Long + loginCmd.Example, "token-file": tokenFileUsage, } { - if strings.Contains(text, "—") { + if strings.Contains(text, string(rune(0x2014))) { t.Errorf("%s help has an em-dash", name) } } diff --git a/cmd/logout_test.go b/internal/cli/logout_test.go similarity index 95% rename from cmd/logout_test.go rename to internal/cli/logout_test.go index a0daa9e..56e252a 100644 --- a/cmd/logout_test.go +++ b/internal/cli/logout_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -67,8 +67,8 @@ func TestLogoutEndsTheSessionOnThePlatformFirst(t *testing.T) { if warn := warnings.String(); warn != "" { t.Errorf("a confirmed logout warns: %q", warn) } - if len(*seen) != 1 || (*seen)[0] != "POST /auth/logout Bearer session-jwt" { - t.Errorf("platform saw %v, want one POST /auth/logout with the session bearer", *seen) + if len(*seen) != 1 || (*seen)[0] != "POST /api/auth/logout Bearer session-jwt" { + t.Errorf("platform saw %v, want one POST /api/auth/logout with the session bearer", *seen) } if sessionFileExists(home) { t.Error("session.json survived the logout") diff --git a/cmd/me.go b/internal/cli/me.go similarity index 62% rename from cmd/me.go rename to internal/cli/me.go index 1406517..1579d9e 100644 --- a/cmd/me.go +++ b/internal/cli/me.go @@ -1,10 +1,9 @@ -package cmd +package cli import ( "encoding/json" "fmt" - "github.com/nxssie/nan-cli/internal/api" "github.com/spf13/cobra" ) @@ -14,18 +13,14 @@ var meCmd = &cobra.Command{ RunE: runMe, } -func init() { - rootCmd.AddCommand(meCmd) -} - -func runMe(cmd *cobra.Command, args []string) error { +func runMe(cmd *cobra.Command, _ []string) error { sess, err := requireSession("nan me") if err != nil { return err } - client := api.New(sess.Token) - me, err := client.GetMe() + client := newAPIClient(sess.Token) + me, err := client.Me(cmd.Context()) if err != nil { return err } diff --git a/cmd/metrics.go b/internal/cli/metrics.go similarity index 70% rename from cmd/metrics.go rename to internal/cli/metrics.go index 33302e1..753d369 100644 --- a/cmd/metrics.go +++ b/internal/cli/metrics.go @@ -1,10 +1,9 @@ -package cmd +package cli import ( "encoding/json" "fmt" - "github.com/nxssie/nan-cli/internal/api" "github.com/spf13/cobra" ) @@ -20,18 +19,17 @@ var metricsUsageCmd = &cobra.Command{ } func init() { - rootCmd.AddCommand(metricsCmd) metricsCmd.AddCommand(metricsUsageCmd) } -func runMetricsUsage(cmd *cobra.Command, args []string) error { +func runMetricsUsage(cmd *cobra.Command, _ []string) error { sess, err := requireSession("nan metrics usage") if err != nil { return err } - client := api.New(sess.Token) - usage, err := client.GetMetricsUsage() + client := newAPIClient(sess.Token) + usage, err := client.UsageRaw(cmd.Context()) if err != nil { return err } diff --git a/cmd/names.go b/internal/cli/names.go similarity index 99% rename from cmd/names.go rename to internal/cli/names.go index 27146d6..1aaa57f 100644 --- a/cmd/names.go +++ b/internal/cli/names.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "context" diff --git a/cmd/names_test.go b/internal/cli/names_test.go similarity index 96% rename from cmd/names_test.go rename to internal/cli/names_test.go index 1e4aec9..9840a99 100644 --- a/cmd/names_test.go +++ b/internal/cli/names_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -51,7 +51,7 @@ type namesServer struct { func goldenBody(t *testing.T, name string) string { t.Helper() - b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "phase2", name)) + b, err := os.ReadFile(filepath.Join("..", "api", "testdata", "phase2", name)) if err != nil { t.Fatal(err) } @@ -365,24 +365,36 @@ func TestNamesNeverWithJSON(t *testing.T) { do func(*runsEnv) error want []string }{ - {"automations ls", func() { autoLsJSON = true }, + { + "automations ls", func() { autoLsJSON = true }, func(env *runsEnv) error { return doAutomationsLs(context.Background(), env) }, - []string{"GET /v1/automations"}}, - {"automations show", func() { autoShowJSON = true }, + []string{"GET /v1/automations"}, + }, + { + "automations show", func() { autoShowJSON = true }, func(env *runsEnv) error { return doAutomationsShow(context.Background(), env, namesAuto) }, - []string{"GET /v1/automations/" + namesAuto}}, - {"approvals ls", func() { approvalsJSON = true }, + []string{"GET /v1/automations/" + namesAuto}, + }, + { + "approvals ls", func() { approvalsJSON = true }, func(env *runsEnv) error { return doApprovalsLs(context.Background(), env, api.ApprovalPending) }, - []string{"GET /v1/approvals"}}, - {"runs show", func() { showJSON = true }, + []string{"GET /v1/approvals"}, + }, + { + "runs show", func() { showJSON = true }, func(env *runsEnv) error { return doRunsShow(context.Background(), env, namesRun) }, - []string{"GET /v1/runs/" + namesRun}}, - {"runs proposal", func() {}, + []string{"GET /v1/runs/" + namesRun}, + }, + { + "runs proposal", func() {}, func(env *runsEnv) error { return doRunsProposal(context.Background(), env, namesRun, false, true) }, - []string{"GET /v1/runs/" + namesRun}}, - {"runs ls", func() { lsJSON = true }, + []string{"GET /v1/runs/" + namesRun}, + }, + { + "runs ls", func() { lsJSON = true }, func(env *runsEnv) error { return doRunsLs(context.Background(), env) }, - []string{"GET /v1/runs"}}, + []string{"GET /v1/runs"}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -421,7 +433,7 @@ func TestNamesOneLookupPerCommand(t *testing.T) { t.Fatal(err) } first := autos["data"].([]any)[0].(map[string]any) - var data []any + data := make([]any, 0, 4) for i, ws := range []string{namesWS, otherWS, namesWS, "33333333-2222-4333-8444-555555555555"} { a := map[string]any{} for k, v := range first { diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..e2376af --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,171 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/term" + + "github.com/nxssie/nan-cli/internal/ui" +) + +// How anything gets printed. +// +// A CLI ends up in a pipe, a file, a cron job and a CI log, and every one of +// those wants something different from the terminal a person is looking at. +// There are three audiences here and one place that knows about all three: +// +// - a terminal, which gets the styled output; +// - a pipe, which gets the same words with the colour taken out; +// - a script that asked for --json, which gets the control plane's own +// shapes and none of the prose. +// +// Lip Gloss v2 is a pure value type with no renderer attached, so unlike v1 +// it does NOT strip colour on its own when nothing is watching — escape +// sequences go wherever you send them. colorprofile.Writer is what closes +// that: it downsamples to what the destination can take, which for a pipe is +// nothing at all, and it honours NO_COLOR and CLICOLOR_FORCE on the way. + +// Printer writes a command's output. +type Printer struct { + out io.Writer + errOut io.Writer + asJSON bool + tty bool + width int +} + +// NewPrinter wires a printer to the real terminal. +// +// --no-color sets the writer's profile directly rather than adding NO_COLOR +// to the environment it is shown. Both look equivalent and are not: +// CLICOLOR_FORCE beats NO_COLOR inside colorprofile, so a shell that forces +// colour on would have silently ignored the flag. A flag somebody typed +// beats a variable somebody's shell profile set years ago. +func NewPrinter(asJSON, noColor bool) *Printer { + tty := term.IsTerminal(os.Stdout.Fd()) + + // One place decides this, in ui, because the forms need the same answer + // and the two used to work it out separately. + width := ui.TerminalWidth() + out := colorprofile.NewWriter(os.Stdout, os.Environ()) + errOut := colorprofile.NewWriter(os.Stderr, os.Environ()) + if noColor { + out.Profile = colorprofile.NoTTY + errOut.Profile = colorprofile.NoTTY + } + + return &Printer{ + out: out, + errOut: errOut, + asJSON: asJSON, + tty: tty, + width: width, + } +} + +// JSONMode reports whether the caller asked for machine-readable output. +// Commands check it before building anything a person would read. +func (p *Printer) JSONMode() bool { return p.asJSON } + +// Interactive reports whether a person is watching. It gates the things +// that only make sense in front of one: a prompt, a dashboard, a banner. +func (p *Printer) Interactive() bool { return p.tty } + +// Width is the terminal's, or a sane fixed width when there isn't one. +func (p *Printer) Width() int { return p.width } + +// JSON writes a value as indented JSON and nothing else. No banner, no +// styling, no trailing prose — whatever is on stdout in this mode has to +// survive being piped into jq. +func (p *Printer) JSON(v any) error { + enc := json.NewEncoder(p.out) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// Line writes one line. +func (p *Printer) Line(s string) { _, _ = fmt.Fprintln(p.out, s) } + +// Prompt writes a line with no newline after it, for a question waiting on +// an answer. +func (p *Printer) Prompt(format string, args ...any) { + _, _ = fmt.Fprintf(p.out, indent+format, args...) +} + +// Blank writes an empty line, for the air between blocks. +func (p *Printer) Blank() { _, _ = fmt.Fprintln(p.out) } + +// Block writes a pre-rendered multi-line block as-is. +func (p *Printer) Block(s string) { _, _ = fmt.Fprint(p.out, s) } + +// Section is a heading: the brand's eyebrow, with air above it. +func (p *Printer) Section(title string) { + p.Blank() + p.Line(indent + ui.Eyebrow(title)) + p.Blank() +} + +// KV prints a run of label/value rows lined up on a shared column. +func (p *Printer) KV(rows [][2]string) { + p.Block(ui.KVBlock(rows, indent)) +} + +// Table prints a table, or the given sentence when there are no rows. +// +// The empty case is a sentence rather than an empty table on purpose: bare +// headers with nothing under them read as a failure to load, and "no API +// keys yet" is both true and actionable. +func (p *Printer) Table(t ui.Table, empty string) { + t.Indent = indent + // The table has to be told the width or it sizes to its content and + // runs off the side of the terminal. It was not being told, so every + // table here overflowed on anything narrower than its widest row — + // which on a real organisation is most terminals. + if t.Width == 0 { + t.Width = p.width - len(indent)*2 + } + p.Block(ui.TableOrEmpty(t, empty)) +} + +// Success reports that a command changed something. It, Warn and Note are the +// three things a command says about what it just did. +func (p *Printer) Success(format string, args ...any) { + p.Line(indent + ui.Success(fmt.Sprintf(format, args...))) +} + +func (p *Printer) Warn(format string, args ...any) { + p.Line(indent + ui.Warn(fmt.Sprintf(format, args...))) +} + +func (p *Printer) Note(format string, args ...any) { + p.Line(indent + ui.Faint(fmt.Sprintf(format, args...))) +} + +// Bar renders a progress bar sized to the terminal, so it fills a wide one +// and still fits a narrow one. +func (p *Printer) Bar(fraction float64, highlight bool) string { + width := p.width / 3 + if width > 40 { + width = 40 + } + if width < 10 { + width = 10 + } + return ui.Bar(fraction, width, highlight) +} + +// LevelBar is Bar in the usage scale, green to red, for a share of an +// allowance: how spent it is reads off the colour. +func (p *Printer) LevelBar(fraction float64) string { + width := min(max(p.width/3, 10), 40) + return ui.LevelBar(fraction, width) +} + +// indent is the left margin everything shares. Two columns: enough that the +// output is not welded to the edge of the terminal, little enough that it +// does not look like a quotation. +const indent = " " diff --git a/cmd/review_test.go b/internal/cli/review_test.go similarity index 99% rename from cmd/review_test.go rename to internal/cli/review_test.go index f649769..9d342a3 100644 --- a/cmd/review_test.go +++ b/internal/cli/review_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -152,7 +152,7 @@ func TestUsageIsCheckedBeforeTheSession(t *testing.T) { func TestRunsLogsSaysWhenTheRunIsStillGoing(t *testing.T) { defer resetRunsFlags() resetRunsFlags() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"data":[],"next_after":0,"done":false}`) })) defer srv.Close() @@ -210,7 +210,7 @@ func TestJSONOutputNeutralisesALoneC1Byte(t *testing.T) { defer resetRunsFlags() resetRunsFlags() logsJSON = true - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "{\"data\":[{\"v\":1,\"seq\":1,\"type\":\"log\",\"data\":{\"text\":\"a\x9b31m\"}}],\"next_after\":1,\"done\":true}") })) defer srv.Close() diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..bf0b101 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,132 @@ +// Package cli is the command tree: what `nan` does when it is given words, +// as opposed to the dashboard it opens when it is given none. +package cli + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/nxssie/nan-cli/internal/build" + "github.com/nxssie/nan-cli/internal/pulse" +) + +// openDashboard is the dashboard. A variable so a test can tell whether a +// command line reached it. +var openDashboard = pulse.Run + +// NewRootCmd builds the command tree. +// +// The account and setup commands are built here, fresh each time. The run +// commands (run, runs, automations, approvals, secrets) are declared once at +// package level with their flags, as they always were, and hung off each new +// root: their tests drive them through those declarations. +func NewRootCmd() *cobra.Command { + var asJSON bool + var noColor bool + // dashboard is set when the run opened the dashboard, which says there + // is an update in its own header. + var dashboard bool + var a *app + + root := &cobra.Command{ + Use: build.Name, + Short: "nan.builders cloud CLI", + Long: "nan.builders from the terminal: your account, your usage, the coding tools\n" + + "on this machine, and agent runs in your workspaces.\n\n" + + "Run it with no arguments for the dashboard; run a subcommand to get one\n" + + "answer and your shell back.\n\n" + + "Examples:\n" + + " nan open the dashboard\n" + + " nan auth login sign in with a link sent to your email\n" + + " nan setup point the coding tools here at NaN\n" + + " nan run \"review PR 42\" run an agent in your workspace\n" + + " nan runs ls your recent runs\n", + + // A failure is not a usage mistake. Printing the flag list under "your + // session has expired" buries the one line that says what happened. + SilenceUsage: true, + SilenceErrors: true, + + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + a = newApp(asJSON, noColor) + return nil + }, + + RunE: func(cmd *cobra.Command, _ []string) error { + // The dashboard is a live view, and a live view written to a + // pipe is unreadable noise: anything that is not a terminal + // gets the help instead, which says what can be asked for. + if !a.p.Interactive() { + return cmd.Help() + } + dashboard = true + return openDashboard(cmd.Context(), a.client, a.session) + }, + + PersistentPostRun: func(cmd *cobra.Command, _ []string) { + noticeUpdate(cmd.Context(), a, dashboard) + }, + } + + // A mistyped flag IS a usage mistake, and the flag list is the answer to + // it - which is the half SilenceUsage above would otherwise take away. + root.SetFlagErrorFunc(func(c *cobra.Command, err error) error { + c.Println(c.UsageString()) + return err + }) + + root.PersistentFlags().BoolVar(&asJSON, "json", false, + "print the platform's JSON instead of a rendering of it") + root.PersistentFlags().BoolVar(&noColor, "no-color", false, + "print without styling (NO_COLOR in the environment does the same)") + + // One line a script can parse (`nan --version | cut -d' ' -f2`), then the + // toolchain. See build.Line for why this is not the banner. + root.Version = build.Version + root.SetVersionTemplate(build.Line() + "\n" + build.Detail() + "\n") + + // Lazily, and not only in PersistentPreRunE: cobra runs just the CLOSEST + // PersistentPreRunE up the chain. + get := func() *app { + if a == nil { + a = newApp(asJSON, noColor) + } + return a + } + root.AddCommand( + authCmd, + meCmd, + metricsCmd, + newSetupCmd(get), + runCmd, + runsCmd, + automationsCmd, + approvalsCmd, + secretsCmd, + ) + return root +} + +// ExitCode is how a failed command ends. `nan run` and `nan runs` have a +// documented table, and a script reads their codes: there anything that is +// not already an ExitError is cobra refusing the arguments - an unknown +// flag, a missing id, two flags that exclude each other - which is a usage +// error. Every other command exits 1. +func ExitCode(cmd *cobra.Command, err error) int { + var exit *ExitError + if errors.As(err, &exit) { + return exit.Code + } + if cmd != nil && cmd.Annotations[sysexitsAnnotation] != "" { + return exitUsage + } + return 1 +} + +// Silent reports whether an error has nothing to print: `--detach` and a +// Ctrl-C that left a run going end with a code and no message. +func Silent(err error) bool { + var exit *ExitError + return errors.As(err, &exit) && exit.Err == nil +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..72fff9b --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,53 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/build" + "github.com/nxssie/nan-cli/internal/session" +) + +// rootCmd is the tree the package's tests drive. The run commands are +// declared once at package level, so one root is enough for all of them. +var rootCmd = NewRootCmd() + +// `nan --version` and `nan -v` print one line a script can parse first, and +// exit 0 without opening the dashboard. +func TestVersionPrintsOneLine(t *testing.T) { + old := openDashboard + defer func() { openDashboard = old }() + openDashboard = func(context.Context, *api.Client, *session.Session) error { + t.Error("--version opened the dashboard") + return nil + } + + for _, flag := range []string{"--version", "-v"} { + var out bytes.Buffer + rootCmd.SetArgs([]string{flag}) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("%s: %v", flag, err) + } + first, _, _ := strings.Cut(out.String(), "\n") + if want := "nan " + build.Version; first != want { + t.Errorf("%s printed %q first, want %q", flag, first, want) + } + _ = rootCmd.Flags().Set("version", "false") + } +} + +// exitCodeFor is how main ends a failed command: the code, and what fang +// prints, which is nothing for a silent exit. +func exitCodeFor(cmd *cobra.Command, err error) (int, string) { + if Silent(err) { + return ExitCode(cmd, err), "" + } + return ExitCode(cmd, err), err.Error() +} diff --git a/cmd/run.go b/internal/cli/run.go similarity index 88% rename from cmd/run.go rename to internal/cli/run.go index c2cb49c..7cb99db 100644 --- a/cmd/run.go +++ b/internal/cli/run.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "context" @@ -13,9 +13,10 @@ import ( "time" "unicode/utf8" + "github.com/spf13/cobra" + "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/runs" - "github.com/spf13/cobra" ) // The limits the platform enforces, checked here too so a mistake costs a @@ -138,7 +139,6 @@ Docs: https://nan.builders/docs/runs`, } func init() { - rootCmd.AddCommand(runCmd) f := runCmd.Flags() f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") f.StringVar(&runOpts.agent, "agent", "pi", "Agent to run: pi, pi_gentle (Pi + Gentle Shell) or hermes, if the workspace has it installed") @@ -199,7 +199,7 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat return usageErrorf("workspace %q not found (or runs are not enabled for your account)", runs.SanitizeLine(req.Workspace)) } if errors.As(err, &apiErr) && apiErr.Code == "workspace_not_running" { - return exitf(exitUnavailable, "workspace %q is not running — start it at https://cloud.nan.builders and try again", runs.SanitizeLine(req.Workspace)) + return exitf(exitUnavailable, "workspace %q is not running - start it at https://cloud.nan.builders and try again", runs.SanitizeLine(req.Workspace)) } return apiExit(err, env.cred) } @@ -209,7 +209,7 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat id := run.ID if replayed { - fmt.Fprintf(env.stderr, "run %s already exists for this idempotency key (%s)\n", id, runs.SanitizeLine(run.State)) + _, _ = fmt.Fprintf(env.stderr, "run %s already exists for this idempotency key (%s)\n", id, runs.SanitizeLine(run.State)) } if opts.detach { @@ -218,8 +218,8 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat return err } } else { - fmt.Fprintln(env.stdout, id) - fmt.Fprintf(env.stderr, "run %s %s on %s\n", id, runs.SanitizeLine(run.State), runs.SanitizeLine(run.Workspace.Name)) + _, _ = fmt.Fprintln(env.stdout, id) + _, _ = fmt.Fprintf(env.stderr, "run %s %s on %s\n", id, runs.SanitizeLine(run.State), runs.SanitizeLine(run.Workspace.Name)) printFollowHints(env.stderr, id) } return runExit(run) @@ -230,7 +230,7 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if where == "" { where = runs.SanitizeLine(req.Workspace) } - fmt.Fprintf(env.stderr, "run %s queued on %s (%s) — Ctrl-C to detach\n", id, where, runs.SanitizeLine(run.Agent)) + _, _ = fmt.Fprintf(env.stderr, "run %s queued on %s (%s) - Ctrl-C to detach\n", id, where, runs.SanitizeLine(run.Agent)) } return follow(ctx, env, run.ID, 0, opts.json, true) } @@ -264,7 +264,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d if f.State.ErrorCode != nil && *f.State.ErrorCode != "" { state += " (" + runs.SanitizeLine(*f.State.ErrorCode) + ")" } - fmt.Fprintf(env.stderr, "── %s\n", state) + _, _ = fmt.Fprintf(env.stderr, "── %s\n", state) } } @@ -272,9 +272,9 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d run *api.Run err error } - env.client.OnRetry = func(attempt, max int, err error) { + env.client.OnRetry = func(attempt, limit int, _ error) { renderer.Flush() - fmt.Fprintf(env.stderr, "── stream dropped, reconnecting (%d/%d)…\n", attempt, max) + _, _ = fmt.Fprintf(env.stderr, "── stream dropped, reconnecting (%d/%d)…\n", attempt, limit) } done := make(chan outcome, 1) go func() { @@ -311,7 +311,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d return err } } else { - fmt.Fprintln(env.stderr, runs.Outcome(res.run)) + _, _ = fmt.Fprintln(env.stderr, runs.Outcome(res.run)) } return runExit(res.run) } @@ -330,9 +330,9 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d } renderer.Flush() safeID := runs.SanitizeLine(id) - fmt.Fprintf(env.stderr, "\ndetached — run %s goes on in the workspace\n", safeID) + _, _ = fmt.Fprintf(env.stderr, "\ndetached - run %s goes on in the workspace\n", safeID) printFollowHints(env.stderr, safeID) - fmt.Fprintf(env.stderr, "press Ctrl-C again within %s to cancel it now\n", cancelWindow) + _, _ = fmt.Fprintf(env.stderr, "press Ctrl-C again within %s to cancel it now\n", cancelWindow) select { case <-interrupts: @@ -349,10 +349,10 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d return apiExit(err, env.cred) } if !changed { - fmt.Fprintf(env.stderr, "run %s had already ended: %s\n", safeID, runs.SanitizeLine(run.State)) + _, _ = fmt.Fprintf(env.stderr, "run %s had already ended: %s\n", safeID, runs.SanitizeLine(run.State)) return runExit(run) } - fmt.Fprintf(env.stderr, "cancel requested for run %s — it stops within seconds; check with: nan runs show %s\n", safeID, safeID) + _, _ = fmt.Fprintf(env.stderr, "cancel requested for run %s - it stops within seconds; check with: nan runs show %s\n", safeID, safeID) return &ExitError{Code: exitCancelled} } } @@ -360,7 +360,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d func streamExit(err error, env *runsEnv, id string) error { safeID := runs.SanitizeLine(id) if errors.Is(err, api.ErrStreamLost) { - return exitf(exitUnavailable, "%s — run %s goes on in the workspace; follow it with: nan runs logs %s -f", runs.SanitizeLine(err.Error()), safeID, safeID) + return exitf(exitUnavailable, "%s - run %s goes on in the workspace; follow it with: nan runs logs %s -f", runs.SanitizeLine(err.Error()), safeID, safeID) } exit := notFound(err, safeID, env.cred) if exit.Code == exitUnavailable { @@ -370,8 +370,8 @@ func streamExit(err error, env *runsEnv, id string) error { } func printFollowHints(w io.Writer, id string) { - fmt.Fprintf(w, " follow: nan runs logs %s -f\n", id) - fmt.Fprintf(w, " cancel: nan runs cancel %s\n", id) + _, _ = fmt.Fprintf(w, " follow: nan runs logs %s -f\n", id) + _, _ = fmt.Fprintf(w, " cancel: nan runs cancel %s\n", id) } func printJSON(w io.Writer, v any, indent bool) error { @@ -453,7 +453,7 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat func readPrompt(in promptInput, opts runOptions, args []string) (string, error) { const how = `pass it as an argument, with -f FILE, or "-" to read stdin` if len(args) > 1 { - return "", usageErrorf("the prompt is one argument — quote it, or %s", how) + return "", usageErrorf("the prompt is one argument - quote it, or %s", how) } if opts.file != "" && len(args) == 1 { return "", usageErrorf("give the prompt once: as an argument or with -f, not both") @@ -466,7 +466,7 @@ func readPrompt(in promptInput, opts runOptions, args []string) (string, error) switch { case opts.file == "-" || (len(args) == 1 && args[0] == "-"): if in.tty && in.notice != nil { - fmt.Fprintln(in.notice, "reading the prompt from stdin — end it with Ctrl-D") + _, _ = fmt.Fprintln(in.notice, "reading the prompt from stdin - end it with Ctrl-D") } if in.r == nil { return "", usageErrorf("no stdin to read the prompt from") @@ -481,21 +481,21 @@ func readPrompt(in promptInput, opts runOptions, args []string) (string, error) return "", usageErrorf("could not read the prompt: %v", openErr) } raw, err = io.ReadAll(io.LimitReader(f, maxPromptBytes+1)) - f.Close() + _ = f.Close() if err != nil { return "", usageErrorf("could not read the prompt: %v", err) } case len(args) == 1: raw = []byte(args[0]) default: - return "", usageErrorf("no prompt — %s", how) + return "", usageErrorf("no prompt - %s", how) } switch { case len(raw) > maxPromptBytes: return "", usageErrorf("the prompt is over %d bytes, the most a run takes", maxPromptBytes) case strings.TrimSpace(string(raw)) == "": - return "", usageErrorf("the prompt is empty — %s", how) + return "", usageErrorf("the prompt is empty - %s", how) case !utf8.Valid(raw): return "", usageErrorf("the prompt is not UTF-8 text") case strings.ContainsRune(string(raw), 0): @@ -513,11 +513,11 @@ func defaultWorkspace(ctx context.Context, env *runsEnv) (string, error) { if exit.Code == exitAuth { return "", exit } - return "", exitf(exitUnavailable, "could not list your workspaces (%v) — name one with --ws", exit.Err) + return "", exitf(exitUnavailable, "could not list your workspaces (%v) - name one with --ws", exit.Err) } switch len(list) { case 0: - return "", usageErrorf("you have no workspaces — create one at https://cloud.nan.builders") + return "", usageErrorf("you have no workspaces - create one at https://cloud.nan.builders") case 1: return list[0].Name, nil } diff --git a/cmd/run_test.go b/internal/cli/run_test.go similarity index 99% rename from cmd/run_test.go rename to internal/cli/run_test.go index c81a42f..c7e8048 100644 --- a/cmd/run_test.go +++ b/internal/cli/run_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" @@ -362,7 +362,7 @@ func TestDefaultWorkspace(t *testing.T) { } func TestExpiredSessionExits65(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"error":{"message":"invalid session","type":"authentication_error","code":"invalid_session"}}`) })) diff --git a/cmd/runs.go b/internal/cli/runs.go similarity index 90% rename from cmd/runs.go rename to internal/cli/runs.go index 84bd303..3d7289e 100644 --- a/cmd/runs.go +++ b/internal/cli/runs.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "context" @@ -9,9 +9,10 @@ import ( "time" "unicode/utf8" + "github.com/spf13/cobra" + "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/runs" - "github.com/spf13/cobra" ) // sysexitsAnnotation marks the commands whose exit codes are the documented @@ -78,7 +79,7 @@ var runsLsCmd = &cobra.Command{ Short: "List your runs, newest first", Args: cobra.NoArgs, Annotations: sysexits, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { if err := checkLsFlags(); err != nil { return err } @@ -178,7 +179,6 @@ Docs: https://nan.builders/docs/runs`, } func init() { - rootCmd.AddCommand(runsCmd) runsCmd.AddCommand(runsLsCmd, runsShowCmd, runsLogsCmd, runsCancelCmd, runsProposalCmd, runsRejectCmd) runsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) @@ -226,16 +226,16 @@ func runtimeError(err error) error { func checkRunID(id string) error { if !runIDPattern.MatchString(id) { - return usageErrorf("%q is not a run id — ids look like 6f1c2a9b-…, see: nan runs ls", runs.Truncate(runs.SanitizeLine(id), 64)) + return usageErrorf("%q is not a run id - ids look like 6f1c2a9b-…, see: nan runs ls", runs.Truncate(runs.SanitizeLine(id), 64)) } return nil } // oneRunID is cobra.ExactArgs(1) with a message that says what is missing. -func oneRunID(cmd *cobra.Command, args []string) error { +func oneRunID(_ *cobra.Command, args []string) error { switch { case len(args) == 0: - return usageErrorf("missing run id — see: nan runs ls") + return usageErrorf("missing run id - see: nan runs ls") case len(args) > 1: return usageErrorf("one run id at a time") } @@ -275,9 +275,9 @@ func doRunsLs(ctx context.Context, env *runsEnv) error { } if len(list.Data) == 0 { if lsWorkspace != "" || lsState != "" { - fmt.Fprintln(env.stderr, "No runs match.") + _, _ = fmt.Fprintln(env.stderr, "No runs match.") } else { - fmt.Fprintln(env.stderr, "No runs yet. Start one with: nan run \"\"") + _, _ = fmt.Fprintln(env.stderr, "No runs yet. Start one with: nan run \"\"") } return nil } @@ -285,9 +285,9 @@ func doRunsLs(ctx context.Context, env *runsEnv) error { env.nameRunWorkspace(ctx, &list.Data[i]) } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tCREATED\tPROMPT") + _, _ = fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tCREATED\tPROMPT") for _, r := range list.Data { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", runs.SanitizeLine(r.ID), runs.SanitizeLine(r.State), runs.Truncate(runs.SanitizeLine(refLabel(r.Workspace.Name, r.Workspace.ID)), 40), @@ -300,9 +300,9 @@ func doRunsLs(ctx context.Context, env *runsEnv) error { } if list.NextCursor != nil { if lsLimit < 100 { - fmt.Fprintf(env.stderr, "showing the %d most recent — raise --limit (up to 100) for more\n", len(list.Data)) + _, _ = fmt.Fprintf(env.stderr, "showing the %d most recent - raise --limit (up to 100) for more\n", len(list.Data)) } else { - fmt.Fprintf(env.stderr, "showing the %d most recent — narrow it with --ws or --state\n", len(list.Data)) + _, _ = fmt.Fprintf(env.stderr, "showing the %d most recent - narrow it with --ws or --state\n", len(list.Data)) } } return nil @@ -332,7 +332,7 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) row := func(k, v string) { if v != "" { - fmt.Fprintf(tw, "%s\t%s\n", k, v) + _, _ = fmt.Fprintf(tw, "%s\t%s\n", k, v) } } state := runs.SanitizeLine(run.State) @@ -428,9 +428,9 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { return err } if run.State == api.StateAwaitingApproval { - fmt.Fprintln(env.stdout) - fmt.Fprintln(env.stdout, "awaiting your approval:") - fmt.Fprintln(env.stdout, runs.ApprovalHint(run)) + _, _ = fmt.Fprintln(env.stdout) + _, _ = fmt.Fprintln(env.stdout, "awaiting your approval:") + _, _ = fmt.Fprintln(env.stdout, runs.ApprovalHint(run)) } prompt := run.Prompt @@ -456,9 +456,9 @@ func printBlock(env *runsEnv, title, text string) { if strings.TrimSpace(text) == "" { return } - fmt.Fprintf(env.stdout, "\n%s:\n", title) + _, _ = fmt.Fprintf(env.stdout, "\n%s:\n", title) for _, l := range strings.Split(text, "\n") { - fmt.Fprintf(env.stdout, " %s\n", l) + _, _ = fmt.Fprintf(env.stdout, " %s\n", l) } } @@ -475,7 +475,7 @@ func doRunsLogs(ctx context.Context, env *runsEnv, id string) error { renderer := &runs.Renderer{Out: env.stdout} after := logsAfter - done := false + var done bool for { page, err := env.client.EventsPage(ctx, id, after, 500) if err != nil { @@ -498,7 +498,7 @@ func doRunsLogs(ctx context.Context, env *runsEnv, id string) error { } renderer.Flush() if !done { - fmt.Fprintf(env.stderr, "run %s is still going — follow it with: nan runs logs %s -f\n", id, id) + _, _ = fmt.Fprintf(env.stderr, "run %s is still going - follow it with: nan runs logs %s -f\n", id, id) } return nil } @@ -515,10 +515,10 @@ func doRunsCancel(ctx context.Context, env *runsEnv, id string) error { return printJSON(env.stdout, run, true) } if !changed { - fmt.Fprintf(env.stdout, "run %s had already ended: %s\n", id, runs.SanitizeLine(run.State)) + _, _ = fmt.Fprintf(env.stdout, "run %s had already ended: %s\n", id, runs.SanitizeLine(run.State)) return nil } - fmt.Fprintf(env.stdout, "cancel requested for run %s — it stops within seconds; check with: nan runs show %s\n", id, id) + _, _ = fmt.Fprintf(env.stdout, "cancel requested for run %s - it stops within seconds; check with: nan runs show %s\n", id, id) return nil } @@ -580,15 +580,15 @@ func doRunsProposal(ctx context.Context, env *runsEnv, id string, withDiff, asJS return nil } if !run.Approval.HasDiff { - fmt.Fprintln(env.stderr, "no diff is stored for this proposal: one is kept only when your approval covers a push") + _, _ = fmt.Fprintln(env.stderr, "no diff is stored for this proposal: one is kept only when your approval covers a push") return nil } diff, err := env.client.ProposalDiff(ctx, id) if err != nil { return notFound(err, id, env.cred) } - fmt.Fprintln(env.stdout) - fmt.Fprintln(env.stdout, "diff:") + _, _ = fmt.Fprintln(env.stdout) + _, _ = fmt.Fprintln(env.stdout, "diff:") runs.Diff(env.stdout, diff) return nil } @@ -629,6 +629,6 @@ func doRunsReject(ctx context.Context, env *runsEnv, id string) error { if rejectJSON { return printJSON(env.stdout, run, true) } - fmt.Fprintf(env.stdout, "rejected: run %s is %s; none of the actions waiting for approval will be applied (actions that applied automatically before it stay applied)\n", id, strings.ReplaceAll(runs.SanitizeLine(run.State), "_", " ")) + _, _ = fmt.Fprintf(env.stdout, "rejected: run %s is %s; none of the actions waiting for approval will be applied (actions that applied automatically before it stay applied)\n", id, strings.ReplaceAll(runs.SanitizeLine(run.State), "_", " ")) return nil } diff --git a/cmd/runs_test.go b/internal/cli/runs_test.go similarity index 99% rename from cmd/runs_test.go rename to internal/cli/runs_test.go index a987820..343e8bd 100644 --- a/cmd/runs_test.go +++ b/internal/cli/runs_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "context" diff --git a/cmd/secrets.go b/internal/cli/secrets.go similarity index 91% rename from cmd/secrets.go rename to internal/cli/secrets.go index 8601a24..7c21a3c 100644 --- a/cmd/secrets.go +++ b/internal/cli/secrets.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "context" @@ -8,10 +8,11 @@ import ( "strings" "text/tabwriter" + "github.com/spf13/cobra" + "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/runs" "github.com/nxssie/nan-cli/internal/session" - "github.com/spf13/cobra" ) var secretsLsJSON bool @@ -66,7 +67,7 @@ var secretsLsCmd = &cobra.Command{ Short: "List your secrets: names and metadata, never values", Args: cobra.NoArgs, Annotations: sysexits, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { env, err := newSessionEnv() if err != nil { return err @@ -76,7 +77,6 @@ var secretsLsCmd = &cobra.Command{ } func init() { - rootCmd.AddCommand(secretsCmd) secretsCmd.AddCommand(secretsLsCmd) secretsLsCmd.Flags().BoolVar(&secretsLsJSON, "json", false, "Print the list as JSON (metadata only)") } @@ -110,11 +110,11 @@ func doSecretsLs(ctx context.Context, env *runsEnv) error { return printJSON(env.stdout, list, true) } if len(list.Data) == 0 { - fmt.Fprintf(env.stderr, "No secrets yet. Add them in the portal: Settings > Secrets at %s\n", secretsURL) + _, _ = fmt.Fprintf(env.stderr, "No secrets yet. Add them in the portal: Settings > Secrets at %s\n", secretsURL) return nil } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "NAME\tKIND\tHINT\tUSED BY\tLAST USED\tUPDATED") + _, _ = fmt.Fprintln(tw, "NAME\tKIND\tHINT\tUSED BY\tLAST USED\tUPDATED") for _, s := range list.Data { hint := "-" if s.Hint != nil && *s.Hint != "" { @@ -136,13 +136,13 @@ func doSecretsLs(ctx context.Context, env *runsEnv) error { if s.LastUsedAt != nil { lastUsed = localTime(*s.LastUsedAt) } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", runs.Truncate(runs.SanitizeLine(s.Name), 64), kind, hint, usedBy, lastUsed, localTime(s.UpdatedAt)) } if err := tw.Flush(); err != nil { return err } - fmt.Fprintf(env.stderr, "add, replace or delete secrets in the portal: Settings > Secrets at %s\n", secretsURL) + _, _ = fmt.Fprintf(env.stderr, "add, replace or delete secrets in the portal: Settings > Secrets at %s\n", secretsURL) return nil } diff --git a/internal/cli/setup.go b/internal/cli/setup.go new file mode 100644 index 0000000..8440e5d --- /dev/null +++ b/internal/cli/setup.go @@ -0,0 +1,292 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "charm.land/huh/v2" + "github.com/spf13/cobra" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/build" + "github.com/nxssie/nan-cli/internal/session" + "github.com/nxssie/nan-cli/internal/tools" + "github.com/nxssie/nan-cli/internal/ui" +) + +func newSetupCmd(get func() *app) *cobra.Command { + var apiKey string + var all bool + + cmd := &cobra.Command{ + Use: "setup", + Short: "Point the coding tools on this machine at NaN", + Long: "Point the coding tools on this machine at NaN: " + tools.List() + ".\n\n" + + "Paste your API key - from cloud.nan.builders - and this writes it into the\n" + + "tools you pick. Only installed tools are offered, only NaN's part of each\n" + + "config is touched, and unticking a tool takes NaN back out of it.\n\n" + + "The key is checked against the cluster before anything is written, so a\n" + + "mistyped one is caught here rather than as a 401 inside every tool.\n\n" + + "For a machine with nobody at the keyboard:\n\n" + + " echo \"$NAN_API_KEY\" | nan setup --key - --all\n", + RunE: func(cmd *cobra.Command, _ []string) error { + return runSetup(cmd.Context(), get(), apiKey, all) + }, + } + cmd.Flags().StringVar(&apiKey, "key", "", + `the API key to write ("-" reads it from stdin, keeping it out of your shell history)`) + cmd.Flags().BoolVar(&all, "all", false, "configure every installed tool without asking") + cmd.AddCommand(newSetupStatusCmd(get), newSetupRemoveCmd(get)) + return cmd +} + +func runSetup(ctx context.Context, a *app, apiKey string, all bool) error { + p := a.p + + // The key, from the flag, from what is already saved, or from a prompt. + if apiKey != "" { + var err error + if apiKey, err = flagValue(apiKey); err != nil { + return err + } + } + if apiKey == "" { + apiKey = a.session.APIKey + } + if apiKey == "" { + if !p.Interactive() { + return fmt.Errorf("no API key - pass one with --key (or \"--key -\" to read it from stdin)") + } + var err error + if apiKey, err = askAPIKey(ctx); err != nil { + return err + } + } + + // Checked before a byte is written. A key that fails here is one + // message; the same key written into five configs is five tools that + // start, answer, and fail deep into a session. + models, err := api.ListModels(ctx, apiKey) + if err != nil { + return fmt.Errorf("the cluster refused this key - %w", err) + } + + installed := tools.Installed(tools.Detect()) + if len(installed) == 0 { + a.session.APIKey = apiKey + if err := session.Save(a.session); err != nil { + return err + } + p.Success("Key accepted by the cluster · %d models. Saved.", len(models)) + p.Note("None of %s is installed on this machine yet.", tools.List()) + return nil + } + + chosen, err := pickTools(ctx, a, installed, all) + if err != nil { + return err + } + + result := tools.Apply(apiKey, chosen) + + a.session.APIKey = apiKey + a.session.EnabledTools = chosen + if err := session.Save(a.session); err != nil { + return err + } + + p.Success("Key accepted by the cluster · %d models · %s", len(models), result.Summary()) + if len(result.Written) > 0 { + p.Section("now open them") + steps := map[string]tools.Tool{} + for _, t := range tools.Registry() { + steps[t.Name] = t + } + rows := make([][2]string, 0, len(result.Written)) + for _, name := range result.Written { + rows = append(rows, [2]string{ui.Mono(steps[name].Open), steps[name].NextStep}) + } + p.KV(rows) + p.Blank() + } + for _, f := range result.Failed { + p.Warn("%s: %s", f.Tool, f.FirstLine()) + } + if len(result.Failed) > 0 { + p.Note("The rest went in. Its page on nan.builders has the manual steps.") + return fmt.Errorf("%d tool(s) could not be configured", len(result.Failed)) + } + return nil +} + +func askAPIKey(ctx context.Context) (string, error) { + var key string + form := ui.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Your NaN API key"). + Description("Copy it from cloud.nan.builders. It is checked against the cluster before anything is written."). + Placeholder("paste your NaN API key here"). + // Dots, not the key: a terminal is shared and screenshotted. + EchoMode(huh.EchoModePassword). + Value(&key). + Validate(func(v string) error { + if strings.TrimSpace(v) == "" { + return fmt.Errorf("paste the key") + } + return nil + }), + )) + + if err := form.RunWithContext(ctx); err != nil { + if errorsIsUserAbort(err) { + return "", fmt.Errorf("cancelled") + } + return "", err + } + return strings.TrimSpace(key), nil +} + +// pickTools asks which tools to configure, or takes every installed one when +// there is nobody to ask and --all was passed. +func pickTools(ctx context.Context, a *app, installed []tools.Status, all bool) (map[string]bool, error) { + if all || !a.p.Interactive() { + if !all { + return nil, fmt.Errorf("there is nobody here to ask which tools - pass --all to configure every installed one") + } + chosen := map[string]bool{} + for _, st := range installed { + chosen[st.Name] = true + } + return chosen, nil + } + + options := make([]huh.Option[string], 0, len(installed)) + selected := make([]string, 0, len(installed)) + for _, st := range installed { + label := st.Name + if st.Configured { + label += " · configured with NaN" + } + on := tools.Enabled(a.session.EnabledTools, st.Name) + options = append(options, huh.NewOption(label, st.Name).Selected(on)) + if on { + selected = append(selected, st.Name) + } + } + + // A Picker rather than a bare MultiSelect, which shows no options at all + // under a description - see ui.Picker. + form := ui.NewForm(huh.NewGroup( + ui.NewPicker( + "Which tools should use NaN?", + "Only the tools installed here are listed. Unticking one takes NaN back out of its config.", + options, + ).Value(&selected), + )) + + if err := form.RunWithContext(ctx); err != nil { + if errorsIsUserAbort(err) { + return nil, fmt.Errorf("cancelled") + } + return nil, err + } + + // Every installed tool, ticked or not: one missing from the map counts as + // ticked, so an untick has to be written down to stick. + chosen := make(map[string]bool, len(installed)) + for _, st := range installed { + chosen[st.Name] = false + } + for _, name := range selected { + chosen[name] = true + } + return chosen, nil +} + +func newSetupStatusCmd(get func() *app) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "What is configured on this machine", + Long: "What is configured on this machine: whether there is an API key, and\n" + + "which tools are pointed at NaN.\n\n" + + "Reads nothing over the network, so it works with no connection and\n" + + "without being signed in.\n", + RunE: func(_ *cobra.Command, _ []string) error { + a := get() + detected := tools.Detect() + + if a.p.JSONMode() { + out := make([]map[string]any, 0, len(detected)) + for _, st := range detected { + out = append(out, map[string]any{ + "tool": st.Name, "installed": st.Installed, + "configured": st.Configured, "config": st.ConfigPath, + }) + } + return a.p.JSON(map[string]any{"hasKey": a.session.APIKey != "", "tools": out}) + } + + a.p.Section("your api key") + if a.session.APIKey == "" { + a.p.Note("No key on this machine. Run: %s setup", build.Name) + } else { + a.p.KV([][2]string{ + {"Key", ui.Mono(ui.Fingerprint(a.session.APIKey))}, + {"Endpoint", ui.Mono(api.InferenceURL())}, + }) + } + + a.p.Section("tools on this machine") + rows := make([][]string, 0, len(detected)) + for _, st := range detected { + status := ui.Faint("not installed") + switch { + case st.Configured: + status = ui.Tag("nan", ui.TagOK) + case st.Installed: + status = ui.Faint("not configured") + } + rows = append(rows, []string{ui.Value(st.Name), status, ui.Faint(ui.HomePath(st.ConfigPath))}) + } + a.p.Table(ui.Table{Columns: []ui.Column{ + {Title: "tool", Max: 18}, {Title: "status"}, {Title: "config", Max: 50}, + }, Rows: rows}, "No supported tools found.") + a.p.Blank() + return nil + }, + } +} + +func newSetupRemoveCmd(get func() *app) *cobra.Command { + return &cobra.Command{ + Use: "remove", + Short: "Take NaN back out of every tool", + Long: "Take NaN back out of every tool, and forget the key here.\n\n" + + "Only our section of each config is removed; everything else in those\n" + + "files is left exactly as it was.\n", + RunE: func(_ *cobra.Command, _ []string) error { + a := get() + removed, failed := tools.RemoveAll() + + a.session.APIKey = "" + a.session.EnabledTools = nil + if err := session.Save(a.session); err != nil { + return err + } + + if len(removed) > 0 { + a.p.Success("Removed the key from %s.", strings.Join(removed, ", ")) + } else { + a.p.Note("No tool on this machine was using it.") + } + for _, f := range failed { + a.p.Warn("%s: %s", f.Tool, f.FirstLine()) + } + if len(failed) > 0 { + return fmt.Errorf("the key is still in %d tool(s)", len(failed)) + } + return nil + }, + } +} diff --git a/internal/cli/theme.go b/internal/cli/theme.go new file mode 100644 index 0000000..d43a34c --- /dev/null +++ b/internal/cli/theme.go @@ -0,0 +1,54 @@ +package cli + +import ( + "image/color" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/fang" + + "github.com/nxssie/nan-cli/internal/ui" +) + +// ColorScheme is Helmcode's palette, applied to the help and error output +// that fang renders. +// +// Without this, `helmcode --help` is the only screen in the program that is +// not Helmcode's — Charm's default scheme, in Charm's colours, under a +// Helmcode banner. Branding the parts we wrote and leaving the framework's +// default underneath is how a CLI ends up looking assembled rather than +// designed. +func ColorScheme(pick lipgloss.LightDarkFunc) fang.ColorScheme { + // fang hands us its light/dark decision as a picker rather than as a + // bool, so the theme is resolved by asking it to choose between black + // and white and looking at which came back. Reading fang's answer + // rather than asking the terminal ourselves is what keeps the help and + // the dashboard from disagreeing about the background. + t := ui.NewTheme(isDark(pick)) + + return fang.ColorScheme{ + Base: t.Text, + Title: t.AccentText, + Description: t.TextDim, + Codeblock: t.Surface, + Program: t.Text, + DimmedArgument: t.TextFaint, + Comment: t.TextFaint, + Flag: t.AccentText, + FlagDefault: t.TextFaint, + Command: t.Text, + QuotedString: t.Mono, + Argument: t.Mono, + Help: t.TextFaint, + Dash: t.Line, + // The error header is the one filled moment here: indigo behind + // near-black, the same treatment the dashboard gives the open tab. + ErrorHeader: [2]color.Color{lipgloss.Color("#0a0a0a"), t.Accent}, + ErrorDetails: t.Red, + } +} + +// isDark asks a picker which way it leans. +func isDark(pick lipgloss.LightDarkFunc) bool { + r, _, _, _ := pick(lipgloss.Color("#000000"), lipgloss.Color("#ffffff")).RGBA() + return r > 0x7fff +} diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..81bc83a --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,54 @@ +package cli + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/charmbracelet/x/term" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/build" + "github.com/nxssie/nan-cli/internal/ui" + "github.com/nxssie/nan-cli/internal/update" +) + +// After a command, one line when a newer release is out - what gh and most +// CLIs do - with the command that updates this copy. +// +// On stderr, and only to a person: never with --json, never into a pipe, +// never under a script. Asked for at most once a day (update.CheckEvery), +// and then with a short timeout, so a slow or missing endpoint costs a run +// a second at worst and usually nothing. The dashboard says the same in its +// own header, so it is not repeated when the dashboard was what ran. + +// updateCheckTimeout bounds the one request this adds, on the days it adds one. +const updateCheckTimeout = 1500 * time.Millisecond + +// noticeUpdate is run after every command. +func noticeUpdate(ctx context.Context, a *app, dashboard bool) { + if a == nil || dashboard || a.p.JSONMode() || !a.p.Interactive() || !term.IsTerminal(os.Stderr.Fd()) { + return + } + st := update.Load() + if st.DueForCheck(time.Now()) { + ctx, cancel := context.WithTimeout(ctx, updateCheckTimeout) + release, err := api.LatestRelease(ctx) + cancel() + if err == nil { + // Checked, whatever the answer: "nothing newer" is an answer, and + // asking again on the next run would make every run pay for it. + st.CheckedAt = time.Now().UTC().Format(time.RFC3339) + if release != nil { + st.Latest = release.Version + } + _ = st.Save() + } + } + if v := st.Available(); v != "" { + _, _ = fmt.Fprintf(a.p.errOut, "\n%s%s %s\n%s%s\n", indent, + ui.Accent(build.Name+" "+v+" is out"), ui.Faint("(you have "+build.Version+"). Update with:"), + indent+" ", ui.Value(update.Command())) + } +} diff --git a/internal/flow/setup.go b/internal/flow/setup.go new file mode 100644 index 0000000..db9f0a0 --- /dev/null +++ b/internal/flow/setup.go @@ -0,0 +1,493 @@ +package flow + +import ( + "context" + "errors" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/tools" + "github.com/nxssie/nan-cli/internal/ui" +) + +type setupStage int + +const ( + setupOff setupStage = iota + setupKey + setupChecking + setupTools + setupApplying + setupDone +) + +// SkippableKey is whether the flow is on the first run's key step, where esc +// skips rather than cancels - so a footer can say so. +func (s Setup) SkippableKey() bool { return s.onboarding && s.stage == setupKey } + +// Setup points the coding tools on this machine at NaN. +// +// Three steps, and the middle one is the point: the key is CHECKED against +// the cluster before a byte is written. A key that fails there is one +// message; the same key written into five configs is five tools that start, +// answer, and fail with a 401 somewhere deep in a session. +type Setup struct { + stage setupStage + form *huh.Form + key string + err error + + // hint is what the key step says about the member's key on the + // platform: that they have one and where to copy it from, or that they + // have none yet. + hint string + // models is how many the cluster serves this key, once it is checked. + models int + // unchecked is a key that could not be checked because the cluster + // could not be reached: let through, and said so. + unchecked error + // onboarding is the first run, straight after signing in, where the + // screen has to explain why it is asking before it asks. + onboarding bool + // noTools is set when this machine has none of the supported tools, so + // the summary can tell that apart from none being picked. + noTools bool + // guide is how the first run ends: how to get around the dashboard. + guide Guide + // skipped is a first run left with esc. It still ends on the guide: + // somebody who skipped the key is exactly who is about to be left + // alone with the dashboard. + skipped bool + + // Result is what the last apply did. + Result tools.Result + + // Key and Tools are what the parent should save once the flow is done. + Key string + Tools map[string]bool + // TourRequested is set when a first run closed on "take the tour". + TourRequested bool +} + +// Guide is what the dashboard hands a first run to end on. +type Guide struct { + // Keys is a list of shortcuts, key and what it does, shown as is. + Keys [][2]string + // Tour offers a guided tour of the dashboard under the list. The + // dashboard runs it; the flow only asks whether to. + Tour bool +} + +func (g Guide) empty() bool { return len(g.Keys) == 0 && !g.Tour } + +type keyCheckedMsg struct { + models int + err error +} + +type appliedMsg struct{ result tools.Result } + +// NewSetup returns a flow that is not running yet. +func NewSetup() Setup { return Setup{} } + +// Active reports whether the flow owns the screen. +func (s Setup) Active() bool { return s.stage != setupOff } + +// Finished reports whether it has something to report and is waiting to be +// dismissed. +func (s Setup) Finished() bool { return s.stage == setupDone } + +// KeyHint is what the key step says about the member's key on the platform, +// from what the platform will tell about it: never the key itself, which is +// handed over once when it is created. +func KeyHint(status *api.KeyStatus) string { + switch { + case status == nil: + return "" + case status.Exists: + name := status.Alias + if name == "" { + name = status.Name + } + if name != "" { + return "Your account has a key (" + name + ") - copy it from cloud.nan.builders." + } + return "Your account has a key - copy it from cloud.nan.builders." + } + return "This account has no key yet - create one at cloud.nan.builders." +} + +// Start opens the flow, pre-filled with whatever key is already saved so +// that re-running it is a confirmation rather than a retype. +func (s *Setup) Start(existing, hint string) tea.Cmd { + *s = Setup{stage: setupKey, key: existing, hint: hint} + s.form = keyForm(existing, hint, false) + return s.form.Init() +} + +// Onboard is Start for the first run: the same flow, opened straight after +// signing in when this machine has no key, with a screen that says why. +// +// Skippable - esc leaves it. A first run that cannot be dismissed turns "I +// only wanted to look around" into a dead end. +func (s *Setup) Onboard(hint string, guide Guide) tea.Cmd { + *s = Setup{stage: setupKey, hint: hint, onboarding: true, guide: guide} + s.form = keyForm("", hint, true) + return s.form.Init() +} + +// The keys the fields are read back by. +// +// By KEY, never by reading the field this form was bound to with Value(&x). +// Bubble Tea hands Update a copy of the model and keeps what it returns, so +// a pointer into one of the model's fields points into a copy that has +// already been discarded. The form itself is a pointer and survives the +// copying, so asking IT is the only reading that stays true. The Value +// binding below only seeds the field with what is already saved. +const ( + fieldKey = "key" + fieldTools = "tools" +) + +func keyForm(existing, hint string, onboarding bool) *huh.Form { + title := "Your NaN API key" + desc := "Paste the key your tools will use. It is checked against the cluster before " + + "anything is written, and it stays on this machine. If anyone has seen it, replace " + + "it at cloud.nan.builders and paste the new one here." + empty := "paste the key, or esc to leave" + if onboarding { + // Optional, and said so: the dashboard works without a key. What + // the key is FOR is the part people skip past, so it comes first. + title = "Signed in. One more step, if you want it: connect this machine" + desc = "An API key lets this set up the coding tools on this machine - " + + tools.List() + " - so they talk to NaN without you editing their " + + "configs. Paste one: it is checked, and written into the ones you pick.\n\n" + + "Not now? esc skips it. The dashboard works without a key, and you can " + + "add one whenever you like: 6 Harnesses, or `nan setup`." + empty = "paste the key, or esc to skip it for now" + } + if hint != "" { + desc += "\n\n" + hint + } + value := existing + return ui.NewForm(huh.NewGroup( + huh.NewInput(). + Key(fieldKey). + Title(title). + Description(desc). + Placeholder("paste your NaN API key here"). + // Dots, not the key: a terminal is shared and screenshotted. + EchoMode(huh.EchoModePassword). + CharLimit(512). + Validate(func(v string) error { + if strings.TrimSpace(v) == "" { + return fmt.Errorf("%s", empty) + } + return nil + }). + Value(&value), + )) +} + +// toolForm is the second step: which of the installed tools to point at +// NaN. Unticking one is meaningful: it takes NaN back out of it. +func (s *Setup) toolForm(saved map[string]bool) tea.Cmd { + installed := tools.Installed(tools.Detect()) + if len(installed) == 0 { + // Nothing to configure is a finished state, not an error: the key is + // good and there is simply no tool on this machine yet. + s.stage = setupDone + s.noTools = true + s.Key = s.key + return nil + } + + options := make([]huh.Option[string], 0, len(installed)) + for _, st := range installed { + label := st.Name + if st.Configured { + label += " · configured with NaN" + } + options = append(options, huh.NewOption(label, st.Name).Selected(tools.Enabled(saved, st.Name))) + } + + s.stage = setupTools + // A Picker rather than a bare MultiSelect, which shows no options at all + // under a description - see ui.Picker. + s.form = ui.NewForm(huh.NewGroup( + ui.NewPicker( + "Which tools should use NaN?", + "Only the tools installed here are listed. Unticking one takes NaN back out of its config.", + options, + ).Key(fieldTools), + )) + return s.form.Init() +} + +// Resize rewraps the open form, if there is one, for a terminal of w by h. +func (s *Setup) Resize(w, h int) { ui.FitForm(s.form, w, h) } + +// leave is esc. Out of the flow, except on a first run with a guide to show, +// which ends on the guide instead. Nothing is saved either way. +func (s Setup) leave() Setup { + if !s.onboarding || s.guide.empty() { + return Setup{} + } + s.stage, s.skipped, s.Key, s.Tools = setupDone, true, "", nil + s.err, s.models, s.unchecked = nil, 0, nil + return s +} + +// offersTour is whether this summary ends on the question "take the tour?". +func (s Setup) offersTour() bool { return s.onboarding && s.guide.Tour } + +// AsksAboutTour is whether the screen is that question right now, so a +// footer can offer its two answers rather than keys that do nothing there. +func (s Setup) AsksAboutTour() bool { return s.stage == setupDone && s.offersTour() } + +// Update drives the flow. `saved` is which tools are ticked, so the picker +// opens on what is true. +func (s Setup) Update(ctx context.Context, msg tea.Msg, saved map[string]bool) (Setup, tea.Cmd) { + switch msg := msg.(type) { + case keyCheckedMsg: + if msg.err != nil && errors.Is(msg.err, api.ErrKeyRefused) { + // Back to the key: one the cluster refused is almost always a + // paste that lost a character, and writing it anywhere would + // only move the failure into every tool. + s.err = fmt.Errorf("the cluster refused this key - %w", msg.err) + s.stage = setupKey + s.form = keyForm("", s.hint, s.onboarding) + return s, s.form.Init() + } + // A key that could not be checked because the cluster could not be + // reached is let through: offline is not a wrong key, and refusing + // it would leave somebody on a train unable to set up at all. + s.err, s.unchecked, s.models = nil, msg.err, msg.models + s.Key = s.key + cmd := s.toolForm(saved) + return s, cmd + + case appliedMsg: + s.stage = setupDone + s.Result = msg.result + return s, nil + + case tea.KeyPressMsg: + if s.stage == setupDone { + // The dismissing key hands back what to save. The dashboard + // saves once the flow is no longer Active, which is only after + // this key. + out := Setup{Key: s.Key, Tools: s.Tools, Result: s.Result} + if !s.offersTour() { + return out, nil // any key + } + // An offer is a question, so only its two answers close it. + switch msg.String() { + case "enter": + out.TourRequested = true + return out, nil + case "s", "esc": + return out, nil + } + return s, nil + } + if msg.String() == "esc" { + return s.leave(), nil + } + } + + if s.form == nil || s.stage == setupChecking || s.stage == setupApplying { + return s, nil + } + + form, cmd := s.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + s.form = f + } + + switch s.form.State { + case huh.StateAborted: + return s.leave(), nil + case huh.StateCompleted: + switch s.stage { + case setupKey: + s.key = strings.TrimSpace(s.form.GetString(fieldKey)) + s.stage = setupChecking + return s, checkKey(ctx, s.key) + case setupTools: + chosen, _ := s.form.Get(fieldTools).([]string) + s.Tools = tickedFrom(chosen) + s.stage = setupApplying + return s, applyTools(s.Key, s.Tools) + } + } + return s, cmd +} + +// tickedFrom is every installed tool, ticked or not. The unticked ones are +// saved too: a tool nobody has unticked is ticked, so forgetting one would +// tick it again on the next run. +func tickedFrom(chosen []string) map[string]bool { + ticked := map[string]bool{} + for _, st := range tools.Installed(tools.Detect()) { + ticked[st.Name] = false + } + for _, name := range chosen { + ticked[name] = true + } + return ticked +} + +// checkKey asks the cluster which models the key can call, which is the only +// thing that can say a key is valid. +func checkKey(ctx context.Context, key string) tea.Cmd { + return func() tea.Msg { + models, err := api.ListModels(ctx, key) + return keyCheckedMsg{models: len(models), err: err} + } +} + +// applyTools runs off the event loop. With Hermes in the list it is several +// processes and several seconds, and run inside a key handler that is a +// dashboard frozen with no repaint and no key accepted. +func applyTools(key string, ticked map[string]bool) tea.Cmd { + return func() tea.Msg { + return appliedMsg{result: tools.Apply(key, ticked)} + } +} + +// Label is what the loader should say for the step the flow is on. +func (s Setup) Label() string { + switch s.stage { + case setupChecking: + return "Checking it against the cluster" + case setupApplying: + // Said because it is the slow one: Hermes is configured by running + // it, not by writing a file. + return "Writing the configs - a few seconds, Hermes is asked rather than written" + default: + return "Loading" + } +} + +// View renders the flow. +func (s Setup) View(indent, spinner string) string { + var b strings.Builder + + switch s.stage { + case setupKey, setupTools: + if s.stage == setupTools { + switch { + case s.unchecked != nil: + b.WriteString(indent + s.uncheckedLine() + "\n\n") + case s.models > 0: + b.WriteString(indent + ui.Success(fmt.Sprintf("Key accepted by the cluster · %d models", s.models)) + "\n\n") + } + } + b.WriteString(s.form.View() + "\n") + + case setupChecking, setupApplying: + b.WriteString(indent + spinner + "\n") + + case setupDone: + s.viewDone(&b, indent) + } + + if s.err != nil { + b.WriteString("\n" + indent + ui.Error(s.err) + "\n") + } + return b.String() +} + +func (s Setup) viewDone(b *strings.Builder, indent string) { + if s.unchecked != nil { + b.WriteString(indent + s.uncheckedLine() + "\n\n") + } + r := s.Result + switch { + case len(r.Written) > 0 || r.Removed > 0 || len(r.Failed) > 0: + b.WriteString(indent + ui.Success(r.Summary()) + "\n") + case s.Key != "": + b.WriteString(indent + ui.Success("Key saved.") + "\n\n") + // Two different situations, and saying the first when it is the + // second tells somebody their tools are missing when they simply + // did not tick any. + if s.noTools { + b.WriteString(indent + ui.Faint("None of "+tools.List()+" is installed on this machine yet.") + "\n") + } else { + b.WriteString(indent + ui.Faint("No tool was ticked, so nothing was written.") + "\n") + } + } + if len(r.Written) > 0 { + writeNextSteps(b, indent, r.Written) + } + writeFailures(b, indent, r.Failed) + + if s.skipped { + b.WriteString(indent + ui.Value("Skipped: no key on this machine yet.") + "\n") + b.WriteString(indent + ui.Faint("The tools here cannot reach NaN until they have one. Add it whenever") + "\n") + b.WriteString(indent + ui.Faint("you like: 6 Harnesses, then enter - or run nan setup.") + "\n") + } + + switch { + case s.onboarding && len(s.guide.Keys) > 0: + b.WriteString("\n" + indent + ui.Eyebrow("Getting around") + "\n\n") + rows := make([][2]string, len(s.guide.Keys)) + for i, r := range s.guide.Keys { + rows[i] = [2]string{ui.Value(r[0]), r[1]} + } + b.WriteString(ui.KVBlock(rows, indent) + "\n") + if s.offersTour() { + b.WriteString("\n" + indent + ui.Eyebrow("Quick tour") + "\n\n") + b.WriteString(indent + "A minute through the dashboard, on your own data. Each step moves on\n") + b.WriteString(indent + "when you do what it says.\n\n") + b.WriteString(indent + ui.Accent("enter") + ui.Faint(" take the tour") + " " + + ui.Accent("s") + ui.Faint(" skip it") + "\n") + b.WriteString(indent + ui.Faint("Press t in the dashboard to take it later.") + "\n") + } else { + b.WriteString("\n" + indent + ui.Faint("any key to start") + "\n") + } + default: + b.WriteString("\n" + indent + ui.Faint("any key to go back") + "\n") + } +} + +func (s Setup) uncheckedLine() string { + return ui.Warn("The key could not be checked (" + s.unchecked.Error() + "); it is used anyway.") +} + +// writeNextSteps says how to use each tool just configured. "4 added" +// answers what was done and not the question a member is left holding: and +// now what. +func writeNextSteps(b *strings.Builder, indent string, written []string) { + b.WriteString("\n" + indent + ui.Eyebrow("Now open them") + "\n\n") + steps := map[string]tools.Tool{} + for _, t := range tools.Registry() { + steps[t.Name] = t + } + rows := make([][2]string, 0, len(written)) + for _, name := range written { + if t, ok := steps[name]; ok && t.Open != "" { + rows = append(rows, [2]string{ui.Mono(t.Open), t.NextStep}) + } + } + b.WriteString(ui.KVBlock(rows, indent) + "\n") +} + +// writeFailures names each tool that could not be configured, by the first +// line of what went wrong, and what to do about it. +func writeFailures(b *strings.Builder, indent string, failed []tools.Failure) { + if len(failed) == 0 { + return + } + b.WriteString("\n") + for _, f := range failed { + b.WriteString(indent + ui.Error(fmt.Errorf("%s: %s", f.Tool, f.FirstLine())) + "\n") + } + b.WriteString(indent + ui.Faint("The rest went in. Fix that one and run setup again, or carry on without it -") + "\n") + b.WriteString(indent + ui.Faint("its page on nan.builders has the manual steps.") + "\n") +} diff --git a/internal/flow/setup_test.go b/internal/flow/setup_test.go new file mode 100644 index 0000000..abe70f7 --- /dev/null +++ b/internal/flow/setup_test.go @@ -0,0 +1,89 @@ +package flow + +import ( + "context" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +// The Setup flow had the same binding bug as the sign-in: huh wrote the typed +// key into a copy of the model that Bubble Tea had already thrown away, so +// what reached the check was an empty string. An empty key is refused by the +// inference API, so a correctly pasted key was reported as a bad one. + +func driveSetup(t *testing.T, s Setup, msgs ...tea.Msg) Setup { + t.Helper() + ctx := context.Background() + + queue := append([]tea.Msg{}, msgs...) + for i := 0; len(queue) > 0 && i < 200; i++ { + msg := queue[0] + queue = queue[1:] + + var cmd tea.Cmd + s, cmd = s.Update(ctx, msg, nil) + + // Stop before anything that would reach the network or write a file. + if s.stage == setupChecking || s.stage == setupApplying { + return s + } + if cmd == nil { + continue + } + done := make(chan tea.Msg, 1) + go func() { done <- cmd() }() + select { + case m := <-done: + if m != nil { + queue = append(queue, m) + } + case <-time.After(40 * time.Millisecond): + // The cursor's blink. Nothing here waits on a timer. + } + } + return s +} + +func TestThePastedKeySurvivesTheModelBeingCopied(t *testing.T) { + var s Setup + s.Start("", "") + s = driveSetup(t, s, typed("sk-abc123")...) + + if s.stage != setupChecking { + t.Fatalf("the flow is at stage %d after submitting a key, want setupChecking", s.stage) + } + if s.key != "sk-abc123" { + t.Fatalf("the flow kept %q as the key to check, want %q - "+ + "an empty one is refused and reported as a bad key", s.key, "sk-abc123") + } +} + +// Re-running Setup on a machine that already has a key opens on it, so it is +// a confirmation rather than a retype — and pressing enter straight away has +// to submit THAT key, not an empty one. +func TestTheExistingKeyIsSeededAndSubmittable(t *testing.T) { + var s Setup + s.Start("sk-alreadyhere", "") + s = driveSetup(t, s, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if s.stage != setupChecking { + t.Fatalf("stage is %d after accepting the saved key, want setupChecking", s.stage) + } + if s.key != "sk-alreadyhere" { + t.Errorf("the saved key came through as %q", s.key) + } +} + +// An empty key never reaches the network: there is nothing to check and the +// answer would be a misleading "that key is not valid". +func TestAnEmptyKeyNeverLeaves(t *testing.T) { + var s Setup + s.Start("", "") + s = driveSetup(t, s, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if s.stage == setupChecking { + t.Error("the flow tried to check an empty key") + } +} diff --git a/internal/flow/signin.go b/internal/flow/signin.go new file mode 100644 index 0000000..933214f --- /dev/null +++ b/internal/flow/signin.go @@ -0,0 +1,290 @@ +// Package flow holds the two interactive flows the dashboard runs: signing +// in, and pointing this machine's tools at NaN. +// +// They live apart from the dashboard because they are flows of their own, +// and a second copy is how two screens that are supposed to be the same +// program start disagreeing about what an expired link says. Each is a small +// Bubble Tea model - Init, Update, View - so a parent embeds it, forwards +// messages while it is active, and reads the result. +package flow + +import ( + "context" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/ui" +) + +type signInStage int + +const ( + signInOff signInStage = iota + signInEmail + signInSending + signInLink + signInVerifying + signInDone +) + +// SignIn is the magic-link flow: ask for an address, send a link, take it +// back. nan.builders has no password, so this is the first thing anybody +// meets and the only thing that works before it is done. +type SignIn struct { + stage signInStage + form *huh.Form + email string + err error + // notice is said above the address, for a machine that already holds a + // credential the dashboard cannot use. + notice string + + // Token is the session the platform issued, once the flow is done. + Token string +} + +// linkSent and signedIn are this flow's own messages. A parent forwards +// everything while the flow is active, so they never need to be exported. +type linkSentMsg struct{ err error } + +type signedInMsg struct { + token string + err error +} + +// NewSignIn returns a flow that is not running yet. +func NewSignIn() SignIn { return SignIn{} } + +// Active reports whether the flow owns the screen. +func (s SignIn) Active() bool { return s.stage != signInOff && s.stage != signInDone } + +// Done reports whether it finished and left a session behind. +func (s SignIn) Done() bool { return s.stage == signInDone } + +// TokenOnlyNotice is what the sign-in says on a machine that has a platform +// token and no session: the token is real and working, and still not +// something the dashboard can use, which is otherwise a mystery. +const TokenOnlyNotice = "the platform token on this machine works for nan run and nan runs only; " + + "the dashboard needs the email sign-in" + +// Start opens the flow on the email step. notice, when there is one, is said +// above the address. +func (s *SignIn) Start(notice string) tea.Cmd { + *s = SignIn{stage: signInEmail, notice: notice} + s.form = emailForm(s.email) + return s.form.Init() +} + +// The keys the fields are read back by. +// +// By KEY, never by binding a pointer with huh's Value(&x). Bubble Tea hands +// Update a copy of the model and keeps what it returns, so a pointer into one +// of the model's fields points into a copy that has already been discarded: +// the form writes the address there and the model that is actually running +// reads its own field and finds it empty. The form itself is a pointer and +// survives the copying, so asking IT is the only reading that stays true. +const ( + fieldEmail = "email" + fieldLink = "link" +) + +func emailForm(previous string) *huh.Form { + // Seeded, not bound: the value is read back by key (see fieldEmail), and + // this local only fills the field when somebody comes back to fix a + // typo in the address they already typed. + value := previous + return ui.NewForm(huh.NewGroup( + huh.NewInput(). + Key(fieldEmail). + Value(&value). + Title("Sign in to nan.builders"). + Description("Your email address - a link goes there. nan.builders has no password."). + Placeholder("you@example.com"). + // Validated in the field, so a typo is caught before an email is + // spent on it rather than after. + Validate(api.ValidateEmail), + )) +} + +func linkForm(client *api.Client) *huh.Form { + return ui.NewForm(huh.NewGroup( + huh.NewInput(). + Key(fieldLink). + Title("Paste the link"). + Description("It works once - do not open it in your browser first, the browser would spend it."). + Placeholder("https://nan.builders/...?token=..."). + // Dots, not the link: it carries a credential, and a screen is + // shared, recorded and screenshotted more often than not. + EchoMode(huh.EchoModePassword). + // The host check runs here, so a link that is not ours is refused + // in the field rather than after its token has been posted. + Validate(func(v string) error { + _, err := client.TokenFromLink(v) + return err + }), + )) +} + +// Resize rewraps the open form, if there is one, for a terminal of w by h. +func (s *SignIn) Resize(w, h int) { ui.FitForm(s.form, w, h) } + +// Update drives the flow. The parent forwards every message while Active. +func (s SignIn) Update(ctx context.Context, msg tea.Msg, client *api.Client) (SignIn, tea.Cmd) { + switch msg := msg.(type) { + case linkSentMsg: + if msg.err != nil { + s.err = msg.err + s.stage = signInEmail + s.form = emailForm(s.email) + return s, s.form.Init() + } + s.err = nil + s.stage = signInLink + s.form = linkForm(client) + return s, s.form.Init() + + case signedInMsg: + if msg.err != nil { + // Back to the link, not to the start: the email has been spent + // and asking for another to fix a bad paste is a worse flow. + s.err = msg.err + s.stage = signInLink + s.form = linkForm(client) + return s, s.form.Init() + } + s.err = nil + s.Token = msg.token + s.stage = signInDone + return s, nil + + case tea.KeyPressMsg: + if msg.String() == "esc" { + // From the link step, esc goes back to the address rather than + // out of the sign-in altogether. The one thing this flow cannot + // tell you is whether the address was right - the platform says + // "sent" for every address, on purpose, so that it cannot be + // used to find out who has an account - and a mistyped one is + // the likeliest reason a link never arrives. + if s.stage == signInLink { + s.stage = signInEmail + s.err = nil + s.form = emailForm(s.email) + return s, s.form.Init() + } + return SignIn{}, nil + } + } + + if s.form == nil || s.stage == signInSending || s.stage == signInVerifying { + return s, nil + } + + form, cmd := s.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + s.form = f + } + + switch s.form.State { + case huh.StateAborted: + return SignIn{}, nil + case huh.StateCompleted: + switch s.stage { + case signInEmail: + value := strings.TrimSpace(s.form.GetString(fieldEmail)) + s.email = value + s.stage = signInSending + return s, requestLink(ctx, client, value) + case signInLink: + token, err := client.TokenFromLink(strings.TrimSpace(s.form.GetString(fieldLink))) + if err != nil { + s.err = err + s.form = linkForm(client) + return s, s.form.Init() + } + s.stage = signInVerifying + return s, verify(ctx, client, token) + } + } + return s, cmd +} + +func requestLink(ctx context.Context, client *api.Client, email string) tea.Cmd { + return func() tea.Msg { + return linkSentMsg{err: client.RequestMagicLink(ctx, email)} + } +} + +func verify(ctx context.Context, client *api.Client, token string) tea.Cmd { + return func() tea.Msg { + session, err := client.Verify(ctx, token) + return signedInMsg{token: session, err: err} + } +} + +// Label is what the loader should say for the step the flow is on. The +// parent owns the animation — one loader for the whole program, so it does +// not restart every time a screen changes — and asks the flow what it is +// waiting for. +func (s SignIn) Label() string { + switch s.stage { + case signInSending: + return "Sending the link" + case signInVerifying: + return "Checking the link" + default: + return "Loading" + } +} + +// View renders the flow. `spinner` is the parent's rendered loader, so the +// whole program animates as one thing. +func (s SignIn) View(indent, spinner string, apiHost string) string { + var b strings.Builder + + switch s.stage { + case signInEmail: + if s.notice != "" { + b.WriteString(indent + ui.Warn(s.notice) + "\n\n") + } + b.WriteString(s.form.View() + "\n") + + case signInSending: + b.WriteString(indent + spinner + "\n") + + case signInLink: + s.linkSent(&b, indent, apiHost) + + b.WriteString("\n" + s.form.View() + "\n") + + case signInVerifying: + b.WriteString(indent + spinner + "\n") + } + + if s.err != nil { + b.WriteString("\n" + indent + ui.Error(s.err) + "\n") + } + return b.String() +} + +// linkSent is what the link step says once a real email is on its way. +func (s SignIn) linkSent(b *strings.Builder, indent, apiHost string) { + b.WriteString(indent + ui.Success("A link is on its way to "+s.email) + "\n") + // Said plainly, because nothing downstream can: the platform answers + // the same for an address with no account behind it. + // Two short lines, not one long one: this screen is drawn outside the + // panes, so nothing clips it, and a line the terminal has to wrap is + // a row the inline renderer never counted. + b.WriteString(indent + ui.Faint("Copy it out of the email without opening it: it works once, and expires") + "\n") + b.WriteString(indent + ui.Faint(api.LinkValidity+" after it is sent. It only arrives if that address has an account.") + "\n") + b.WriteString(indent + ui.Faint("Nothing after a minute? Check spam, or esc to fix the address.") + "\n") + // Which platform, when it is not the real one. "A link is on its + // way" with no host on it is unfalsifiable: production answers 202 + // to any address, so a shell that has lost NAN_API_URL gives the + // identical line and no email. + if apiHost != "" { + b.WriteString(indent + ui.Faint("Sent to "+apiHost+" - not the real nan.builders.") + "\n") + } +} diff --git a/internal/flow/signin_test.go b/internal/flow/signin_test.go new file mode 100644 index 0000000..232d994 --- /dev/null +++ b/internal/flow/signin_test.go @@ -0,0 +1,127 @@ +package flow + +import ( + "context" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/nxssie/nan-cli/internal/api" +) + +// Bubble Tea hands Update a COPY of the model and keeps what it returns, so a +// pointer into one of the model's fields — which is how huh's Value(&x) binds +// — points into a copy that has already been discarded. The form writes the +// typed address there; the copy that is actually running reads its own field +// and finds it empty. +// +// That is not a subtle failure. It sent {"email":""} to the control plane, +// which answers invalid_request, which this CLI renders as "this CLI sent +// something the control plane could not read — please report it". Somebody +// typing their address correctly was told the program was broken. It was. + +// drive is the part of the Bubble Tea runtime these flows need: run the +// command an update returned, and feed its message back in. +// +// Commands run on their own goroutine with a deadline, exactly because the +// runtime does not wait for them either. Every keystroke into a text field +// returns the cursor's blink — a tick half a second out — and waiting on +// those in turn is thirteen seconds of test for a form with an email in it. +func drive(t *testing.T, s SignIn, client *api.Client, msgs ...tea.Msg) SignIn { + t.Helper() + ctx := context.Background() + + queue := append([]tea.Msg{}, msgs...) + for i := 0; len(queue) > 0 && i < 200; i++ { + msg := queue[0] + queue = queue[1:] + + var cmd tea.Cmd + s, cmd = s.Update(ctx, msg, client) + + // Stop at the first stage that would talk to the network: what these + // tests check is what the flow decided to send, not the sending. + if s.stage == signInSending || s.stage == signInVerifying || cmd == nil { + if s.stage == signInSending || s.stage == signInVerifying { + return s + } + continue + } + + done := make(chan tea.Msg, 1) + go func() { done <- cmd() }() + select { + case m := <-done: + if m != nil { + queue = append(queue, m) + } + case <-time.After(40 * time.Millisecond): + // A timer, not an answer. The runtime would deliver it later; + // nothing here is waiting on one. + } + } + return s +} + +func typed(text string) []tea.Msg { + msgs := make([]tea.Msg, 0, len(text)+1) + for _, r := range text { + msgs = append(msgs, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + return append(msgs, tea.KeyPressMsg{Code: tea.KeyEnter}) +} + +func TestTheTypedEmailSurvivesTheModelBeingCopied(t *testing.T) { + client := api.New("") + + s := NewSignIn() + s.Start("") + s = drive(t, s, client, typed("dev@acme.com")...) + + if s.stage != signInSending { + t.Fatalf("the flow is at stage %d after submitting an address, want signInSending", s.stage) + } + if s.email != "dev@acme.com" { + t.Fatalf("the flow kept %q as the address to send to, want %q - "+ + "an empty one reaches the control plane as invalid_request", s.email, "dev@acme.com") + } +} + +// The pasted link was bound the same way and failed the same way: read as +// empty, and posted as empty. +func TestThePastedLinkSurvivesTheModelBeingCopied(t *testing.T) { + client := api.New("") + + s := NewSignIn() + s.Start("") + s = drive(t, s, client, typed("dev@acme.com")...) + + // The link step opens when the send comes back. + s = drive(t, s, client, linkSentMsg{}) + if s.stage != signInLink { + t.Fatalf("stage is %d after the link was sent, want signInLink", s.stage) + } + + s = drive(t, s, client, typed("https://nan.builders/auth/verify?token=abc123")...) + if s.stage != signInVerifying { + t.Fatalf("stage is %d after pasting a good link, want signInVerifying - "+ + "a link read as empty is refused in the field instead of being spent", s.stage) + } +} + +// An address that is not one never reaches the network: the field refuses it, +// so no email is spent on a typo and the control plane is not asked a +// question with an obvious answer. +func TestAnAddressThatIsNotOneNeverLeaves(t *testing.T) { + client := api.New("") + + for _, bad := range []string{"notanemail", "y@y", "", " "} { + s := NewSignIn() + s.Start("") + s = drive(t, s, client, typed(bad)...) + if s.stage == signInSending { + t.Errorf("the flow tried to send a link to %q", bad) + } + } +} diff --git a/internal/flow/validate_test.go b/internal/flow/validate_test.go new file mode 100644 index 0000000..0d29e9d --- /dev/null +++ b/internal/flow/validate_test.go @@ -0,0 +1,248 @@ +package flow + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/nxssie/nan-cli/internal/api" +) + +// fakeCluster is the inference API's model list, which is what decides +// whether a key works. +func fakeCluster(t *testing.T, good string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + w.WriteHeader(http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer "+good { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"data":[{"id":"glm5.3"},{"id":"glm5.3-flash"}]}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("NAN_INFERENCE_URL", srv.URL+"/v1") +} + +func checked(t *testing.T, s Setup, key string) Setup { + t.Helper() + t.Setenv("HOME", t.TempDir()) // no tools found, so the flow finishes + msg, ok := checkKey(context.Background(), key)().(keyCheckedMsg) + if !ok { + t.Fatal("checkKey did not return a keyCheckedMsg") + } + s.key = key + s, _ = s.Update(context.Background(), msg, nil) + return s +} + +func TestAKeyTheClusterAcceptsGoesThrough(t *testing.T) { + fakeCluster(t, "sk-good") + var s Setup + s.Start("", "") + s = checked(t, s, "sk-good") + if s.err != nil || s.Key != "sk-good" || s.models != 2 { + t.Fatalf("err=%v key=%q models=%d", s.err, s.Key, s.models) + } +} + +// A refused key goes back to an empty field under the error: writing it +// anywhere would only move the failure into every tool. +func TestARefusedKeyOpensTheFieldAgain(t *testing.T) { + fakeCluster(t, "sk-good") + var s Setup + s.Start("", "") + s = checked(t, s, "sk-wrong") + if s.stage != setupKey || s.Key != "" { + t.Fatalf("stage=%d key=%q, want back on the key with nothing to save", s.stage, s.Key) + } + if !strings.Contains(s.View("", ""), "the cluster refused this key") { + t.Errorf("the refusal is not said:\n%s", s.View("", "")) + } +} + +// A key that could not be checked because the cluster could not be reached +// is let through, and the summary says it was not checked. +func TestAKeyThatCannotBeCheckedIsSavedAndSaidSo(t *testing.T) { + t.Setenv("NAN_INFERENCE_URL", "http://127.0.0.1:1/v1") + var s Setup + s.Start("", "") + s = checked(t, s, "sk-offline") + if s.Key != "sk-offline" || s.unchecked == nil { + t.Fatalf("key=%q unchecked=%v", s.Key, s.unchecked) + } + if !strings.Contains(prose(s.View("", "")), "could not be checked") { + t.Errorf("the summary does not say the key was not checked:\n%s", s.View("", "")) + } +} + +// What the key step says about the member's key on the platform. The +// platform never hands the key itself back, only whether there is one. +func TestTheKeyHintSaysWhereTheKeyIs(t *testing.T) { + cases := []struct { + status *api.KeyStatus + want string + }{ + {&api.KeyStatus{Exists: true, Alias: "an-alias"}, "has a key (an-alias) - copy it from cloud.nan.builders"}, + {&api.KeyStatus{Exists: true, Name: "sk-name"}, "has a key (sk-name)"}, + {&api.KeyStatus{}, "no key yet - create one at cloud.nan.builders"}, + {nil, ""}, + } + for _, c := range cases { + if got := KeyHint(c.status); !strings.Contains(got, c.want) || (c.want == "" && got != "") { + t.Errorf("KeyHint(%+v) = %q, want %q", c.status, got, c.want) + } + } + var s Setup + s.Start("", KeyHint(&api.KeyStatus{})) + if !strings.Contains(prose(s.View("", "")), "create one at cloud.nan.builders") { + t.Error("the key step does not show the hint") + } +} + +// The key never appears on screen, typed or seeded. +func TestTheKeyIsNeverShown(t *testing.T) { + var s Setup + s.Start("sk-secret-on-screen", "") + if strings.Contains(s.View("", ""), "sk-secret-on-screen") { + t.Error("the saved key is on screen") + } +} + +// A machine with only a platform token is told why the dashboard still +// asks for an email. +func TestTheSignInExplainsAPlatformToken(t *testing.T) { + s := NewSignIn() + s.Start(TokenOnlyNotice) + if !strings.Contains(prose(s.View("", "", "")), "works for nan run and nan runs only") { + t.Errorf("the notice is not said:\n%s", s.View("", "", "")) + } +} + +// The first-run screen says why it is asking, and is skippable. +func TestOnboardingExplainsItselfAndCanBeSkipped(t *testing.T) { + var s Setup + s.Onboard("", Guide{Keys: [][2]string{{"tab", "into the pane"}}}) + // Why the key matters, that it can be skipped, and where it goes later. + for _, want := range []string{"One more step", "set up the coding tools", "esc skips it", "6 Harnesses", "nan setup"} { + if !strings.Contains(prose(s.View("", "")), want) { + t.Errorf("the onboarding screen does not say %q:\n%s", want, s.View("", "")) + } + } + + // esc skips the key but still ends on how to get around: somebody who + // skipped is exactly who is about to be left alone with the dashboard. + s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: tea.KeyEscape}, nil) + if !s.Active() { + t.Fatal("esc on the first run closed it before showing the tour") + } + view := prose(s.View("", "")) + for _, want := range []string{"Skipped: no key on this machine yet", "cannot reach NaN", "6 Harnesses", "tab into the pane", "any key to start"} { + if !strings.Contains(view, want) { + t.Errorf("the skipped first run does not say %q:\n%s", want, view) + } + } + if s.Key != "" { + t.Error("skipping left a key to save") + } + + s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: 'x', Text: "x"}, nil) + if s.Active() { + t.Error("a key on the tour did not close it") + } +} + +// Outside a first run, esc is simply out: no tour for somebody who pressed e +// in a dashboard they already know. +func TestEscFromARerunLeavesStraightAway(t *testing.T) { + var s Setup + s.Start("", "") + s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: tea.KeyEscape}, nil) + if s.Active() { + t.Error("esc from a re-run of Setup did not leave") + } +} + +// prose is a screen as text: the text wraps to the terminal, so a phrase can +// straddle a line break and the field's border. +func prose(view string) string { + return strings.Join(strings.Fields(strings.ReplaceAll(ansi.Strip(view), "┃", "")), " ") +} + +// From the link step, esc goes back to the address - filled in - rather +// than out of the sign-in. A mistyped address is the likeliest reason a +// link never arrives, and nothing else can tell you it was mistyped. +func TestEscFromTheLinkStepGoesBackToTheAddress(t *testing.T) { + client := api.New("") + s := NewSignIn() + s.Start("") + s = drive(t, s, client, typed("dev@acme.com")...) + s = drive(t, s, client, linkSentMsg{}) + if s.stage != signInLink { + t.Fatalf("not at the link step: %d", s.stage) + } + + s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: tea.KeyEscape}, client) + if s.stage != signInEmail { + t.Fatalf("esc from the link step left the flow at stage %d, want the email step", s.stage) + } + if !strings.Contains(s.form.View(), "dev@acme.com") { + t.Error("the address was not filled back in") + } + + // From the email step, esc still leaves. + s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: tea.KeyEscape}, client) + if s.Active() { + t.Error("esc from the email step did not leave the sign-in") + } +} + +// The summary says "Key saved", and the key has to still be there when it +// is dismissed: the dashboard saves once the flow closes, which is on that +// keypress. Dismissing used to hand back an empty flow and the key was lost. +func TestDismissingTheSummaryHandsBackTheKey(t *testing.T) { + s := Setup{stage: setupDone, Key: "sk-2b8d0000aaaa441e", Tools: map[string]bool{"Pi": true}} + s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: 'x', Text: "x"}, nil) + if s.Active() { + t.Fatal("a key on the summary did not close it") + } + if s.Key != "sk-2b8d0000aaaa441e" || !s.Tools["Pi"] { + t.Errorf("closing the summary lost what to save: key=%q tools=%v", s.Key, s.Tools) + } +} + +// A tour on offer is a question with two answers: enter takes it, s skips +// it, and a stray key is neither. The shortcuts are shown either way. +func TestTheTourOfferTakesOnlyItsTwoAnswers(t *testing.T) { + offer := func() Setup { + var s Setup + s.Onboard("", Guide{Keys: [][2]string{{"tab", "next area"}}, Tour: true}) + s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: tea.KeyEscape}, nil) + return s + } + s := offer() + view := prose(s.View("", "")) + for _, want := range []string{"tab next area", "enter take the tour", "s skip it"} { + if !strings.Contains(view, want) { + t.Errorf("the offer does not say %q:\n%s", want, view) + } + } + if s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: 'x', Text: "x"}, nil); !s.Active() { + t.Error("a stray key answered the offer") + } + if s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: tea.KeyEnter}, nil); s.Active() || !s.TourRequested { + t.Errorf("enter did not take the tour: active=%v requested=%v", s.Active(), s.TourRequested) + } + s = offer() + if s, _ = s.Update(context.Background(), tea.KeyPressMsg{Code: 's', Text: "s"}, nil); s.Active() || s.TourRequested { + t.Errorf("s did not skip the tour: active=%v requested=%v", s.Active(), s.TourRequested) + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 4ed05f0..36f669f 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -13,7 +13,7 @@ // the platform moves one, this is the file to change. package models -// What the model is for. Everything except Chat is here so the Models tab can +// Kind is what the model is for. Everything except Chat is here so the Models tab can // say what an id is; only Chat models go into the tool configs, because a // coding agent has nothing to do with a reranker. type Kind string diff --git a/internal/pulse/areas.go b/internal/pulse/areas.go new file mode 100644 index 0000000..eeae291 --- /dev/null +++ b/internal/pulse/areas.go @@ -0,0 +1,220 @@ +package pulse + +import ( + "fmt" + "sort" + + "github.com/nxssie/nan-cli/internal/ui" +) + +// Areas: the inventory, always on screen. +// +// Every management TUI people keep open - lazygit, lazydocker, k9s - answers +// "what is there, and where" the same way: the list of places is the +// navigation, it is always visible, and each place has a number. So here: +// seven areas down the left, 1 to 7, and whatever is selected on the right. + +type area int + +const ( + areaHome area = iota + areaProfile + areaUsage + areaModels + areaCosts + areaHarness + areaAbout + areaCount +) + +func (a area) title() string { + return [...]string{"Home", "Profile", "Usage", "Models", "Costs", "Harnesses", "About"}[a] +} + +// needsSession is whether an area is empty without the email sign-in. +// Models is not: a key alone can ask the cluster what it serves. +func (a area) needsSession() bool { + return a == areaProfile || a == areaUsage || a == areaCosts +} + +// row is one selectable line in an area: what it shows, what it is, and +// what can be done to it. +type row struct { + cells []string + // id identifies the thing, so a selection survives a reload and a + // signal can land on it. + id string + // what the row is: at most one of these is set. + model string + field string + sig *signal + quick *quickAction + newModel string + // dim draws the row faint: a model with no traffic. + dim bool +} + +// quickAction is a Home shortcut into another area, or into something to do. +type quickAction struct { + label string + hint string + in area + do action +} + +// columns are an area's table headings and their share of the width. +type column struct { + title string + // width is a fixed width, or 0 to take what is left. + width int + right bool +} + +func (a area) columns() []column { + switch a { + case areaProfile: + return []column{{title: "Field", width: 22}, {title: "Value"}} + case areaUsage: + return []column{{title: "Model"}, {title: "24 hours", width: 9, right: true}, {title: "30 days", width: 9, right: true}, {title: "All time", width: 9, right: true}, {title: "Share · 30 days", width: 18}} + case areaModels: + return []column{{title: "Model"}, {title: "Kind", width: 20}, {title: "30 days", width: 9, right: true}, {title: "24 hours", width: 9, right: true}} + } + return nil +} + +// rows builds an area's selectable lines from the snapshot. +func (m model) rows(a area) []row { + s := m.snap + switch a { + case areaHome: + var out []row + for i := range m.sigs { + sg := m.sigs[i] + out = append(out, row{id: "sig:" + sg.say, sig: &sg}) + } + for _, name := range m.newModels { + out = append(out, row{id: "new:" + name, newModel: name}) + } + for _, q := range m.quickActions() { + out = append(out, row{id: "do:" + q.label, quick: &q}) + } + return out + + case areaProfile: + keys := make([]string, 0, len(s.me)) + for k, v := range s.me { + // A nested object is the platform's own structure, not a fact + // about the member; it would print as a Go map. + if _, nested := v.(map[string]any); nested { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + out := make([]row, 0, len(keys)) + for _, k := range keys { + out = append(out, row{id: "field:" + k, field: k, cells: []string{humanKey(k), censor(k, fmt.Sprintf("%v", s.me[k]))}}) + } + return out + + case areaUsage: + if s.usage == nil { + return nil + } + total30 := float64(s.usage.Last30d.TotalTokens) + var out []row + for _, name := range s.usedModels() { + t24, t30, all := tokensIn(s.usage.Last24h, name), tokensIn(s.usage.Last30d, name), tokensIn(s.usage.AllTime, name) + share := "" + if total30 > 0 && t30 > 0 { + f := t30 / total30 + share = ui.Bar(f*m.reveal, 10, false) + " " + ui.Percent(f) + } + out = append(out, row{ + id: name, model: name, + cells: []string{name, tokensOrDash(t24), tokensOrDash(t30), tokensOrDash(all), share}, + dim: t30 == 0, + }) + } + return out + + case areaModels: + var last24, last30 float64 + var out []row + for _, mi := range s.sortedModels() { + if s.usage != nil { + last24, last30 = tokensIn(s.usage.Last24h, mi.id), tokensIn(s.usage.Last30d, mi.id) + } + out = append(out, row{ + id: mi.id, model: mi.id, + cells: []string{mi.id, kindBadge(mi.kind), tokensOrDash(last30), tokensOrDash(last24)}, + dim: last30 == 0 && last24 == 0, + }) + } + return out + } + return nil +} + +func tokensOrDash(n float64) string { + if n == 0 { + return "-" + } + return ui.Tokens(n) +} + +// quickActions are Home's shortcuts, for what people come here to do. +func (m model) quickActions() []quickAction { + var out []quickAction + if !m.session.SignedIn() { + out = append(out, quickAction{label: "Sign in", hint: "s, a link goes to your email", do: actSignIn}) + } + out = append(out, + quickAction{label: "Set up your tools", hint: "e, the key and the tools that use it", in: areaHarness, do: actSetup}, + quickAction{label: "See what you have spent", hint: "3", in: areaUsage}, + quickAction{label: "Compare it with other providers", hint: "5", in: areaCosts}, + ) + return out +} + +// areaOf is where a signal's or a search result's target lives. +func areaOf(t target) area { + switch t.kind { + case cardModel: + return areaModels + case cardUsage: + return areaUsage + case cardHarness: + return areaHarness + case cardProfile: + return areaProfile + } + return areaHome +} + +// count is the number shown beside an area in the navigation. +func (m model) count(a area) string { + s := m.snap + switch a { + case areaHome: + if n := len(m.sigs); n > 0 { + return fmt.Sprint(n) + } + case areaModels: + if n := len(s.models); n > 0 { + return fmt.Sprint(n) + } + case areaHarness: + installed := s.installedTools() + configured := 0 + for _, t := range installed { + if t.Configured { + configured++ + } + } + if len(installed) > 0 { + return fmt.Sprintf("%d/%d", configured, len(installed)) + } + } + return "" +} diff --git a/internal/pulse/cards.go b/internal/pulse/cards.go new file mode 100644 index 0000000..0105670 --- /dev/null +++ b/internal/pulse/cards.go @@ -0,0 +1,132 @@ +package pulse + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + catalog "github.com/nxssie/nan-cli/internal/models" + "github.com/nxssie/nan-cli/internal/ui" +) + +// Cards: what enter shows about one thing, over the area it lives in. + +type cardKind int + +const ( + cardModel cardKind = iota + 1 + cardUsage + cardHarness + cardProfile + cardSignIn + cardUpdate +) + +// link is a way from one card to another. +type link struct { + label string + hint string + to target +} + +type card struct { + title string + body string + links []link +} + +func buildCard(s snapshot, t target, width int, reveal float64) card { + switch t.kind { + case cardModel, cardUsage: + return modelCard(s, t.id, width, reveal) + } + return card{title: "?", body: ui.Faint("Nothing here.")} +} + +func kindLabel(k cardKind) string { + if k == cardModel || k == cardUsage { + return "Model" + } + return "" +} + +// figure is a number with what it counts under it. +func figure(value, label string) string { + t := ui.Current() + return lipgloss.NewStyle().Foreground(t.Text).Bold(true).Render(value) + "\n" + + lipgloss.NewStyle().Foreground(t.TextFaint).Render(label) +} + +// figures sets figures side by side, each in an equal share of the width. +func figures(width int, cells ...string) string { + if len(cells) == 0 { + return "" + } + w := max(width/len(cells), 12) + styled := make([]string, len(cells)) + for i, c := range cells { + styled[i] = cutPad(c, w-2) + " " + } + return lipgloss.JoinHorizontal(lipgloss.Top, styled...) +} + +// modelCard is a model: what the catalogue knows of it, and what the member +// spent on it in each window, input and output apart. +func modelCard(s snapshot, id string, width int, reveal float64) card { + c := card{title: id} + var b strings.Builder + + if m, ok := catalog.Get(id); ok { + b.WriteString(kindBadge(kindOf(id)) + " " + ui.Faint(m.Name) + "\n") + if m.Context > 0 { + facts := []string{ + ui.Count(float64(m.Context)) + " tokens of context", + ui.Count(float64(m.Output)) + " out", + "takes " + strings.Join(m.Inputs, ", "), + } + if m.Reasoning { + facts = append(facts, "reasons") + } + b.WriteString(ui.Faint(strings.Join(facts, " · ")) + "\n") + } + b.WriteString("\n") + } else { + b.WriteString(kindBadge(kindOf(id)) + "\n\n") + } + + found := false + for _, p := range s.periods() { + for _, mu := range p.ByModel { + if mu.Model != id || mu.Total() == 0 { + continue + } + found = true + b.WriteString(ui.Faint(p.title) + "\n") + total := float64(p.TotalTokens) + share := 0.0 + if total > 0 { + share = mu.Total() / total + } + b.WriteString(ui.Bar(share*reveal, barWidth(width), false) + " " + ui.Value(ui.Percent(share)) + + ui.Faint(fmt.Sprintf(" %s tokens · %s in, %s out", ui.Tokens(mu.Total()), ui.Tokens(float64(mu.InputTokens)), ui.Tokens(float64(mu.OutputTokens)))) + "\n\n") + } + } + if !found { + b.WriteString(ui.Faint("No usage on this model yet.") + "\n") + } + c.body = b.String() + return c +} + +func barWidth(width int) int { return min(max(width-40, 10), 40) } + +// cutPad cuts a block to width, padding every line to it. +func cutPad(block string, width int) string { + lines := strings.Split(strings.TrimRight(block, "\n"), "\n") + for i, l := range lines { + lines[i] = ui.Pad(ansi.Truncate(l, width, "…"), width) + } + return strings.Join(lines, "\n") +} diff --git a/internal/pulse/costs.go b/internal/pulse/costs.go new file mode 100644 index 0000000..8e522ed --- /dev/null +++ b/internal/pulse/costs.go @@ -0,0 +1,200 @@ +package pulse + +import ( + "fmt" + "image/color" + "strings" + "time" + + "charm.land/lipgloss/v2" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/ui" +) + +// Costs is what a member's NaN usage would have cost on other providers. + +type providerPricing struct { + model string + provider string + inPer1M float64 // $ per 1M input tokens + outPer1M float64 // $ per 1M output tokens +} + +// Per 1M tokens, read off each vendor's own pricing page on 2026-09-14, and +// the Anthropic and OpenAI rows again on 2026-09-30 when both had newer +// models out: Opus 5.5 and Sonnet 5.5 for Opus 5 and Sonnet 5, GPT-6.1 Sol and +// GPT-6 Luna for their 5.6 namesakes. Terra stays at 5.6 because OpenAI has +// not shipped a GPT-6 one. Each is the price of the model a member would +// reach for today. +// +// Ten rows, for the spread rather than the count: this multiplies a member's +// usage by each of them, so a table of only mid-range models answers only +// the mid-range question. From Luna at $0.10 in to Astra and Fable at $10, a +// reader can find the row that matches what they would actually have used. +// +// Some rows carry a condition the table cannot express, so each is taken at +// its lowest published rate and the comparison stays conservative: the GPT-6 +// and GPT-5.6 rows cost more above a 272k-token prompt, Gemini 3.1 Pro costs +// $4/$18 above a 200k-token prompt, and Gemini 3.8 Flash is on a promotional +// rate that doubles on 2027-01-01. TestGeminiFlashPromoHasNotExpired fails on +// that date so the number is changed rather than forgotten. +var pricingTable = []providerPricing{ + {"Claude Fable 5.1", "Anthropic", 10.00, 50.00}, + {"Claude Opus 5.5", "Anthropic", 4.00, 20.00}, + {"Claude Sonnet 5.5", "Anthropic", 2.00, 10.00}, + {"Claude Haiku 4.5", "Anthropic", 1.00, 5.00}, + {"GPT-6 Astra", "OpenAI", 10.00, 50.00}, + {"GPT-6.1 Sol", "OpenAI", 2.00, 10.00}, + {"GPT-5.6 Terra", "OpenAI", 2.00, 12.00}, + {"GPT-6 Luna", "OpenAI", 0.10, 0.50}, + {"Gemini 3.1 Pro", "Google", 2.00, 12.00}, + {"Gemini 3.8 Flash", "Google", 0.75, 3.75}, +} + +// geminiFlashPromoEnds is the day Gemini 3.8 Flash stops being half price. +var geminiFlashPromoEnds = time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC) + +var providerColor = map[string]color.Color{ + "Anthropic": lipgloss.Color("#F97316"), + "OpenAI": lipgloss.Color("#10B981"), + "Google": lipgloss.Color("#3B82F6"), +} + +// calcCost prices a period's tokens. Input and output are summed over the +// models, because that is what a price applies to. +func calcCost(p api.Period, price providerPricing) float64 { + in, out := p.Sum() + return in/1_000_000*price.inPer1M + out/1_000_000*price.outPer1M +} + +// fmtCost is a dollar amount with thousands separators. Go has no `%,` +// verb: that format once printed `$%!,(float64=1234.5).2f` on every figure +// over a thousand. +func fmtCost(v float64) string { + s := fmt.Sprintf("%.2f", v) + dot := len(s) - 3 + whole, dec := s[:dot], s[dot:] + var b strings.Builder + for i, c := range whole { + if i > 0 && (len(whole)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(c) + } + return "$" + b.String() + dec +} + +// costsNote is said under the table: why none of this is a bill. +const costsNote = "Your usage is included in your membership. No per-token charges." + +func (m model) renderCosts(w int) string { + t := ui.Current() + var b strings.Builder + b.WriteString("\n" + m.eyebrow("Cost comparison") + "\n") + + // The longest that fits: measured rather than guessed. + subtitle := "" + for _, candidate := range []string{ + "Estimated cost based on your actual input/output tokens on other providers.", + "Estimated cost of your usage on other providers.", + "Estimated cost elsewhere.", + } { + subtitle = candidate + if lipgloss.Width(candidate) <= w { + break + } + } + b.WriteString(ui.Faint(subtitle) + "\n\n") + + s := m.snap + if s.usage == nil { + return b.String() + } + t24, t30, all := s.usage.Last24h, s.usage.Last30d, s.usage.AllTime + in30, out30 := t30.Sum() + inAll, outAll := all.Sum() + showAll := w >= 64 && (inAll != in30 || outAll != out30) + // Below this the provider word goes: it is the widest part of the label + // and the least load-bearing, since each provider has its own colour. + showProvider := w >= 64 + + type line struct{ plain, styled, c24, c30, call string } + lines := make([]line, len(pricingTable)) + nameW, c24W, c30W, callW := len("Provider"), len("24 hours"), len("30 days"), len("All time") + for i, p := range pricingTable { + pc := providerColor[p.provider] + l := line{ + plain: p.model, + styled: lipgloss.NewStyle().Foreground(pc).Bold(true).Render(p.model), + c24: fmtCost(calcCost(t24, p) * m.reveal), + c30: fmtCost(calcCost(t30, p) * m.reveal), + call: fmtCost(calcCost(all, p) * m.reveal), + } + if showProvider { + l.plain = p.model + " " + p.provider + l.styled = lipgloss.NewStyle().Foreground(t.Text).Bold(true).Render(p.model) + + " " + lipgloss.NewStyle().Foreground(pc).Render(p.provider) + } + nameW, c24W = max(nameW, lipgloss.Width(l.plain)), max(c24W, len(l.c24)) + c30W, callW = max(c30W, len(l.c30)), max(callW, len(l.call)) + lines[i] = l + } + nameW += 2 + + head := ui.Pad("Provider", nameW) + " " + ui.PadLeft("24 hours", c24W) + " " + ui.PadLeft("30 days", c30W) + width := nameW + 2 + c24W + 2 + c30W + if showAll { + head += " " + ui.PadLeft("All time", callW) + width += 2 + callW + } + b.WriteString(" " + ui.Faint(head) + "\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(t.Line).Render(strings.Repeat("─", width)) + "\n") + + num := lipgloss.NewStyle().Foreground(t.TextDim) + last := lipgloss.NewStyle().Foreground(t.Text).Bold(true) + // One line each, and a blank line only where the provider changes: the + // gaps group the rows by who charges them. + for i, l := range lines { + if i > 0 && pricingTable[i].provider != pricingTable[i-1].provider { + b.WriteString("\n") + } + text := l.styled + strings.Repeat(" ", max(nameW-lipgloss.Width(l.plain), 0)) + " " + num.Render(ui.PadLeft(l.c24, c24W)) + if showAll { + text += " " + num.Render(ui.PadLeft(l.c30, c30W)) + " " + last.Render(ui.PadLeft(l.call, callW)) + } else { + text += " " + last.Render(ui.PadLeft(l.c30, c30W)) + } + b.WriteString(" " + text + "\n") + } + + // The note, boxed, and wrapped rather than run off a narrow terminal. + noteW := min(lipgloss.Width("NaN "+costsNote), max(w-6, 8)) + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Line).Padding(0, 1).Width(noteW + 4) + note := lipgloss.NewStyle().Foreground(t.AccentText).Bold(true).Render("NaN") + " " + ui.Faint(costsNote) + b.WriteString("\n" + ui.Indent(box.Render(note), " ") + "\n") + return b.String() +} + +// kindBadge colours a model's kind the same way everywhere it is shown. +func kindBadge(kind string) string { + c, ok := kindColor[kind] + if !ok { + c = ui.Current().TextFaint + } + return lipgloss.NewStyle().Foreground(c).Render(kind) +} + +var kindColor = map[string]color.Color{ + "text generation": lipgloss.Color("#3B82F6"), + "audio speech": lipgloss.Color("#8B5CF6"), + "audio transcription": lipgloss.Color("#F59E0B"), + "embedding": lipgloss.Color("#10B981"), + "chat": lipgloss.Color("#3B82F6"), + "chat · premium": lipgloss.Color("#A78BFA"), + "rerank": lipgloss.Color("#10B981"), + "text to speech": lipgloss.Color("#8B5CF6"), + "speech to text": lipgloss.Color("#F59E0B"), + "image": lipgloss.Color("#EC4899"), + "unknown": lipgloss.Color("#71717A"), +} diff --git a/internal/pulse/data.go b/internal/pulse/data.go new file mode 100644 index 0000000..e2b42b2 --- /dev/null +++ b/internal/pulse/data.go @@ -0,0 +1,241 @@ +package pulse + +import ( + "context" + "errors" + "sort" + "strings" + "sync" + + "github.com/nxssie/nan-cli/internal/api" + catalog "github.com/nxssie/nan-cli/internal/models" + "github.com/nxssie/nan-cli/internal/tools" +) + +// snapshot is everything the dashboard knows, fetched together. +// +// One load, not one per area: Home is built from all of it at once - the +// session, the key, the tools, the usage - so a page that loaded piecemeal +// would spend its first seconds saying nothing needs you, and then change +// its mind. +type snapshot struct { + me api.Me + usage *api.Usage + keyStatus *api.KeyStatus + models []modelInfo + // tools is every supported tool and its state on this machine, read + // here rather than on every frame: it means reading their configs. + tools []tools.Status + + // release is the newest CLI release, when it was asked for this load; + // askedRelease says whether it was, since "no answer" is also one. + release *api.Release + askedRelease bool + + // token is the session this load was made with, so a refusal that + // arrives after a newer sign-in does not end the newer session. + token string + // expired is the platform refusing that session. + expired bool + // keyRefused is the cluster refusing the API key on this machine. + keyRefused bool + + // err is the first thing that failed. Everything that did arrive is + // still shown: a dashboard with no usage is more use than none. + err error +} + +// modelInfo is one model as Models lists it: its id and what kind it is. +type modelInfo struct { + id string + kind string +} + +type loadedMsg struct{ snap snapshot } + +// load fetches the snapshot, every call at once. With no session it fetches +// what a key alone can: the models. +func load(ctx context.Context, c *api.Client, apiKey string, askRelease bool) snapshot { + var ( + s = snapshot{token: c.Token()} + mu sync.Mutex + wg sync.WaitGroup + keep = func(err error) { + mu.Lock() + defer mu.Unlock() + switch { + case err == nil: + case errors.Is(err, api.ErrSessionExpired): + s.expired = true + case s.err == nil: + s.err = err + } + } + ) + run := func(f func()) { + wg.Add(1) + go func() { defer wg.Done(); f() }() + } + if c.Token() != "" { + run(func() { me, err := c.Me(ctx); keep(err); s.me = me }) + run(func() { u, err := c.Usage(ctx); keep(err); s.usage = u }) + // The key's status only says where to find a key, so a failure to + // read it is not worth saying. + run(func() { k, _ := c.KeyStatus(ctx); s.keyStatus = k }) + } + run(func() { + models, refused, err := loadModels(ctx, c, apiKey) + keep(err) + s.models, s.keyRefused = models, refused + }) + run(func() { s.tools = tools.Detect() }) + if askRelease { + s.askedRelease = true + run(func() { r, _ := api.LatestRelease(ctx); s.release = r }) + } + wg.Wait() + return s +} + +// loadModels asks the cluster which models the key can call - the list the +// docs call definitive - and falls back on the platform's own list when +// there is no key to ask with. +func loadModels(ctx context.Context, c *api.Client, apiKey string) ([]modelInfo, bool, error) { + if apiKey != "" { + ids, err := api.ListModels(ctx, apiKey) + if errors.Is(err, api.ErrKeyRefused) { + return nil, true, err + } + if err != nil { + return nil, false, err + } + out := make([]modelInfo, 0, len(ids)) + for _, id := range ids { + out = append(out, modelInfo{id: id, kind: kindOf(id)}) + } + return out, false, nil + } + if c.Token() == "" { + return nil, false, nil + } + list, err := c.AgentModels(ctx) + if err != nil { + return nil, false, err + } + out := make([]modelInfo, 0, len(list)) + for _, m := range list { + kind := modeLabel[m.Mode] + if kind == "" { + kind = strings.ReplaceAll(m.Mode, "_", " ") + } + out = append(out, modelInfo{id: m.Name, kind: kind}) + } + return out, false, nil +} + +// kindOf is what the catalogue says an id is, or "unknown": worth showing +// rather than hiding, because that is how a model nobody documented gets +// noticed. +func kindOf(id string) string { + m, ok := catalog.Get(id) + if !ok { + return "unknown" + } + if m.Premium { + return string(m.Kind) + " · premium" + } + return string(m.Kind) +} + +// The platform's modes, in words. +var modeLabel = map[string]string{ + "": "text generation", + "audio_speech": "audio speech", + "audio_transcription": "audio transcription", + "embedding": "embedding", +} + +// ── derived views of the snapshot ──────────────────────────────────────── + +// periods are the usage windows, in the order they are shown. +func (s snapshot) periods() []namedPeriod { + if s.usage == nil { + return nil + } + return []namedPeriod{ + {"Last 24 hours", "24h", s.usage.Last24h}, + {"Last 30 days", "30 days", s.usage.Last30d}, + {"All time", "all time", s.usage.AllTime}, + } +} + +type namedPeriod struct { + title string + short string + api.Period +} + +// tokens is one model's total in a period. +func tokensIn(p api.Period, model string) float64 { + for _, m := range p.ByModel { + if m.Model == model { + return m.Total() + } + } + return 0 +} + +// usedModels is every model with usage in any period, biggest over thirty +// days first, then by name. +func (s snapshot) usedModels() []string { + if s.usage == nil { + return nil + } + seen := map[string]bool{} + var out []string + for _, p := range []api.Period{s.usage.Last30d, s.usage.Last24h, s.usage.AllTime} { + for _, m := range p.ByModel { + if m.Model != "" && !seen[m.Model] { + seen[m.Model] = true + out = append(out, m.Model) + } + } + } + sort.SliceStable(out, func(a, b int) bool { + ta, tb := tokensIn(s.usage.Last30d, out[a]), tokensIn(s.usage.Last30d, out[b]) + if ta != tb { + return ta > tb + } + return out[a] < out[b] + }) + return out +} + +// sortedModels is Models' list: thirty days of usage first, then by name. +func (s snapshot) sortedModels() []modelInfo { + out := append([]modelInfo(nil), s.models...) + var last30 api.Period + if s.usage != nil { + last30 = s.usage.Last30d + } + sort.SliceStable(out, func(a, b int) bool { + ta, tb := tokensIn(last30, out[a].id), tokensIn(last30, out[b].id) + if ta != tb { + return ta > tb + } + return out[a].id < out[b].id + }) + return out +} + +// modelIDs is every model the key can call, for spotting new ones. +func (s snapshot) modelIDs() []string { + out := make([]string, 0, len(s.models)) + for _, m := range s.models { + out = append(out, m.id) + } + return out +} + +// installedTools is the supported tools found on this machine. +func (s snapshot) installedTools() []tools.Status { return tools.Installed(s.tools) } diff --git a/internal/pulse/keys.go b/internal/pulse/keys.go new file mode 100644 index 0000000..3c5c39e --- /dev/null +++ b/internal/pulse/keys.go @@ -0,0 +1,64 @@ +package pulse + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// The keymap. The verbs are one letter each and mean the same thing in every +// area; which of them the footer offers depends on where you are, read from +// the same list the menu and the handler use. +// +// s signs in and o signs out, as they always have here; e opens Setup from +// anywhere, because "paste your key" is what a member is told to do from +// whichever area they are looking at. +type keyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Area key.Binding + NextArea key.Binding + PrevArea key.Binding + Find key.Binding + Reload key.Binding + SignIn key.Binding + SignOut key.Binding + Setup key.Binding + Tour key.Binding + Help key.Binding + Quit key.Binding + + Copy key.Binding +} + +func newKeyMap() keyMap { + return keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "move")), + Down: key.NewBinding(key.WithKeys("down", "j")), + Enter: key.NewBinding(key.WithKeys("enter", "right", "l"), key.WithHelp("enter", "actions")), + Back: key.NewBinding(key.WithKeys("esc", "left", "h"), key.WithHelp("esc", "back")), + Area: key.NewBinding(key.WithKeys("1", "2", "3", "4", "5", "6", "7"), key.WithHelp("1-7", "areas")), + NextArea: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next area")), + PrevArea: key.NewBinding(key.WithKeys("shift+tab")), + Find: key.NewBinding(key.WithKeys("/", "ctrl+k"), key.WithHelp("/", "find")), + Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")), + SignIn: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sign in")), + SignOut: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "sign out")), + Setup: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "set up")), + Tour: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tour")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "all keys")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + + Copy: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy")), + } +} + +// verbFor is the verb a key press asks for, if any. e is not here: it is +// Setup from anywhere, a global key rather than a row's verb. +func (k keyMap) verbFor(msg tea.KeyPressMsg) verb { + if key.Matches(msg, k.Copy) { + return verbCopy + } + return verbNone +} diff --git a/internal/pulse/modal.go b/internal/pulse/modal.go new file mode 100644 index 0000000..539baf8 --- /dev/null +++ b/internal/pulse/modal.go @@ -0,0 +1,101 @@ +package pulse + +import ( + "charm.land/bubbles/v2/key" +) + +// Verbs, and the modals they open. +// +// One letter per verb and the same letter everywhere, the way lazygit does +// it. Enter on a row opens a menu of exactly what that row allows, with the +// letters beside them, so the letters are learned by using the menu rather +// than by reading about them. + +type verb int + +const ( + verbNone verb = iota + verbDetails + verbEdit + verbCopy +) + +// menuItem is a line in the enter menu. +type menuItem struct { + label string + key string + verb verb +} + +// verbs is what can be done in an area, on its selected row. The one list +// the menu, the footer and the key handler all read, so a shortcut that is +// offered and a shortcut that works are the same thing. +func (m model) verbs(a area, r *row) []menuItem { + var out []menuItem + add := func(label, k string, v verb) { out = append(out, menuItem{label: label, key: k, verb: v}) } + + switch a { + case areaUsage, areaModels: + if r != nil { + add("Details", "enter", verbDetails) + add("Copy the id", "y", verbCopy) + } + case areaHarness: + add("Set up the tools", "e", verbEdit) + case areaHome: + if r != nil && r.newModel != "" { + add("Add it to your tools", "e", verbEdit) + } + } + return out +} + +// has reports whether a verb is available here. +func (m model) has(v verb) bool { + for _, it := range m.verbs(m.area, m.selectedRow()) { + if it.verb == v { + return true + } + } + return false +} + +// ── modals ─────────────────────────────────────────────────────────────── + +type modalKind int + +const ( + modalNone modalKind = iota + modalMenu + modalLink +) + +type modal struct { + kind modalKind + + // menu + items []menuItem + cursor int + + // link + url string + title string + note string + copied bool +} + +func (m *model) openMenu() { + items := m.verbs(m.area, m.selectedRow()) + if len(items) == 0 { + return + } + m.modal = modal{kind: modalMenu, items: items} +} + +// menuKeys are the keys inside the enter menu. +var menuKeys = struct{ up, down, pick, close key.Binding }{ + up: key.NewBinding(key.WithKeys("up", "k")), + down: key.NewBinding(key.WithKeys("down", "j")), + pick: key.NewBinding(key.WithKeys("enter")), + close: key.NewBinding(key.WithKeys("esc", "q")), +} diff --git a/internal/pulse/motion.go b/internal/pulse/motion.go new file mode 100644 index 0000000..42f0bce --- /dev/null +++ b/internal/pulse/motion.go @@ -0,0 +1,67 @@ +package pulse + +import "math" + +// Motion: what moves, and why it is allowed to. +// +// Everything here is driven by one number, reveal, which a harmonica spring +// takes from 0 to 1 whenever something new comes on screen: the first load, +// a change of area, a card opening. Figures count up to their value, bars +// grow to their length, the sparkline rises, and a table's rows decode from +// the top down - each name starting as noise and resolving left to right. +// +// It is short (the spring settles in well under a second) and it only runs +// when what is on screen changed. That is the line between motion that says +// "this is new" and motion that is just in the way: nothing moves while you +// are reading, and nothing moves twice for the same thing. + +// noise is what an undecoded character is drawn as. Lower case and digits: +// the shape of a name or a key, so the column does not jump when it lands. +const noise = "abcdefghijklmnopqrstuvwxyz0123456789" + +// decode draws s with the first progress fraction of it real and the rest +// noise. Spaces and punctuation stay put, so the words' outline is there +// from the first frame and only the letters resolve. frame reshuffles the +// noise, so it shimmers rather than sitting still. +func decode(s string, progress float64, frame int) string { + if progress >= 1 { + return s + } + runes := []rune(s) + cut := int(math.Floor(math.Max(progress, 0) * float64(len(runes)))) + out := make([]rune, len(runes)) + for i, r := range runes { + switch { + case i < cut: + out[i] = r + case !isLetterOrDigit(r): + out[i] = r + default: + // A cheap mix of position, frame and letter: different every + // frame, the same for the same inputs, so a test can pin it. + x := (i*7919 + frame*104729 + int(r)*31) * 2654435761 + x ^= x >> 15 + out[i] = rune(noise[(x&0x7fffffff)%len(noise)]) + } + } + return string(out) +} + +func isLetterOrDigit(r rune) bool { + return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' +} + +// stagger is row i's own progress when the whole table is at reveal: each +// row starts a little after the one above, so the decoding runs down the +// table as a wave rather than every row resolving at once. +// +// The delay stops growing after the first few rows, so a long table still +// lands in the same short time, and a settled reveal resolves every row: +// the wave is how things arrive, never a state they can be left in. +func stagger(reveal float64, i int) float64 { + const lead, step, rows = 1.6, 0.09, 6 + if reveal >= 1 { + return 1 + } + return math.Min(math.Max(reveal*lead-float64(min(i, rows))*step, 0), 1) +} diff --git a/internal/pulse/motion_test.go b/internal/pulse/motion_test.go new file mode 100644 index 0000000..b5bc864 --- /dev/null +++ b/internal/pulse/motion_test.go @@ -0,0 +1,65 @@ +package pulse + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +// Decoding never changes the width of a line - a column that jumps while it +// resolves is worse than no effect - and keeps the outline of the words. +func TestDecodingKeepsTheShapeOfTheName(t *testing.T) { + name := "ci-runner prod.2" + for _, p := range []float64{0, 0.3, 0.7} { + got := decode(name, p, 3) + if len([]rune(got)) != len([]rune(name)) { + t.Fatalf("decode at %v changed the length: %q", p, got) + } + for i, r := range name { + if !isLetterOrDigit(r) && rune(got[i]) != r { + t.Errorf("decode at %v moved the %q at %d: %q", p, r, i, got) + } + } + } + if got := decode(name, 0, 3); got == name { + t.Error("nothing was scrambled at the start") + } + if got := decode(name, 1, 3); got != name { + t.Errorf("a finished decode is %q", got) + } + // Left to right: what has resolved is the start of the name. + if got := decode(name, 0.5, 9); got[:8] != name[:8] { + t.Errorf("the first half has not resolved: %q", got) + } +} + +// The wave runs down the table: a row never resolves before the one above. +func TestTheWaveRunsTopDown(t *testing.T) { + for _, r := range []float64{0.1, 0.4, 0.8, 1} { + for i := 1; i < 12; i++ { + if stagger(r, i) > stagger(r, i-1) { + t.Fatalf("at %v row %d is ahead of row %d", r, i, i-1) + } + } + } + if stagger(1, 11) != 1 { + t.Error("a settled reveal leaves rows undecoded") + } +} + +// Changing area is new content, and it arrives: the reveal starts again. +// Moving the cursor is not, and nothing moves. +func TestAChangeOfAreaAnimatesAndMovingDoesNot(t *testing.T) { + m, _ := demoModel(t, 130, 40) + next, cmd := m.Update(keyOf("2")) + m = next.(model) + if !m.animating || m.reveal != 0 || cmd == nil { + t.Fatalf("switching area did not start the reveal: animating=%v reveal=%v", m.animating, m.reveal) + } + + m.reveal, m.animating = 1, false + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m = next.(model); m.animating { + t.Error("moving the cursor started the reveal") + } +} diff --git a/internal/pulse/palette.go b/internal/pulse/palette.go new file mode 100644 index 0000000..1f4f28c --- /dev/null +++ b/internal/pulse/palette.go @@ -0,0 +1,146 @@ +package pulse + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" + "github.com/sahilm/fuzzy" + + "github.com/nxssie/nan-cli/internal/ui" +) + +// The palette: one box that finds anything. +// +// Type what you are thinking of - "glm", "costs", "setup" - and the thing +// itself comes back, whatever kind it is. Actions live in the same list, so +// "setup" finds the setup and nobody has to learn a key for something they +// do once a month. + +// action is something the palette does rather than somewhere it goes. +type action int + +const ( + actNone action = iota + actSetup + actSignIn + actSignOut + actReload + actQuit +) + +// entry is one thing that can be found. +type entry struct { + kind string // what it is, shown as a tag: model, area, do + name string // what it is called, and what is matched + hint string // one faint fact, so two similar names can be told apart + open target + in area + do action + match string // name plus kind, lower-cased, for matching +} + +// entries is everything the palette can find. +func (m model) entries() []entry { + s := m.snap + var out []entry + add := func(e entry) { + e.match = strings.ToLower(e.name + " " + e.kind) + out = append(out, e) + } + for _, mi := range s.sortedModels() { + hint := mi.kind + if s.usage != nil { + if n := tokensIn(s.usage.Last30d, mi.id); n > 0 { + hint += " · " + ui.Tokens(n) + " tokens in 30 days" + } + } + add(entry{kind: "model", name: mi.id, hint: hint, open: target{kind: cardModel, id: mi.id}, in: areaModels}) + } + for a := area(0); a < areaCount; a++ { + add(entry{kind: "area", name: a.title(), in: a}) + } + add(entry{kind: "do", name: "Set up your tools", hint: "API key and tools", do: actSetup}) + if m.session.SignedIn() { + add(entry{kind: "do", name: "Sign out", hint: "also takes the key out of the tools", do: actSignOut}) + } else { + add(entry{kind: "do", name: "Sign in", hint: "a link goes to your email", do: actSignIn}) + } + add(entry{kind: "do", name: "Reload everything", do: actReload}) + add(entry{kind: "do", name: "Quit", do: actQuit}) + return out +} + +// palette is the open palette: a text input and what it matches. +type palette struct { + input textinput.Model + all []entry + shown []entry + cursor int + visible bool +} + +func newPalette() palette { + in := textinput.New() + in.Prompt = "/ " + in.Placeholder = "a model, an area, or something to do" + t := ui.Current() + st := in.Styles() + st.Focused.Prompt = lipgloss.NewStyle().Foreground(t.AccentText).Bold(true) + st.Focused.Text = lipgloss.NewStyle().Foreground(t.Text) + st.Focused.Placeholder = lipgloss.NewStyle().Foreground(t.TextFaint) + in.SetStyles(st) + return palette{input: in} +} + +// open shows the palette over s, empty. +func (p *palette) open(all []entry) { + p.all = all + p.input.SetValue("") + p.input.Focus() + p.visible = true + p.filter() +} + +func (p *palette) close() { + p.visible = false + p.input.Blur() +} + +// filter recomputes what the query matches. Fuzzy, so "glm" finds glm5.3 and +// "mfig" finds marta.figueroa; an empty query lists everything as it came. +func (p *palette) filter() { + q := strings.ToLower(strings.TrimSpace(p.input.Value())) + if q == "" { + p.shown = p.all + } else { + words := make([]string, len(p.all)) + for i, e := range p.all { + words[i] = e.match + } + matches := fuzzy.Find(q, words) + p.shown = make([]entry, 0, len(matches)) + for _, m := range matches { + p.shown = append(p.shown, p.all[m.Index]) + } + } + if p.cursor >= len(p.shown) { + p.cursor = max(len(p.shown)-1, 0) + } +} + +// move steps the cursor, wrapping. +func (p *palette) move(delta int) { + if len(p.shown) == 0 { + return + } + p.cursor = (p.cursor + delta + len(p.shown)) % len(p.shown) +} + +// selected is the entry under the cursor. +func (p palette) selected() (entry, bool) { + if p.cursor < 0 || p.cursor >= len(p.shown) { + return entry{}, false + } + return p.shown[p.cursor], true +} diff --git a/internal/pulse/profile.go b/internal/pulse/profile.go new file mode 100644 index 0000000..78b3b3c --- /dev/null +++ b/internal/pulse/profile.go @@ -0,0 +1,67 @@ +package pulse + +import "strings" + +// Profile shows the member's record as the platform sends it, and these two +// decide how a field reads: its name in words, and its value shortened when +// it identifies somebody. + +// sensitivePatterns are the field names whose values are cut short. A +// screen is shared, recorded and screenshotted more often than not, and an +// id or an address is the part of a profile nobody needs to see whole. +var sensitivePatterns = []string{ + "id", "token", "secret", "key", "password", + "email", "mail", "session", "auth", "hash", + "discord", "guild", "user", +} + +func isSensitive(key string) bool { + lower := strings.ToLower(key) + for _, p := range sensitivePatterns { + if strings.Contains(lower, p) { + return true + } + } + return false +} + +// censor keeps the first six characters of a sensitive value: enough to +// recognise it, not enough to use it. +func censor(key, value string) string { + if !isSensitive(key) { + return value + } + const keep = 6 + runes := []rune(value) + if len(runes) <= keep { + return "..." + } + return string(runes[:keep]) + "..." +} + +// humanKey turns a field name into words: "userUUID" is "User UUID", not +// "User U U I D". A run of capitals is one word, and it ends where a +// lowercase letter starts a new one. +func humanKey(s string) string { + runes := []rune(s) + var out []rune + for i, r := range runes { + if i > 0 && isUpper(r) { + startsWord := !isUpper(runes[i-1]) + endsRun := i+1 < len(runes) && !isUpper(runes[i+1]) + if startsWord || endsRun { + out = append(out, ' ') + } + } + out = append(out, r) + } + s = string(out) + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "-", " ") + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' } diff --git a/internal/pulse/pulse.go b/internal/pulse/pulse.go new file mode 100644 index 0000000..6f8334c --- /dev/null +++ b/internal/pulse/pulse.go @@ -0,0 +1,805 @@ +// Package pulse is the dashboard for a nan.builders member. +// +// Seven areas down the left - Home, Profile, Usage, Models, Costs, +// Harnesses, About - always on screen and numbered, so what exists and where +// it lives is never a question. Home says what needs attention and offers +// what people come here to do; the other areas are lists, and enter on a row +// opens a menu of exactly what that row allows. +// +// Built from what the TUIs people keep open have in common - lazygit's +// always-visible, numbered places and stable verbs, lazydocker's action menu, +// soft-serve's copy over OSC 52 - and the same structure as the Helmcode +// CLI's dashboard, so the two are maintained the same way. +package pulse + +import ( + "context" + "math" + "os" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/harmonica" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/flow" + "github.com/nxssie/nan-cli/internal/session" + "github.com/nxssie/nan-cli/internal/tools" + "github.com/nxssie/nan-cli/internal/ui" + "github.com/nxssie/nan-cli/internal/update" +) + +// guide is how a first run ends here. +var guide = [][2]string{ + {"1-7 tab", "the areas: Home, Profile, Usage, Models, Costs, Harnesses, About"}, + {"↑/↓ enter", "pick a row, and see what you can do with it"}, + {"e", "the API key and the tools that use it, from anywhere"}, + {"/", "find anything by name"}, +} + +type model struct { + ctx context.Context + client *api.Client + session *session.Session + + w, h int + + keys keyMap + help help.Model + loader ui.Loader + + loading bool + loaded bool + snap snapshot + sigs []signal + + area area + cursor [areaCount]int + // picked is the id under each area's cursor, so a reload puts the + // cursor back on the same thing rather than on whatever now sits at the + // same index. + picked [areaCount]string + + // stack is the cards open over the current area; links the cursor on + // each card's links. + stack []target + links []int + + modal modal + pal palette + + // flash is the result of the last action, for a few seconds. + flash string + flashBad bool + flashID int + + signIn flow.SignIn + setup flow.Setup + + // confirmSignOut is the first o: signing out takes the key back out of + // every tool, so it takes a second press. signingOut is the platform + // being asked to end the session. + confirmSignOut bool + signingOut bool + // expiredNotice is the platform having refused the session: said on + // Home until the member signs in again. + expiredNotice bool + + spring harmonica.Spring + reveal float64 + velocity float64 + animating bool + // frame counts animation ticks, to shimmer the decoding noise. + frame int + + // tour is the guided tour's step, from 1, or 0 when it is not running. + tour int + + // upd is what is remembered between runs: the models known, the latest + // version (internal/update). newModels is what appeared since the last + // run, kept for the whole visit. + upd update.State + newModels []string + + quitting bool +} + +type ( + animTickMsg struct{} + flashDoneMsg struct{ id int } + // signedOutMsg is the platform's answer to ending the session; err is + // nil when it confirmed. + signedOutMsg struct{ err error } +) + +const ( + animFPS = 60 + flashFor = 6 * time.Second + navWidth = 20 + cardWidth = 88 +) + +func newModel(ctx context.Context, client *api.Client, sess *session.Session) model { + h := help.New() + h.Styles = ui.HelpStyles() + return model{ + ctx: ctx, + client: client, + session: sess, + keys: newKeyMap(), + help: h, + loader: ui.NewLoader("Reading your account"), + pal: newPalette(), + spring: harmonica.NewSpring(harmonica.FPS(animFPS), 7.0, 0.8), + reveal: 1, + upd: update.Load(), + } +} + +// Run opens the dashboard. +func Run(ctx context.Context, client *api.Client, sess *session.Session) error { + // The palette is chosen by asking the terminal for its background, which + // only works before Bubble Tea takes the input over. + _ = ui.Current() + m := newModel(ctx, client, sess) + // Started here, not in Init, which gets a copy of the model. + opening := m.opening() + p := tea.NewProgram(m) + go func() { p.Send(opening()) }() + _, err := p.Run() + return err +} + +// opening is what the first frame starts: the load, and whatever is +// missing before the dashboard is any use - the sign-in, then the key. +func (m *model) opening() tea.Cmd { + cmds := []tea.Cmd{m.startLoad()} + switch { + case !m.session.SignedIn(): + cmds = append(cmds, m.signIn.Start(m.notice())) + case m.session.APIKey == "": + cmds = append(cmds, m.setup.Onboard("", flow.Guide{Keys: guide, Tour: true})) + } + return tea.Batch(cmds...) +} + +// notice is what the sign-in says above the address, on a machine that has +// a credential the dashboard cannot use. +func (m model) notice() string { + if m.session.PlatformToken != "" || os.Getenv(session.TokenEnvVar) != "" { + return flow.TokenOnlyNotice + } + return "" +} + +func (m model) Init() tea.Cmd { return m.loader.Tick() } + +func (m *model) startLoad() tea.Cmd { + m.loading = true + ctx, c, key := m.ctx, m.client, m.session.APIKey + // The latest version at most once a day: most runs ask nothing extra. + ask := m.upd.DueForCheck(time.Now()) + return tea.Batch(m.loader.Tick(), func() tea.Msg { return loadedMsg{snap: load(ctx, c, key, ask)} }) +} + +func (m *model) animate() tea.Cmd { + m.reveal, m.velocity = 0, 0 + if m.animating { + return nil + } + m.animating = true + return animTick() +} + +func animTick() tea.Cmd { + return tea.Tick(time.Second/animFPS, func(time.Time) tea.Msg { return animTickMsg{} }) +} + +// say puts a result on the flash line, and clears it after a while. +func (m *model) say(text string, bad bool) tea.Cmd { + m.flash, m.flashBad = text, bad + m.flashID++ + id := m.flashID + return tea.Tick(flashFor, func(time.Time) tea.Msg { return flashDoneMsg{id: id} }) +} + +func (m model) inCard() bool { return len(m.stack) > 0 } + +// inFlow is whether a sign-in or a Setup has the screen. +func (m model) inFlow() bool { return m.signIn.Active() || m.setup.Active() } + +// selectedRow is the row under the current area's cursor. +func (m model) selectedRow() *row { + rows := m.rows(m.area) + i := m.cursor[m.area] + if i < 0 || i >= len(rows) { + return nil + } + return &rows[i] +} + +// Update is the entry point: the real update, then the reveal restarted if +// what is on screen changed - another area, a card opened or closed, a flow +// closing. In one place, so no path that changes the screen can forget it. +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + before, depth, flow := m.area, len(m.stack), m.inFlow() + next, cmd := m.update(msg) + nm, ok := next.(model) + if ok { + nm.advanceTour() + next = nm + } + closed := flow && !nm.inFlow() + if !ok || !nm.loaded || nm.area == before && len(nm.stack) == depth && !closed { + return next, cmd + } + anim := nm.animate() + return nm, tea.Batch(cmd, anim) +} + +func (m model) update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.w, m.h = msg.Width, msg.Height + m.help.SetWidth(msg.Width - 4) + m.pal.input.SetWidth(max(msg.Width-10, 10)) + m.signIn.Resize(m.w, m.h-3) + m.setup.Resize(m.w, m.h-3) + return m, nil + + case ui.LoaderTickMsg: + if !m.loading && !m.signingOut && !m.inFlow() { + return m, nil + } + var cmd tea.Cmd + m.loader, cmd = m.loader.Update(msg) + return m, cmd + + case animTickMsg: + m.frame++ + m.reveal, m.velocity = m.spring.Update(m.reveal, m.velocity, 1) + if math.Abs(1-m.reveal) < 0.002 && math.Abs(m.velocity) < 0.002 { + m.reveal, m.velocity, m.animating = 1, 0, false + return m, nil + } + return m, animTick() + + case flashDoneMsg: + if msg.id == m.flashID { + m.flash = "" + } + return m, nil + + case loadedMsg: + return m.takeLoad(msg.snap) + + case signedOutMsg: + return m.finishSignOut(msg.err) + } + + // ctrl+c leaves from anywhere, a form included: every other key may be + // somebody typing, this one never is. + if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + + if m.signIn.Active() { + next, cmd := m.signIn.Update(m.ctx, msg, m.client) + m.signIn = next + if m.signIn.Done() { + return m.afterSignIn() + } + return m, cmd + } + if m.setup.Active() { + next, cmd := m.setup.Update(m.ctx, msg, m.session.EnabledTools) + m.setup = next + if !m.setup.Active() { + return m.afterSetup(cmd) + } + return m, cmd + } + + if k, ok := msg.(tea.KeyPressMsg); ok { + // The tour's own keys first. The closing line goes on the next key, + // which then does its usual job; x skips, except while typing a + // search, where it is a letter like any other. + if m.touring() { + switch { + case m.tour >= tourFinished: + m.stopTour() + case k.String() == "x" && !m.pal.visible: + m.stopTour() + return m, nil + } + } + // Any key but o lets go of a sign-out that was waiting for its + // second press. + if m.confirmSignOut && !key.Matches(k, m.keys.SignOut) { + m.confirmSignOut = false + m.flash = "" + } + switch { + case m.modal.kind != modalNone: + return m.onModalKey(k) + case m.pal.visible: + return m.onPaletteKey(k) + } + return m.onKey(k) + } + if m.pal.visible { + var cmd tea.Cmd + m.pal.input, cmd = m.pal.input.Update(msg) + return m, cmd + } + return m, nil +} + +// takeLoad takes a snapshot in. +func (m model) takeLoad(s snapshot) (tea.Model, tea.Cmd) { + // A refusal of a session that is no longer the one on this machine - an + // old load arriving after a new sign-in - says nothing about the new one. + if s.token != m.session.Token { + s.expired = false + } + first := !m.loaded + m.loading, m.loaded = false, true + m.snap = s + m.takeNote(s) + + var cmds []tea.Cmd + if s.expired { + // The session is gone on the platform; the key and the ticked tools + // are still the member's, so only the token goes. + m.session.Token = "" + _ = session.Save(m.session) + m.client = api.New("") + m.expiredNotice = true + switch { + case first && !m.inFlow(): + // On a machine opened on an expired session, the sign-in is the + // first thing anybody needs, so it opens on its own. + cmds = append(cmds, m.signIn.Start(m.notice())) + case !m.inFlow(): + // Later, it is said, and the keyboard is left alone: whoever is + // mid-way through something keeps doing it. + cmds = append(cmds, m.say(api.ErrSessionExpired.Error()+" - press s to sign in again", true)) + } + } + m.sigs = m.signals() + m.restoreCursors() + if first { + cmds = append(cmds, m.animate()) + } + return m, tea.Batch(cmds...) +} + +// restoreCursors puts every area's cursor back on the thing it was on. +func (m *model) restoreCursors() { + for a := area(0); a < areaCount; a++ { + rows := m.rows(a) + if id := m.picked[a]; id != "" { + for i, r := range rows { + if r.id == id { + m.cursor[a] = i + break + } + } + } + if m.cursor[a] >= len(rows) { + m.cursor[a] = max(len(rows)-1, 0) + } + } +} + +func (m *model) remember() { + if r := m.selectedRow(); r != nil { + m.picked[m.area] = r.id + } +} + +func (m model) afterSignIn() (tea.Model, tea.Cmd) { + m.session.Token = m.signIn.Token + _ = session.Save(m.session) + // A client still holding the token it started with sends no session, + // and the platform answers `unauthorized`: exactly what a failed + // sign-in looks like. + m.client = api.New(m.signIn.Token) + m.signIn = flow.NewSignIn() + m.expiredNotice = false + cmds := []tea.Cmd{m.startLoad()} + if m.session.APIKey == "" { + cmds = append(cmds, m.setup.Onboard(flow.KeyHint(m.snap.keyStatus), flow.Guide{Keys: guide, Tour: true})) + } + return m, tea.Batch(cmds...) +} + +func (m model) afterSetup(cmd tea.Cmd) (tea.Model, tea.Cmd) { + if m.setup.TourRequested { + m.startTour() + } + if m.setup.Key != "" { + m.session.APIKey = m.setup.Key + if m.setup.Tools != nil { + m.session.EnabledTools = m.setup.Tools + } + _ = session.Save(m.session) + } + m.setup = flow.NewSetup() + // What Setup just wrote is what Home and Harnesses should now say. + return m, tea.Batch(cmd, m.startLoad()) +} + +// startSetup opens Setup over the Harnesses area. +func (m *model) startSetup() tea.Cmd { + m.goTo(areaHarness) + return m.setup.Start(m.session.APIKey, flow.KeyHint(m.snap.keyStatus)) +} + +// goTo switches area. +func (m *model) goTo(a area) { + m.area = a + m.stack, m.links = nil, nil + if a == areaModels { + // Opening the list is seeing what is new in it. + m.seeNewModels() + } +} + +// signOut is o: the first press asks, the second does it. +func (m model) signOut() (tea.Model, tea.Cmd) { + if !m.session.SignedIn() || m.signingOut { + return m, nil + } + if !m.confirmSignOut { + m.confirmSignOut = true + return m, m.say("press o again to sign out: it also takes your key back out of the tools. Any other key keeps the session.", false) + } + m.confirmSignOut, m.signingOut = false, true + m.flash = "" + ctx, c := m.ctx, m.client + // The platform first, while the token is still here: deleting the file + // alone would leave any copy of the token working until it expires. + return m, tea.Batch(m.loader.Tick(), func() tea.Msg { return signedOutMsg{err: c.Logout(ctx)} }) +} + +// finishSignOut is the local half, whatever the platform said: the session +// file goes, and the key comes back out of every tool it was written into. +func (m model) finishSignOut(revokeErr error) (tea.Model, tea.Cmd) { + if !m.signingOut { + return m, nil // a stray answer, with no sign-out in flight + } + m.signingOut = false + _ = session.Delete() + removed, failed := tools.RemoveAll() + + m.session = &session.Session{} + m.client = api.New("") + m.snap, m.sigs, m.newModels = snapshot{}, nil, nil + m.expiredNotice = false + m.goTo(areaHome) + + msg, bad := "signed out", false + switch { + case len(failed) > 0: + names := make([]string, len(failed)) + for i, f := range failed { + names[i] = f.Tool + " (" + f.FirstLine() + ")" + } + msg, bad = "signed out, but the key is still in "+joinNames(names), true + case len(removed) > 0: + msg = "signed out · key removed from " + joinNames(removed) + } + if revokeErr != nil { + msg += " · the platform did not confirm the sign-out (" + revokeErr.Error() + "): the session stays valid until it expires" + bad = true + } + // Signed out is a fresh machine again, and a fresh machine is asked to + // sign in. + return m, tea.Batch(m.say(msg, bad), m.signIn.Start(""), m.startLoad()) +} + +func joinNames(names []string) string { + out := "" + for i, n := range names { + if i > 0 { + out += ", " + } + out += n + } + return out +} + +func (m model) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + return m, nil + case key.Matches(msg, m.keys.Tour) && m.loaded: + m.startTour() + return m, nil + case key.Matches(msg, m.keys.Find): + m.pal.open(m.entries()) + return m, nil + case key.Matches(msg, m.keys.Reload): + if !m.loading { + return m, m.startLoad() + } + return m, nil + case key.Matches(msg, m.keys.SignIn) && !m.session.SignedIn() && !m.signingOut: + return m, m.signIn.Start(m.notice()) + case key.Matches(msg, m.keys.SignOut): + return m.signOut() + case key.Matches(msg, m.keys.Setup): + return m, m.startSetup() + case key.Matches(msg, m.keys.Area): + m.goTo(area(msg.String()[0] - '1')) + return m, nil + case key.Matches(msg, m.keys.NextArea): + m.goTo((m.area + 1) % areaCount) + return m, nil + case key.Matches(msg, m.keys.PrevArea): + m.goTo((m.area + areaCount - 1) % areaCount) + return m, nil + } + + if m.inCard() { + return m.onCardKey(msg) + } + + switch { + case key.Matches(msg, m.keys.Up): + m.move(-1) + case key.Matches(msg, m.keys.Down): + m.move(1) + case key.Matches(msg, m.keys.Back): + if m.area != areaHome { + m.goTo(areaHome) + } + case key.Matches(msg, m.keys.Enter): + return m.onEnter() + default: + if v := m.keys.verbFor(msg); v != verbNone && m.has(v) { + return m.do(v) + } + } + return m, nil +} + +func (m *model) move(delta int) { + n := len(m.rows(m.area)) + if n == 0 { + return + } + m.cursor[m.area] = (m.cursor[m.area] + delta + n) % n + m.remember() +} + +// onEnter is what enter does where you are: follow a signal, run a Home +// shortcut, set up the machine, or open the row's menu. +func (m model) onEnter() (tea.Model, tea.Cmd) { + r := m.selectedRow() + switch { + case m.area == areaHarness: + return m, m.startSetup() + case r != nil && r.sig != nil: + return m.follow(r.sig.goTo) + case r != nil && r.newModel != "": + m.land(target{kind: cardModel, id: r.newModel}) + return m, nil + case r != nil && r.quick != nil: + return m.act(r.quick.do, r.quick.in) + } + m.openMenu() + return m, nil +} + +// follow goes where a signal points. +func (m model) follow(t target) (tea.Model, tea.Cmd) { + switch t.kind { + case cardSignIn: + if !m.session.SignedIn() { + return m, m.signIn.Start(m.notice()) + } + case cardHarness: + m.goTo(areaHarness) + case cardUpdate: + m.updateModal() + default: + m.land(t) + } + return m, nil +} + +// act runs a shortcut: something to do, or somewhere to go. +func (m model) act(a action, in area) (tea.Model, tea.Cmd) { + switch a { + case actSetup: + return m, m.startSetup() + case actSignIn: + if !m.session.SignedIn() { + return m, m.signIn.Start(m.notice()) + } + return m, nil + case actSignOut: + return m.signOut() + case actReload: + return m, m.startLoad() + case actQuit: + m.quitting = true + return m, tea.Quit + } + m.goTo(in) + return m, nil +} + +// land goes to where a target lives and puts the cursor on it. +func (m *model) land(t target) { + m.goTo(areaOf(t)) + for i, r := range m.rows(m.area) { + if r.id == t.id { + m.cursor[m.area] = i + m.picked[m.area] = t.id + } + } +} + +// do performs a verb on the current area and row. +func (m model) do(v verb) (tea.Model, tea.Cmd) { + r := m.selectedRow() + m.modal = modal{} + switch v { + case verbDetails: + if r != nil && r.model != "" { + kind := cardModel + if m.area == areaUsage { + kind = cardUsage + } + m.stack, m.links = []target{{kind: kind, id: r.model}}, []int{0} + return m, m.animate() + } + case verbEdit: + return m, m.startSetup() + case verbCopy: + if r != nil && r.model != "" { + return m, tea.Batch(tea.SetClipboard(r.model), m.say("Copied "+r.model+".", false)) + } + } + return m, nil +} + +func (m model) onModalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + md := &m.modal + switch md.kind { + case modalMenu: + switch { + case key.Matches(msg, menuKeys.up): + md.cursor = (md.cursor + len(md.items) - 1) % len(md.items) + case key.Matches(msg, menuKeys.down): + md.cursor = (md.cursor + 1) % len(md.items) + case key.Matches(msg, menuKeys.close): + m.modal = modal{} + case key.Matches(msg, menuKeys.pick): + return m.do(md.items[md.cursor].verb) + default: + // The letters shown in the menu work in it too. + if v := m.keys.verbFor(msg); v != verbNone { + for _, it := range md.items { + if it.verb == v { + return m.do(v) + } + } + } + if key.Matches(msg, m.keys.Setup) { + for _, it := range md.items { + if it.verb == verbEdit { + return m.do(verbEdit) + } + } + } + } + return m, nil + + case modalLink: + switch msg.String() { + case "y": + md.copied = true + return m, tea.SetClipboard(md.url) + case "enter", "esc", "q": + m.modal = modal{} + } + return m, nil + } + return m, nil +} + +// onCardKey moves through a card's links, follows them, and walks back. +func (m model) onCardKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + c := m.currentCard() + i := len(m.links) - 1 + switch { + case key.Matches(msg, m.keys.Up): + if n := len(c.links); n > 0 { + m.links[i] = (m.links[i] + n - 1) % n + } + case key.Matches(msg, m.keys.Down): + if n := len(c.links); n > 0 { + m.links[i] = (m.links[i] + 1) % n + } + case key.Matches(msg, m.keys.Enter): + if m.links[i] < len(c.links) { + m.stack = append(m.stack, c.links[m.links[i]].to) + m.links = append(m.links, 0) + return m, m.animate() + } + case key.Matches(msg, m.keys.Back): + m.stack, m.links = m.stack[:len(m.stack)-1], m.links[:len(m.links)-1] + case key.Matches(msg, m.keys.Copy): + id := m.stack[len(m.stack)-1].id + return m, tea.Batch(tea.SetClipboard(id), m.say("Copied "+id+".", false)) + } + return m, nil +} + +func (m model) currentCard() card { + return buildCard(m.snap, m.stack[len(m.stack)-1], m.contentWidth(), m.reveal) +} + +func (m model) onPaletteKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "ctrl+c": + m.pal.close() + return m, nil + case "up", "ctrl+p": + m.pal.move(-1) + return m, nil + case "down", "ctrl+n", "tab": + m.pal.move(1) + return m, nil + case "enter": + e, ok := m.pal.selected() + m.pal.close() + if !ok { + return m, nil + } + if e.do != actNone { + return m.act(e.do, e.in) + } + if e.open.kind != 0 { + // A search lands on the thing in its area, where its actions + // are, rather than on a card with none. + m.land(e.open) + return m, nil + } + m.goTo(e.in) + return m, nil + } + var cmd tea.Cmd + m.pal.input, cmd = m.pal.input.Update(msg) + m.pal.filter() + return m, cmd +} + +// mood is the mascot's face, from what the dashboard is doing. It answers +// "what is going on" from across the room. +func (m model) mood() ui.Mood { + switch { + case m.loading || m.signingOut: + return ui.MoodThinking + case m.snap.err != nil || m.flashBad: + return ui.MoodSad + case m.signIn.Active(): + return ui.MoodSurprised + case !m.session.SignedIn(): + return ui.MoodAsleep + case m.session.APIKey != "": + return ui.MoodHappy + } + return ui.MoodNormal +} diff --git a/internal/pulse/pulse_test.go b/internal/pulse/pulse_test.go new file mode 100644 index 0000000..1b4c862 --- /dev/null +++ b/internal/pulse/pulse_test.go @@ -0,0 +1,577 @@ +package pulse + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/session" + "github.com/nxssie/nan-cli/internal/ui" +) + +// ── fixtures ───────────────────────────────────────────────────────────── + +func usagePeriod(total float64, models ...api.ModelUsage) api.Period { + return api.Period{TotalTokens: api.Count(total), ByModel: models} +} + +func demoSnapshot() snapshot { + return snapshot{ + token: "t", + me: api.Me{ + "email": "cristian@example.com", + "userId": "u_1234567890", + "username": "crstian", + "createdAt": "2025-08-02T10:00:00Z", + "settings": map[string]any{"theme": "dark"}, + }, + usage: &api.Usage{ + Last24h: usagePeriod(3e6, + api.ModelUsage{Model: "glm5.3-flash", InputTokens: 2e6, OutputTokens: 1e6}), + Last30d: usagePeriod(90e6, + api.ModelUsage{Model: "glm5.3-flash", InputTokens: 50e6, OutputTokens: 10e6}, + api.ModelUsage{Model: "deepseek-v4-flash", InputTokens: 20e6, OutputTokens: 10e6}), + AllTime: usagePeriod(240e6, + api.ModelUsage{Model: "glm5.3-flash", InputTokens: 150e6, OutputTokens: 30e6}, + api.ModelUsage{Model: "deepseek-v4-flash", InputTokens: 40e6, OutputTokens: 20e6}), + }, + keyStatus: &api.KeyStatus{Exists: true, Alias: "laptop"}, + models: []modelInfo{ + {id: "deepseek-v4-flash", kind: kindOf("deepseek-v4-flash")}, + {id: "glm5.3", kind: kindOf("glm5.3")}, + {id: "glm5.3-flash", kind: kindOf("glm5.3-flash")}, + {id: "qwen3-embedding", kind: kindOf("qwen3-embedding")}, + }, + } +} + +// platform is a nan.builders that records what it is asked. +type platform struct { + mu sync.Mutex + seen []string +} + +func (p *platform) saw(want string) bool { + p.mu.Lock() + defer p.mu.Unlock() + for _, s := range p.seen { + if strings.Contains(s, want) { + return true + } + } + return false +} + +// sandbox points everything this package touches at a test's own folders +// and servers: HOME (the session and the tools' configs), the cache (the +// update state), the platform and the inference API. +func sandbox(t *testing.T) *platform { + t.Helper() + p := &platform{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + p.seen = append(p.seen, r.Method+" "+r.URL.Path) + p.mu.Unlock() + switch r.URL.Path { + case "/v1/models": + if r.Header.Get("Authorization") != "Bearer sk-good" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"data":[{"id":"glm5.3-flash"},{"id":"deepseek-v4-flash"}]}`)) + case "/api/auth/logout": + _, _ = w.Write([]byte(`{"status":"logged out"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("NAN_API_URL", srv.URL) + t.Setenv("NAN_INFERENCE_URL", srv.URL+"/v1") + t.Setenv("PATH", t.TempDir()) // no tool on PATH: tests see none installed + t.Setenv(session.TokenEnvVar, "") + return p +} + +func demoModel(t *testing.T, w, h int) (model, *platform) { + t.Helper() + p := sandbox(t) + sess := &session.Session{Token: "t", APIKey: "sk-good"} + m := newModel(context.Background(), api.New(sess.Token), sess) + next, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + m = next.(model) + next, _ = m.Update(loadedMsg{snap: demoSnapshot()}) + m = next.(model) + m.reveal, m.animating = 1, false // the tests read the settled screen + return m, p +} + +func keyOf(s string) tea.KeyPressMsg { + switch s { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "tab": + return tea.KeyPressMsg{Code: tea.KeyTab} + } + return tea.KeyPressMsg{Code: rune(s[0]), Text: s} +} + +// press sends a key and settles the screen: the commands it returns are run +// and fed back, the way Bubble Tea would, so a test sees what a person +// would. A reload keeps the fixture rather than asking the fake for one. +func press(t *testing.T, m model, k tea.KeyPressMsg) model { + t.Helper() + next, cmd := m.Update(k) + return settle(t, next.(model), cmd) +} + +func settle(t *testing.T, m model, cmd tea.Cmd) model { + t.Helper() + queue := []tea.Cmd{cmd} + for steps := 0; len(queue) > 0 && steps < 50; steps++ { + c := queue[0] + queue = queue[1:] + if c == nil { + continue + } + got := make(chan tea.Msg, 1) + go func() { got <- c() }() + var msg tea.Msg + select { + case msg = <-got: + case <-time.After(150 * time.Millisecond): + continue // a timer: the flash, the loader, a cursor blink + } + switch msg := msg.(type) { + case tea.BatchMsg: + queue = append(queue, msg...) + case tea.QuitMsg, animTickMsg: + case loadedMsg: + keep := m.snap + keep.token = m.session.Token + next, _ := m.Update(loadedMsg{snap: keep}) + m = next.(model) + default: + next, more := m.Update(msg) + m = next.(model) + queue = append(queue, more) + } + } + m.reveal = 1 + return m +} + +func screen(m model) string { return ansi.Strip(m.render()) } + +func footerOf(screen string) string { + lines := strings.Split(strings.TrimRight(screen, "\n"), "\n") + return lines[len(lines)-1] +} + +// ── what there is and where ────────────────────────────────────────────── + +func TestTheAreasAreAlwaysOnScreen(t *testing.T) { + m, _ := demoModel(t, 130, 40) + for _, a := range []string{"1 Home", "2 Profile", "3 Usage", "4 Models", "5 Costs", "6 Harnesses", "7 About"} { + for _, at := range []model{m, press(t, m, keyOf("3")), press(t, m, keyOf("7"))} { + if !strings.Contains(screen(at), a) { + t.Errorf("%q is not on the %s screen", a, at.area.title()) + } + } + } +} + +func TestNumbersAndTabMoveBetweenAreas(t *testing.T) { + m, _ := demoModel(t, 130, 40) + for a := areaProfile; a < areaCount; a++ { + if n := press(t, m, keyOf(string(rune('1'+a)))); n.area != a { + t.Errorf("%c went to %s", '1'+a, n.area.title()) + } + } + for want := areaProfile; want < areaCount; want++ { + m = press(t, m, keyOf("tab")) + if m.area != want { + t.Fatalf("tab went to %s, want %s", m.area.title(), want.title()) + } + } + if m = press(t, m, keyOf("tab")); m.area != areaHome { + t.Errorf("tab from the last area went to %s", m.area.title()) + } +} + +func TestEachAreaShowsItsThings(t *testing.T) { + m, _ := demoModel(t, 140, 44) + cases := map[string][]string{ + "1": {"welcome to", "nan.builders", "Nothing needs you right now", "Set up your tools", "240.0M"}, + "2": {"Username", "crstia...", "User Id", "u_1234...", "Created At"}, + "3": {"glm5.3-flash", "deepseek-v4-flash", "240.0M", "67%"}, + "4": {"glm5.3-flash", "chat", "chat · premium", "embedding"}, + "5": {"Claude Opus 5.5", "Anthropic", "GPT-6 Luna", "No per-token charges"}, + "6": {"Set up the tools", "saved on this machine", "Checks the key against the cluster"}, + "7": {"https://cloud.nan.builders", "@Nxssie", ".config/nan/session.json"}, + } + for k, wants := range cases { + out := screen(press(t, m, keyOf(k))) + for _, want := range wants { + if !strings.Contains(out, want) { + t.Errorf("area %s does not show %q:\n%s", k, want, out) + } + } + } +} + +// Nested objects are the platform's own structure, not facts about the +// member, and identifiers are cut short: a screen is shared and recorded. +func TestProfileCutsIdentifiersShort(t *testing.T) { + m, _ := demoModel(t, 140, 40) + out := screen(press(t, m, keyOf("2"))) + if strings.Contains(out, "cristian@example.com") || strings.Contains(out, "u_1234567890") { + t.Errorf("an identifier is on screen whole:\n%s", out) + } + if strings.Contains(out, "map[") || strings.Contains(out, "Settings") { + t.Errorf("a nested object is on screen:\n%s", out) + } + if got := censor("email", "abc"); got != "..." { + t.Errorf("a short sensitive value is %q", got) + } + if got := censor("plan", "membership"); got != "membership" { + t.Errorf("an ordinary value was cut: %q", got) + } +} + +func TestHumanKeyKeepsAcronymsWhole(t *testing.T) { + for in, want := range map[string]string{ + "userUUID": "User UUID", + "createdAt": "Created At", + "discord_id": "Discord id", + "APIKeyStatus": "API Key Status", + "x": "X", + } { + if got := humanKey(in); got != want { + t.Errorf("humanKey(%q) = %q, want %q", in, got, want) + } + } +} + +// Without a session, the areas that need one say how to get it, rather +// than the platform's `unauthorized`. +func TestDataAreasSayToSignIn(t *testing.T) { + m, _ := demoModel(t, 130, 40) + m.session.Token = "" + for _, k := range []string{"2", "3", "5"} { + out := screen(press(t, m, keyOf(k))) + if !strings.Contains(out, "not signed in - press s") || strings.Contains(strings.ToLower(out), "unauthorized") { + t.Errorf("area %s without a session:\n%s", k, out) + } + } + // Models does not need one: a key alone can ask the cluster. + if out := screen(press(t, m, keyOf("4"))); strings.Contains(out, "not signed in - press s") { + t.Errorf("Models asks for a sign-in it does not need:\n%s", out) + } +} + +// With a key and no session, the models still load, from the cluster. +func TestModelsLoadWithAKeyAndNoSession(t *testing.T) { + sandbox(t) + s := load(context.Background(), api.New(""), "sk-good", false) + if len(s.models) != 2 || s.err != nil { + t.Fatalf("models=%v err=%v", s.models, s.err) + } + s = load(context.Background(), api.New(""), "sk-wrong", false) + if !s.keyRefused { + t.Error("a refused key is not said to be refused") + } +} + +// ── the frame ──────────────────────────────────────────────────────────── + +func TestTheFrameFitsTheTerminal(t *testing.T) { + for _, size := range [][2]int{{72, 20}, {100, 30}, {160, 50}} { + m, _ := demoModel(t, size[0], size[1]) + for _, k := range []string{"1", "2", "3", "4", "5", "6", "7"} { + v := press(t, m, keyOf(k)) + for _, view := range []model{v, press(t, v, keyOf("enter")), press(t, v, keyOf("/"))} { + lines := strings.Split(view.render(), "\n") + if len(lines) != size[1] { + t.Errorf("%dx%d area %s is %d lines tall", size[0], size[1], k, len(lines)) + } + for _, l := range lines { + if w := ansi.StringWidth(l); w > size[0] { + t.Errorf("%dx%d area %s has a line %d wide: %q", size[0], size[1], k, w, ansi.Strip(l)) + break + } + } + } + } + } +} + +func TestNoEmDashReachesTheScreen(t *testing.T) { + m, _ := demoModel(t, 140, 44) + for _, k := range []string{"1", "2", "3", "4", "5", "6", "7"} { + if strings.ContainsRune(screen(press(t, m, keyOf(k))), 0x2014) { + t.Errorf("an em dash is on area %s", k) + } + } +} + +// ── what can be done ───────────────────────────────────────────────────── + +// e is Setup from anywhere: "paste your key" is what a member is told from +// whichever area they are looking at. +func TestSetupOpensFromAnywhere(t *testing.T) { + m, _ := demoModel(t, 130, 40) + for _, k := range []string{"2", "5", "7"} { + n := press(t, press(t, m, keyOf(k)), keyOf("e")) + if !n.setup.Active() || n.area != areaHarness { + t.Errorf("e from area %s: setup=%v area=%s", k, n.setup.Active(), n.area.title()) + } + } +} + +func TestEnterOpensTheRowsActions(t *testing.T) { + m, _ := demoModel(t, 130, 40) + m = press(t, press(t, m, keyOf("4")), keyOf("enter")) + if m.modal.kind != modalMenu { + t.Fatal("enter on a model did not open its menu") + } + out := screen(m) + for _, want := range []string{"Details", "Copy the id"} { + if !strings.Contains(out, want) { + t.Errorf("the menu does not offer %q:\n%s", want, out) + } + } + m = press(t, m, keyOf("enter")) + if !m.inCard() || !strings.Contains(screen(m), "tokens of context") { + t.Errorf("Details did not open the model:\n%s", screen(m)) + } +} + +func TestASearchLandsOnTheThing(t *testing.T) { + m, _ := demoModel(t, 130, 40) + m = press(t, m, keyOf("/")) + for _, r := range "deepseek" { + m = press(t, m, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + m = press(t, m, keyOf("enter")) + if m.area != areaModels || m.selectedRow() == nil || m.selectedRow().id != "deepseek-v4-flash" { + t.Errorf("the search landed on %s, row %+v", m.area.title(), m.selectedRow()) + } +} + +// The footer offers what works here: sign out when signed in, set up from +// anywhere, and the ways around. +func TestTheFooterOffersWhatWorks(t *testing.T) { + m, _ := demoModel(t, 200, 40) + foot := footerOf(screen(m)) + for _, want := range []string{"e set up", "o sign out", "tab next area", "/ find", "q quit"} { + if !strings.Contains(foot, want) { + t.Errorf("the footer does not offer %q: %s", want, foot) + } + } + m.session.Token = "" + if foot := footerOf(screen(m)); !strings.Contains(foot, "s sign in") { + t.Errorf("signed out, the footer does not offer the sign-in: %s", foot) + } +} + +// ── signing in and out ─────────────────────────────────────────────────── + +// A fresh machine is asked to sign in, a signed-in one without a key is +// asked for the key, and a machine with both is asked for nothing. +func TestTheFirstRunAsksForWhatIsMissing(t *testing.T) { + sandbox(t) + fresh := newModel(context.Background(), api.New(""), &session.Session{}) + _ = fresh.opening() + if !fresh.signIn.Active() { + t.Error("a fresh machine was not asked to sign in") + } + noKey := newModel(context.Background(), api.New("t"), &session.Session{Token: "t"}) + _ = noKey.opening() + if noKey.signIn.Active() || !noKey.setup.Active() { + t.Error("a signed-in machine without a key was not asked for one") + } + done := newModel(context.Background(), api.New("t"), &session.Session{Token: "t", APIKey: "sk-good"}) + _ = done.opening() + if done.inFlow() { + t.Error("a configured machine was asked for something") + } +} + +// Signing out takes two presses of o, ends the session on the platform, +// deletes it here, and opens the sign-in again. +func TestSigningOutTakesTwoPresses(t *testing.T) { + m, p := demoModel(t, 130, 40) + if err := session.Save(m.session); err != nil { + t.Fatal(err) + } + m = press(t, m, keyOf("o")) + if p.saw("logout") || !m.session.SignedIn() { + t.Fatal("one o signed out") + } + if !strings.Contains(screen(m), "press o again") { + t.Errorf("the first o does not say what the second does:\n%s", screen(m)) + } + m = press(t, m, keyOf("o")) + if !p.saw("POST /api/auth/logout") { + t.Error("the platform was not asked to end the session") + } + if m.session.SignedIn() || !m.signIn.Active() { + t.Errorf("after signing out: signed in=%v sign-in open=%v", m.session.SignedIn(), m.signIn.Active()) + } + if _, err := os.Stat(session.Path()); !os.IsNotExist(err) { + t.Error("session.json is still on disk") + } +} + +// Any other key between the two presses keeps the session. +func TestAnotherKeyKeepsTheSession(t *testing.T) { + m, p := demoModel(t, 130, 40) + m = press(t, press(t, press(t, m, keyOf("o")), keyOf("3")), keyOf("o")) + if p.saw("logout") || !m.confirmSignOut { + t.Error("o, another key, o signed out") + } +} + +func TestAStraySignedOutMsgIsIgnored(t *testing.T) { + m, _ := demoModel(t, 130, 40) + next, _ := m.Update(signedOutMsg{}) + if n := next.(model); !n.session.SignedIn() { + t.Error("an answer to a sign-out nobody asked for signed out") + } +} + +// A refusal that arrives after a newer sign-in says nothing about the new +// session. +func TestALateRefusalDoesNotEndTheNewSession(t *testing.T) { + m, _ := demoModel(t, 130, 40) + old := demoSnapshot() + old.token, old.expired = "an-older-token", true + next, _ := m.Update(loadedMsg{snap: old}) + if n := next.(model); !n.session.SignedIn() || n.expiredNotice { + t.Error("a late refusal of an old session ended the current one") + } +} + +// Opening on an expired session asks to sign in; one that expires later is +// said, and the keyboard is left alone. +func TestAnExpiredSessionIsHandled(t *testing.T) { + sandbox(t) + sess := &session.Session{Token: "t", APIKey: "sk-good"} + m := newModel(context.Background(), api.New("t"), sess) + m.w, m.h = 130, 40 + expired := snapshot{token: "t", expired: true} + next, _ := m.Update(loadedMsg{snap: expired}) + m = next.(model) + if m.session.SignedIn() || !m.signIn.Active() || m.session.APIKey != "sk-good" { + t.Errorf("opening expired: signed in=%v sign-in=%v key=%q", m.session.SignedIn(), m.signIn.Active(), m.session.APIKey) + } + + later, _ := demoModel(t, 130, 40) + expired.token = "t" + next, _ = later.Update(loadedMsg{snap: expired}) + later = next.(model) + if later.signIn.Active() || !strings.Contains(later.flash, "press s") { + t.Errorf("a later expiry: sign-in=%v flash=%q", later.signIn.Active(), later.flash) + } + if !strings.Contains(screen(later), "Your session has expired") { + t.Errorf("Home does not say the session expired:\n%s", screen(later)) + } +} + +// ── Costs ──────────────────────────────────────────────────────────────── + +func TestPricingTableIsPlausible(t *testing.T) { + seen := map[string]bool{} + for _, p := range pricingTable { + if seen[p.model] { + t.Errorf("%s is listed twice", p.model) + } + seen[p.model] = true + if p.inPer1M <= 0 || p.outPer1M < p.inPer1M { + t.Errorf("%s: in %.2f out %.2f", p.model, p.inPer1M, p.outPer1M) + } + if _, ok := providerColor[p.provider]; !ok { + t.Errorf("%s has no colour", p.provider) + } + } +} + +// Fails on the day the promotional rate ends, so the number is changed +// rather than forgotten. +func TestGeminiFlashPromoHasNotExpired(t *testing.T) { + if !time.Now().Before(geminiFlashPromoEnds) { + t.Error("Gemini 3.8 Flash is no longer on its promotional rate: update pricingTable") + } +} + +func TestCostsAreDollarsWithSeparators(t *testing.T) { + for v, want := range map[float64]string{0: "$0.00", 12.5: "$12.50", 1234.5: "$1,234.50", 1234567: "$1,234,567.00"} { + if got := fmtCost(v); got != want { + t.Errorf("fmtCost(%v) = %q, want %q", v, got, want) + } + } + p := usagePeriod(0, api.ModelUsage{InputTokens: 1e6, OutputTokens: 1e6}) + if got := calcCost(p, providerPricing{inPer1M: 2, outPer1M: 10}); got != 12 { + t.Errorf("a million in and out at 2/10 costs %v", got) + } +} + +// One line a row, a blank line only where the provider changes. +func TestCostRowsAreNotDoubleSpaced(t *testing.T) { + m, _ := demoModel(t, 140, 44) + out := screen(press(t, m, keyOf("5"))) + blanks := 0 + in := false + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, "Claude Fable") { + in = true + } + if in && strings.Contains(l, "Gemini 3.8 Flash") { + break + } + if in && strings.TrimSpace(strings.TrimLeft(l, " │")) == "" { + blanks++ + } + } + if blanks != 2 { + t.Errorf("%d blank lines between the rows, want 2 (one per change of provider):\n%s", blanks, out) + } +} + +// ── the mascot ─────────────────────────────────────────────────────────── + +func TestTheFaceFollowsWhatTheDashboardIsDoing(t *testing.T) { + m, _ := demoModel(t, 130, 40) + if m.mood() != ui.MoodHappy { + t.Errorf("signed in with a key: %v", m.mood()) + } + m.loading = true + if m.mood() != ui.MoodThinking { + t.Errorf("loading: %v", m.mood()) + } + m.loading = false + m.session.Token = "" + if m.mood() != ui.MoodAsleep { + t.Errorf("signed out: %v", m.mood()) + } +} diff --git a/internal/pulse/signals.go b/internal/pulse/signals.go new file mode 100644 index 0000000..86bba66 --- /dev/null +++ b/internal/pulse/signals.go @@ -0,0 +1,92 @@ +package pulse + +import ( + "sort" + "strings" + + "github.com/nxssie/nan-cli/internal/build" +) + +// Signals: what needs the member, worked out from the snapshot. +// +// Home leads with these because a fresh machine used to land on a page that +// explained the arrow keys and then answered every other area with +// `unauthorized`: nothing said "sign in", and the command that does it is a +// subcommand you have to already know about. Each signal says what is wrong +// in a line and goes, on enter, to where it is fixed. + +type severity int + +const ( + sevInfo severity = iota + sevWarn + sevAlarm +) + +// target is something a signal or a search lands on. +type target struct { + kind cardKind + id string +} + +type signal struct { + sev severity + say string + goTo target +} + +// signals is every signal in the snapshot, the worst first. +func (m model) signals() []signal { + s := m.snap + var out []signal + add := func(sev severity, say string, to target) { + out = append(out, signal{sev: sev, say: say, goTo: to}) + } + + switch { + case m.expiredNotice: + add(sevAlarm, "Your session has expired: press s to sign in again", target{kind: cardSignIn}) + case !m.session.SignedIn(): + add(sevWarn, "Not signed in: Profile, Usage and Costs stay empty until you are", target{kind: cardSignIn}) + } + + switch { + case m.session.APIKey == "": + add(sevWarn, "No API key on this machine: the tools here cannot reach NaN", target{kind: cardHarness}) + case s.keyRefused: + add(sevAlarm, "The cluster refused the API key on this machine: paste the current one", target{kind: cardHarness}) + default: + var waiting []string + for _, t := range s.installedTools() { + if !t.Configured && enabled(m.session.EnabledTools, t.Name) { + waiting = append(waiting, t.Name) + } + } + if len(waiting) > 0 { + add(sevInfo, strings.Join(waiting, ", ")+" "+plural(len(waiting), "is", "are")+" installed and not pointed at NaN", target{kind: cardHarness}) + } + } + + if v := m.upd.Available(); v != "" { + add(sevWarn, build.Name+" "+v+" is out; you have "+build.Version, target{kind: cardUpdate}) + } + + sort.SliceStable(out, func(a, b int) bool { return out[a].sev > out[b].sev }) + return out +} + +// enabled is tools.Enabled, here so this file reads on its own: a tool +// nobody has unticked counts as wanted. +func enabled(ticked map[string]bool, name string) bool { + if v, ok := ticked[name]; ok { + return v + } + return true +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} diff --git a/internal/pulse/tour.go b/internal/pulse/tour.go new file mode 100644 index 0000000..52655fb --- /dev/null +++ b/internal/pulse/tour.go @@ -0,0 +1,72 @@ +package pulse + +import ( + "fmt" + + "charm.land/lipgloss/v2" + + "github.com/nxssie/nan-cli/internal/ui" +) + +// The tour: a minute through the dashboard, on the member's own data. Each +// step says one thing to do and moves on when it is done, so the keys are +// learned by pressing them rather than by reading about them. + +type tourStep struct { + say string + done func(m model) bool +} + +var tourSteps = []tourStep{ + { + say: "Press tab to go to the next area", + done: func(m model) bool { return m.area != areaHome }, + }, + { + say: "Press 4 to jump straight to Models", + done: func(m model) bool { return m.area == areaModels }, + }, + { + say: "Move with ↓, then press enter to see what you can do with that row", + done: func(m model) bool { return m.modal.kind == modalMenu }, + }, + { + say: "esc closes it. Now press / and type a few letters of anything", + done: func(m model) bool { return m.pal.visible && m.pal.input.Value() != "" }, + }, + { + say: "enter goes to it. Then press 1 to come back Home", + done: func(m model) bool { return m.area == areaHome && !m.pal.visible }, + }, +} + +// tourFinished is the step after the last: the closing line. +var tourFinished = len(tourSteps) + 1 + +func (m model) touring() bool { return m.tour > 0 } + +func (m *model) startTour() { + m.goTo(areaHome) + m.tour = 1 +} + +func (m *model) stopTour() { m.tour = 0 } + +// advanceTour moves on when the current step has been done. +func (m *model) advanceTour() { + if m.tour > 0 && m.tour <= len(tourSteps) && tourSteps[m.tour-1].done(*m) { + m.tour++ + } +} + +func (m model) renderTour() string { + t := ui.Current() + badge := lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")).Background(t.Accent).Bold(true).Padding(0, 1) + say := lipgloss.NewStyle().Foreground(t.Text) + if m.tour >= tourFinished { + return badge.Render("TOUR") + " " + + say.Render("Done. ? shows every key, t takes the tour again.") + " " + ui.Faint("any key to close") + } + return badge.Render(fmt.Sprintf("TOUR %d/%d", m.tour, len(tourSteps))) + " " + + say.Render(tourSteps[m.tour-1].say) + " " + ui.Accent("x") + ui.Faint(" skip tour") +} diff --git a/internal/pulse/view.go b/internal/pulse/view.go new file mode 100644 index 0000000..000eabe --- /dev/null +++ b/internal/pulse/view.go @@ -0,0 +1,695 @@ +package pulse + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/build" + "github.com/nxssie/nan-cli/internal/flow" + "github.com/nxssie/nan-cli/internal/session" + "github.com/nxssie/nan-cli/internal/tools" + "github.com/nxssie/nan-cli/internal/ui" + "github.com/nxssie/nan-cli/internal/update" +) + +const ( + margin = 2 + minW = 72 + minH = 20 + maxResults = 10 +) + +func (m model) View() tea.View { + v := tea.NewView(m.render()) + v.AltScreen = true + return v +} + +func pad() string { return strings.Repeat(" ", margin) } + +// contentWidth is the right-hand side: the terminal minus the margins, the +// navigation and the rule after it. +func (m model) contentWidth() int { return max(m.w-2*margin-navWidth-3, 20) } + +func (m model) render() string { + if m.quitting || m.w == 0 { + return "" + } + if m.w < minW || m.h < minH { + // Every area is also a command, which fits any terminal. + return fmt.Sprintf("\n This dashboard needs %dx%d; the terminal is %dx%d.\n Make it bigger, or use the commands: nan me, nan metrics usage, nan setup...\n", minW, minH, m.w, m.h) + } + + header := m.renderHeader() + footer := m.renderFooter() + // The flash line and the footer each start with a newline, which ends + // the line before rather than adding one; the flash line is one row. + room := m.h - lipgloss.Height(header) - 1 - (lipgloss.Height(footer) - 1) + + var body string + switch { + case m.signIn.Active(): + body = m.flowBanner(room) + m.signIn.View(pad(), m.loader.SetLabel(m.signIn.Label()).View(), m.client.APIHost()) + case m.setup.Active(): + body = m.flowBanner(room) + m.setup.View(pad(), m.loader.SetLabel(m.setup.Label()).View()) + case !m.loaded: + body = "\n" + pad() + m.loader.View() + default: + t := ui.Current() + rule := lipgloss.NewStyle().Foreground(t.Line).Render(strings.TrimRight(strings.Repeat("│\n", room), "\n")) + body = lipgloss.JoinHorizontal(lipgloss.Top, + pad(), fit(m.renderNav(room), room, navWidth), " ", rule, " ", + fit(m.renderContent(room), room, m.contentWidth())) + } + return header + "\n" + fit(body, room, m.w) + m.renderFlash() + footer +} + +// flowBanner is the banner over a sign-in or a Setup, when there is room +// for it under the form: the mascot goes first, then the wordmark. +func (m model) flowBanner(room int) string { + switch { + case room >= ui.BannerRoom+12 && m.w >= ui.BannerWidth(build.Version)+2*margin: + return "\n" + ui.Banner(build.Version, pad(), m.mood(), true) + "\n" + case room >= 6+14 && m.w >= ui.BannerWidthCompact(build.Version)+2*margin: + return "\n" + ui.Banner(build.Version, pad(), m.mood(), false) + "\n" + } + return "" +} + +// fit cuts or pads a block to exactly rows lines of exactly width columns. +func fit(block string, rows, width int) string { + lines := strings.Split(strings.TrimRight(block, "\n"), "\n") + if len(lines) > rows { + lines = lines[:max(rows, 0)] + } + for i := range lines { + lines[i] = ui.Pad(ansi.Truncate(lines[i], width, "…"), width) + } + for len(lines) < rows { + lines = append(lines, strings.Repeat(" ", width)) + } + return strings.Join(lines, "\n") +} + +// ── chrome ─────────────────────────────────────────────────────────────── + +// renderHeader is what this is on the left and where the session stands on +// the right, with an update said first when there is one. +func (m model) renderHeader() string { + t := ui.Current() + left := lipgloss.NewStyle().Foreground(t.AccentText).Bold(true).Render("nan.builders") + + " " + ui.Faint("cloud CLI") + + right := ui.Faint("not signed in") + if m.session.SignedIn() { + right = ui.Faint("signed in") + } + if m.loading && m.loaded { + right = ui.Faint("reloading… ") + right + } + if v := m.upd.Available(); v != "" { + right = lipgloss.NewStyle().Foreground(t.Yellow).Render("update "+v) + ui.Faint(" ") + right + } + gap := max(m.w-2*margin-lipgloss.Width(left)-lipgloss.Width(right), 1) + line := ansi.Truncate(pad()+left+strings.Repeat(" ", gap)+right, m.w, "…") + return line + "\n" + pad() + lipgloss.NewStyle().Foreground(t.Line).Render(strings.Repeat("─", m.w-2*margin)) +} + +// navTips are pinned to the foot of the navigation: the three ways around, +// said where the eye already is. tab is the one nobody guesses. +var navTips = [][2]string{{"tab", "next area"}, {"1-7", "jump"}, {"/", "find"}} + +func (m model) renderNav(room int) string { + list := m.renderAreas() + if rows := room - len(navTips) - 1; rows > int(areaCount)+1 { + var tips strings.Builder + for _, tip := range navTips { + tips.WriteString(" " + ui.Value(ui.Pad(tip[0], 4)) + ui.Faint(tip[1]) + "\n") + } + list = fit(list, rows, navWidth) + "\n\n" + tips.String() + } + return list +} + +func (m model) renderAreas() string { + t := ui.Current() + var b strings.Builder + b.WriteString("\n") + for a := area(0); a < areaCount; a++ { + num := fmt.Sprintf("%d ", a+1) + count := m.count(a) + width := navWidth - 2 - len(num) - len([]rune(count)) + if a == m.area { + b.WriteString(lipgloss.NewStyle().Foreground(t.Accent).Render("▌") + " " + + lipgloss.NewStyle().Foreground(t.AccentText).Bold(true).Render(num+ui.Pad(a.title(), width)) + + ui.Faint(count) + "\n") + } else { + b.WriteString(" " + ui.Faint(num) + lipgloss.NewStyle().Foreground(t.TextDim).Render(ui.Pad(a.title(), width)) + ui.Faint(count) + "\n") + } + } + return b.String() +} + +// renderFlash is the result of the last action, or a blank line. +func (m model) renderFlash() string { + width := m.w - 2*margin + switch { + case m.signingOut: + return "\n" + pad() + m.loader.SetLabel("Signing out").View() + case m.flash == "": + return "\n" + case m.flashBad: + return "\n" + pad() + ansi.Truncate(ui.Error(fmt.Errorf("%s", m.flash)), width, "…") + } + return "\n" + pad() + ansi.Truncate(ui.Success(m.flash), width, "…") +} + +// renderFooter says what the keys do HERE: the verbs this row allows, then +// the few that work everywhere. Read from the same list as the menu and the +// key handler, so an offered key is a working key. +func (m model) renderFooter() string { + k := m.keys + var binds []key.Binding + switch { + case m.setup.AsksAboutTour(): + binds = []key.Binding{hint("enter", "take the tour"), hint("s", "skip it"), hint("ctrl+c", "quit")} + case m.setup.SkippableKey(): + binds = []key.Binding{hint("enter", "check the key"), hint("esc", "skip for now"), hint("ctrl+c", "quit")} + case m.signIn.Active(), m.setup.Active(): + // A form owns the keyboard: none of the dashboard's keys work here, + // so none are offered. + binds = []key.Binding{hint("enter", "continue"), hint("esc", "back"), hint("ctrl+c", "quit")} + case m.touring() && !m.help.ShowAll: + // For as long as the tour runs, the tour is the help. + return "\n" + pad() + ansi.Truncate(m.renderTour(), m.w-2*margin, "…") + case m.pal.visible: + binds = []key.Binding{hint("type", "to find"), hint("↑/↓", "pick"), hint("enter", "go"), hint("esc", "close")} + case m.modal.kind == modalMenu: + binds = []key.Binding{hint("↑/↓", "pick"), hint("enter", "do it"), hint("esc", "close")} + case m.modal.kind == modalLink: + binds = []key.Binding{hint("y", "copy it"), hint("enter", "close")} + case m.inCard(): + binds = []key.Binding{hint("y", "copy the id"), k.Back} + default: + binds = append(binds, k.Up) + verbs := m.verbs(m.area, m.selectedRow()) + switch { + case m.area == areaHome: + binds = append(binds, hint("enter", "go")) + case m.area == areaHarness: + binds = append(binds, hint("enter", "set up")) + case len(verbs) > 0: + binds = append(binds, k.Enter) + } + for _, it := range verbs { + if it.key != "enter" && it.key != "e" { + binds = append(binds, hint(it.key, strings.ToLower(it.label))) + } + } + binds = append(binds, k.Setup, k.NextArea, k.Area, k.Find) + if m.session.SignedIn() { + binds = append(binds, k.SignOut) + } else { + binds = append(binds, k.SignIn) + } + if m.help.ShowAll { + binds = append(binds, k.Reload, k.Back, k.Tour) + } + binds = append(binds, k.Help, k.Quit) + } + return "\n" + pad() + ui.Truncate(m.help.ShortHelpView(binds), m.w-2*margin) +} + +// hint is a footer entry that is not a binding of its own. +func hint(k, desc string) key.Binding { return key.NewBinding(key.WithKeys(k), key.WithHelp(k, desc)) } + +// ── content ────────────────────────────────────────────────────────────── + +func (m model) renderContent(room int) string { + w := m.contentWidth() + if m.pal.visible { + return m.renderPalette(w) + } + var body string + switch { + case m.inCard(): + body = m.renderCard() + case m.area.needsSession() && !m.session.SignedIn(): + body = m.heading(m.area.title(), "") + + ui.Error(fmt.Errorf("not signed in - press s")) + "\n" + case m.area == areaHome: + body = m.renderHome(w, room) + case m.area == areaProfile: + body = m.renderProfile(w, room) + case m.area == areaUsage: + body = m.renderUsage(w, room) + case m.area == areaModels: + body = m.renderModels(w, room) + case m.area == areaCosts: + body = m.renderCosts(w) + case m.area == areaHarness: + body = m.renderHarness(w) + case m.area == areaAbout: + body = m.renderAbout(w, room) + } + if m.modal.kind != modalNone { + // Over the top of the area, which stays visible under it. + return overlay(body, m.renderModal(w)) + } + return body +} + +// overlay draws box over the top of block, a line down. +func overlay(block, box string) string { + under := strings.Split(block, "\n") + for i, l := range strings.Split(box, "\n") { + if i+1 < len(under) { + under[i+1] = l + } else { + under = append(under, l) + } + } + return strings.Join(under, "\n") +} + +func (m model) heading(title, summary string) string { + out := "\n" + m.eyebrow(title) + "\n" + if summary != "" { + out += ui.Faint(summary) + "\n" + } + return out + "\n" +} + +// eyebrow is a section heading, typed in with the rest of the screen. +func (m model) eyebrow(title string) string { + return ui.EyebrowTyped(title, m.reveal, m.frame) +} + +// selectable draws a line, with the cursor's bar and bold text when it is +// the selected one. +func selectable(on bool, text string) string { + t := ui.Current() + if on { + return lipgloss.NewStyle().Foreground(t.Accent).Render("▌") + " " + + lipgloss.NewStyle().Foreground(t.Text).Bold(true).Render(ansi.Strip(text)) + } + return " " + text +} + +// banner is the mascot and the wordmark at the top of a landing area, as +// much of it as the room allows: the mascot goes first, then the wordmark. +func (m model) banner(w, room int) string { + switch { + case w >= ui.BannerWidth(build.Version) && room >= ui.BannerRoom: + return ui.Banner(build.Version, "", m.mood(), true) + case w >= ui.BannerWidthCompact(build.Version): + return ui.Banner(build.Version, "", m.mood(), false) + } + return ui.PlainBanner(build.Version, "") +} + +func (m model) renderHome(w, room int) string { + s := m.snap + t := ui.Current() + var b strings.Builder + + b.WriteString("\n" + m.banner(w, room) + "\n") + + // The pulse: the member's figures counting up as the area opens. + if s.usage != nil { + b.WriteString(figures(w, + figure(ui.Tokens(float64(s.usage.Last24h.TotalTokens)*m.reveal), "tokens · 24 hours"), + figure(ui.Tokens(float64(s.usage.Last30d.TotalTokens)*m.reveal), "tokens · 30 days"), + figure(ui.Tokens(float64(s.usage.AllTime.TotalTokens)*m.reveal), "tokens · all time"), + ) + "\n") + } + if s.err != nil { + b.WriteString("\n" + ui.Error(s.err) + "\n") + } + + rows := m.rows(areaHome) + cur := m.cursor[areaHome] + dot := map[severity]string{ + sevAlarm: lipgloss.NewStyle().Foreground(t.Red).Render("●"), + sevWarn: lipgloss.NewStyle().Foreground(t.Yellow).Render("●"), + sevInfo: lipgloss.NewStyle().Foreground(t.TextFaint).Render("○"), + } + arrive := func(i int, text string) string { + if p := stagger(m.reveal, i); p < 1 { + return decode(text, p, m.frame) + } + return text + } + + b.WriteString("\n" + m.eyebrow("Needs you") + "\n\n") + if len(m.sigs) == 0 { + b.WriteString(" " + ui.Success("Nothing needs you right now.") + "\n") + } + i := 0 + for ; i < len(rows) && rows[i].sig != nil; i++ { + sg := rows[i].sig + where := ui.Faint(" → " + areaOf(sg.goTo).title()) + switch sg.goTo.kind { + case cardUpdate: + where = ui.Faint(" → enter: how to update") + case cardSignIn: + where = ui.Faint(" → enter: sign in") + } + say := arrive(i, sg.say) + if i == cur { + say = lipgloss.NewStyle().Foreground(t.Text).Bold(true).Render(say) + b.WriteString(ansi.Truncate(lipgloss.NewStyle().Foreground(t.Accent).Render("▌")+" "+dot[sg.sev]+" "+say+where, w, "…") + "\n") + } else { + b.WriteString(ansi.Truncate(" "+dot[sg.sev]+" "+say+where, w, "…") + "\n") + } + } + + if i < len(rows) && rows[i].newModel != "" { + b.WriteString("\n" + m.eyebrow("What's new") + "\n\n") + for ; i < len(rows) && rows[i].newModel != ""; i++ { + line := ui.Pad("model", 9) + arrive(i, rows[i].newModel+" is available to your key") + b.WriteString(ansi.Truncate(selectable(i == cur, line)+ui.Faint(" → Models, e adds it to your tools"), w, "…") + "\n") + } + } + + b.WriteString("\n" + m.eyebrow("What you can do") + "\n\n") + for ; i < len(rows); i++ { + q := rows[i].quick + b.WriteString(selectable(i == cur, ui.Pad(arrive(i, q.label), 34)+ui.Faint(q.hint)) + "\n") + } + return b.String() +} + +// motion is the reveal as a table sees it. +type motion struct { + reveal float64 + frame int +} + +func (m model) motion() motion { return motion{reveal: m.reveal, frame: m.frame} } + +// table draws rows under column headings with the cursor's row marked, +// scrolled so the cursor stays in view in the lines given. +func table(cols []column, rows []row, cursor, width, lines int, mo motion) string { + t := ui.Current() + fixed := 0 + for _, c := range cols { + fixed += c.width + 2 + } + flex := max(width-4-fixed, 12) + widthOf := func(c column) int { + if c.width == 0 { + return flex + } + return c.width + } + cell := func(c column, s string) string { + s = ansi.Truncate(s, widthOf(c), "…") + if c.right { + return ui.PadLeft(s, widthOf(c)) + } + return ui.Pad(s, widthOf(c)) + } + + var b strings.Builder + head := make([]string, len(cols)) + for i, c := range cols { + head[i] = cell(c, c.title) + } + b.WriteString(" " + ui.Faint(strings.Join(head, " ")) + "\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(t.Line).Render(strings.Repeat("─", max(min(width-2, flex+fixed), 1))) + "\n") + + if len(rows) == 0 { + return b.String() + " " + ui.Faint("Nothing here yet.") + "\n" + } + show := max(lines-3, 1) + first := 0 + if cursor >= show { + first = cursor - show + 1 + } + last := min(first+show, len(rows)) + for i := first; i < last; i++ { + r := rows[i] + cells := make([]string, len(cols)) + for j, c := range cols { + v := "" + if j < len(r.cells) { + v = r.cells[j] + } + cells[j] = cell(c, v) + } + // The name decodes into place, row by row down the table. + if p := stagger(mo.reveal, i-first); p < 1 && len(cells) > 0 { + cells[0] = decode(ansi.Strip(cells[0]), p, mo.frame) + } + text := strings.Join(cells, " ") + switch { + case i == cursor: + // Bold cell by cell, so a coloured cell keeps its colour on + // exactly the row somebody is looking at. + bold := lipgloss.NewStyle().Foreground(t.Text).Bold(true) + lit := make([]string, len(cells)) + for j, c := range cells { + if ansi.Strip(c) == c { + c = bold.Render(c) + } + lit[j] = c + } + b.WriteString(lipgloss.NewStyle().Foreground(t.Accent).Render("▌") + " " + strings.Join(lit, " ") + "\n") + case r.dim: + b.WriteString(" " + ui.Faint(ansi.Strip(text)) + "\n") + default: + b.WriteString(" " + text + "\n") + } + } + if len(rows) > show { + b.WriteString(" " + ui.Faint(fmt.Sprintf("%d-%d of %d", first+1, last, len(rows))) + "\n") + } + return b.String() +} + +func (m model) renderProfile(w, room int) string { + h := m.heading("Profile", "Your nan.builders account. Identifiers are shortened; o signs out.") + if m.snap.err != nil && m.snap.me == nil { + return h + ui.Error(m.snap.err) + "\n" + } + return h + table(areaProfile.columns(), m.rows(areaProfile), m.cursor[areaProfile], w, room-lipgloss.Height(h), m.motion()) +} + +func (m model) renderUsage(w, room int) string { + s := m.snap + var b strings.Builder + b.WriteString("\n" + m.eyebrow("Usage") + "\n\n") + if s.usage == nil { + if s.err != nil { + b.WriteString(ui.Error(s.err) + "\n") + } + return b.String() + } + cells := make([]string, 0, 3) + for _, p := range s.periods() { + in, out := p.Sum() + cells = append(cells, figure(ui.Tokens(float64(p.TotalTokens)*m.reveal), "tokens · "+p.short+"\n"+ + ui.Tokens(in)+" in · "+ui.Tokens(out)+" out")) + } + b.WriteString(figures(w, cells...) + "\n\n") + b.WriteString(m.eyebrow("By model") + "\n\n") + head := b.String() + return head + table(areaUsage.columns(), m.rows(areaUsage), m.cursor[areaUsage], w, room-lipgloss.Height(head), m.motion()) +} + +func (m model) renderModels(w, room int) string { + s := m.snap + summary := "What your key can call, and what you have spent on each." + switch { + case s.keyRefused: + summary = "The cluster refused the API key on this machine: e to paste the current one." + case m.session.APIKey == "" && m.session.SignedIn(): + summary = "The platform's list. With an API key (e) this is what your key can actually call." + case m.session.APIKey == "" && !m.session.SignedIn(): + summary = "Sign in (s) or set an API key (e) to see the models." + } + h := m.heading("Models", summary) + if len(s.models) == 0 && s.err != nil { + return h + ui.Error(s.err) + "\n" + } + return h + table(areaModels.columns(), m.rows(areaModels), m.cursor[areaModels], w, room-lipgloss.Height(h), m.motion()) +} + +func (m model) renderHarness(w int) string { + var b strings.Builder + b.WriteString("\n" + m.eyebrow("Harnesses") + "\n") + for _, line := range strings.Split(ansi.Wrap("The coding tools on this machine that can talk to NaN, and the API key they use.", max(w-2, 20), ""), "\n") { + b.WriteString(ui.Faint(line) + "\n") + } + b.WriteString("\n") + + // The key first, and the one thing to do right under it: on a short + // terminal the explanation below is what gets cut, not the action. + switch k := m.session.APIKey; { + case k == "": + b.WriteString(ui.Warn("No API key set: the tools here cannot reach NaN.") + "\n") + if h := flow.KeyHint(m.snap.keyStatus); h != "" { + b.WriteString(ui.Faint(h) + "\n") + } + case m.snap.keyRefused: + b.WriteString(ui.Error(fmt.Errorf("the cluster refused the key on this machine - paste the current one")) + "\n") + default: + // Dots, never any of the key: not even its ends. + b.WriteString(ui.Value(strings.Repeat("•", 24)) + " " + ui.Faint("saved on this machine") + "\n") + b.WriteString(ui.Faint("If anyone has seen it: replace it at cloud.nan.builders, paste the new one here.") + "\n") + } + b.WriteString("\n" + selectable(true, "Set up the tools") + " " + ui.Faint("enter or e") + "\n") + + // What setting it does, said as the steps it is, because "paste your + // key" alone does not say that it will edit other programs' files. + b.WriteString("\n" + m.eyebrow("What setting the key does") + "\n\n") + steps := []string{ + "Checks the key against the cluster first, so a mistyped one is caught here.", + "Writes it into each tool you tick, as a \"NaN\" provider pointed at " + api.InferenceURL() + ".", + "Touches only that provider: the rest of each config, your other providers included, stays as it was.", + "Keeps the key on this machine, readable by you alone: " + ui.HomePath(session.Path()) + ".", + } + hang := strings.Repeat(" ", 5) + wrap := func(text string) string { + return strings.ReplaceAll(ansi.Wrap(text, max(w-7, 20), ""), "\n", "\n"+hang) + } + for i, step := range steps { + b.WriteString(" " + ui.Value(fmt.Sprintf("%d", i+1)) + " " + wrap(step) + "\n") + } + for _, line := range strings.Split(ansi.Wrap("Untick a tool to take NaN back out of it; signing out (o) takes it out of all of them.", max(w-7, 20), ""), "\n") { + b.WriteString(hang + ui.Faint(line) + "\n") + } + + b.WriteString("\n" + m.eyebrow("On this machine") + "\n\n") + installed := m.snap.installedTools() + if len(installed) == 0 { + b.WriteString(" " + ui.Faint("None of the supported tools is installed: "+tools.List()+".") + "\n") + return b.String() + } + for _, h := range installed { + state := lipgloss.NewStyle().Foreground(ui.Current().Yellow).Render("○ not configured") + switch { + case h.Configured: + state = ui.Success("configured with NaN") + case !enabled(m.session.EnabledTools, h.Name): + state = ui.Faint("unticked") + } + b.WriteString(" " + ui.Value(ui.Pad(h.Name, 12)) + ui.Pad(state, 22) + ui.Faint(ui.HomePath(h.ConfigPath)) + "\n") + } + return b.String() +} + +func (m model) renderAbout(w, room int) string { + var b strings.Builder + b.WriteString("\n" + m.banner(w, room) + "\n") + rows := [][2]string{ + {"Version", ui.Value(build.Version)}, + {"Platform", ui.Accent("https://nan.builders")}, + {"Cloud", ui.Accent("https://cloud.nan.builders")}, + {"Source", ui.Accent(build.Repo)}, + {"Maintainer", ui.Value("@Nxssie") + ui.Faint(", and the Helmcode team")}, + // The real path, resolved: a member told to look somewhere finds + // the file there, on Windows too. + {"Session", ui.Mono(ui.HomePath(session.Path()))}, + } + if v := m.upd.Available(); v != "" { + rows = append(rows, [2]string{"Update", lipgloss.NewStyle().Foreground(ui.Current().Yellow).Render(v+" is out: ") + ui.Mono(update.Command())}) + } + b.WriteString(ui.KVBlock(rows, "") + "\n") + return b.String() +} + +// ── a card ─────────────────────────────────────────────────────────────── + +func (m model) renderCard() string { + t := ui.Current() + c := m.currentCard() + var b strings.Builder + + crumbs := []string{ui.Faint(m.area.title())} + for i, st := range m.stack { + title := buildCard(m.snap, st, 0, 1).title + if i == len(m.stack)-1 { + crumbs = append(crumbs, ui.Value(title)) + } else { + crumbs = append(crumbs, ui.Faint(title)) + } + } + b.WriteString("\n" + strings.Join(crumbs, ui.Faint(" › ")) + "\n\n") + top := m.stack[len(m.stack)-1] + b.WriteString(m.eyebrow(kindLabel(top.kind)) + "\n") + b.WriteString(lipgloss.NewStyle().Foreground(t.Text).Bold(true).Render(c.title) + "\n\n") + b.WriteString(c.body + "\n") + if len(c.links) > 0 { + b.WriteString("\n" + m.eyebrow("Linked") + "\n\n") + at := m.links[len(m.links)-1] + for i, l := range c.links { + b.WriteString(selectable(i == at, ui.Pad(ui.Truncate(l.label, 32), 32)+" "+ui.Faint(l.hint)) + "\n") + } + } + return b.String() +} + +// ── modals ─────────────────────────────────────────────────────────────── + +func (m model) renderModal(w int) string { + t := ui.Current() + width := min(w, cardWidth) + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()).BorderForeground(t.Accent). + Padding(0, 2).Width(width) + inner := width - 6 + + switch m.modal.kind { + case modalMenu: + var b strings.Builder + if r := m.selectedRow(); r != nil && len(r.cells) > 0 { + b.WriteString(ui.Faint(ansi.Strip(r.cells[0])) + "\n\n") + } + for i, it := range m.modal.items { + b.WriteString(selectable(i == m.modal.cursor, ui.Pad(it.label, 34)+ui.Faint(it.key)) + "\n") + } + return box.Render(strings.TrimRight(b.String(), "\n")) + + case modalLink: + var b strings.Builder + b.WriteString(m.eyebrow(m.modal.title) + "\n\n") + b.WriteString(ui.Accent(ansi.Hardwrap(m.modal.url, inner, true)) + "\n\n") + b.WriteString(ui.Faint(m.modal.note) + "\n") + if m.modal.copied { + b.WriteString("\n" + ui.Success("Copied to the clipboard.") + "\n") + } + return box.Render(strings.TrimRight(b.String(), "\n")) + } + return "" +} + +// ── the palette ────────────────────────────────────────────────────────── + +func (m model) renderPalette(w int) string { + t := ui.Current() + var b strings.Builder + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Accent).Padding(0, 1).Width(w) + b.WriteString("\n" + box.Render(m.pal.input.View()) + "\n\n") + if len(m.pal.shown) == 0 { + return b.String() + " " + ui.Faint("Nothing matches.") + "\n" + } + first := max(m.pal.cursor-maxResults+1, 0) + last := min(first+maxResults, len(m.pal.shown)) + tag := lipgloss.NewStyle().Foreground(t.AccentText).Width(8) + for i := first; i < last; i++ { + e := m.pal.shown[i] + b.WriteString(selectable(i == m.pal.cursor, tag.Render(e.kind)+ui.Pad(ui.Truncate(e.name, 40), 42)+ui.Faint(e.hint)) + "\n") + } + if n := len(m.pal.shown); n > maxResults { + b.WriteString(" " + ui.Faint(fmt.Sprintf("%d of %d", last-first, n)) + "\n") + } + return b.String() +} diff --git a/internal/pulse/whatsnew.go b/internal/pulse/whatsnew.go new file mode 100644 index 0000000..6d6f0b8 --- /dev/null +++ b/internal/pulse/whatsnew.go @@ -0,0 +1,52 @@ +package pulse + +import ( + "time" + + "github.com/nxssie/nan-cli/internal/build" + "github.com/nxssie/nan-cli/internal/update" +) + +// What changed since the last run: a model the key can call that it could +// not before, and a newer release of this CLI. + +// takeNote compares a load with what was remembered: new models are found, +// and the latest release recorded. +func (m *model) takeNote(s snapshot) { + if !s.keyRefused && len(s.models) > 0 { + if found := m.upd.SeeModels(s.modelIDs()); len(found) > 0 { + m.newModels = append([]string(nil), found...) + } + } + if s.askedRelease { + m.upd.CheckedAt = time.Now().UTC().Format(time.RFC3339) + if s.release != nil { + m.upd.Latest = s.release.Version + } + } + _ = m.upd.Save() +} + +// seeNewModels is the new models having been looked at: they stay marked +// for this visit and are not announced again next time. +func (m *model) seeNewModels() { + if len(m.newModels) == 0 { + return + } + m.upd.MarkSeen() + _ = m.upd.Save() +} + +// updateModal shows the command that updates this copy, to copy and run. +func (m *model) updateModal() { + v := m.upd.Available() + if v == "" { + return + } + m.modal = modal{ + kind: modalLink, + title: "Update to " + v, + url: update.Command(), + note: "You have " + build.Version + ". Run this in a terminal, then start " + build.Name + " again.", + } +} diff --git a/internal/runs/render.go b/internal/runs/render.go index ce4d568..336b92b 100644 --- a/internal/runs/render.go +++ b/internal/runs/render.go @@ -143,14 +143,14 @@ type Renderer struct { func (r *Renderer) newline() { if r.midLine { - fmt.Fprintln(r.Out) + _, _ = fmt.Fprintln(r.Out) r.midLine = false } } func (r *Renderer) line(format string, args ...any) { r.newline() - fmt.Fprintf(r.Out, format+"\n", args...) + _, _ = fmt.Fprintf(r.Out, format+"\n", args...) } // prefixed prints text one line at a time, each behind the same label, so a @@ -159,7 +159,7 @@ func (r *Renderer) prefixed(label, text string) { r.newline() text = strings.TrimRight(Sanitize(text), "\n") for _, l := range strings.Split(text, "\n") { - fmt.Fprintf(r.Out, "%s │ %s\n", label, l) + _, _ = fmt.Fprintf(r.Out, "%s │ %s\n", label, l) } } @@ -212,7 +212,7 @@ func (r *Renderer) Event(ev api.Event) { _ = json.Unmarshal(ev.Data, &d) text := Sanitize(d.Text) if text != "" { - fmt.Fprint(r.Out, text) + _, _ = fmt.Fprint(r.Out, text) r.midLine = !strings.HasSuffix(text, "\n") } if d.Final { @@ -387,7 +387,7 @@ func isUUID(s string) bool { return false } default: - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { return false } } @@ -458,7 +458,7 @@ func SafeJSON(v any, indent bool) ([]byte, error) { func Proposal(w io.Writer, run *api.Run) { a := run.Approval id := SanitizeLine(run.ID) - row := func(k, v string) { fmt.Fprintf(w, "%-12s%s\n", k, v) } + row := func(k, v string) { _, _ = fmt.Fprintf(w, "%-12s%s\n", k, v) } row("run", id+" ("+strings.ReplaceAll(SanitizeLine(run.State), "_", " ")+")") if run.Automation != nil { @@ -503,17 +503,17 @@ func Proposal(w io.Writer, run *api.Run) { if a.ApprovalFrom != nil { from = *a.ApprovalFrom } - fmt.Fprintln(w) - fmt.Fprintln(w, "actions:") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "actions:") if len(p.Actions) == 0 { - fmt.Fprintln(w, " (none)") + _, _ = fmt.Fprintln(w, " (none)") } for i, act := range p.Actions { mode := "automatic" if from >= 0 && i >= from { mode = "needs approval" } - fmt.Fprintf(w, " %d. %s [%s]\n", i+1, actionLine(act), mode) + _, _ = fmt.Fprintf(w, " %d. %s [%s]\n", i+1, actionLine(act), mode) if act.CommitMessage != "" { indented(w, "commit message", act.CommitMessage) } @@ -523,30 +523,30 @@ func Proposal(w io.Writer, run *api.Run) { } if ds != nil && len(ds.Files) > 0 { - fmt.Fprintln(w) - fmt.Fprintln(w, "files:") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "files:") for _, f := range ds.Files { change := fmt.Sprintf("+%d -%d", f.Added, f.Deleted) if f.Binary { change = "binary" } - fmt.Fprintf(w, " %-12s %s\n", change, SanitizeLine(f.Path)) + _, _ = fmt.Fprintf(w, " %-12s %s\n", change, SanitizeLine(f.Path)) } if ds.Truncated { - fmt.Fprintln(w, " … (more files than the summary lists)") + _, _ = fmt.Fprintln(w, " … (more files than the summary lists)") } } - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) if a.HasDiff { - fmt.Fprintf(w, "The full diff is stored: nan runs proposal %s --diff\n", id) + _, _ = fmt.Fprintf(w, "The full diff is stored: nan runs proposal %s --diff\n", id) } if a.DiffMasked { - fmt.Fprintln(w, "The diff contains the value of one of your secrets (shown here as [masked]); approving pushes it to your repository.") + _, _ = fmt.Fprintln(w, "The diff contains the value of one of your secrets (shown here as [masked]); approving pushes it to your repository.") } if a.State == api.ApprovalPending && run.State == api.StateAwaitingApproval { - fmt.Fprintln(w, approveLine(run)) - fmt.Fprintln(w, rejectLine(run)) + _, _ = fmt.Fprintln(w, approveLine(run)) + _, _ = fmt.Fprintln(w, rejectLine(run)) } } @@ -593,18 +593,18 @@ func indented(w io.Writer, title, text string) { if strings.TrimSpace(text) == "" { return } - fmt.Fprintf(w, " %s:\n", title) + _, _ = fmt.Fprintf(w, " %s:\n", title) for _, l := range strings.Split(text, "\n") { - fmt.Fprintf(w, " %s\n", l) + _, _ = fmt.Fprintf(w, " %s\n", l) } } // Diff prints a proposal's diff, sanitised: it is text the agent produced. func Diff(w io.Writer, diff string) { text := Sanitize(diff) - fmt.Fprint(w, text) + _, _ = fmt.Fprint(w, text) if text != "" && !strings.HasSuffix(text, "\n") { - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) } } diff --git a/internal/runs/render_test.go b/internal/runs/render_test.go index 785aa39..89788ef 100644 --- a/internal/runs/render_test.go +++ b/internal/runs/render_test.go @@ -25,7 +25,7 @@ func TestSanitizeStripsEscapesAndControls(t *testing.T) { {"backspace and bell", "ab\x08\x07c", "abc"}, {"NUL and DEL", "a\x00b\x7fc", "abc"}, {"C1 CSI", "a\u009b31mb", "a31mb"}, - {"bidi override", "a‮b⁦c", "abc"}, + {"bidi override", "a\u202eb\u2066c", "abc"}, {"invalid UTF-8", "a\xffb", "a�b"}, {"non-ASCII text survives", "¡Hola, señor! 日本 🚀", "¡Hola, señor! 日本 🚀"}, } { @@ -115,15 +115,15 @@ func TestExitCodeFollowsTheTable(t *testing.T) { } func TestSafeJSONEscapesWhatTerminalsActOn(t *testing.T) { - out, err := SafeJSON(map[string]string{"t": "a\x1b[31m\u009b‮b"}, false) + out, err := SafeJSON(map[string]string{"t": "a\x1b[31m\u009b\u202eb"}, false) if err != nil { t.Fatal(err) } - if strings.ContainsAny(string(out), "\x1b\u009b‮") { + if strings.ContainsAny(string(out), "\x1b\u009b\u202e") { t.Errorf("raw control in JSON: %q", out) } var back map[string]string - if err := json.Unmarshal(out, &back); err != nil || back["t"] != "a\x1b[31m\u009b‮b" { + if err := json.Unmarshal(out, &back); err != nil || back["t"] != "a\x1b[31m\u009b\u202eb" { t.Errorf("did not round-trip: %v %q", err, back["t"]) } } diff --git a/internal/session/session.go b/internal/session/session.go index 1d74920..8d522b4 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1,76 +1,126 @@ +// Package session stores the signed-in session on disk. +// +// One file, one member, no database. What makes it worth its own package is +// not the JSON — it is that the file holds a credential, and so how it is +// written matters as much as what is in it. package session import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "runtime" + + "github.com/nxssie/nan-cli/internal/build" ) +// Session is what survives between runs. +// +// The JSON is a contract with every copy of this CLI already installed: the +// file is read by whichever version runs next, so a field is never renamed +// and a new one is always omitempty. type Session struct { - Token string `json:"token"` + // Token is the session the platform issued. It is the credential: + // anyone holding it is the member until it expires. Empty after the + // platform refused it, while the fields below are kept. + Token string `json:"token"` + + // APIKey is the member's inference key, as pasted into Setup. It is a + // SECOND credential, and it outlives the session: it is kept because the + // tools it configures need it again, to rewrite a config and to take it + // back out on sign-out. The file is 0600 for the session's sake and this + // rides along. APIKey string `json:"apiKey,omitempty"` + // PlatformToken is a nan_pat_ token saved with `nan auth login - // --api-token`. It is kept apart from APIKey because the Setup tab writes - // APIKey into every tool it configures, and the inference API refuses - // platform tokens. - PlatformToken string `json:"platformToken,omitempty"` - EnabledTools map[string]bool `json:"enabledTools,omitempty"` + // --api-token`. It is kept apart from APIKey because Setup writes APIKey + // into every tool it configures, and the inference API refuses platform + // tokens. + PlatformToken string `json:"platformToken,omitempty"` + + // EnabledTools is which tools are ticked, so Setup opens showing what + // is true rather than an empty list. A tool missing from it is ticked. + EnabledTools map[string]bool `json:"enabledTools,omitempty"` } // TokenEnvVar is the environment variable `nan run` and `nan runs` read a // platform token or an API key from. Nothing writes it to disk. const TokenEnvVar = "NAN_TOKEN" -var ErrNotLoggedIn = errors.New("not logged in — run: nan auth login") +// ErrNotLoggedIn is what every command hits on a fresh machine. +var ErrNotLoggedIn = errors.New("not logged in - run: " + build.Name + " auth login") +// SignedIn reports whether there is a session to send. +func (s *Session) SignedIn() bool { return s != nil && s.Token != "" } + +// dir is ~/.config/nan on every platform, Windows included, because that is +// where every earlier version put it: moving it would sign everybody out on +// the update and strand the key they pasted. func dir() (string, error) { home, err := os.UserHomeDir() if err != nil { - return "", err + return "", fmt.Errorf("could not work out where to keep the session: %w", err) } - return filepath.Join(home, ".config", "nan"), nil + return filepath.Join(home, ".config", build.Name), nil } -// Path is where the session actually lives, for the About tab to show. It was -// a "~/.config/nan/session.json" typed into the renderer, which is not a path -// on Windows and is not where anything is: a member told to look there finds -// nothing, and the tilde is not something Explorer resolves. +// Path is where the session lives, for the About screen and for error +// messages to name. It falls back to a relative path rather than failing, +// because a screen that cannot say where the file is should still render. func Path() string { d, err := dir() if err != nil { - return filepath.Join(".config", "nan", "session.json") + return filepath.Join(".config", build.Name, "session.json") } return filepath.Join(d, "session.json") } +// Load reads the session. A missing file is ErrNotLoggedIn rather than an +// I/O error: it is the normal state of a machine nobody has signed in on. +// +// A file without a token is still returned: it is a session the platform +// refused, and the key and the ticked tools in it are still the member's. +// Whoever needs the session checks SignedIn. func Load() (*Session, error) { - d, err := dir() - if err != nil { - return nil, err - } - data, err := os.ReadFile(filepath.Join(d, "session.json")) - if os.IsNotExist(err) { + data, err := os.ReadFile(Path()) + if errors.Is(err, os.ErrNotExist) { return nil, ErrNotLoggedIn } if err != nil { return nil, err } var s Session - return &s, json.Unmarshal(data, &s) + if err := json.Unmarshal(data, &s); err != nil { + // A corrupt file is recoverable by signing in again, and saying so + // is more use than the parser's offset. + return nil, fmt.Errorf("the saved session is unreadable (%s) - run: %s auth login", Path(), build.Name) + } + return &s, nil +} + +// LoadOrEmpty is for the dashboard, which opens on a machine nobody has +// signed in on and offers to fix that, rather than refusing to start. +func LoadOrEmpty() *Session { + s, err := Load() + if err != nil { + return &Session{} + } + return s } -// Save writes the session, which holds both the token and the API key, so how -// it is written matters as much as where. +// Save writes the session. // -// os.WriteFile applies its mode only where it CREATES the file: a session.json -// left behind by an older version, or by a restore that widened it, kept -// whatever mode it already had and the 0o600 here did nothing at all. Writing -// to a temp file created 0600 and renaming it over the top is the only version -// of this that ends with the mode we asked for whatever was there before - and -// it never leaves a half-written session.json behind either, which used to -// read on the next run as not being logged in. +// Via a temp file and a rename, for two reasons that both cost a release +// somewhere. os.WriteFile applies its mode only where it CREATES the file, +// so a session.json left behind by an older version — or restored by a +// backup tool that widened it — keeps whatever mode it already had and the +// 0600 does nothing at all. And a write interrupted halfway leaves a +// truncated file, which reads on the next run as not being signed in. +// Creating a new file 0600 and renaming it over the top is the only version +// of this that ends with the mode we asked for whatever was there before, +// and never leaves a half-written one behind. func Save(s *Session) error { d, err := dir() if err != nil { @@ -79,7 +129,10 @@ func Save(s *Session) error { if err := os.MkdirAll(d, 0o700); err != nil { return err } - data, err := json.Marshal(s) + // G117 flags this for marshalling a field that looks like a secret. It + // is one, and writing it here is the point: the file is created 0600 and + // re-tightened on every save by the rename below. + data, err := json.Marshal(s) //nolint:gosec // G117: deliberate, see the Session doc if err != nil { return err } @@ -89,32 +142,30 @@ func Save(s *Session) error { return err } name := tmp.Name() - defer os.Remove(name) // does nothing once the rename has taken it + defer func() { _ = os.Remove(name) }() // a no-op once the rename has taken it if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return err } - // On Windows the mode bits are not the mechanism - the file inherits the - // ACL of the profile directory - so a filesystem that will not take the - // chmod is no reason to fail the login that is being saved. + // On Windows the mode bits are not the mechanism — the file inherits + // the ACL of the profile directory — so a filesystem that will not take + // the chmod is no reason to fail the sign-in being saved. if err := tmp.Chmod(0o600); err != nil && runtime.GOOS != "windows" { - tmp.Close() + _ = tmp.Close() return err } if err := tmp.Close(); err != nil { return err } - return os.Rename(name, filepath.Join(d, "session.json")) + return os.Rename(name, Path()) } +// Delete removes the session. A file that is already gone is success: that +// is the state `logout` is trying to reach. func Delete() error { - d, err := dir() - if err != nil { - return err - } - err = os.Remove(filepath.Join(d, "session.json")) - if os.IsNotExist(err) { + err := os.Remove(Path()) + if errors.Is(err, os.ErrNotExist) { return nil } return err diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 4afbd03..648f3fb 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1,6 +1,7 @@ package session import ( + "errors" "os" "path/filepath" "runtime" @@ -75,7 +76,32 @@ func TestSaveAndLoad(t *testing.T) { func TestLoadWithoutASessionSaysSo(t *testing.T) { tempHome(t) - if _, err := Load(); err != ErrNotLoggedIn { + if _, err := Load(); !errors.Is(err, ErrNotLoggedIn) { t.Errorf("Load() on a clean machine = %v, want %v", err, ErrNotLoggedIn) } } + +// A session the platform refused keeps the rest: the key pasted into Setup +// and the ticked tools are still the member's, and the next sign-in should +// not ask for them again. +func TestASessionWithoutATokenKeepsItsKey(t *testing.T) { + tempHome(t) + if err := Save(&Session{APIKey: "a-key", EnabledTools: map[string]bool{"Pi": false}}); err != nil { + t.Fatal(err) + } + got, err := Load() + if err != nil { + t.Fatal(err) + } + if got.SignedIn() || got.APIKey != "a-key" || got.EnabledTools["Pi"] { + t.Errorf("loaded %+v", got) + } +} + +// The file stays where every earlier version put it, on every platform. +func TestTheSessionLivesUnderDotConfigNan(t *testing.T) { + home := tempHome(t) + if want := filepath.Join(home, ".config", "nan", "session.json"); Path() != want { + t.Errorf("Path() = %s, want %s", Path(), want) + } +} diff --git a/internal/tools/codex.go b/internal/tools/codex.go new file mode 100644 index 0000000..147dc74 --- /dev/null +++ b/internal/tools/codex.go @@ -0,0 +1,417 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + catalog "github.com/nxssie/nan-cli/internal/models" +) + +func writeCodexConfig(cfgPath, apiKey string) error { + data, _ := os.ReadFile(cfgPath) //nolint:gosec // G304: a config file at a path this program chose + codexModel, _ := catalog.Get(catalog.Coding) + + if strings.Contains(string(data), "api.nan.builders") { + // Already pointing at the cluster, so there is nothing to add - but + // the section may be one we wrote back when it said wire_api = + // "chat", and Codex 0.154 will not load a config with that value at + // all: it prints the error and exits before the TUI is up. A member + // whose Codex does not open is the last member who can be asked to + // disconnect and reconnect it, so the repair happens here. + // + // The same member is also the one most likely to be carrying a + // model_context_window we appended once per run, back when it went to + // the end of the file. Two of them is a duplicate key, and Codex + // refuses the whole file for it - the desktop app too, which opens on + // an error dialog and nothing else. Repairing the wire_api alone left + // that member exactly as stuck as before, so the pruning runs first + // and puts the key back where it is read from. + repaired, pruned := codexContextWindowsPruned(data, codexModel.Context) + if pruned { + repaired = []byte(withCodexContextWindow(string(repaired), codexModel.Context)) + } + rewired, changed := codexWireAPIRepaired(repaired) + if pruned || changed { + if err := writeConfigFile(cfgPath, rewired); err != nil { + return err + } + } + return writeCodexProfiles(filepath.Dir(cfgPath)) + } + + // If no existing config, write a complete starter config. + if len(data) == 0 { + content := fmt.Sprintf(`model = %q +model_provider = "nan" +model_context_window = %d + +[model_providers.nan] +name = "NaN" +base_url = "https://api.nan.builders/v1" +experimental_bearer_token = %q +wire_api = "responses" +`, codexModel.ID, codexModel.Context, apiKey) + if err := writeConfigFile(cfgPath, []byte(content)); err != nil { + return err + } + return writeCodexProfiles(filepath.Dir(cfgPath)) + } + + // Existing config: only append the provider section; preserve user's model/provider choices. + section := fmt.Sprintf(` +[model_providers.nan] +name = "NaN" +base_url = "https://api.nan.builders/v1" +experimental_bearer_token = %q +wire_api = "responses" +`, apiKey) + content := strings.TrimRight(string(data), "\n") + "\n" + section + if err := writeConfigFile(cfgPath, []byte(withCodexContextWindow(content, codexModel.Context))); err != nil { + return err + } + return writeCodexProfiles(filepath.Dir(cfgPath)) +} + +// One file per chat model, so every model on the cluster is a flag away with +// the window it is actually served at. +// +// `codex --model ` switches the model and nothing else, and the +// model_context_window in config.toml stays where it was: point it at a +// 262,144-token model with a million-token window declared and Codex compacts +// against a window that is not there. Codex 0.155 layers +// $CODEX_HOME/.config.toml over the base config for `--profile `, +// which is the one place a per-model window can live without a second copy of +// the member's provider, MCP servers and permissions. +// +// No API key goes in them: the provider section in config.toml is what +// carries it, and a key in eight files is a key to rotate in eight files. +// +// They also switch off the two tools Codex adds on its own. Codex 0.159 +// sends the sub-agent tools as a "namespace" tool and web search as a +// "web_search" one, and with either in the request the cluster's /responses +// stops streaming items for qwen3.8-flash and mimo: all that comes back is +// response.completed, which Codex does not read an answer from, so the turn +// ends with no text and no tool call. Neither tool has anywhere to go on the +// cluster anyway. Here and not in config.toml, because these files are ours +// and leave with the disconnect - a web_search = "disabled" at the root +// would stay behind and take search from the member's OpenAI sessions too. +func writeCodexProfiles(codexHome string) error { + for _, m := range catalog.ChatModels() { + body := fmt.Sprintf(`# Written by the NaN CLI. Disconnecting Codex removes it. +model = %q +model_provider = "nan" +model_context_window = %d +web_search = "disabled" + +[features] +multi_agent = false +`, m.ID, m.Context) + path := filepath.Join(codexHome, codexProfileName(m.ID)+".config.toml") + if err := writeConfigFile(path, []byte(body)); err != nil { + return err + } + } + return nil +} + +// Codex takes "a plain name" for --profile and rejects a dot in it outright, +// so the ids that carry a version number cannot be used as they are spelled: +// glm5.3-flash is refused, glm53-flash is not. The nan- prefix is what makes +// the file ours to delete later, and what keeps it clear of a profile the +// member wrote themselves. +func codexProfileName(id string) string { + return "nan-" + strings.ReplaceAll(id, ".", "") +} + +// Takes back only the files this CLI wrote, by the prefix it wrote them under. +func removeCodexProfiles(codexHome string) error { + entries, err := os.ReadDir(codexHome) + if err != nil { + return nil //nolint:nilerr // a file that cannot be read has nothing of ours in it + } + for _, e := range entries { + name := e.Name() + if !e.IsDir() && strings.HasPrefix(name, "nan-") && strings.HasSuffix(name, ".config.toml") { + if err := os.Remove(filepath.Join(codexHome, name)); err != nil && !os.IsNotExist(err) { + return err + } + } + } + return nil +} + +// Codex has no metadata for a model on this cluster, so without +// model_context_window it compacts against a window it guessed. (It prints +// the "metadata not found" warning either way - that fires before it reads +// the override.) +// +// The key is a root key, so it belongs above the first table header. +// Appended at the end of the file the way the section around it is, it lands +// inside whatever table came last - for a member with [projects.*] entries it +// became a key of the last project they trusted, and Codex never looked +// there. +func withCodexContextWindow(content string, window int) string { + declared := fmt.Sprintf("model_context_window = %d", window) + lines := strings.Split(content, "\n") + for i, raw := range lines { + trimmed := strings.TrimSpace(raw) + // Everything above the first table header is the root table, so this + // only ever reads the member's root keys. + if strings.HasPrefix(trimmed, "[") { + head := strings.TrimRight(strings.Join(lines[:i], "\n"), "\n") + tail := strings.Join(lines[i:], "\n") + if head == "" { + return declared + "\n\n" + tail + } + return head + "\n" + declared + "\n\n" + tail + } + if key, _, ok := strings.Cut(trimmed, "="); ok && strings.TrimSpace(key) == "model_context_window" { + return content // the member declared one of their own; it stands + } + } + return strings.TrimRight(content, "\n") + "\n" + declared + "\n" +} + +// [projects."/home/member/repo"] and the [projects] table above it. This is +// the only shape the appending bug could put a key into: it wrote at the end +// of the file, and what sits at the end of a config.toml is the last project +// the member trusted. +func isCodexProjectsHeader(trimmed string) bool { + inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]")) + return inner == "projects" || strings.HasPrefix(inner, "projects.") +} + +// Takes out every model_context_window this CLI is responsible for putting +// somewhere it does not belong, and leaves at most one standing. +// +// Two of them anywhere in the file is a duplicate key, and Codex does not +// load a config with one: `codex` exits on the error and the desktop app +// opens an error dialog instead of a window. That is how a member ends up +// unable to use Codex at all, for a key that only ever existed to stop it +// compacting against a guessed window. +// +// Inside a table the key is not a root key at all - it reads as a key of +// whatever table came last, and the table it landed in was a [projects.*] +// the member trusted, because that is what a config.toml ends with. One +// carrying our own value under a [projects.*] header is ours, appended by a +// version that wrote it at the end of the file, and it goes. +// +// Under any other header it stays, whatever its value. The member's own +// tables are somewhere our appending bug could never reach, and [profiles.*] +// is now a place a window belongs: this CLI writes one per model, and the +// value it writes there is the same number the coding model declares. Judging +// those by value alone took a window the member had set on a profile of their +// own and moved it to the root, quietly changing what that profile does. +// +// In the root table the first one stands and any repeat after it is the +// duplicate - unless we are taking our own work back out, where the first one +// goes too if it reads as ours. See codexContextWindowsCleared. +// +// Done line by line for the same reason the wire_api repair is: a round trip +// through a TOML library reflows a document the member also edits by hand. +func codexContextWindowsPruned(data []byte, window int) ([]byte, bool) { + return codexContextWindowsSwept(data, window, true) +} + +// The disconnect half. Everything above still holds, and the root table stops +// being off limits: the key we put there went in beside the provider section +// and means nothing without it, so leaving it behind hands back a file +// declaring a window for a model the member no longer has. +// +// Ours is the one whose value is the window we write. A root key that says +// anything else is the member's - withCodexContextWindow steps aside for it +// on the way in, and this steps around it on the way out. The one case both +// halves get wrong is a member who wrote our exact number themselves before +// we ever ran: connecting leaves their key alone and disconnecting takes it +// for ours. Telling those apart needs a mark on the line, which the keys +// already out there do not carry. +func codexContextWindowsCleared(data []byte, window int) ([]byte, bool) { + return codexContextWindowsSwept(data, window, false) +} + +func codexContextWindowsSwept(data []byte, window int, keepRoot bool) ([]byte, bool) { + ours := strconv.Itoa(window) + lines := strings.Split(string(data), "\n") + out := make([]string, 0, len(lines)) + inTable, inProjects, keptRoot, pruned := false, false, false, false + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") { + inTable = true + inProjects = isCodexProjectsHeader(trimmed) + out = append(out, line) + continue + } + key, value, ok := strings.Cut(trimmed, "=") + if !ok || strings.TrimSpace(key) != "model_context_window" { + out = append(out, line) + continue + } + mine := strings.TrimSpace(value) == ours + drop := false + switch { + case inProjects: + drop = mine + // Under any other header it is the member's, and the root cases + // below are not about it. + case inTable: + drop = false + case !keepRoot && mine: + drop = true + case keptRoot: + drop = true + default: + keptRoot = true + } + if !drop { + out = append(out, line) + continue + } + pruned = true + // The line was written with a blank line on either side of it. Left + // alone both survive it and the file gains a gap where a key used to + // be, which is a diff the member did not ask for on a file they read. + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" && + i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "" { + i++ + } + } + if !pruned { + return data, false + } + return []byte(strings.Join(out, "\n")), true +} + +// Brings our own [model_providers.nan] section up to the only wire protocol +// Codex still speaks, and touches nothing else in the file - not another +// provider's wire_api, not the member's own keys, not a byte of the layout. +// +// The section used to say wire_api = "chat" on purpose: the cluster's +// /responses endpoint answered in one terminal event, so "responses" meant +// the whole reply arriving at once at the end instead of streaming. The +// endpoint streams deltas now, and Codex 0.154 dropped "chat" entirely, so +// the reason for the old value is gone twice over. +// +// Done by hand rather than through a TOML library because a round trip +// through one reflows the whole document - a member's comments, their +// spacing, their quoting - to satisfy a change of one word on one line. +func codexWireAPIRepaired(data []byte) ([]byte, bool) { + lines := strings.Split(string(data), "\n") + inNaN, changed := false, false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") { + inNaN = isCodexNaNProviderHeader(trimmed) + continue + } + if !inNaN { + continue + } + key, value, ok := strings.Cut(trimmed, "=") + if !ok || strings.TrimSpace(key) != "wire_api" { + continue + } + if strings.Trim(strings.TrimSpace(value), `"'`) != "chat" { + continue + } + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + lines[i] = indent + `wire_api = "responses"` + changed = true + } + if !changed { + return data, false + } + return []byte(strings.Join(lines, "\n")), true +} + +// [model_providers.nan], and the quoted spelling of it that TOML also allows. +func isCodexNaNProviderHeader(line string) bool { + name := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + if !strings.HasPrefix(name, "model_providers.") { + return false + } + return strings.Trim(strings.TrimPrefix(name, "model_providers."), `"'`) == "nan" +} + +// ── Hermes ─────────────────────────────────────────────────────────────────── +// +// Every other tool here is configured by writing its file. Hermes is not, +// because its config.yaml is a long commented document the member also edits +// by hand, and because it ships the command to do it: `hermes config set` +// writes a key without flattening the comments around it and rejects a key it +// does not know. Reproducing that schema in Go would buy nothing and would +// start drifting the day Hermes moves a field. +// +// Two things the tool knows and the docs page does not say. The path in +// nan.builders/docs/hermes is the Unix one; on Windows Hermes keeps its home +// under LOCALAPPDATA. And with the `custom` provider Hermes asks the endpoint +// what it serves, so there is no model list to write here and none to keep in +// step with the cluster - only which model to open with. + +func removeCodexConfig(cfgPath string) error { + // Before either way out below, because the profile files are ours whatever + // state config.toml is in. A member who edited our provider section out by + // hand, or deleted the file, used to keep all seven of them - each one + // naming model_provider = "nan", which by then resolves to nothing, so + // `codex -p nan-qwen36` fails and no tool on the machine admits to having + // put them there. + // + // The error waits until the end. A profile that will not delete - open in + // an editor, locked by a running Codex - is a cosmetic failure, and + // returning it here would leave experimental_bearer_token sitting in + // config.toml after the member asked us to take it out. + profileErr := removeCodexProfiles(filepath.Dir(cfgPath)) + + data, err := os.ReadFile(cfgPath) //nolint:gosec // G304: a config file at a path this program chose + if err != nil { + return profileErr + } + // Nothing of ours in it, so nothing to rewrite: this is called on every + // sign-out, against a config.toml that may never have been ours at all. + if !strings.Contains(string(data), "[model_providers.nan]") { + return profileErr + } + lines := strings.Split(string(data), "\n") + var out []string + inNanSection := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "[model_providers.nan]" { + inNanSection = true + continue + } + if inNanSection { + // End of the nan section when a new section header appears. + if strings.HasPrefix(trimmed, "[") { + inNanSection = false + } else { + continue + } + } + out = append(out, line) + } + result := strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n" + // The section is gone, and so is the reason for the model_context_window + // that went in beside it. Left behind it is a key the member never wrote, + // naming a window no model of theirs has - and if there are two of them + // it is a file their Codex does not open, now with nothing left in it to + // say who did that or which tool to disconnect to undo it. + codexModel, _ := catalog.Get(catalog.Coding) + if cleared, changed := codexContextWindowsCleared([]byte(result), codexModel.Context); changed { + result = string(cleared) + } + if strings.TrimSpace(result) == "" { + if err := os.Remove(cfgPath); err != nil { + return err + } + return profileErr + } + if err := writeConfigFile(cfgPath, []byte(result)); err != nil { + return err + } + return profileErr +} diff --git a/internal/tools/detect.go b/internal/tools/detect.go new file mode 100644 index 0000000..4af6795 --- /dev/null +++ b/internal/tools/detect.go @@ -0,0 +1,65 @@ +package tools + +import ( + "encoding/json" + "os" + "strings" +) + +// nanHost is how a config is recognised as pointing at NaN. +const nanHost = "api.nan.builders" + +func factoryConfigured(path string) bool { + data, err := os.ReadFile(path) //nolint:gosec // G304: a config file at a path this program chose + if err != nil { + return false + } + var cfg struct { + CustomModels []map[string]any `json:"customModels"` + } + if json.Unmarshal(data, &cfg) != nil { + return false + } + for _, m := range cfg.CustomModels { + if base, _ := m["baseUrl"].(string); strings.Contains(base, nanHost) { + return true + } + } + return false +} + +func opencodeConfigured(path string) bool { + cfg := readJSONMap(path) + providers, _ := cfg["provider"].(map[string]any) + nan, _ := providers["nan"].(map[string]any) + opts, _ := nan["options"].(map[string]any) + base, _ := opts["baseURL"].(string) + return strings.Contains(base, nanHost) +} + +func piConfigured(path string) bool { + cfg := readJSONMap(path) + providers, _ := cfg["providers"].(map[string]any) + nan, _ := providers["nan"].(map[string]any) + base, _ := nan["baseUrl"].(string) + return strings.Contains(base, nanHost) +} + +// mentionsNaN is the check for the configs that are not JSON: Codex's TOML, +// edited as text to keep its comments, and Hermes' YAML, written by Hermes. +func mentionsNaN(path string) bool { + data, err := os.ReadFile(path) //nolint:gosec // G304: a config file at a path this program chose + return err == nil && strings.Contains(string(data), nanHost) +} + +func readJSONMap(path string) map[string]any { + data, err := os.ReadFile(path) //nolint:gosec // G304: a config file at a path this program chose + if err != nil { + return nil + } + var cfg map[string]any + if json.Unmarshal(data, &cfg) != nil { + return nil + } + return cfg +} diff --git a/internal/tools/factory.go b/internal/tools/factory.go new file mode 100644 index 0000000..c19dfb0 --- /dev/null +++ b/internal/tools/factory.go @@ -0,0 +1,140 @@ +package tools + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + catalog "github.com/nxssie/nan-cli/internal/models" +) + +// factoryCustomID is the id Factory knows one of our models by, built the way +// Factory builds its own: the display name with dashes, and the position. +func factoryCustomID(displayName string, index int) string { + return fmt.Sprintf("custom:%s-%d", strings.ReplaceAll(displayName, " ", "-"), index) +} + +func writeFactoryConfig(cfgPath, apiKey string) error { + // A map, so unknown top-level fields (logoAnimation, etc.) survive. + cfg, err := readJSONConfig(cfgPath) + if err != nil { + return err + } + + // Extract existing customModels + var models []map[string]any + if raw, ok := cfg["customModels"].([]any); ok { + for _, item := range raw { + if m, ok := item.(map[string]any); ok { + models = append(models, m) + } + } + } + + // Index NaN model IDs already present + existingIDs := map[string]bool{} + for _, m := range models { + if base, _ := m["baseUrl"].(string); strings.Contains(base, "api.nan.builders") { + if id, _ := m["model"].(string); id != "" { + existingIDs[id] = true + } + } + } + + // Append only missing models + added := false + for _, nm := range catalog.ChatModels() { + if !existingIDs[nm.ID] { + idx := len(models) + display := nm.Name + " (NaN)" + models = append(models, map[string]any{ + "model": nm.ID, + "id": factoryCustomID(display, idx), + "index": idx, + "baseUrl": "https://api.nan.builders/v1", + "apiKey": apiKey, + "displayName": display, + "noImageSupport": !nm.Accepts(catalog.InputImage), + "provider": "openai", + }) + added = true + } + } + if !added { + return nil + } + cfg["customModels"] = models + + // Leave it pointing at the model the quickstart starts everyone with. It + // used to be qwen3.6, which the docs now call the previous generation. + if _, ok := cfg["sessionDefaultSettings"]; !ok { + for _, m := range models { + if id, _ := m["model"].(string); id == catalog.Default { + cfg["sessionDefaultSettings"] = map[string]any{ + "model": m["id"], + "reasoningEffort": "none", + "autonomyMode": "normal", + } + break + } + } + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return writeConfigFile(cfgPath, data) +} + +func removeFactoryConfig(cfgPath string) error { + data, err := os.ReadFile(cfgPath) //nolint:gosec // G304: a config file at a path this program chose + if err != nil { + return nil //nolint:nilerr // a file that cannot be read has nothing of ours in it + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + + raw, _ := cfg["customModels"].([]any) + var filtered []map[string]any + var removedIDs []string + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + if base, _ := m["baseUrl"].(string); strings.Contains(base, "api.nan.builders") { + if id, _ := m["id"].(string); id != "" { + removedIDs = append(removedIDs, id) + } + } else { + filtered = append(filtered, m) + } + } + if len(removedIDs) == 0 { + return nil + } + cfg["customModels"] = filtered + + // Clear sessionDefaultSettings.model if it pointed to a removed NaN model + if sds, ok := cfg["sessionDefaultSettings"].(map[string]any); ok { + if model, _ := sds["model"].(string); model != "" { + for _, rid := range removedIDs { + if model == rid { + delete(sds, "model") + cfg["sessionDefaultSettings"] = sds + break + } + } + } + } + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return writeConfigFile(cfgPath, out) +} diff --git a/internal/tools/file.go b/internal/tools/file.go new file mode 100644 index 0000000..ee4e83f --- /dev/null +++ b/internal/tools/file.go @@ -0,0 +1,104 @@ +package tools + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +// ── writing a file that carries a key ──────────────────────────────────────── + +// writeConfigFile writes a tool's config the way a file holding an API key has +// to be written, which is not what os.WriteFile does on its own. +// +// Its mode argument applies only where the file is CREATED, and the usual case +// here is the opposite one: ~/.codex/config.toml and the rest already exist, +// written by the tool itself, commonly 0644. The key went into them and the +// 0o600 in the call did nothing whatsoever - the file kept the mode it had, +// readable by every account on the machine. +// +// And a plain write truncates first: interrupted halfway it leaves a member's +// config - every provider in it, not only ours - cut in two. So the bytes go +// to a temp file in the same directory, which is created 0600, and that file +// is renamed over the destination. The rename happens once or not at all, and +// it carries the temp file's mode with it, which is where the 0600 sticks. +func writeConfigFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + // Managed dotfiles are symlinks into a repo. Renaming over the link would + // replace it with a regular file and quietly detach the config from the + // thing that manages it, so the write follows the link first. + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + + tmp, err := os.CreateTemp(filepath.Dir(path), ".nan-*.tmp") + if err != nil { + return err + } + name := tmp.Name() + defer func() { _ = os.Remove(name) }() // does nothing once the rename has taken it + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + // On Windows the mode bits are not the mechanism - the file inherits the + // ACL of the profile directory it sits in - so a filesystem that will not + // take the chmod is no reason to refuse to write the config at all. + if err := tmp.Chmod(0o600); err != nil && runtime.GOOS != "windows" { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + // Windows will not replace a file another process is holding open, + // where the plain write this replaced would have gone through. The + // mode bits are not the mechanism there in any case, so falling back + // to that write leaves nothing worse than what came before it. On + // every other platform a rename that failed is a failure. + if runtime.GOOS == "windows" { + return os.WriteFile(path, data, 0o600) //nolint:gosec // G703: the path is a tool config this program chose + } + return err + } + return nil +} + +// readJSONConfig reads a tool's config, and refuses to guess at one it cannot +// parse. +// +// These files are shared: opencode.json and Pi's models.json hold every +// provider a member has, other vendors' API keys included. The readers here +// used to drop the unmarshal error on the floor, which meant a file this CLI +// could not parse - a stray comma, a half-finished hand edit, a schema the +// tool has since moved on to - was read as nothing and then written again from +// scratch, taking every other provider in it along with it. Not being able to +// read the file is precisely the case where it must not be touched. +func readJSONConfig(path string) (map[string]any, error) { + data, err := os.ReadFile(path) //nolint:gosec // G304: a config file at a path this program chose + if os.IsNotExist(err) { + return map[string]any{}, nil + } + if err != nil { + return nil, err + } + if strings.TrimSpace(string(data)) == "" { + return map[string]any{}, nil + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("%s does not parse as JSON, so it was left exactly as it is: %w", + filepath.Base(path), err) + } + if cfg == nil { + cfg = map[string]any{} + } + return cfg, nil +} diff --git a/internal/tools/hermes.go b/internal/tools/hermes.go new file mode 100644 index 0000000..fcf5074 --- /dev/null +++ b/internal/tools/hermes.go @@ -0,0 +1,231 @@ +package tools + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + catalog "github.com/nxssie/nan-cli/internal/models" +) + +// Swapped in tests, so nothing here spawns a process or goes near the +// member's own Hermes. HERMES_HOME is passed on every call rather than +// inherited: the writer configures the install it detected, not whichever one +// the environment happens to point at. +var runHermesConfig = func(home string, args ...string) error { + cmd := exec.Command("hermes", append([]string{"config"}, args...)...) //nolint:gosec // G204: the binary is hermes, found on PATH; the arguments are ours and carry no secret + cmd.Env = append(os.Environ(), "HERMES_HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + return hermesConfigError(args, string(out)) + } + return nil +} + +// The verb and the key it was called with, and never the value. +// +// This used to join every argument into the message, and the message is +// rendered in the panel: one `set` that Hermes refused printed the member's +// API key onto their screen, into their scrollback, and into the issue they +// then pasted all of it into. Nothing past the key belongs in an error. +func hermesConfigError(args []string, out string) error { + said := args + if len(said) > 2 { + said = said[:2] + } + return fmt.Errorf("hermes config %s: %s", strings.Join(said, " "), strings.TrimSpace(out)) +} + +// Reading a value back, for the one thing that has to be confirmed rather than +// assumed. Swapped in tests alongside the writer above. +var readHermesConfig = func(home string, args ...string) (string, error) { + cmd := exec.Command("hermes", append([]string{"config"}, args...)...) //nolint:gosec // G204: the binary is hermes, found on PATH; the arguments are ours and carry no secret + cmd.Env = append(os.Environ(), "HERMES_HOME="+home) + out, err := cmd.Output() + if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + return "", hermesConfigError(args, string(exit.Stderr)) + } + return "", hermesConfigError(args, err.Error()) + } + return strings.TrimSpace(string(out)), nil +} + +// The resolution Hermes itself uses: HERMES_HOME, then the platform default. +func hermesHome() string { + if home := strings.TrimSpace(os.Getenv("HERMES_HOME")); home != "" { + return home + } + if runtime.GOOS == "windows" { + if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" { + return filepath.Join(local, "hermes") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, "AppData", "Local", "hermes") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".hermes") +} + +func hermesConfigPath(home string) string { + return filepath.Join(home, "config.yaml") +} + +// Hermes keeps its secrets in a .env beside its config - it has a `hermes +// config env-path` for exactly this - and expands ${VAR} in any config value +// against it when it loads. So the config gets the placeholder and the .env +// gets the key. +const ( + hermesEnvFile = ".env" + hermesKeyVar = "NAN_API_KEY" + hermesKeyRef = "${" + hermesKeyVar + "}" + hermesKeyNote = "# Written by nan-cli. config.yaml points at it as " + hermesKeyRef + "." +) + +func hermesEnvPath(home string) string { + return filepath.Join(home, hermesEnvFile) +} + +func writeHermesConfig(home, apiKey string) error { + // The key goes into Hermes' .env, and the config points at it. Not because + // the config is a worse place to keep it - same machine, same member, same + // 0600 - but because of the way it would have to get there. + // + // Every value here is written by running `hermes config set `, + // and an argument is not private: on Linux /proc//cmdline is readable + // by other accounts on the box, and `ps` hands it to anything running as + // the member. A key passed that way is exposed for as long as the process + // lives, to readers this CLI never meant to hand it to, and there is no + // taking it back afterwards. The placeholder is not a secret and can go + // through argv; the key is written straight to disk and appears in no + // argument list at all. + if err := writeHermesEnvKey(hermesEnvPath(home), apiKey); err != nil { + return err + } + settings := [][2]string{ + {"model.provider", "custom"}, + {"model.base_url", "https://api.nan.builders/v1"}, + {"model.api_key", hermesKeyRef}, + {"model.default", catalog.Coding}, + } + for _, s := range settings { + if err := runHermesConfig(home, "set", s[0], s[1]); err != nil { + return err + } + } + return confirmHermesResolvesTheKey(home, apiKey) +} + +// Everything above rests on Hermes expanding ${VAR} in a config value against +// its own .env, which it does today and which nothing here controls. Asking it +// what it ended up with is the only way to know it happened: a build that +// stopped expanding would leave the literal ${NAN_API_KEY} in place as the +// key, and what a member would see is a 401 from the cluster naming nothing in +// particular. +// +// The answer is the key itself, so it is compared and dropped on the spot. It +// does not go into a message, a log or an error. +func confirmHermesResolvesTheKey(home, apiKey string) error { + resolved, err := readHermesConfig(home, "get", "model.api_key") + if err != nil { + // No answer is not a wrong answer. An older Hermes without this + // subcommand, or one that could not be run twice, says nothing either + // way about what it will do with the config - and failing the whole + // step over a question we could not ask would be its own bug. + return nil //nolint:nilerr // a file that cannot be read has nothing of ours in it + } + if resolved != apiKey { + //nolint:staticcheck // ST1005: Hermes is a name, and starts the sentence + return fmt.Errorf("Hermes did not resolve %s from its .env, so it has no usable key - "+ + "nan.builders/docs/hermes has the manual steps", hermesKeyRef) + } + return nil +} + +// One variable set in the .env, and the rest of the file - a long commented +// document with the member's other providers in it - left as it was. +func writeHermesEnvKey(envPath, apiKey string) error { + var lines []string + if data, err := os.ReadFile(envPath); err == nil { //nolint:gosec // G304: a config file at a path this program chose + if body := strings.TrimRight(string(data), "\n"); body != "" { + lines = strings.Split(body, "\n") + } + } else if !os.IsNotExist(err) { + return err + } + + entry := hermesKeyVar + "=" + apiKey + replaced := false + for i, line := range lines { + if isHermesKeyLine(line) { + lines[i] = entry + replaced = true + } + } + if !replaced { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, hermesKeyNote, entry) + } + return writeConfigFile(envPath, []byte(strings.Join(lines, "\n")+"\n")) +} + +// The assignment, and not a commented-out example of the same name: blanking +// one of those would leave the real one further down the file still winning. +func isHermesKeyLine(line string) bool { + name, _, ok := strings.Cut(line, "=") + return ok && strings.TrimSpace(name) == hermesKeyVar +} + +// The same four keys and nothing else: a member's channels, skills and +// persona live in this file too. +func removeHermesConfig(home string) error { + // Four processes, so only where there is something to unset. The .env + // below is checked either way: it is written before the first of these + // calls, so a run that failed part-way through leaves the key there and + // nothing in config.yaml to say so. + if cfg, err := os.ReadFile(hermesConfigPath(home)); err == nil && strings.Contains(string(cfg), "api.nan.builders") { + for _, key := range []string{"model.api_key", "model.base_url", "model.default", "model.provider"} { + if err := runHermesConfig(home, "unset", key); err != nil { + return err + } + } + } + // And the key the config was pointing at. Unsetting model.api_key drops + // the reference, which leaves the secret itself sitting in the .env. + return removeHermesEnvKey(hermesEnvPath(home)) +} + +func removeHermesEnvKey(envPath string) error { + data, err := os.ReadFile(envPath) //nolint:gosec // G304: a config file at a path this program chose + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + var kept []string + dropped := false + for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { + if isHermesKeyLine(line) || line == hermesKeyNote { + dropped = true + continue + } + kept = append(kept, line) + } + if !dropped { + return nil + } + // A .env that held nothing but our two lines was ours to begin with. + if len(kept) == 0 { + return os.Remove(envPath) + } + return writeConfigFile(envPath, []byte(strings.Join(kept, "\n")+"\n")) +} diff --git a/internal/tools/opencode.go b/internal/tools/opencode.go new file mode 100644 index 0000000..424d651 --- /dev/null +++ b/internal/tools/opencode.go @@ -0,0 +1,137 @@ +package tools + +import ( + "encoding/json" + "os" + "strings" + + catalog "github.com/nxssie/nan-cli/internal/models" +) + +func writeOpencodeConfig(cfgPath, apiKey string) error { + cfg, err := readJSONConfig(cfgPath) + if err != nil { + return err + } + + providers, _ := cfg["provider"].(map[string]any) + if providers == nil { + providers = map[string]any{} + } + + // `limit` is not decoration: without it opencode falls back to its own + // guess at the window and compacts a 1M-token session as if it were a + // small one. An unknown key (this used to be written as `contextWindow`) + // raises nothing anyone sees, which is why nan.builders/docs/opencode + // spells the field out and why this writes it. + nanModels := map[string]any{} + for _, m := range catalog.ChatModels() { + nanModels[m.ID] = map[string]any{ + "name": m.Name, + "limit": map[string]any{ + "context": m.Context, + "output": m.Output, + }, + "modalities": map[string]any{ + "input": m.Inputs, + "output": []string{"text"}, + }, + } + } + + if nan, ok := providers["nan"].(map[string]any); ok { + if opts, ok := nan["options"].(map[string]any); ok { + if base, _ := opts["baseURL"].(string); strings.Contains(base, "api.nan.builders") { + // Already configured — merge any missing models + existing, _ := nan["models"].(map[string]any) + if existing == nil { + existing = map[string]any{} + } + changed := false + for id, m := range nanModels { + found, ok := existing[id].(map[string]any) + if !ok { + existing[id] = m + changed = true + continue + } + // An entry written by an older version of this CLI has no + // `limit` at all. Filling it in is the only way those + // configs ever stop compacting early; anything the member + // set themselves is left exactly as it is. + for _, field := range []string{"limit", "modalities"} { + if _, present := found[field]; !present { + found[field] = m.(map[string]any)[field] + changed = true + } + } + } + if !changed { + return nil + } + nan["models"] = existing + providers["nan"] = nan + cfg["provider"] = providers + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return writeConfigFile(cfgPath, data) + } + } + } + + if _, ok := cfg["$schema"]; !ok { + cfg["$schema"] = "https://opencode.ai/config.json" + } + providers["nan"] = map[string]any{ + "npm": "@ai-sdk/openai-compatible", + "name": "NaN", + "options": map[string]any{ + "baseURL": "https://api.nan.builders/v1", + "apiKey": apiKey, + }, + "models": nanModels, + } + cfg["provider"] = providers + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return writeConfigFile(cfgPath, data) +} + +func removeOpencodeConfig(cfgPath string) error { + data, err := os.ReadFile(cfgPath) //nolint:gosec // G304: a config file at a path this program chose + if err != nil { + return nil //nolint:nilerr // a file that cannot be read has nothing of ours in it + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + providers, _ := cfg["provider"].(map[string]any) + if providers == nil { + return nil + } + if _, ok := providers["nan"]; !ok { + return nil // nothing to remove + } + delete(providers, "nan") + cfg["provider"] = providers + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return writeConfigFile(cfgPath, out) +} + +// The sign-in questions, drawn where the tab content would be. +// The guided setup: the banner, the four steps with the one you are on marked, +// and whatever that step needs underneath. +// +// It replaced a sign-in screen that showed "Step 1 of 2" and then handed you +// back to the panel to find the other two yourself. Four numbered lines cost +// almost nothing and answer "how much is left", which is the question someone +// halfway through a setup is actually holding. diff --git a/internal/tools/pi.go b/internal/tools/pi.go new file mode 100644 index 0000000..9c3241c --- /dev/null +++ b/internal/tools/pi.go @@ -0,0 +1,171 @@ +package tools + +import ( + "encoding/json" + "os" + "path/filepath" + + catalog "github.com/nxssie/nan-cli/internal/models" +) + +// Pi takes a provider two ways: a models.json, which is data, or an extension +// that calls pi.registerProvider, which is code. Both are current in 0.85.1. +// This used to write the extension - a TypeScript file Pi loads and runs - and +// now writes the data, which is what nan.builders/docs/pi publishes: Pi +// validates models.json against its own schema and names the field that is +// wrong, nothing this CLI writes has to execute on a member's machine, and the +// two ways of setting Pi up stop being two things to keep in step. +func writePiConfig(cfgPath, apiKey string) error { + // Unlike the extension file, models.json is shared: other providers live in + // it and none of them are ours to touch. + cfg, err := readJSONConfig(cfgPath) + if err != nil { + return err + } + + providers, _ := cfg["providers"].(map[string]any) + if providers == nil { + providers = map[string]any{} + } + + models := make([]map[string]any, 0, len(catalog.ChatModels())) + for _, m := range catalog.ChatModels() { + models = append(models, map[string]any{ + "id": m.ID, + "name": m.Name, + "input": piInputs(m), + "reasoning": m.Reasoning, + "contextWindow": m.Context, + "maxTokens": m.Output, + }) + } + + providers["nan"] = map[string]any{ + "name": "NaN", + "baseUrl": "https://api.nan.builders/v1", + "apiKey": apiKey, + "api": "openai-completions", + "compat": map[string]any{"supportsDeveloperRole": true}, + "models": models, + } + cfg["providers"] = providers + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + if err := writeConfigFile(cfgPath, data); err != nil { + return err + } + return writePiDefaults(piSettingsPath(cfgPath)) +} + +// Pi reads the provider it calls from a second file, and until this existed +// the CLI wrote only the first one. nan.builders/docs/pi marks this step "not +// optional" for a reason: with models.json alone Pi goes on calling its +// factory provider, and what the member sees is a 401 that names neither file. +func writePiDefaults(settingsPath string) error { + settings, err := readJSONConfig(settingsPath) + if err != nil { + return err + } + + // A member who already picked a default picked it, and ours is one more + // provider they can switch to from inside Pi. This only fills the gap that + // leaves a fresh install calling nothing. + if _, chosen := settings["defaultProvider"]; chosen { + return nil + } + settings["defaultProvider"] = "nan" + settings["defaultModel"] = catalog.Coding + + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + return writeConfigFile(settingsPath, out) +} + +// settings.json sits next to models.json in Pi's agent directory. +func piSettingsPath(cfgPath string) string { + return filepath.Join(filepath.Dir(cfgPath), "settings.json") +} + +// Pi's schema takes "text" and "image" and nothing else, so mimo-v2.5 goes in +// without its audio: a third value fails validation, and Pi then refuses the +// whole file with every other provider in it. +func piInputs(m catalog.Model) []string { + out := make([]string, 0, len(m.Inputs)) + for _, in := range m.Inputs { + if in == catalog.InputText || in == catalog.InputImage { + out = append(out, in) + } + } + return out +} + +// Deleting the file is no longer an option: models.json is where every Pi +// provider lives, and the extension it replaced was a file of ours alone. +func removePiConfig(cfgPath string) error { + data, err := os.ReadFile(cfgPath) //nolint:gosec // G304: a config file at a path this program chose + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + var cfg map[string]any + if json.Unmarshal(data, &cfg) != nil { + return nil //nolint:nilerr // a file that cannot be read has nothing of ours in it + } + providers, _ := cfg["providers"].(map[string]any) + if _, ours := providers["nan"]; !ours { + return nil + } + delete(providers, "nan") + + // A default pointing at a provider that is no longer in models.json is + // worse than no default: Pi starts and fails on the first message. + if err := removePiDefaults(piSettingsPath(cfgPath)); err != nil { + return err + } + + // A models.json with nothing left in it is not a config Pi needs to read. + if len(providers) == 0 && len(cfg) == 1 { + return os.Remove(cfgPath) + } + cfg["providers"] = providers + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return writeConfigFile(cfgPath, out) +} + +// Only the default we wrote, and only while it still points at us: anything +// the member set themselves is theirs. +func removePiDefaults(settingsPath string) error { + data, err := os.ReadFile(settingsPath) //nolint:gosec // G304: a config file at a path this program chose + if err != nil { + return nil //nolint:nilerr // a file that cannot be read has nothing of ours in it + } + var settings map[string]any + if json.Unmarshal(data, &settings) != nil { + return nil //nolint:nilerr // a file that cannot be read has nothing of ours in it + } + if settings["defaultProvider"] != "nan" { + return nil + } + delete(settings, "defaultProvider") + delete(settings, "defaultModel") + + // The file existed only to hold our default, so it goes with it. + if len(settings) == 0 { + return os.Remove(settingsPath) + } + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + return writeConfigFile(settingsPath, out) +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go new file mode 100644 index 0000000..e5b266c --- /dev/null +++ b/internal/tools/tools.go @@ -0,0 +1,321 @@ +// Package tools points the coding tools on this machine at NaN, and takes +// them back off it. +// +// Only installed tools are touched, only NaN's part of each config is +// written, and a tool that is unticked has ours taken back out. Every tool +// is its own failure: one that cannot be configured never stops the rest. +package tools + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Tool is one coding tool this CLI knows how to configure. +type Tool struct { + Name string + Binary string + // ConfigPath is the file that ends up pointing at NaN. + ConfigPath string + // InstallPath, when set, is what says the tool is installed when its + // binary is not on PATH; otherwise the config file is. + InstallPath string + // Open and NextStep are how to check it works once it is configured: + // the command, and what to do in it. They are the steps each tool's page + // on nan.builders publishes under "check that it works". + Open string + NextStep string + + apply func(t Tool, apiKey string) error + remove func(t Tool) error + configured func(path string) bool + // keyFiles are the files that hold the key once it is written. For + // every tool but Hermes that is the config itself. + keyFiles func(t Tool) []string +} + +// Status is a tool as found on this machine. +type Status struct { + Tool + Installed bool + Configured bool +} + +// Registry is every tool this version knows, in the order they are listed. +func Registry() []Tool { + home, _ := os.UserHomeDir() + hermes := hermesHome() + return []Tool{ + { + Name: "Factory AI", + Binary: "droid", + ConfigPath: filepath.Join(home, ".factory", "settings.json"), + Open: "droid", + NextStep: "pick a model with (NaN) in its name", + apply: func(t Tool, key string) error { return writeFactoryConfig(t.ConfigPath, key) }, + remove: func(t Tool) error { return removeFactoryConfig(t.ConfigPath) }, + configured: factoryConfigured, + }, + { + Name: "OpenCode", + Binary: "opencode", + ConfigPath: filepath.Join(home, ".config", "opencode", "opencode.json"), + Open: "opencode", + NextStep: "then /models inside it, and pick a NaN one", + apply: func(t Tool, key string) error { return writeOpencodeConfig(t.ConfigPath, key) }, + remove: func(t Tool) error { return removeOpencodeConfig(t.ConfigPath) }, + configured: opencodeConfigured, + }, + { + Name: "Pi", + Binary: "pi", + ConfigPath: filepath.Join(home, ".pi", "agent", "models.json"), + InstallPath: filepath.Join(home, ".pi"), + Open: "pi", + NextStep: "NaN is already its default provider", + apply: func(t Tool, key string) error { return writePiConfig(t.ConfigPath, key) }, + remove: func(t Tool) error { return removePiConfig(t.ConfigPath) }, + configured: piConfigured, + }, + { + Name: "Codex", + Binary: "codex", + ConfigPath: filepath.Join(home, ".codex", "config.toml"), + Open: "codex", + NextStep: "already aimed at the cluster; codex --model to switch", + apply: func(t Tool, key string) error { return writeCodexConfig(t.ConfigPath, key) }, + remove: func(t Tool) error { return removeCodexConfig(t.ConfigPath) }, + configured: mentionsNaN, + }, + { + // Not a coding agent like the rest: it lives in the member's + // messaging channels. It is here because connecting it is the + // same two values, and because it is the one tool that tells us + // where its config is instead of making us guess. + Name: "Hermes", + Binary: "hermes", + ConfigPath: hermesConfigPath(hermes), + InstallPath: hermes, + Open: "hermes doctor", + NextStep: "says whether the provider answers, then talk to it", + apply: func(t Tool, key string) error { return writeHermesConfig(filepath.Dir(t.ConfigPath), key) }, + remove: func(t Tool) error { return removeHermesConfig(filepath.Dir(t.ConfigPath)) }, + configured: mentionsNaN, + // Its config.yaml carries a reference; the secret is in its .env. + keyFiles: func(t Tool) []string { return []string{hermesEnvPath(filepath.Dir(t.ConfigPath))} }, + }, + } +} + +// List names every tool, for a sentence: "Factory AI, OpenCode, Pi, Codex +// and Hermes". +func List() string { + names := make([]string, 0, len(Registry())) + for _, t := range Registry() { + names = append(names, t.Name) + } + if len(names) < 2 { + return strings.Join(names, "") + } + return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1] +} + +// Detect looks for every tool on this machine. A tool is installed when its +// binary is on PATH or its install directory exists: a member who installed +// it somewhere PATH does not reach still has the config we would write. +func Detect() []Status { + registry := Registry() + out := make([]Status, 0, len(registry)) + for _, t := range registry { + st := Status{Tool: t} + _, binErr := exec.LookPath(t.Binary) + probe := t.ConfigPath + if t.InstallPath != "" { + probe = t.InstallPath + } + _, pathErr := os.Stat(probe) + st.Installed = binErr == nil || pathErr == nil + if st.Installed && t.configured != nil { + st.Configured = t.configured(t.ConfigPath) + } + out = append(out, st) + } + return out +} + +// Installed keeps the ones on this machine. +func Installed(all []Status) []Status { + out := make([]Status, 0, len(all)) + for _, s := range all { + if s.Installed { + out = append(out, s) + } + } + return out +} + +// Enabled reads whether a tool is ticked. A tool nobody has unticked is. +func Enabled(ticked map[string]bool, name string) bool { + if v, ok := ticked[name]; ok { + return v + } + return true +} + +// Apply writes NaN into the tool, then makes sure every file holding the key +// is private to the member. +func (t Tool) Apply(apiKey string) error { + if t.apply == nil { + return fmt.Errorf("%s cannot be configured by this version", t.Name) + } + if err := t.apply(t, apiKey); err != nil { + return err + } + // Every writer can decide it has nothing to change - a tool already + // pointing at the cluster is left alone - and that file is exactly the + // one that has been sitting there with a key in it since before this + // CLI tightened modes on its way past. So the mode is checked whether + // or not anything was written. + return t.tightenKeyFiles() +} + +// Remove takes NaN back out of the tool. Every remover is a no-op where +// there is nothing of ours to take. +func (t Tool) Remove() error { + if t.remove == nil { + return nil + } + return t.remove(t) +} + +func (t Tool) keyPaths() []string { + if t.keyFiles != nil { + return t.keyFiles(t) + } + return []string{t.ConfigPath} +} + +// tightenKeyFiles puts a key file's mode right on its own. A config written +// before this CLI wrote through a temp file kept whatever mode the tool gave +// it, which for most of them is 0644. +func (t Tool) tightenKeyFiles() error { + if runtime.GOOS == "windows" { + // Not the mechanism there: the file inherits the ACL of the + // profile directory it sits in. + return nil + } + for _, path := range t.keyPaths() { + info, err := os.Stat(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if info.Mode().Perm()&0o077 == 0 { + continue + } + if err := os.Chmod(path, 0o600); err != nil { + return err + } + } + return nil +} + +// Failure is what went wrong for one tool, kept apart from the others so a +// failure in one does not read as a failure in all of them. +type Failure struct { + Tool string + Err error +} + +// FirstLine of the reason, for a summary. A tool that is configured by +// running it - Hermes - hands back whatever it printed, which can be a +// paragraph. +func (f Failure) FirstLine() string { + reason := f.Err.Error() + if i := strings.IndexAny(reason, "\r\n"); i >= 0 { + return strings.TrimSpace(reason[:i]) + } + return strings.TrimSpace(reason) +} + +// Result is what one Apply did. +type Result struct { + // Written is every tool configured, in order, for saying how to use + // each one next. + Written []string + Removed int + Failed []Failure +} + +// Summary is the result in one line: "3 added · 1 removed · Hermes +// failed", or "nothing to sync". +// +// One tool failing used to throw away everything that worked: a member with +// a broken Hermes was told the whole step had failed. So every part is said. +func (r Result) Summary() string { + var parts []string + if n := len(r.Written); n > 0 { + parts = append(parts, fmt.Sprintf("%d added", n)) + } + if r.Removed > 0 { + parts = append(parts, fmt.Sprintf("%d removed", r.Removed)) + } + for _, f := range r.Failed { + parts = append(parts, f.Tool+" failed") + } + if len(parts) == 0 { + return "nothing to sync" + } + return strings.Join(parts, " · ") +} + +// Apply configures every installed tool that is ticked and takes NaN out of +// every installed one that is not, which is how unticking a tool undoes it. +func Apply(apiKey string, ticked map[string]bool) Result { + var r Result + for _, st := range Installed(Detect()) { + if Enabled(ticked, st.Name) { + if err := st.Apply(apiKey); err != nil { + r.Failed = append(r.Failed, Failure{Tool: st.Name, Err: err}) + continue + } + r.Written = append(r.Written, st.Name) + continue + } + if st.Configured { + if err := st.Remove(); err != nil { + r.Failed = append(r.Failed, Failure{Tool: st.Name, Err: err}) + continue + } + r.Removed++ + } + } + return r +} + +// RemoveAll takes the key back out of every tool, which is half of signing +// out: the key was copied into as many as five files that have nothing to do +// with the session, and it goes on working from there. +// +// Installed is the only gate, not Configured. Configured is read off the +// tool's own config, and the case that matters most is where that file is +// not the whole story: a run that wrote Hermes' .env and then failed before +// its config.yaml left a key on disk that a Configured gate would walk past. +func RemoveAll() (removed []string, failed []Failure) { + for _, st := range Installed(Detect()) { + if err := st.Remove(); err != nil { + failed = append(failed, Failure{Tool: st.Name, Err: err}) + continue + } + if st.Configured { + removed = append(removed, st.Name) + } + } + return removed, failed +} diff --git a/internal/tui/config_test.go b/internal/tools/tools_test.go similarity index 52% rename from internal/tui/config_test.go rename to internal/tools/tools_test.go index d2d7860..b8e80c2 100644 --- a/internal/tui/config_test.go +++ b/internal/tools/tools_test.go @@ -1,34 +1,19 @@ -package tui +package tools import ( "encoding/json" "errors" "fmt" - "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" - "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/nxssie/nan-cli/internal/api" catalog "github.com/nxssie/nan-cli/internal/models" - "github.com/nxssie/nan-cli/internal/session" ) -// What this CLI actually does for a member is write four config files, and -// nothing ever read them back. They had drifted a long way from the cluster: -// three models out of seven, a 128000-token window on models served at -// 1,048,576, and an opencode block with no `limit` at all - the exact bug -// nan.builders/docs/opencode was rewritten to stop publishing. A wrong config -// here is silent twice over: the tool starts, answers, and only behaves oddly -// much later, deep into a session. - const testKey = "sk-test-key-not-a-real-one" func tempConfig(t *testing.T, name string) string { @@ -334,11 +319,11 @@ wire_api = "chat" t.Error("a repair that should touch one line rewrote the member's key") } // Another provider's wire_api is that provider's business. - openai := content[strings.Index(content, "[model_providers.openai]"):strings.Index(content, "[model_providers.nan]")] + openai := content[strings.Index(content, "[model_providers.openai]"):strings.Index(content, "[model_providers.nan]")] //nolint:gocritic // both headers are written by this test if !strings.Contains(openai, `wire_api = "chat"`) { t.Error("a wire_api outside our own section was rewritten") } - nan := content[strings.Index(content, "[model_providers.nan]"):] + nan := content[strings.Index(content, "[model_providers.nan]"):] //nolint:gocritic // written by this test if !strings.Contains(nan, `wire_api = "responses"`) { t.Errorf("our section still does not speak a protocol Codex loads:\n%s", nan) } @@ -858,25 +843,6 @@ func TestEveryModelIsWrittenTheSameEverywhere(t *testing.T) { } } -func TestHumanKeyKeepsAcronymsWhole(t *testing.T) { - // The Profile tab renders whatever keys /auth/me returns, and `userUUID` - // came out as "User U U I D" on its first line. - for _, c := range []struct{ in, want string }{ - {"userUUID", "User UUID"}, - {"inferenceProfile", "Inference Profile"}, - {"isAdmin", "Is Admin"}, - {"handle", "Handle"}, - {"expiresAt", "Expires At"}, - {"APIKey", "API Key"}, - {"image_gen", "Image gen"}, - {"", ""}, - } { - if got := humanKey(c.in); got != c.want { - t.Errorf("humanKey(%q) = %q, want %q", c.in, got, c.want) - } - } -} - // The Setup tab's `c` key runs configureTools, which finds the tools on the // machine and writes into their real config paths. Nothing covered it, so the // only way to try it was to press the key and look at your own home directory. @@ -929,9 +895,9 @@ func TestConfigureToolsWritesEveryEnabledTool(t *testing.T) { if len(written) != 5 { t.Errorf("configureTools named %v, want all five it wrote", written) } - for _, name := range written { - if _, ok := nextStepFor[name]; !ok { - t.Errorf("%s is configured and the tab has nothing to tell anyone about using it", name) + for _, tool := range Registry() { + if tool.Open == "" || tool.NextStep == "" { + t.Errorf("%s is configured and nothing says how to use it", tool.Name) } } @@ -965,136 +931,6 @@ func TestConfigureToolsWritesEveryEnabledTool(t *testing.T) { } } -func TestModelsTabShowsCallableIds(t *testing.T) { - // GET /v1/models answers with the ids a request can name. The platform's - // /agents/models answers with deployment names instead - it carries - // `deepseek-v4-flash-fallback`, `glm5.3-fallback` and `glm5.2`, none of - // which a member can put in a `model` field - and that is what this tab - // used to list. - out := renderModels([]string{"deepseek-v4-flash", "kokoro", "glm5.3", "minimax-h3"}, nil, newLayout(80, 24)) - - for _, want := range []string{ - "deepseek-v4-flash", "chat", - "kokoro", "text to speech", - "glm5.3", "premium", - // An id the cluster serves and this catalogue has never heard of has to - // show, not disappear: that is how an undocumented model gets noticed. - "minimax-h3", "unknown", - } { - if !strings.Contains(out, want) { - t.Errorf("the Models tab does not mention %q", want) - } - } -} - -func TestModelsTabStillReadsThePlatformShape(t *testing.T) { - // Without an API key there is no /v1/models to ask, and the tab falls back - // to what the platform returns. - data := map[string]any{"models": []any{ - map[string]any{"name": "deepseek-v4-flash", "mode": "chat"}, - }} - if out := renderModels(data, nil, newLayout(80, 24)); !strings.Contains(out, "deepseek-v4-flash") { - t.Error("the fallback list renders nothing") - } -} - -func TestCostsFooterBoxIsSquare(t *testing.T) { - // `l.indent + Render(box)` indented the first line only, so the top edge sat - // two columns to the right of the sides. And the width was measured with - // len() on a string carrying an em dash: three bytes, one column. - usage := map[string]any{ - "last24h": map[string]any{"byModel": []any{ - map[string]any{"model": "gemma4", "inputTokens": 1000.0, "outputTokens": 500.0}, - }}, - } - var box []string - for _, line := range strings.Split(renderCosts(usage, newLayout(90, 30)), "\n") { - if strings.ContainsAny(line, "╭│╰") { - box = append(box, line) - } - } - // Three lines, or more when the note wraps at a narrow width. - if len(box) < 3 { - t.Fatalf("the footer box has %d lines, want at least 3", len(box)) - } - for i, line := range box { - if !strings.HasPrefix(line, " ") { - t.Errorf("box line %d does not carry the indent: %q", i, line) - } - } - for i, line := range box { - if lipgloss.Width(line) != lipgloss.Width(box[0]) { - t.Errorf("box line %d measures %d columns, the top edge measures %d", - i, lipgloss.Width(line), lipgloss.Width(box[0])) - } - } -} - -func TestBannerFitsAndCarriesTheNames(t *testing.T) { - out := Banner(" ", moodNormal, true) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - if len(lines) != MascotHeight { - t.Fatalf("the banner is %d rows, the mascot is %d", len(lines), MascotHeight) - } - // Without it, the wordmark alone, which is what a short terminal gets. - plain := strings.Split(strings.TrimRight(Banner(" ", moodNormal, false), "\n"), "\n") - if len(plain) != len(wordmark) { - t.Errorf("the plain banner is %d rows, the wordmark is %d", len(plain), len(wordmark)) - } - for i, line := range lines { - if !strings.HasPrefix(line, " ") { - t.Errorf("row %d does not carry the indent", i) - } - // It has to fit the narrowest panel it is drawn in. - if w := lipgloss.Width(line); w > BannerWidth+4 { - t.Errorf("row %d measures %d columns, wider than the About tab allows", i, w) - } - } - for _, want := range []string{"nan.builders", "@Nxssie", "Helmcode Team", Version} { - if !strings.Contains(out, want) { - t.Errorf("the banner does not mention %q", want) - } - } -} - -func TestAboutFallsBackToOneLineWhenNarrow(t *testing.T) { - // A 40-column terminal has no room for the art plus the wordmark, and a - // wrapped banner is worse than no banner. - if strings.Contains(renderAbout(newLayout(40, 24)), "█") { - t.Error("the banner is drawn at a width where it wraps") - } - if !strings.Contains(renderAbout(newLayout(BannerWidthPlain+4, 24)), "█") { - t.Error("the banner is missing at a width that fits it") - } -} - -func TestHomeIsTheFirstTabAndNeedsNoNetwork(t *testing.T) { - // The panel used to open on Profile, which is a table of account fields - // fetched over the network: a spinner, and no sign of what you were - // running. Home is drawn from nothing, so it is there before any request. - if tabDefs[0].id != tabHome { - t.Fatalf("the first tab is %v, want Home", tabDefs[0].name) - } - m := newModel(nil, &session.Session{}) - if cmd := m.maybeLoad(); cmd != nil { - t.Error("Home asks the API for something, so it cannot be the landing tab") - } -} - -func TestHomeSaysHowToMoveAround(t *testing.T) { - out := renderHome(newLayout(BannerWidthPlain+4, 24), true, true, moodNormal) - for _, want := range []string{"█", "welcome to", "←/→", "↑/↓", "refresh", "quit", "Setup"} { - if !strings.Contains(out, want) { - t.Errorf("the Home tab does not mention %q", want) - } - } - // A landing screen that needs scrolling on a standard terminal has already - // failed at the one thing it does. - if rows := len(strings.Split(strings.TrimRight(out, "\n"), "\n")); rows > 24-4 { - t.Errorf("Home is %d rows, the viewport of a 24-row terminal is %d", rows, 24-4) - } -} - // The provider block alone does not connect Pi to anything. Pi reads its // default from a second file, settings.json, and nan.builders/docs/pi marks // that step "not optional": without it Pi keeps calling its factory provider @@ -1173,7 +1009,7 @@ func recordHermes(t *testing.T) *[][]string { t.Helper() var calls [][]string original := runHermesConfig - runHermesConfig = func(home string, args ...string) error { + runHermesConfig = func(_ string, args ...string) error { calls = append(calls, args) return nil } @@ -1183,7 +1019,7 @@ func recordHermes(t *testing.T) *[][]string { // .env this CLI just wrote - so the check is exercised rather than // stubbed past. originalRead := readHermesConfig - readHermesConfig = func(home string, args ...string) (string, error) { + readHermesConfig = func(home string, _ ...string) (string, error) { for _, line := range strings.Split(readFile(t, hermesEnvPath(home)), "\n") { if name, value, ok := strings.Cut(line, "="); ok && strings.TrimSpace(name) == hermesKeyVar { return value, nil @@ -1205,7 +1041,7 @@ func recordHermes(t *testing.T) *[][]string { func TestHermesIsRefusedWhenItDoesNotResolveTheReference(t *testing.T) { recordHermes(t) original := readHermesConfig - readHermesConfig = func(home string, args ...string) (string, error) { + readHermesConfig = func(_ string, _ ...string) (string, error) { return hermesKeyRef, nil // an unexpanded literal, as an older build would } t.Cleanup(func() { readHermesConfig = original }) @@ -1226,7 +1062,7 @@ func TestHermesIsRefusedWhenItDoesNotResolveTheReference(t *testing.T) { func TestHermesThatCannotAnswerIsNotTreatedAsBroken(t *testing.T) { recordHermes(t) original := readHermesConfig - readHermesConfig = func(home string, args ...string) (string, error) { + readHermesConfig = func(_ string, _ ...string) (string, error) { return "", errors.New("unknown command: get") } t.Cleanup(func() { readHermesConfig = original }) @@ -1453,1020 +1289,260 @@ func TestHermesConfigAgainstTheRealBinary(t *testing.T) { // ── the API key, and what the platform will and will not tell us ───────────── -func setupModel(t *testing.T, sess *session.Session) model { - t.Helper() +// One tool failing used to read as the whole step failing: the message became +// "error: " and the configs that HAD been written +// went unmentioned. Reported from a machine where Hermes would not configure, +// which left the setup on its last screen with nothing to do but escape. +func TestOneToolFailingDoesNotLoseTheOthers(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) - t.Setenv("HERMES_HOME", filepath.Join(home, "hermes")) - return newModel(nil, sess) -} - -// The idea this replaced was to have the Setup tab fetch the key with the -// session it already holds. It cannot: GET /api/keys answers with metadata -// and no secret, because a key is shown once, at creation. What is left worth -// doing is telling a member there is one and where it is, instead of showing -// an empty field and nothing else. -func TestSetupSaysWhereTheKeyIsWhenTheFieldIsEmpty(t *testing.T) { - m := setupModel(t, &session.Session{}) - m.keyStatus = &api.KeyStatus{Exists: true, Alias: "an-alias"} - - out := m.renderSetup(newLayout(80, 24)) - for _, want := range []string{"an-alias", "cloud.nan.builders"} { - if !strings.Contains(out, want) { - t.Errorf("the Setup tab does not mention %q", want) - } - } -} - -func TestSetupSaysWhenTheAccountHasNoKeyAtAll(t *testing.T) { - m := setupModel(t, &session.Session{}) - m.keyStatus = &api.KeyStatus{Exists: false} - - out := m.renderSetup(newLayout(80, 24)) - if !strings.Contains(out, "no key yet") { - t.Error("an account with no key is told nothing about it") - } -} - -// Once there is a key in the field, the hint about where to go and fetch one -// is noise - and the key itself is never printed, here or anywhere. -func TestSetupHidesTheHintAndTheKeyOnceOneIsSet(t *testing.T) { - m := setupModel(t, &session.Session{APIKey: testKey}) - m.keyStatus = &api.KeyStatus{Exists: true, Alias: "an-alias"} - - out := m.renderSetup(newLayout(80, 24)) - if strings.Contains(out, "an-alias") || strings.Contains(out, "copy it from") { - t.Error("the hint about fetching a key is still shown after one was set") - } - if strings.Contains(out, testKey) { - t.Error("the API key is printed on screen") - } -} - -// What to do about a key that has been seen by somebody. This CLI cannot -// revoke one - the platform issues and retires them - so the least it can do -// is say where, rather than leaving a member to guess whether replacing it is -// even possible. -func TestSetupSaysHowToReplaceAKeyThatLeaked(t *testing.T) { - m := setupModel(t, &session.Session{APIKey: testKey}) - - out := m.renderSetup(newLayout(80, 24)) - for _, want := range []string{"replace it at cloud.nan.builders", "press c"} { - if !strings.Contains(out, want) { - t.Errorf("the tab does not say %q", want) - } - } -} - -// A key the cluster refuses used to be found out five times over, as a 401 -// inside each tool it had been written into. It is said once, here, before -// anything is written at all. -func TestSetupShowsAKeyTheClusterRefused(t *testing.T) { - m := setupModel(t, &session.Session{APIKey: testKey}) - m.keyCheck = "error: the cluster refused this key - the API key in Setup is not valid" - - out := m.renderSetup(newLayout(80, 24)) - if !strings.Contains(out, "refused this key") { - t.Error("a refused key is not reported in the Setup tab") + hermesHomeDir := filepath.Join(home, "hermes") + t.Setenv("HERMES_HOME", hermesHomeDir) + if err := os.MkdirAll(hermesHomeDir, 0o700); err != nil { + t.Fatal(err) } -} - -// The Home tab tells a member that `?` lists "every shortcut, including the -// ones for Setup". It listed e and c and not space, which is the one that -// decides which tools get written at all. -func TestHelpListsTheKeysHomePromises(t *testing.T) { - out := renderHelp() - for _, want := range []string{"space", "e", "c"} { - if !strings.Contains(out, want) { - t.Errorf("the help screen does not mention %q", want) - } + // Hermes refuses; everything else is written as usual. + original := runHermesConfig + runHermesConfig = func(string, ...string) error { + return fmt.Errorf("hermes config set: exit status 1\nsomething it printed\nand more of it") } -} + t.Cleanup(func() { runHermesConfig = original }) -// ── the cost comparison ────────────────────────────────────────────────────── - -// The Costs tab exists to answer "what would this have cost me elsewhere". -// Every number in it is typed in by hand from a vendor's pricing page, nothing -// reads them back, and the table went a long time with five of six rows wrong -// - each of them understating the competitor. These are the mechanical checks -// that catch the shapes of wrong a reader would not notice. -func TestPricingTableIsPlausible(t *testing.T) { - if len(pricingTable) == 0 { - t.Fatal("nothing to compare against") - } - seen := map[string]bool{} - for _, p := range pricingTable { - if p.inPer1M <= 0 || p.outPer1M <= 0 { - t.Errorf("%s: a free model is a typo, not a price (%v/%v)", p.model, p.inPer1M, p.outPer1M) - } - // Every frontier vendor charges more for output than for input, so a - // row where that flips is a transposed pair. None of the six wrong - // rows failed this way - they were each plausible and simply not what - // the vendor charged - which is the point: this catches the typo, and - // only reading the pricing page catches the rest. - if p.outPer1M < p.inPer1M { - t.Errorf("%s: output (%v) cheaper than input (%v) - transposed?", p.model, p.outPer1M, p.inPer1M) - } - if seen[p.model] { - t.Errorf("%s is listed twice", p.model) - } - seen[p.model] = true - if p.provider == "" { - t.Errorf("%s has no provider, so it renders with no colour and no attribution", p.model) + for _, p := range []string{ + filepath.Join(home, ".factory", "settings.json"), + filepath.Join(home, ".config", "opencode", "opencode.json"), + filepath.Join(home, ".pi", "agent", "models.json"), + filepath.Join(home, ".codex", "config.toml"), + } { + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) } - } -} - -// Every provider in the table needs a colour, or it renders grey and looks -// like a different kind of row. -func TestEveryPricedProviderHasAColour(t *testing.T) { - for _, p := range pricingTable { - if _, ok := providerColor[p.provider]; !ok { - t.Errorf("%s has no colour in providerColor", p.provider) + if err := os.WriteFile(p, nil, 0o600); err != nil { + t.Fatal(err) } } -} - -// Gemini 3.8 Flash is on a promotional rate that doubles on 2027-01-01. That -// is a number which is right today and silently wrong on a date we already -// know, which no amount of care at review time catches. This is the only -// thing that will. -func TestGeminiFlashPromoHasNotExpired(t *testing.T) { - if time.Now().Before(geminiFlashPromoEnds) { - return - } - t.Errorf("Gemini 3.8 Flash's promotional rate ended on %s: "+ - "its price in pricingTable doubles to 1.50/7.50, and the Costs tab has been "+ - "understating Google ever since. Update the row and move geminiFlashPromoEnds "+ - "or drop it if the row no longer needs one.", geminiFlashPromoEnds.Format("2006-01-02")) -} -// The footer box was drawn at the width of its own text, 70 columns plus a -// border and the indent, whatever terminal it was in. Anything narrower than -// about 74 got a box running off the right-hand side - which is where this tab -// is read on half a laptop screen. -func TestCostsFitsTheTerminalItIsDrawnIn(t *testing.T) { - usage := map[string]any{ - "last24h": map[string]any{"byModel": []any{ - map[string]any{"model": "gemma4", "inputTokens": 1_000_000.0, "outputTokens": 500_000.0}, - }}, - } - for _, w := range []int{40, 50, 60, 72, 80, 120} { - for _, line := range strings.Split(renderCosts(usage, newLayout(w, 40)), "\n") { - if got := lipgloss.Width(line); got > w { - t.Errorf("at %d columns a line measures %d: %q", w, got, line) - } - } - } -} + msg, written, failed := configureTools(testKey, nil) -// Ten rows with a blank line between each is not a table any more, it is two -// screens of alternating text and gap. The blank lines that are left group the -// rows by provider, which is the only thing they were ever doing well. -func TestCostsRowsAreNotDoubleSpaced(t *testing.T) { - usage := map[string]any{ - "last24h": map[string]any{"byModel": []any{ - map[string]any{"model": "gemma4", "inputTokens": 1_000_000.0, "outputTokens": 500_000.0}, - }}, - } - out := renderCosts(usage, newLayout(100, 40)) - lines := strings.Split(out, "\n") - - // Find each priced row, then check the one after it is only blank when the - // provider changes. - index := map[string]int{} - for i, line := range lines { - for _, p := range pricingTable { - if strings.Contains(line, p.model) { - index[p.model] = i - } - } + if !strings.Contains(msg, "4 added") { + t.Errorf("the message is %q, and does not say what worked", msg) } - for i := 0; i < len(pricingTable)-1; i++ { - this, next := pricingTable[i], pricingTable[i+1] - at, ok := index[this.model] - if !ok { - t.Errorf("%s is priced but never rendered", this.model) - continue - } - gap := strings.TrimSpace(lines[at+1]) == "" - if want := this.provider != next.provider; gap != want { - if want { - t.Errorf("no blank line between %s and %s, which are different providers", this.provider, next.provider) - } else { - t.Errorf("a blank line inside %s's rows, after %s", this.provider, this.model) - } - } + if !strings.Contains(msg, "Hermes failed") { + t.Errorf("the message is %q, and does not name what did not", msg) } -} - -// The About tab printed "~/.config/nan/session.json" as a literal. On Windows -// that is not a path, not where the file is, and not something Explorer -// resolves - so a member told to look there finds nothing. -func TestAboutShowsTheSessionPathThatExists(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - - out := renderAbout(newLayout(100, 30)) - if strings.Contains(out, "~/") { - t.Error("the About tab still prints a tilde path") + if len(written) != 4 { + t.Errorf("wrote %v, want the four that did not fail", written) } - if want := session.Path(); !strings.Contains(out, want) { - t.Errorf("the About tab does not show %q", want) + if len(failed) != 1 || failed[0].Tool != "Hermes" { + t.Fatalf("failures are %v, want just Hermes", failed) } - if !strings.Contains(session.Path(), home) { - t.Errorf("session.Path() = %q, which is not under the home it was given", session.Path()) + // The summary takes one line of what a tool printed, not the paragraph. + if strings.ContainsAny(failed[0].FirstLine(), "\r\n") { + t.Errorf("the summary carries a line break: %q", failed[0].FirstLine()) } } -// A fresh install on a machine that has never logged in drew "unauthorized" -// over Profile, Usage and Models: the platform's word for what it decided, and -// not one word about what to do next. Reported from a real first run. -func TestDataTabsSayToLogInRatherThanUnauthorized(t *testing.T) { - m := setupModel(t, &session.Session{}) - - for _, id := range []tabID{tabProfile, tabUsage, tabModels, tabCosts} { - if !m.needsLogin(id) { - t.Errorf("%v: would go to the network with no session and report `unauthorized`", id) +// The tools' configs are theirs, not ours: by the time `nan` writes a key into +// one it usually exists already, created by the tool itself and commonly 0644. +// os.WriteFile does not change the mode of a file it did not create, so the +// key went into a world-readable file and the 0o600 in the call did nothing. +func TestWritingAKeyTightensAConfigThatWasWideOpen(t *testing.T) { + home := toolHome(t) + for _, path := range toolPaths(home) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) } - } - - // And the message names the key, not a shell command: there is one for - // this, and quitting to run something else is the detour it replaced. - msg, ok := m.fetchTab(tabProfile)().(fetchErrMsg) - if !ok || !errors.Is(msg.err, errNotSignedIn) { - t.Errorf("Profile reports %v, want the one that says which key to press", msg.err) - } - if !strings.Contains(errNotSignedIn.Error(), "press s") { - t.Errorf("the message is %q, which does not say what to press", errNotSignedIn) - } -} - -// The three tabs that need nothing from the platform have to stay usable with -// no session: Setup is where a member pastes the key in the first place. -func TestTheOfflineTabsNeverAskForALogin(t *testing.T) { - m := setupModel(t, &session.Session{}) - - for _, id := range []tabID{tabHome, tabAbout, tabSetup} { - if m.needsLogin(id) { - t.Errorf("%v: asks for a login it does not need", id) + // As the tool would have left it. + if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { + t.Fatal(err) } } -} - -// An API key opens /v1/models on its own, so that tab stays useful to someone -// who pasted a key and never logged in. -func TestModelsStillLoadsWithAKeyAndNoSession(t *testing.T) { - m := setupModel(t, &session.Session{APIKey: testKey}) - if m.needsLogin(tabModels) { - t.Error("a member with an API key is told to log in for the Models tab") + if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { + t.Fatalf("tools failed on a clean run: %v", failed) } -} - -// ── the first run ──────────────────────────────────────────────────────────── - -// The first time this was opened on a machine that had never logged in, Home -// explained the arrow keys and every data tab answered "unauthorized". Nothing -// anywhere said to log in, and the command that does it is a subcommand you -// have to already know exists. -func TestHomeLeadsWithSigningInWhenThereIsNoSession(t *testing.T) { - out := renderHome(newLayout(90, 40), false, false, moodNormal) - - for _, want := range []string{"Start here", "sign in", "stay empty until you sign in"} { - if !strings.Contains(out, want) { - t.Errorf("Home does not mention %q to someone with no session", want) + for name, path := range toolPaths(home) { + if !strings.Contains(readFile(t, path), testKey) { + t.Errorf("%s was not configured at all", name) + continue } - } - // Every step is a key to press here. This list used to open with "q, quit, - // so you have your shell back", because signing in meant leaving for a - // subcommand - which is exactly where people got stuck. - if strings.Contains(out, "quit, so you have your shell back") { - t.Error("Home still sends a member out to the shell to sign in") - } - if strings.Contains(out, "nan auth login") { - t.Error("Home names a shell command for something the panel does itself") - } -} - -// Signed in but with no key, the sign-in steps are done and the one that is -// not is the key. -func TestHomeMovesOnOnceSignedIn(t *testing.T) { - out := renderHome(newLayout(90, 40), true, false, moodNormal) - - if !strings.Contains(out, "Start here") { - t.Fatal("Home stops guiding before the setup is finished") - } - if !strings.Contains(out, "paste your API key") { - t.Error("Home does not name the step that is actually left") - } - // The done ones are still listed, struck through by their marker, so the - // list does not renumber itself between runs. - if !strings.Contains(out, "✓") { - t.Error("finished steps are not marked as finished") - } -} - -// And once there is nothing left to do it gets out of the way. -func TestHomeDropsTheGuideWhenSetupIsDone(t *testing.T) { - out := renderHome(newLayout(90, 40), true, true, moodNormal) - - if strings.Contains(out, "Start here") { - t.Error("Home still shows the first-run steps to a configured member") - } - if !strings.Contains(out, "Getting around") { - t.Error("the rest of Home went with it") + assertNotWorldReadable(t, path) } } -// ── signing in without leaving the panel ───────────────────────────────────── - -// The flow used to be: read Home, quit, run `nan auth login`, answer two -// prompts on stdin, start the panel again. Reported twice from a real machine, -// stuck at different steps of it. The panel owns the keyboard already, so it -// asks the same two questions itself. -// -// It asks View, and not a renderer of its own, because the login screen this -// replaced stopped being drawn when the wizard arrived: the test went on -// passing against a function nothing called, which is the one way a test can be -// green and wrong at the same time. -func TestSigningInHappensInsideThePanel(t *testing.T) { - m := setupModel(t, &session.Session{}) - // Said out loud, because View wraps to it and truncates to it. Left to - // the zero value this asserted against whatever the default happened to - // be: at 60 columns the renderer breaks the sentence as "expires 15 / - // minutes" and at 80x20 the truncation eats it, so the test failed for a - // screen that was right. - m.lay = newLayout(90, 30) - - if m.loginStage != loginOff { - t.Fatal("the panel opens mid-login") - } - m.startLogin() - if m.loginStage != loginAskEmail { - t.Fatal("s does not start the sign-in") - } - - out := m.View() - for _, want := range []string{"Let's get you logged in", "Email"} { - if !strings.Contains(out, want) { - t.Errorf("the first step does not show %q:\n%s", want, out) +// The tools that are already pointing at the cluster are the ones that have +// held a key the longest, and the writers leave them alone: there is nothing +// to change. Their mode is still whatever it was when the key went in, which +// for a config the tool created itself is world-readable. +func TestConfiguringTightensAToolThatNeedsNothingWritten(t *testing.T) { + home := toolHome(t) + paths := toolPaths(home) + for _, path := range paths { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) } - } - - // Second question, once the link is on its way, driven through the message - // the flow actually sends rather than by setting the stage by hand: that is - // what makes this fail when the two halves drift apart. - mod, _ := m.Update(linkSentMsg{}) - m = mod.(model) - if m.loginStage != loginAskLink { - t.Fatal("an accepted request does not move on to the link") - } - out = m.View() - // Against the screen with its line breaks collapsed: whether "15 minutes" - // survives as two words on one line is the renderer's business and - // changes with one more word in the message. What this is here to catch - // is the sentence going missing. - flat := strings.Join(strings.Fields(out), " ") - for _, want := range []string{"Let's confirm it with the magic link", "Paste the link", "15 minutes"} { - if !strings.Contains(flat, want) { - t.Errorf("the second step does not ask for the link:\n%s", out) + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) } } -} - -func TestEscapeLeavesTheSignInAlone(t *testing.T) { - m := setupModel(t, &session.Session{}) - m.startLogin() - m.loginInput.SetValue("half typed@") - m.cancelLogin() - - if m.loginStage != loginOff { - t.Error("esc does not leave the sign-in") + if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { + t.Fatalf("tools failed on a clean run: %v", failed) } - if m.loginInput.Value() != "" { - t.Error("a cancelled sign-in keeps what was typed into it") - } -} - -// A member who is already signed in has nothing to start, and the key that -// starts it is `s` because `l` is the vim spelling of "next tab". -func TestSignInKeyIsNotOneThatAlreadyMoves(t *testing.T) { - out := renderHelp() - if !strings.Contains(out, "sign in") { - t.Error("the help screen does not mention signing in") - } - if strings.Contains(out, "l") && strings.Contains(out, "sign in, when there is no session") { - // only a smoke check that the description is the one bound to s - if !strings.Contains(out, "s") { - t.Error("sign-in is not bound to s") + // As an older version of this CLI would have left them. + for _, path := range paths { + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) } } -} - -// Signing in from the panel wrote the session and left the API client holding -// the token it was built with at start-up, which on a fresh machine is none. -// So it signed a member in and then answered every tab `unauthorized` - which -// reads exactly like the login having failed, and was reported as such. -func TestSigningInGivesTheClientTheNewToken(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Setenv("HERMES_HOME", filepath.Join(home, "h")) - - m := newModel(api.New(""), &session.Session{}) - if m.client.Token() != "" { - t.Fatal("this test starts from a client with no token") - } - - // What the flow leaves on disk before the panel is told about it. - if err := session.Save(&session.Session{Token: "a-fresh-session-token"}); err != nil { - t.Fatal(err) - } - - updated, _ := m.Update(signedInMsg{nil}) - after := updated.(model) - - if after.sess.Token != "a-fresh-session-token" { - t.Errorf("the panel did not pick up the session: %q", after.sess.Token) - } - if got := after.client.Token(); got != "a-fresh-session-token" { - t.Errorf("the client still sends %q, so every tab answers unauthorized", got) - } - if after.loginStage != loginOff { - t.Error("the sign-in is still on screen after it succeeded") - } - if len(after.cache) != 0 { - t.Error("the tabs keep the answers they got before signing in") - } -} - -// `e` is published on Home as the way to set the API key, and Home is not -// Setup. It did nothing at all anywhere but Setup - which is the tab you have -// to already be on to know that. -func TestTheKeyForTheKeyWorksFromWhereItIsAdvertised(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t"}) - m.lay = newLayout(90, 30) - m.active = tabIndex(tabHome) - - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) - after := updated.(model) - - if !after.editingKey { - t.Error("e from Home does nothing, which is where Home tells you to press it") - } - if after.activeID() != tabSetup { - t.Error("e opened the key editor without moving to the tab that shows it") - } -} -// Pressing c used to call configureTools straight out of the key handler, so -// the panel sat frozen for as long as it took - and with Hermes in the list -// that is four processes and several seconds, with no repaint and no key -// accepted. Reported as the panel being hung, which is the only thing it -// could look like. -func TestConfiguringDoesNotBlockTheEventLoop(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) - m.lay = newLayout(90, 30) - m.active = tabIndex(tabSetup) - recordHermes(t) - - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) - after := updated.(model) - - if !after.configuring { - t.Error("c does not put the tab into a state it can draw") + // A second run has nothing to write, and has to fix them anyway. + if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { + t.Fatalf("tools failed on the second run: %v", failed) } - if cmd == nil { - t.Fatal("c did the work inline instead of handing back a command") - } - // And the tab says so rather than looking hung. - if out := after.renderSetup(after.lay); !strings.Contains(out, "writing the configs") { - t.Errorf("nothing on screen says it is working:\n%s", out) - } -} - -// "5 added" answers what it did, not the question a member is left holding. -func TestTheTabSaysHowToUseWhatItJustConfigured(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) - m.lay = newLayout(90, 40) - m.configured = []string{"OpenCode", "Codex"} - - out := m.renderSetup(m.lay) - for _, want := range []string{"Now open them", "opencode", "/models", "codex"} { - if !strings.Contains(out, want) { - t.Errorf("the tab does not mention %q after configuring", want) + for name, path := range paths { + if !strings.Contains(readFile(t, path), testKey) { + t.Errorf("%s lost its key on the second run", name) } + assertNotWorldReadable(t, path) } } -// Every tool the Setup tab can configure has to have something to say about -// using it, or the list it prints has a hole in it exactly where a member -// looks. -func TestEveryConfigurableToolHasANextStep(t *testing.T) { - for _, tool := range detectTools() { - if _, ok := nextStepFor[tool.name]; !ok { - t.Errorf("%s can be configured and has no next step", tool.name) - } - } -} +// A config this CLI cannot parse is the one case where it must not write: the +// readers used to discard the unmarshal error, read the file as nothing, and +// write it again from scratch - taking every other provider in it, and their +// keys, with them. +func TestAConfigThatDoesNotParseIsLeftExactlyAsItIs(t *testing.T) { + const theirs = `{"provider": {"anthropic": {"apiKey": "theirs"}},,,` -// ── the mascot ─────────────────────────────────────────────────────────────── + for _, c := range []struct { + name string + file string + write func(path, key string) error + }{ + {"Factory AI", "settings.json", writeFactoryConfig}, + {"OpenCode", "opencode.json", writeOpencodeConfig}, + {"Pi", "models.json", writePiConfig}, + } { + t.Run(c.name, func(t *testing.T) { + path := tempConfig(t, c.file) + if err := os.WriteFile(path, []byte(theirs), 0o600); err != nil { + t.Fatal(err) + } -// Every face has to be the same grid, or the head jumps around as the panel -// changes what it is doing. -func TestEveryFaceIsTheSameHead(t *testing.T) { - for mood, grid := range mascotFaces { - if len(grid) != 21 { - t.Errorf("mood %d is %d pixels tall, want 21", mood, len(grid)) - } - for y, row := range grid { - if len(row) != MascotWidth { - t.Errorf("mood %d row %d is %d wide, want %d", mood, y, len(row), MascotWidth) + err := c.write(path, testKey) + if err == nil { + t.Fatal("a config that does not parse was written over without a word") } - } - } - // Every mood has to actually change the eyes, or a face that is supposed - // to say something says the same thing as the last one. - seen := map[string]mascotMood{} - for mood, grid := range mascotFaces { - eyes := "" - for _, row := range grid { - for _, c := range row { - if c == 'O' { - eyes += "O" - } else { - eyes += "." - } + if strings.Contains(err.Error(), testKey) { + t.Errorf("the error carries the key: %v", err) } - } - if other, dup := seen[eyes]; dup { - t.Errorf("moods %d and %d draw the same eyes", other, mood) - } - seen[eyes] = mood - } - - // And the silhouette itself has to be identical: only the eyes move. - base := mascotFaces[moodNormal] - for mood, grid := range mascotFaces { - for y := range grid { - for x := range grid[y] { - a, b := base[y][x], grid[y][x] - if (a == '#') != (b == '#') { - t.Fatalf("mood %d changes the body at %d,%d", mood, x, y) - } + if got := readFile(t, path); got != theirs { + t.Errorf("the file was rewritten:\n%s", got) } - } + }) } } -// Two pixels to a cell, so 21 rows of pixels are 11 rows of terminal. -func TestTheMascotIsHalfAsTallOnScreen(t *testing.T) { - art := renderMascot(moodNormal) - if len(art) != MascotHeight { - t.Errorf("the mascot draws %d rows, want %d", len(art), MascotHeight) - } - for i, row := range art { - if w := lipgloss.Width(row); w != MascotWidth { - t.Errorf("row %d measures %d columns, want %d", i, w, MascotWidth) +// Signing out deletes session.json, which holds the token and the key. It used +// to stop there, and the key was still in every tool configured from the +// panel - working, billing, and out of reach of anyone who thought signing out +// was the end of it. +func TestSigningOutTakesTheKeyOutOfTheTools(t *testing.T) { + home := toolHome(t) + for _, path := range toolPaths(home) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) } } -} - -// The face answers "what is going on" without words, which is the question -// this panel spent a long time failing to answer with them. -func TestTheFaceFollowsWhatThePanelIsDoing(t *testing.T) { - base := func() model { return setupModel(t, &session.Session{Token: "t", APIKey: testKey}) } - - m := setupModel(t, &session.Session{}) - if got := m.mood(); got != moodAsleep { - t.Errorf("with no session the mascot is %v, want asleep", got) - } - - m = base() - m.configuring = true - if got := m.mood(); got != moodThinking { - t.Errorf("while configuring the mascot is %v, want thinking", got) - } - - m = base() - m.err = errNotSignedIn - if got := m.mood(); got != moodSad { - t.Errorf("on an error the mascot is %v, want sad", got) - } - - m = base() - if got := m.mood(); got != moodHappy { - t.Errorf("all set, the mascot is %v, want happy", got) - } -} - -// A landing screen that needs scrolling on a 24-row terminal has failed at the -// one thing it does, and the mascot is the part that goes. -func TestTheMascotGivesWayOnAShortTerminal(t *testing.T) { - wide := BannerWidth + 4 - short := renderHome(newLayout(wide, 24), true, true, moodNormal) - if rows := len(strings.Split(strings.TrimRight(short, "\n"), "\n")); rows > 20 { - t.Errorf("Home is %d rows on a 24-row terminal, the viewport is 20", rows) - } - - tall := renderHome(newLayout(wide, BannerRoom), true, true, moodNormal) - if len(strings.Split(tall, "\n")) <= len(strings.Split(short, "\n")) { - t.Error("the mascot never appears, even with room for it") - } -} - -// ── the first run, as one sequence ─────────────────────────────────────────── - -// Three keys a member had to know to press - s, then e, then c - with nothing -// leading from one to the next. The panel asks for each in turn now. -func TestAFreshMachineIsAskedToSignInImmediately(t *testing.T) { - m := setupModel(t, &session.Session{}) - - if cmd := m.resumeSetup(); cmd == nil { - t.Fatal("nothing happens when the panel opens with no session") - } - if m.loginStage != loginAskEmail { - t.Errorf("the panel opens on stage %v, want the email question", m.loginStage) - } -} - -// Signed in already, but no key: that is the next thing missing, so that is -// what it asks for. -func TestASignedInMachineIsAskedForTheKey(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t"}) - m.lay = newLayout(90, 30) - - m.resumeSetup() - if m.loginStage != loginOff { - t.Error("it asks for an account that is already there") - } - if !m.editingKey { - t.Error("the key field is not open") - } - if m.activeID() != tabSetup { - t.Error("the key field is open on a tab that does not show it") - } -} - -// And a member who has everything is left alone. This is the one that would -// annoy people most if it regressed. -func TestAConfiguredMachineIsNotAskedForAnything(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) - - if cmd := m.resumeSetup(); cmd != nil { - t.Error("a configured member is asked to set something up again") - } - if m.loginStage != loginOff || m.editingKey { - t.Error("the panel opens into a setup step that is already done") - } -} - -// Init cannot mutate the model - value receiver, returns only a Cmd - so it -// asks Update instead. If that message ever stops arriving, nothing on a fresh -// machine happens at all and the whole sequence is silently gone. -func TestTheFirstRunQuestionIsActuallyAsked(t *testing.T) { - m := setupModel(t, &session.Session{}) - - cmd := m.Init() - if cmd == nil { - t.Fatal("Init does nothing") - } - updated, _ := m.Update(firstRunMsg{}) - if updated.(model).loginStage != loginAskEmail { - t.Error("the first-run message does not start the sign-in") - } -} - -// Signing in leads to the key without the member pressing anything. -func TestSigningInLeadsStraightToTheKey(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Setenv("HERMES_HOME", filepath.Join(home, "h")) - - m := newModel(api.New(""), &session.Session{}) - m.lay = newLayout(90, 30) - if err := session.Save(&session.Session{Token: "a-token"}); err != nil { - t.Fatal(err) - } - - updated, _ := m.Update(signedInMsg{nil}) - after := updated.(model) - if !after.editingKey { - t.Error("after signing in the member is left to work out that `e` is next") + if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { + t.Fatalf("tools failed on a clean run: %v", failed) } -} - -// And the key leads to the tools - named, not pressed: writing into someone's -// opencode is a side effect that has to be asked for. -func TestTheAcceptedKeyPointsAtTheToolsStep(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) - updated, _ := m.Update(keyCheckedMsg{models: 7}) - got := updated.(model).keyCheck - if !strings.Contains(got, "key accepted") { - t.Errorf("the check says %q", got) + removed, failed := RemoveNanFromTools() + if len(failed) != 0 { + t.Fatalf("removal failed: %v", failed) } - if installedTools() > 0 && !strings.Contains(got, "press c") { - t.Errorf("the check says %q, which does not lead anywhere", got) + if len(removed) == 0 { + t.Fatal("removal found nothing to take out, having just written five") } -} - -// ── the guided setup ───────────────────────────────────────────────────────── - -func wizardAt(t *testing.T, step wizardStep) model { - t.Helper() - m := setupModel(t, &session.Session{}) - m.lay = newLayout(96, 44) - m.wizard = step - return m -} - -// Four numbered steps, in the member's own words, with the banner over them. -// It replaced a screen that said "Step 1 of 2" and then handed you back to the -// panel to find the other two yourself. -func TestTheGuidedSetupShowsAllFourSteps(t *testing.T) { - out := wizardAt(t, wizardEmail).renderWizard(newLayout(96, 44)) - - for _, want := range []string{ - "Setting up", - "1. Let's get you logged in", - "2. Let's confirm it with the magic link", - "3. Let's set up your API key", - "4. Let's configure your tools", - } { - if !strings.Contains(out, want) { - t.Errorf("the setup screen does not show %q", want) + for name, path := range toolPaths(home) { + if strings.Contains(readFile(t, path), testKey) { + t.Errorf("%s still carries the key after signing out", name) } } - // And the banner, because the thing you just opened should say what it is. - if !strings.Contains(out, "nan.builders") { - t.Error("the setup screen has no banner") - } -} - -// The one you are on is marked, and the ones behind it are ticked. -func TestTheGuidedSetupSaysWhereYouAre(t *testing.T) { - third := wizardAt(t, wizardKey).renderWizard(newLayout(96, 44)) - lines := strings.Split(third, "\n") - for _, line := range lines { - switch { - case strings.Contains(line, "1. Let's get you"), strings.Contains(line, "2. Let's confirm"): - if !strings.Contains(line, "✓") { - t.Errorf("a finished step is not ticked: %q", strings.TrimSpace(line)) - } - case strings.Contains(line, "3. Let's set up"): - if !strings.Contains(line, "▶") { - t.Errorf("the current step is not marked: %q", strings.TrimSpace(line)) - } - } + if strings.Contains(readFile(t, hermesEnvPath(filepath.Join(home, "hermes"))), testKey) { + t.Error("Hermes still carries the key after signing out") } } -// A face per step, so the mascot is doing something that matches it. -func TestEachStepHasItsOwnFace(t *testing.T) { - if wizardLink.mood() != moodThinking { - t.Error("waiting for a link is not a thinking face") - } - if wizardDone.mood() != moodHappy { - t.Error("finishing the setup is not a happy face") +// The reason a failed `hermes config set` prints. The value is the key. +func TestHermesFailuresDoNotPrintTheValue(t *testing.T) { + err := hermesConfigError([]string{"set", "model.api_key", testKey}, "unknown key\n") + if strings.Contains(err.Error(), testKey) { + t.Fatalf("the key is in the error the panel renders: %v", err) } - for _, step := range []wizardStep{wizardEmail, wizardLink, wizardKey, wizardTools, wizardDone} { - if _, ok := mascotFaces[step.mood()]; !ok { - t.Errorf("step %v asks for a face that does not exist", step) + for _, want := range []string{"set", "model.api_key", "unknown key"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the error no longer says %q: %v", want, err) } } } -// Esc leaves the setup and lands in the panel. It must not quit the program: -// someone who skipped a step has not asked to close the CLI. -func TestEscapeLeavesTheSetupAndNotTheProgram(t *testing.T) { - m := wizardAt(t, wizardEmail) - - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) - after := updated.(model) - if after.wizard != wizardOff { - t.Error("esc does not leave the setup") - } - if cmd != nil { - t.Error("esc during the setup quits the program") - } -} - -// Signing out from the panel, which `nan auth logout` is no use for when you -// are looking at the panel and want to switch accounts. -func TestSigningOutTakesTwoPressesAndClearsEverything(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Setenv("HERMES_HOME", filepath.Join(home, "h")) - - sess := &session.Session{Token: "t", APIKey: testKey} - if err := session.Save(sess); err != nil { - t.Fatal(err) - } - m := newModel(api.New("t").WithBaseURL(newFakePlatform(t, http.StatusOK).url), sess) - m.lay = newLayout(90, 30) - m.cache[tabUsage] = "somebody else's numbers" - - press := func(m model, r rune) model { - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - return updated.(model) - } - - // One press asks. - after := press(m, 'o') - if after.sess.Token == "" { - t.Fatal("one press signed out, with no confirmation") - } - if !strings.Contains(after.setupMsg, "press o again") { - t.Errorf("the first press says %q", after.setupMsg) - } - - // Anything else calls it off. - if press(after, 'r').confirmSignOut { - t.Error("the confirmation survives another key") - } - - // Two presses do it, and take the key and the cached answers with them. - out := signOut(t, m) - if out.sess.Token != "" || out.sess.APIKey != "" { - t.Error("signing out left the session behind") - } - if out.client.Token() != "" { - t.Error("the client still carries the old token") - } - if len(out.cache) != 0 { - t.Error("the tabs keep answers that were true for another account") - } - if _, err := session.Load(); err == nil { - t.Error("the session file is still on disk") - } -} +// ── the shared harness ─────────────────────────────────────────────────────── -// And signing out drops straight back into the setup, which is the only thing -// left to do. -func TestSigningOutStartsTheSetupAgain(t *testing.T) { +// A home directory of this test's own, Hermes included, so nothing here +// reaches the machine it is running on. +func toolHome(t *testing.T) string { + t.Helper() home := t.TempDir() t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Setenv("HERMES_HOME", filepath.Join(home, "h")) - sess := &session.Session{Token: "t", APIKey: testKey} - if err := session.Save(sess); err != nil { + t.Setenv("USERPROFILE", home) // os.UserHomeDir reads this one on Windows + hermesDir := filepath.Join(home, "hermes") + t.Setenv("HERMES_HOME", hermesDir) + if err := os.MkdirAll(hermesDir, 0o700); err != nil { t.Fatal(err) } - m := newModel(api.New("t").WithBaseURL(newFakePlatform(t, http.StatusOK).url), sess) - m.lay = newLayout(90, 30) - - out := signOut(t, m) - if out.wizard != wizardEmail { - t.Errorf("after signing out the panel is at %v, want the first step", out.wizard) - } + recordHermes(t) // no hermes process is spawned, here or anywhere + return home } -// Signing out was only behind `?`, which is a place you look for something if -// you already suspect it is there. Reported as exactly that: "en ningún lado -// aparece que pulsando o dos veces te deslogeas". -func TestSigningOutIsAdvertisedWhereItIsLookedFor(t *testing.T) { - // The account screen, which is where a person goes for account things. - profile := renderProfile(map[string]any{"handle": "bperez"}, newLayout(90, 30)) - if !strings.Contains(profile, "sign out") { - t.Error("the Profile tab does not offer to sign out") - } - if !strings.Contains(profile, "twice") { - t.Error("Profile does not say it takes two presses, which is the surprising half") - } - - // The Setup tab footer, next to the other keys it lists. - m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) - m.lay = newLayout(96, 40) - if out := m.renderSetup(m.lay); !strings.Contains(out, "o sign out") { - t.Error("the Setup tab lists its keys and leaves this one out") - } - - // Home, where the keys are introduced. - if out := renderHome(newLayout(96, 40), true, true, moodNormal); !strings.Contains(out, "sign out") { - t.Error("Home lists the keys and leaves this one out") - } - - // And not offered to somebody who has no session to end. - if out := renderHome(newLayout(96, 40), false, false, moodNormal); strings.Contains(out, "sign out") { - t.Error("Home offers to sign out of a session that is not there") +func toolPaths(home string) map[string]string { + return map[string]string{ + "Factory AI": filepath.Join(home, ".factory", "settings.json"), + "OpenCode": filepath.Join(home, ".config", "opencode", "opencode.json"), + "Pi": filepath.Join(home, ".pi", "agent", "models.json"), + "Codex": filepath.Join(home, ".codex", "config.toml"), } } -// One tool failing used to read as the whole step failing: the message became -// "error: " and the configs that HAD been written -// went unmentioned. Reported from a machine where Hermes would not configure, -// which left the setup on its last screen with nothing to do but escape. -func TestOneToolFailingDoesNotLoseTheOthers(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - hermesHomeDir := filepath.Join(home, "hermes") - t.Setenv("HERMES_HOME", hermesHomeDir) - if err := os.MkdirAll(hermesHomeDir, 0o700); err != nil { - t.Fatal(err) - } - // Hermes refuses; everything else is written as usual. - original := runHermesConfig - runHermesConfig = func(string, ...string) error { - return fmt.Errorf("hermes config set: exit status 1\nsomething it printed\nand more of it") - } - t.Cleanup(func() { runHermesConfig = original }) +// ── the old names, over the new API ───────────────────────────────────────── +// +// These tests were written against the dashboard's own functions before the +// writers moved here. Three thin adapters keep each test reading as it did, +// so what they pin is visibly the same behaviour. - for _, p := range []string{ - filepath.Join(home, ".factory", "settings.json"), - filepath.Join(home, ".config", "opencode", "opencode.json"), - filepath.Join(home, ".pi", "agent", "models.json"), - filepath.Join(home, ".codex", "config.toml"), - } { - if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(p, nil, 0o600); err != nil { - t.Fatal(err) +func isNaNConfigured(name, path string) bool { + for _, t := range Registry() { + if t.Name == name { + return t.configured(path) } } - - msg, written, failed := configureTools(testKey, nil) - - if !strings.Contains(msg, "4 added") { - t.Errorf("the message is %q, and does not say what worked", msg) - } - if !strings.Contains(msg, "Hermes failed") { - t.Errorf("the message is %q, and does not name what did not", msg) - } - if len(written) != 4 { - t.Errorf("wrote %v, want the four that did not fail", written) - } - if len(failed) != 1 || failed[0].name != "Hermes" { - t.Fatalf("failures are %v, want just Hermes", failed) - } - // The summary takes one line of what a tool printed, not the paragraph. - if strings.ContainsAny(failed[0].firstLine(), "\r\n") { - } + return false } -// And the step finishes anyway. Holding somebody on the last screen of a setup -// with no way on but escape is the thing that was reported. -func TestAFailedToolDoesNotStrandTheSetup(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) - m.lay = newLayout(96, 44) - m.wizard = wizardTools - - updated, _ := m.Update(configuredMsg{ - msg: "4 added · Hermes failed", - written: []string{"OpenCode"}, - failed: []toolFailure{{"Hermes", "hermes config set: exit status 1"}}, - }) - after := updated.(model) - - if after.wizard != wizardDone { - t.Error("a failed tool holds the setup on its last step") - } - out := after.renderWizard(after.lay) - if !strings.Contains(out, "Hermes") { - t.Error("the screen does not say which tool failed") - } - if !strings.Contains(out, "The rest went in") { - t.Error("the screen does not say the others worked") - } - if !strings.Contains(out, "press c again") { - t.Error("the screen does not say what can be done about it") - } +func configureTools(apiKey string, ticked map[string]bool) (string, []string, []Failure) { + r := Apply(apiKey, ticked) + return r.Summary(), r.Written, r.Failed } -// Step 3 on a short terminal. The banner went on top and View cut what did not -// fit from the bottom, so the part that went was the field for the key. -// Reported as: "no hay espacio para ponerla en el setup". -func TestTheKeyFieldFitsOnAShortTerminal(t *testing.T) { - for _, size := range [][2]int{{80, 14}, {80, 18}, {120, 22}, {120, 30}} { - m := setupModel(t, &session.Session{Token: "t"}) - m.lay = newLayout(size[0], size[1]) - m.wizard = wizardLink - m.startKeyEdit() - if !strings.Contains(m.View(), "paste your NaN API key here") { - t.Errorf("at %dx%d step 3 has no field to paste the key into", size[0], size[1]) - } - } -} - -// And a key the cluster refuses leaves the field open for another one, instead -// of the error and nothing under it. -func TestARefusedKeyOpensTheFieldAgain(t *testing.T) { - m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) - m.wizard = wizardKey - - updated, _ := m.Update(keyCheckedMsg{err: errors.New("401")}) - after := updated.(model) - if !after.editingKey { - t.Error("after a refused key step 3 has nowhere to paste another") - } - if !strings.Contains(after.keyCheck, "refused") { - t.Errorf("the refusal is gone: %q", after.keyCheck) +func RemoveNanFromTools() (removed []string, failed []string) { + removed, failures := RemoveAll() + for _, f := range failures { + failed = append(failed, f.Tool+" ("+f.Err.Error()+")") } + return removed, failed } diff --git a/internal/tui/banner.go b/internal/tui/banner.go deleted file mode 100644 index 90c336b..0000000 --- a/internal/tui/banner.go +++ /dev/null @@ -1,301 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// The welcome wordmark: NaN in block letters, in the brand's own violet. -// -// The glyphs are two different things and are coloured as such. `█` is the body -// of the letter; `╔ ╗ ╚ ╝ ═ ║` are its edge, and drawing those darker is what -// gives the letterform depth on a terminal instead of a flat slab. -// -// `#7D39EB` is `--color-violet` on nan.builders, the single accent of the -// system, and that token file says it is for FILLS - which a block letter is. -// `#9B6BF0` is `--color-violet-2`, the one that carries text. -const ( - brandViolet = "#7D39EB" - brandVioletText = "#9B6BF0" - brandVioletDeep = "#3B1578" -) - -// The middle letter sits one row lower than the two N's, which is what makes it -// read as the lowercase 'a' of NaN instead of a third capital. -var wordmark = []string{ - "███╗ ██╗ ███╗ ██╗", - "████╗ ██║ █████╗ ████╗ ██║", - "██╔██╗ ██║ ██╔══██╗ ██╔██╗ ██║", - "██║╚██╗██║ ███████║ ██║╚██╗██║", - "██║ ╚████║ ██╔══██║ ██║ ╚████║", - "╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝", -} - -const ( - wordmarkWidth = 30 - sideGap = 6 - // Between the mascot and the wordmark. Wider than sideGap because the - // mascot is a solid shape and the letters are not: the same gap reads as - // crowding. - mascotGap = 4 -) - -func styles() (name, dim, text lipgloss.Style) { - return lipgloss.NewStyle().Foreground(lipgloss.Color(brandVioletText)).Bold(true), - lipgloss.NewStyle().Foreground(cDimGray), - lipgloss.NewStyle().Foreground(cGray) -} - -// paint colours one wordmark row: the body of the letter in the accent, its -// edge in a deeper violet. -func paint(row string) string { - body := lipgloss.NewStyle().Foreground(lipgloss.Color(brandViolet)) - edge := lipgloss.NewStyle().Foreground(lipgloss.Color(brandVioletDeep)) - - var b strings.Builder - for _, r := range row { - switch r { - case '█': - b.WriteString(body.Render(string(r))) - case ' ': - b.WriteString(" ") - default: - b.WriteString(edge.Render(string(r))) - } - } - return b.String() -} - -// What goes beside the wordmark, row by row, so the text sits on the letters' -// baseline instead of floating above them. -func bannerSide() []string { - name, dim, text := styles() - return []string{ - dim.Render("welcome to"), - name.Render("nan.builders"), - dim.Render("cloud CLI · v" + Version), - "", - text.Render("created by ") + name.Render("@Nxssie"), - text.Render("maintained by ") + name.Render("Helmcode Team"), - } -} - -// BannerWidth is the columns the whole thing needs, indent aside. -var BannerWidth = func() int { - widest := 0 - for _, line := range bannerSide() { - if w := lipgloss.Width(line); w > widest { - widest = w - } - } - return MascotWidth + mascotGap + wordmarkWidth + sideGap + widest -}() - -// BannerWidthPlain is the same without the mascot, which is what a narrow -// terminal gets. -var BannerWidthPlain = BannerWidth - MascotWidth - mascotGap - -// Banner draws the mascot, then the wordmark, then the text. Eleven rows, no -// frame: it is what goes inside a tab that has other things to say. -// -// The mascot is eleven rows and the wordmark six, so the letters are centred -// against it rather than sat on its top edge - which put the whole of the -// text above the eyes and read as two unrelated drawings. -// MascotCost is the extra rows the mascot adds over the wordmark alone, and -// BannerRoom the terminal height from which it is worth spending them. Home is -// a landing screen: one that needs scrolling on a 24-row terminal has already -// failed at the only thing it does, and the mascot is the part that goes. -const ( - MascotCost = MascotHeight - 6 - BannerRoom = 24 + MascotCost + 1 -) - -func Banner(indent string, mood mascotMood, withMascot bool) string { - side := bannerSide() - if !withMascot { - var b strings.Builder - for i, row := range wordmark { - line := indent + paint(row) - if side[i] != "" { - line += strings.Repeat(" ", sideGap) + side[i] - } - b.WriteString(line + "\n") - } - return b.String() - } - - art := renderMascot(mood) - top := (len(art) - len(wordmark)) / 2 - - var b strings.Builder - for i := 0; i < len(art); i++ { - line := indent + art[i] + strings.Repeat(" ", mascotGap) - if w := i - top; w >= 0 && w < len(wordmark) { - line += paint(wordmark[w]) - if side[w] != "" { - line += strings.Repeat(" ", sideGap) + side[w] - } - } - b.WriteString(strings.TrimRight(line, " ") + "\n") - } - return b.String() -} - -// Welcome draws the banner inside corner brackets, the way a command that -// prints once and exits can afford to. The frame is measured from the content -// rather than from a constant, so it keeps hugging it when the text changes. -func Welcome(indent string) string { - corner := lipgloss.NewStyle().Foreground(lipgloss.Color(brandVioletDeep)) - - // The corner piece is two columns and then one of air before the content, - // which is what `pad` is; the far edge gets the same, so the frame sits the - // same distance from the text on both sides. - const pad = 3 - inner := BannerWidth + 2 - edge := func(left, right string) string { - return indent + corner.Render(left) + strings.Repeat(" ", inner) + corner.Render(right) - } - - var b strings.Builder - b.WriteString(edge("┌─", "─┐") + "\n\n") - b.WriteString(Banner(indent+strings.Repeat(" ", pad), moodNormal, true)) - b.WriteString("\n" + edge("└─", "─┘") + "\n") - return b.String() -} - -// ── home ────────────────────────────────────────────────────────────────────── - -// renderHome is the first thing the panel shows: the wordmark, and how to move -// around it. -// -// The banner used to live only in About, which is the last tab, so the program -// opened on a table of account fields and you had to go looking for the name of -// what you were running. The keys are along the bottom of every tab too, but a -// one-line footer is where you look when you already know what you are doing, -// not when you have just arrived. -// What a member has to do before the panel is any use to them, in the order -// they have to do it. -// -// The first run of this on a fresh machine landed on Home, which explained -// the arrow keys, and then answered every data tab with `unauthorized`. -// Nothing anywhere said "log in", and the command that does it is a -// subcommand you have to already know about. So Home leads with the step -// that is missing, and stops mentioning it once it is done. -func renderNextStep(l layout, loggedIn, hasKey bool) string { - if loggedIn && hasKey { - return "" - } - - section := lipgloss.NewStyle().Foreground(cGray).Bold(true) - num := lipgloss.NewStyle().Foreground(cCyan).Bold(true) - cmd := lipgloss.NewStyle().Foreground(cWhite).Bold(true) - desc := lipgloss.NewStyle().Foreground(cGray) - done := lipgloss.NewStyle().Foreground(cDimGray) - - var b strings.Builder - b.WriteString(l.indent + section.Render("Start here") + "\n\n") - - // Every one of these is a key to press right here. The list used to open - // with "q, quit, so you have your shell back", because signing in meant - // leaving the panel for a subcommand and two prompts on stdin - which is - // where people got stuck, and the reason the panel signs you in itself now. - steps := []struct { - what, why string - done bool - }{ - {"s", "sign in - a link goes to your email", loggedIn}, - {"e", "paste your API key, in Setup", hasKey}, - {"space, then c", "pick your tools and apply", false}, - } - - // One column for the commands, measured over all of them, so the reasons - // line up under each other the way the two lists below this one do. - column := 0 - for _, s := range steps { - if w := lipgloss.Width(s.what); w > column { - column = w - } - } - column += 2 - - for i, st := range steps { - marker := num.Render(fmt.Sprintf("%d.", i+1)) - body := cmd.Width(column).Render(st.what) + desc.Render(st.why) - if st.done { - marker = done.Render("✓ ") - body = done.Width(column).Render(st.what) + done.Render(st.why) - } - b.WriteString(l.indent + marker + " " + body + "\n") - } - - if !loggedIn { - b.WriteString("\n" + l.indent + done.Render("Profile, Usage and Costs stay empty until you sign in.") + "\n") - } - b.WriteString("\n") - return b.String() -} - -func renderHome(l layout, loggedIn, hasKey bool, mood mascotMood) string { - var b strings.Builder - - if l.w >= BannerWidthPlain+4 { - b.WriteString(Banner(l.indent, mood, l.w >= BannerWidth+4 && l.h >= BannerRoom) + "\n") - } else { - name, dim, _ := styles() - b.WriteString(l.indent + name.Render("nan.builders") + "\n") - b.WriteString(l.indent + dim.Render("cloud CLI · v"+Version) + "\n\n") - } - - b.WriteString(renderNextStep(l, loggedIn, hasKey)) - - section := lipgloss.NewStyle().Foreground(cGray).Bold(true) - key := lipgloss.NewStyle().Foreground(lipgloss.Color(brandVioletText)).Bold(true) - desc := lipgloss.NewStyle().Foreground(cGray) - - // One blank line after the header, not two: "The tabs" below already does - // it this way, and the row that buys is what the sign-out line needs to fit - // a 24-row terminal. - b.WriteString(l.indent + section.Render("Getting around") + "\n") - - keys := []struct{ k, d string }{ - {"←/→", "move between tabs"}, - {"↑/↓", "scroll the tab you are on"}, - {"r", "refresh it"}, - } - if loggedIn { - keys = append(keys, struct{ k, d string }{"o", "sign out of this account"}) - } - keys = append(keys, - struct{ k, d string }{"?", "every shortcut, including the ones for Setup"}, - struct{ k, d string }{"q", "quit"}, - ) - tabs := []struct{ k, d string }{ - {"Usage", "what you have spent, over 24 hours, 30 days and all time"}, - {"Models", "what your key can call, and what you have spent on each"}, - {"Costs", "what that usage would have cost you elsewhere"}, - {"Setup", "your API key, and the tools this configures for you"}, - } - - // One column for both lists, measured over both, so the descriptions line - // up under each other instead of starting wherever the key ends. - column := 0 - for _, s := range append(append([]struct{ k, d string }{}, keys...), tabs...) { - if w := lipgloss.Width(s.k); w > column { - column = w - } - } - column += 3 - - for _, s := range keys { - b.WriteString(l.indent + key.Width(column).Render(s.k) + desc.Render(s.d) + "\n") - } - - b.WriteString("\n" + l.indent + section.Render("The tabs") + "\n") - for _, t := range tabs { - b.WriteString(l.indent + key.Width(column).Render(t.k) + desc.Render(t.d) + "\n") - } - - return b.String() -} diff --git a/internal/tui/security_test.go b/internal/tui/security_test.go deleted file mode 100644 index 8df170d..0000000 --- a/internal/tui/security_test.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package tui, and what a member's API key is allowed to touch. -// -// Everything here is one question asked five ways: once the key is in this -// process, where can it end up? On disk with the wrong mode, in an argument -// list, in an error rendered on screen, or left behind in a tool's config -// after the member believes they have signed out. Each of those was true at -// some point, which is why each of them is a test. -package tui - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// The tools' configs are theirs, not ours: by the time `nan` writes a key into -// one it usually exists already, created by the tool itself and commonly 0644. -// os.WriteFile does not change the mode of a file it did not create, so the -// key went into a world-readable file and the 0o600 in the call did nothing. -func TestWritingAKeyTightensAConfigThatWasWideOpen(t *testing.T) { - home := toolHome(t) - for _, path := range toolPaths(home) { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - // As the tool would have left it. - if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - } - - if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { - t.Fatalf("tools failed on a clean run: %v", failed) - } - for name, path := range toolPaths(home) { - if !strings.Contains(readFile(t, path), testKey) { - t.Errorf("%s was not configured at all", name) - continue - } - assertNotWorldReadable(t, path) - } -} - -// The tools that are already pointing at the cluster are the ones that have -// held a key the longest, and the writers leave them alone: there is nothing -// to change. Their mode is still whatever it was when the key went in, which -// for a config the tool created itself is world-readable. -func TestConfiguringTightensAToolThatNeedsNothingWritten(t *testing.T) { - home := toolHome(t) - paths := toolPaths(home) - for _, path := range paths { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { - t.Fatal(err) - } - } - if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { - t.Fatalf("tools failed on a clean run: %v", failed) - } - // As an older version of this CLI would have left them. - for _, path := range paths { - if err := os.Chmod(path, 0o644); err != nil { - t.Fatal(err) - } - } - - // A second run has nothing to write, and has to fix them anyway. - if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { - t.Fatalf("tools failed on the second run: %v", failed) - } - for name, path := range paths { - if !strings.Contains(readFile(t, path), testKey) { - t.Errorf("%s lost its key on the second run", name) - } - assertNotWorldReadable(t, path) - } -} - -// A config this CLI cannot parse is the one case where it must not write: the -// readers used to discard the unmarshal error, read the file as nothing, and -// write it again from scratch - taking every other provider in it, and their -// keys, with them. -func TestAConfigThatDoesNotParseIsLeftExactlyAsItIs(t *testing.T) { - const theirs = `{"provider": {"anthropic": {"apiKey": "theirs"}},,,` - - for _, c := range []struct { - name string - file string - write func(path, key string) error - }{ - {"Factory AI", "settings.json", writeFactoryConfig}, - {"OpenCode", "opencode.json", writeOpencodeConfig}, - {"Pi", "models.json", writePiConfig}, - } { - t.Run(c.name, func(t *testing.T) { - path := tempConfig(t, c.file) - if err := os.WriteFile(path, []byte(theirs), 0o600); err != nil { - t.Fatal(err) - } - - err := c.write(path, testKey) - if err == nil { - t.Fatal("a config that does not parse was written over without a word") - } - if strings.Contains(err.Error(), testKey) { - t.Errorf("the error carries the key: %v", err) - } - if got := readFile(t, path); got != theirs { - t.Errorf("the file was rewritten:\n%s", got) - } - }) - } -} - -// Signing out deletes session.json, which holds the token and the key. It used -// to stop there, and the key was still in every tool configured from the -// panel - working, billing, and out of reach of anyone who thought signing out -// was the end of it. -func TestSigningOutTakesTheKeyOutOfTheTools(t *testing.T) { - home := toolHome(t) - for _, path := range toolPaths(home) { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, nil, 0o600); err != nil { - t.Fatal(err) - } - } - if _, _, failed := configureTools(testKey, nil); len(failed) != 0 { - t.Fatalf("tools failed on a clean run: %v", failed) - } - - removed, failed := RemoveNanFromTools() - if len(failed) != 0 { - t.Fatalf("removal failed: %v", failed) - } - if len(removed) == 0 { - t.Fatal("removal found nothing to take out, having just written five") - } - for name, path := range toolPaths(home) { - if strings.Contains(readFile(t, path), testKey) { - t.Errorf("%s still carries the key after signing out", name) - } - } - if strings.Contains(readFile(t, hermesEnvPath(filepath.Join(home, "hermes"))), testKey) { - t.Error("Hermes still carries the key after signing out") - } -} - -// The reason a failed `hermes config set` prints. The value is the key. -func TestHermesFailuresDoNotPrintTheValue(t *testing.T) { - err := hermesConfigError([]string{"set", "model.api_key", testKey}, "unknown key\n") - if strings.Contains(err.Error(), testKey) { - t.Fatalf("the key is in the error the panel renders: %v", err) - } - for _, want := range []string{"set", "model.api_key", "unknown key"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the error no longer says %q: %v", want, err) - } - } -} - -// ── the shared harness ─────────────────────────────────────────────────────── - -// A home directory of this test's own, Hermes included, so nothing here -// reaches the machine it is running on. -func toolHome(t *testing.T) string { - t.Helper() - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) // os.UserHomeDir reads this one on Windows - hermesDir := filepath.Join(home, "hermes") - t.Setenv("HERMES_HOME", hermesDir) - if err := os.MkdirAll(hermesDir, 0o700); err != nil { - t.Fatal(err) - } - recordHermes(t) // no hermes process is spawned, here or anywhere - return home -} - -func toolPaths(home string) map[string]string { - return map[string]string{ - "Factory AI": filepath.Join(home, ".factory", "settings.json"), - "OpenCode": filepath.Join(home, ".config", "opencode", "opencode.json"), - "Pi": filepath.Join(home, ".pi", "agent", "models.json"), - "Codex": filepath.Join(home, ".codex", "config.toml"), - } -} diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go deleted file mode 100644 index 78d62a3..0000000 --- a/internal/tui/session_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/nxssie/nan-cli/internal/api" - "github.com/nxssie/nan-cli/internal/session" -) - -// A session the platform stopped honouring left its token in session.json, so -// the panel went on believing it: Home said the member was signed in, `s` did -// nothing because there was a session, and every other tab said `invalid -// session`. The one key that fixes it was the one switched off. -func TestAnExpiredSessionCanBeSignedBackInto(t *testing.T) { - sess := &session.Session{Token: "stale", APIKey: "sk-still-good"} - m := setupModel(t, sess) - m.client = api.New("stale") - m.lay = newLayout(100, 40) - m.active = tabIndex(tabProfile) - - mod, _ := m.Update(sessionExpiredMsg{token: "stale"}) - m = mod.(model) - - if m.sess.Token != "" { - t.Error("the panel still holds the session the platform refused") - } - if m.sess.APIKey != "sk-still-good" { - t.Error("the API key went with the session, and it had not expired") - } - if m.client.Token() != "" { - t.Error("the client still sends the refused session") - } - if !strings.Contains(m.View(), "press s") { - t.Errorf("the tab does not say what to do:\n%s", m.View()) - } - - // On disk too, or the next start is the same panel again. - saved, err := session.Load() - if err != nil { - t.Fatal(err) - } - if saved.Token != "" || saved.APIKey != "sk-still-good" { - t.Errorf("session.json kept %+v", saved) - } - - // Back to Home, which has to offer the sign-in again - and not the error - // the tab it came from was showing. - mod, _ = m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - m = mod.(model) - if m.activeID() != tabHome { - t.Fatalf("shift+tab from Profile lands on %v", m.activeID()) - } - home := m.View() - if strings.Contains(home, errSessionExpired.Error()) { - t.Errorf("Home is drawing the error from the tab before it:\n%s", home) - } - if !strings.Contains(home, "sign in") { - t.Errorf("Home does not offer the sign-in:\n%s", home) - } - - mod, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) - if mod.(model).loginStage != loginAskEmail { - t.Error("s still does nothing after the session expired") - } -} - -// A refusal of the old session that arrives after the member has signed in -// again is about a session that is already gone, and must not take the new -// one with it. -func TestALateRefusalDoesNotSignOutTheNewSession(t *testing.T) { - m := setupModel(t, &session.Session{Token: "fresh"}) - m.client = api.New("fresh") - - mod, _ := m.Update(sessionExpiredMsg{token: "stale"}) - if got := mod.(model).sess.Token; got != "fresh" { - t.Errorf("the new session was dropped for the old one's refusal (token %q)", got) - } -} - -// Opening the panel onto an expired session is the same moment as opening it -// with none, so it goes straight to the sign-in the way a fresh machine does. -func TestOpeningOnAnExpiredSessionAsksToSignIn(t *testing.T) { - m := setupModel(t, &session.Session{Token: "stale", APIKey: "sk"}) - m.client = api.New("stale") - - mod, cmd := m.Update(firstRunMsg{}) - m = mod.(model) - if cmd == nil { - t.Fatal("the panel opens on a session without asking whether it still works") - } - if m.loginStage != loginOff { - t.Fatal("the panel asks for a sign-in before it knows the session is gone") - } - - mod, _ = m.Update(sessionExpiredMsg{token: "stale", atStart: true}) - if mod.(model).loginStage != loginAskEmail { - t.Error("opening on an expired session does not start the sign-in") - } -} - -// The same answer later on, while someone is reading a tab, says so and waits: -// the sign-in takes every key while it is up. -func TestASessionThatExpiresLaterDoesNotGrabTheKeyboard(t *testing.T) { - m := setupModel(t, &session.Session{Token: "stale"}) - m.client = api.New("stale") - m.active = tabIndex(tabUsage) - - mod, _ := m.Update(sessionExpiredMsg{token: "stale"}) - if mod.(model).loginStage != loginOff { - t.Error("an expiry mid-session threw the member into the sign-in") - } -} - -// A platform token does not open the dashboard; the sign-in says so instead -// of asking for an email as if nothing were set up. -func TestSignInExplainsAPlatformToken(t *testing.T) { - t.Setenv("NAN_TOKEN", "") - m := setupModel(t, &session.Session{}) - m.startLogin() - if m.loginMsg != "" { - t.Errorf("a machine with nothing set up gets %q", m.loginMsg) - } - - for name, setup := range map[string]func() *session.Session{ - "saved": func() *session.Session { return &session.Session{PlatformToken: "nan_pat_x"} }, - "env": func() *session.Session { - t.Setenv("NAN_TOKEN", "nan_pat_x") - return &session.Session{} - }, - } { - m := setupModel(t, setup()) - m.lay = newLayout(90, 30) - m.startLogin() - flat := strings.Join(strings.Fields(m.View()), " ") - for _, want := range []string{"nan run and nan runs only", "email sign-in"} { - if !strings.Contains(flat, want) { - t.Errorf("%s: the sign-in does not say %q:\n%s", name, want, m.View()) - } - } - if strings.Contains(flat, "nan_pat_x") { - t.Errorf("%s: the token is on screen", name) - } - } -} diff --git a/internal/tui/signout_server_test.go b/internal/tui/signout_server_test.go deleted file mode 100644 index c35242a..0000000 --- a/internal/tui/signout_server_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package tui - -import ( - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/nxssie/nan-cli/internal/api" - "github.com/nxssie/nan-cli/internal/session" -) - -// fakePlatform stands in for cloud-api so a sign-out in a test never reaches -// the real one, and records what it was asked. -type fakePlatform struct { - url string - mu sync.Mutex - seen []string - // sessionOnDisk records, per request, whether session.json still existed. - sessionOnDisk []bool -} - -func newFakePlatform(t *testing.T, status int) *fakePlatform { - t.Helper() - fp := &fakePlatform{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := session.Load() - fp.mu.Lock() - fp.seen = append(fp.seen, r.Method+" "+r.URL.Path+" "+r.Header.Get("Authorization")) - fp.sessionOnDisk = append(fp.sessionOnDisk, err == nil) - fp.mu.Unlock() - w.WriteHeader(status) - })) - t.Cleanup(srv.Close) - fp.url = srv.URL - return fp -} - -func pressKey(m model, r rune) (model, tea.Cmd) { - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - return updated.(model), cmd -} - -// signOut presses `o` twice and lets the sign-out command run to completion, -// feeding its answer back the way the bubbletea runtime would. -func signOut(t *testing.T, m model) model { - t.Helper() - m, _ = pressKey(m, 'o') - m, cmd := pressKey(m, 'o') - if cmd == nil { - t.Fatal("the second press started no sign-out") - } - msg, ok := cmd().(signedOutMsg) - if !ok { - t.Fatal("the sign-out command did not answer with signedOutMsg") - } - updated, _ := m.Update(msg) - return updated.(model) -} - -func signedInPanel(t *testing.T, platformURL string) model { - t.Helper() - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Setenv("HERMES_HOME", filepath.Join(home, "h")) - sess := &session.Session{Token: "session-jwt", APIKey: testKey} - if err := session.Save(sess); err != nil { - t.Fatal(err) - } - m := newModel(api.New("session-jwt").WithBaseURL(platformURL), sess) - m.lay = newLayout(90, 30) - return m -} - -// Spec (prod E2E 2026-10-12): signing out from the panel ends the session on -// the platform before session.json goes, and a platform that does not answer -// does not keep the member signed in on this machine; the member is told the -// session may outlive the sign-out. -func TestSigningOutEndsTheSessionOnThePlatform(t *testing.T) { - for _, status := range []int{http.StatusOK, http.StatusBadGateway} { - t.Run(http.StatusText(status), func(t *testing.T) { - fp := newFakePlatform(t, status) - m := signedInPanel(t, fp.url) - - if _, cmd := pressKey(m, 'o'); cmd != nil || len(fp.seen) != 0 { - t.Fatalf("one press already started a sign-out (%v)", fp.seen) - } - out := signOut(t, m) - - if len(fp.seen) != 1 || fp.seen[0] != "POST /auth/logout Bearer session-jwt" { - t.Fatalf("platform saw %v, want one POST /auth/logout with the session bearer", fp.seen) - } - if !fp.sessionOnDisk[0] { - t.Error("session.json was deleted before the platform was asked") - } - if out.sess.Token != "" || out.signingOut { - t.Error("the panel kept the session") - } - if _, err := session.Load(); err == nil { - t.Error("session.json is still on disk") - } - warned := strings.Contains(out.setupMsg, "did not confirm") - if status == http.StatusOK && warned { - t.Errorf("a confirmed sign-out warns: %q", out.setupMsg) - } - if status != http.StatusOK && !warned { - t.Errorf("an unconfirmed sign-out does not say so: %q", out.setupMsg) - } - }) - } -} - -// The platform is asked from a command, not from Update: a platform that -// accepts the connection and never answers must not freeze the panel. Until -// it answers, the member stays signed in here, the panel says it is signing -// out, and another `o` does not ask again. -func TestSigningOutDoesNotFreezeThePanelOnAHangingPlatform(t *testing.T) { - release := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-release - })) - t.Cleanup(func() { close(release); srv.Close() }) - m := signedInPanel(t, srv.URL) - - m, _ = pressKey(m, 'o') - done := make(chan struct{}) - var cmd tea.Cmd - go func() { - m, cmd = pressKey(m, 'o') - close(done) - }() - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("the second press blocked on the platform") - } - if cmd == nil || !m.signingOut || !strings.Contains(m.setupMsg, "signing out") { - t.Fatalf("no sign-out in flight: cmd=%v signingOut=%v msg=%q", cmd != nil, m.signingOut, m.setupMsg) - } - if _, err := session.Load(); err != nil { - t.Error("session.json went before the platform answered") - } - m, _ = pressKey(m, 'o') - if _, again := pressKey(m, 'o'); again != nil { - t.Error("another o while signing out asks the platform again") - } -} - -// A stray answer (no sign-out in flight) changes nothing. -func TestAStraySignedOutMsgIsIgnored(t *testing.T) { - fp := newFakePlatform(t, http.StatusOK) - m := signedInPanel(t, fp.url) - updated, _ := m.Update(signedOutMsg{}) - if updated.(model).sess.Token == "" { - t.Error("a signedOutMsg nobody asked for signed the member out") - } -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index 4b40663..0000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,3732 +0,0 @@ -package tui - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "sort" - "strconv" - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/nxssie/nan-cli/internal/api" - "github.com/nxssie/nan-cli/internal/auth" - catalog "github.com/nxssie/nan-cli/internal/models" - "github.com/nxssie/nan-cli/internal/session" -) - -// ── palette ─────────────────────────────────────────────────────────────────── - -var ( - cCyan = lipgloss.AdaptiveColor{Light: "#6d28d9", Dark: "#a78bfa"} - cBlue = lipgloss.AdaptiveColor{Light: "#5b21b6", Dark: "#8b5cf6"} - cBlueDim = lipgloss.AdaptiveColor{Light: "#ddd6fe", Dark: "#2e1065"} - cGray = lipgloss.AdaptiveColor{Light: "#52525b", Dark: "#71717a"} - cDimGray = lipgloss.AdaptiveColor{Light: "#a1a1aa", Dark: "#52525b"} - cWhite = lipgloss.AdaptiveColor{Light: "#18181b", Dark: "#ffffff"} - cText = lipgloss.AdaptiveColor{Light: "#374151", Dark: "#cbd5e1"} - cRed = lipgloss.AdaptiveColor{Light: "#dc2626", Dark: "#ef4444"} - - modelColors = []lipgloss.Color{ - lipgloss.Color("#8b5cf6"), - lipgloss.Color("#a78bfa"), - lipgloss.Color("#10B981"), - lipgloss.Color("#F59E0B"), - lipgloss.Color("#EF4444"), - } -) - -// ── layout ──────────────────────────────────────────────────────────────────── - -// layout holds all computed dimensions for a given terminal size. -type layout struct { - w int // terminal width - h int // terminal height - barW int // progress bar width - keyW int // label column width for KV pairs - nameW int // model name column width in usage - indent string -} - -func newLayout(w, h int) layout { - if w < 1 { - w = 80 - } - if h < 1 { - h = 24 - } - // bar: leave room for " XX.X%" - nameW := clamp(w/6, 8, 18) - barW := clamp(w-nameW-12, 8, 56) - keyW := clamp(w/4, 12, 22) - return layout{w: w, h: h, barW: barW, keyW: keyW, nameW: nameW, indent: " "} -} - -func clamp(v, min, max int) int { - if v < min { - return min - } - if v > max { - return max - } - return v -} - -// ── tabs ────────────────────────────────────────────────────────────────────── - -type tabID int - -const ( - tabHome tabID = iota - tabProfile - tabUsage - tabModels - tabCosts - tabSetup - tabAbout -) - -var tabDefs = []struct { - id tabID - name string -}{ - {tabHome, "Home"}, - {tabProfile, "Profile"}, - {tabUsage, "Usage"}, - {tabModels, "Models"}, - {tabCosts, "Costs"}, - {tabSetup, "Setup"}, - {tabAbout, "About"}, -} - -// ── messages ────────────────────────────────────────────────────────────────── - -type fetchedMsg struct { - tab tabID - data any -} -type fetchErrMsg struct{ err error } - -// The Setup tab never blocks on the network: it is the one tab a member can -// use with no connection, and the two things below are extra information -// rather than its content. They arrive when they arrive. -type keyStatusMsg struct{ status *api.KeyStatus } -type keyCheckedMsg struct { - models int - err error -} - -// What an unauthenticated tab says. Not session.ErrNotLoggedIn, which names -// the shell command: inside the panel there is a key for this, and telling -// someone to quit and run something is the detour this replaced. -var errNotSignedIn = errors.New("not signed in — press s") - -// What a tab says when the platform turned the saved session down. The token -// was still in session.json, so until the first request the panel had every -// reason to think the member was signed in. -var errSessionExpired = errors.New("your session has expired — press s to sign in again") - -// The platform refused the session the panel was holding. token is the one -// the request carried, so an answer that was already on its way before a new -// sign-in cannot throw the new session away. atStart is the check the panel -// makes when it opens. -type sessionExpiredMsg struct { - token string - atStart bool -} - -type configuredMsg struct { - msg string - written []string - failed []toolFailure -} - -// Sent once, by Init, so the panel can pick up wherever the setup was left. -type firstRunMsg struct{} - -type linkSentMsg struct{ err error } -type signedInMsg struct{ err error } - -// signedOutMsg is the platform's answer to ending the session (err nil when it -// confirmed). -type signedOutMsg struct{ err error } - -// revokeSession ends the session on the platform. It never fails the -// sign-out: the error only decides what the member is told. -func revokeSession(client *api.Client) tea.Cmd { - return func() tea.Msg { - return signedOutMsg{err: client.Logout()} - } -} - -// ── model ───────────────────────────────────────────────────────────────────── - -type model struct { - client *api.Client - sess *session.Session - active int - loading bool - err error - spin spinner.Model - cache map[tabID]any - lay layout - scrollY int - showHelp bool - keyInput textinput.Model - editingKey bool - setupMsg string - setupCursor int - keyStatus *api.KeyStatus - keyAsked bool - keyCheck string - - // Signing in, without leaving the panel. See startLogin. - configuring bool - configured []string - failures []toolFailure - - // Set by the first press of `o`, cleared by anything else: signing out is - // not something to do to somebody on a stray keystroke. - confirmSignOut bool - // signingOut: the platform has been asked to end the session and has not - // answered yet. A third `o` does not ask again. - signingOut bool - wizard wizardStep - loginStage loginStage - loginInput textinput.Model - loginEmail string - loginMsg string - loginBusy bool -} - -// Where a member is in the guided setup. The inner inputs still drive -// themselves - loginStage for the two sign-in questions, editingKey for the -// key - and this is the step the screen is showing around them, so the panel -// can draw the four of them as one sequence with a banner over it. -type wizardStep int - -const ( - wizardOff wizardStep = iota - wizardEmail - wizardLink - wizardKey - wizardTools - wizardDone -) - -// The words are the member's, from the report that asked for this. -var wizardSteps = [...]struct { - n int - title string -}{ - {1, "Let's get you logged in"}, - {2, "Let's confirm it with the magic link"}, - {3, "Let's set up your API key"}, - {4, "Let's configure your tools"}, -} - -// Which of the four a step belongs to. wizardLink is step 2, and everything -// after the tools is step 4 still finishing. -func (w wizardStep) index() int { - switch w { - case wizardEmail: - return 0 - case wizardLink: - return 1 - case wizardKey: - return 2 - } - return 3 -} - -// The face for each, so the mascot is doing something that matches the step -// rather than staring through it. -func (w wizardStep) mood() mascotMood { - switch w { - case wizardEmail, wizardKey: - return moodNormal - case wizardLink: - return moodThinking - case wizardDone: - return moodHappy - } - return moodNormal -} - -// Where a member is in the sign-in flow. It is two questions - an address and -// the link that arrives at it. -type loginStage int - -const ( - loginOff loginStage = iota - loginAskEmail - loginAskLink -) - -func newModel(client *api.Client, sess *session.Session) model { - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(cBlue) - - ti := textinput.New() - ti.Placeholder = "paste your NaN API key here" - ti.CharLimit = 512 - // A key typed in the clear is a key in the scrollback, and from there in - // whatever the member was screen-sharing or recording at the time. The - // saved one is already shown as bullets a few lines down the same tab; - // there was no reason for the field that takes it to be the exception. - ti.EchoMode = textinput.EchoPassword - ti.EchoCharacter = '•' - ti.PromptStyle = lipgloss.NewStyle().Foreground(cCyan) - ti.TextStyle = lipgloss.NewStyle().Foreground(cText) - ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(cDimGray) - - li := textinput.New() - li.CharLimit = 2048 // a sign-in link is long - li.PromptStyle = lipgloss.NewStyle().Foreground(cCyan) - li.TextStyle = lipgloss.NewStyle().Foreground(cText) - li.PlaceholderStyle = lipgloss.NewStyle().Foreground(cDimGray) - - return model{ - client: client, - sess: sess, - loginInput: li, - cache: make(map[tabID]any), - spin: sp, - lay: newLayout(80, 24), - keyInput: ti, - } -} - -func (m model) Init() tea.Cmd { - // Init takes a value receiver and returns only a Cmd, so anything it - // changes about the model is thrown away. The first-run question is asked - // as a message instead, and answered in Update where the state lives. - return tea.Batch(m.spin.Tick, func() tea.Msg { return firstRunMsg{} }) -} - -func (m model) activeID() tabID { return tabDefs[m.active].id } - -// Where a tab sits in the bar, for the keys that jump straight to one. -func tabIndex(id tabID) int { - for i, t := range tabDefs { - if t.id == id { - return i - } - } - return 0 -} - -// ── update ──────────────────────────────────────────────────────────────────── - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.lay = newLayout(msg.Width, msg.Height) - - case spinner.TickMsg: - if m.loading || m.configuring { - var cmd tea.Cmd - m.spin, cmd = m.spin.Update(msg) - return m, cmd - } - - case fetchedMsg: - m.loading = false - m.err = nil - m.cache[msg.tab] = msg.data - - case fetchErrMsg: - m.loading = false - m.err = msg.err - - case signedOutMsg: - // Only the answer to a sign-out still in flight. The session may have - // expired meanwhile (sessionExpiredMsg cleared the token); the member - // still asked to sign out, so the local half still runs. - if !m.signingOut { - return m, nil - } - m.signingOut = false - return m.finishSignOut(msg.err) - - case sessionExpiredMsg: - if msg.token == "" || msg.token != m.sess.Token { - return m, nil - } - // An expired session is no session. Leaving the token in place kept - // Home saying the member was signed in, kept `s` doing nothing, and - // answered every tab `invalid session` - with the one key that fixes - // it switched off. The key and the tools stay: they are the member's - // and still work, and the session was the only thing that went stale. - m.sess.Token = "" - _ = session.Save(m.sess) - m.client = api.New("") - m.cache = make(map[tabID]any) - m.keyStatus, m.keyAsked = nil, false - m.loading = false - m.err = errSessionExpired - // Opening the panel on an expired session is the same moment as - // opening it with none, so it gets the same sign-in. Anywhere later it - // only says so: the prompt takes every key while it is up, and a - // member reading their usage did not ask for it. The key field the - // panel may have opened meanwhile gives way, as it would have with no - // session at all - unless something has already been typed into it. - if msg.atStart && m.wizard == wizardOff && m.loginStage == loginOff && m.keyInput.Value() == "" { - m.editingKey = false - m.keyInput.Blur() - m.err = nil - return m, m.startLogin() - } - - case configuredMsg: - m.configuring = false - m.setupMsg = msg.msg - m.configured = msg.written - m.failures = msg.failed - // The step is finished either way. One tool refusing to be configured - // is not a reason to hold somebody on the last screen of a setup with - // no way on but escape - which is exactly what it did, and what was - // reported. What failed is shown, and they can carry on. - if m.wizard == wizardTools { - m.wizard = wizardDone - } - - case firstRunMsg: - return m, tea.Batch(m.resumeSetup(), m.checkSession()) - - case linkSentMsg: - m.loginBusy = false - if msg.err != nil { - m.loginMsg = "error: " + msg.err.Error() - m.loginStage = loginAskEmail - m.loginInput.EchoMode = textinput.EchoNormal - return m, m.loginInput.Focus() - } - m.wizard = wizardLink - m.loginStage = loginAskLink - m.loginMsg = "a link is on its way to " + m.loginEmail + - " — copy it out of the email without opening it, the link works once" + - " and expires " + auth.LinkValidity + " after it is sent" - m.loginInput.SetValue("") - m.loginInput.Placeholder = "https://nan.builders/...?token=..." - m.loginInput.Prompt = "Paste the link: " - // The link carries the token in its query, so from here the field is - // holding a credential and stops echoing one. - m.loginInput.EchoMode = textinput.EchoPassword - m.loginInput.EchoCharacter = '•' - return m, m.loginInput.Focus() - - case signedInMsg: - m.loginBusy = false - if msg.err != nil { - m.loginMsg = "error: " + msg.err.Error() - return m, m.loginInput.Focus() - } - // Reload the session so the token is the one just written, and drop - // the cached tabs: they are holding the `not logged in` they answered - // a moment ago. - if sess, err := session.Load(); err == nil { - m.sess = sess - } - // And a client that carries it. The old one was built at start-up with - // whatever token existed then - none - so leaving it in place signed a - // member in and then answered every tab `unauthorized`, which reads - // exactly like the login having failed. - m.client = api.New(m.sess.Token) - wasGuided := m.wizard != wizardOff - m.cancelLogin() - if wasGuided { - m.wizard = wizardKey - } - m.cache = make(map[tabID]any) - m.err = nil - m.keyAsked = false - // And straight on to whatever is still missing, which on a fresh - // machine is the key. - return m, tea.Batch(m.maybeLoad(), m.resumeSetup()) - - case keyStatusMsg: - m.keyStatus = msg.status - - case keyCheckedMsg: - // A key that the cluster refuses is worth saying once, here, rather - // than five times later as a 401 inside five different tools. - if msg.err != nil { - m.keyCheck = "error: the cluster refused this key — " + msg.err.Error() - // During the guided setup the field closed on enter and stayed - // closed, so a refused key left step 3 with the error and nothing to - // paste a better one into. It opens again, empty, under the error. - if m.wizard == wizardKey && !m.editingKey { - return m, m.startKeyEdit() - } - } else { - m.keyCheck = fmt.Sprintf("key accepted by the cluster · %d models", msg.models) - // The last link in the chain. Signing in leads to the key, and the - // key leads here - to the tools, which is the step the panel - // cannot take for someone: writing into their opencode or their - // codex is a side effect that has to be asked for, so this names - // the key to press rather than pressing it. - if n := installedTools(); n > 0 { - m.keyCheck += fmt.Sprintf(" · press c to configure the %d tools found", n) - } - if m.wizard == wizardKey { - m.wizard = wizardTools - } - } - - case tea.KeyMsg: - // The sign-in input takes every key while it is up, the same way the - // API key input does below it. - if m.loginStage != loginOff { - switch msg.String() { - case "enter": - if m.loginBusy { - return m, nil - } - value := strings.TrimSpace(m.loginInput.Value()) - if value == "" { - return m, nil - } - m.loginBusy = true - if m.loginStage == loginAskEmail { - if !strings.Contains(value, "@") { - m.loginBusy = false - m.loginMsg = "error: that is not an email address" - return m, nil - } - m.loginEmail = value - m.loginMsg = "sending a link to " + value + "…" - return m, sendLink(value) - } - m.loginMsg = "signing in…" - return m, signIn(value) - case "esc": - m.cancelLogin() - return m, nil - default: - var cmd tea.Cmd - m.loginInput, cmd = m.loginInput.Update(msg) - return m, cmd - } - } - - // When the API key input is active, route all keys to it - if m.editingKey { - switch msg.String() { - case "enter": - val := strings.TrimSpace(m.keyInput.Value()) - var check tea.Cmd - if val != "" { - m.sess.APIKey = val - if err := session.Save(m.sess); err != nil { - m.setupMsg = "error saving: " + err.Error() - } else { - m.setupMsg = "API key saved" - m.keyCheck = keyChecking - check = checkKey(val) - } - } - m.editingKey = false - m.keyInput.Blur() - if check != nil { - return m, check - } - case "esc": - m.editingKey = false - m.keyInput.Blur() - m.setupMsg = "" - // Leaving the field during the guided setup leaves the setup: - // staying would show step 3 with nothing to type into. - m.wizard = wizardOff - default: - var cmd tea.Cmd - m.keyInput, cmd = m.keyInput.Update(msg) - return m, cmd - } - return m, nil - } - - if m.confirmSignOut && msg.String() != "o" { - m.confirmSignOut = false - m.setupMsg = "" - } - - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc": - switch { - case m.showHelp: - m.showHelp = false - case m.wizard != wizardOff: - // Out of the setup and into the panel. Not out of the program: - // someone who wanted that has q, and quitting the whole CLI - // because they skipped a step would be its own small betrayal. - m.wizard = wizardOff - m.active = tabIndex(tabSetup) - default: - return m, tea.Quit - } - case "enter": - // Step 3 with its field closed - the key is still being checked, or - // something closed it - says "enter to continue", so enter opens it. - if m.wizard == wizardKey && m.keyCheck != keyChecking { - return m, m.startKeyEdit() - } - if m.wizard == wizardDone { - m.wizard = wizardOff - m.active = tabIndex(tabHome) - return m, m.maybeLoad() - } - case "?": - m.showHelp = !m.showHelp - case "right", "l", "tab": - if !m.showHelp && m.active < len(tabDefs)-1 { - m.active++ - m.scrollY = 0 - m.setupMsg = "" - m.setupCursor = 0 - return m, m.maybeLoad() - } - case "left", "h", "shift+tab": - if !m.showHelp && m.active > 0 { - m.active-- - m.scrollY = 0 - m.setupMsg = "" - m.setupCursor = 0 - return m, m.maybeLoad() - } - case "up", "k": - if !m.showHelp { - if m.activeID() == tabSetup || m.wizard == wizardTools { - if m.setupCursor > 0 { - m.setupCursor-- - } - } else if m.scrollY > 0 { - m.scrollY-- - } - } - case "down", "j": - if !m.showHelp { - if m.activeID() == tabSetup || m.wizard == wizardTools { - m.setupCursor++ - } else { - m.scrollY++ - } - } - case " ": - if !m.showHelp && !m.configuring && (m.activeID() == tabSetup || m.wizard == wizardTools) { - tools := detectTools() - if m.setupCursor < len(tools) && tools[m.setupCursor].installed { - name := tools[m.setupCursor].name - if m.sess.EnabledTools == nil { - m.sess.EnabledTools = map[string]bool{} - } - m.sess.EnabledTools[name] = !m.toolEnabled(name) - _ = session.Save(m.sess) - } - } - case "r": - if !m.showHelp { - id := m.activeID() - if id == tabCosts { - delete(m.cache, tabUsage) - } else { - delete(m.cache, id) - } - m.scrollY = 0 - return m, m.maybeLoad() - } - // Signing out, in two presses. `nan auth logout` already existed as a - // command, which is no use to someone who is looking at the panel and - // wants to switch accounts. - case "o": - if m.showHelp || m.wizard != wizardOff || m.sess.Token == "" { - break - } - if !m.confirmSignOut { - m.confirmSignOut = true - m.setupMsg = "press o again to sign out — it also takes your key back out of the tools; any other key to keep the session" - m.active = tabIndex(tabSetup) - break - } - m.confirmSignOut = false - // End the session on the platform first, while session.json - // still holds it: deleting the file alone leaves any copy of the - // token working until it expires. That is a network call, so it - // runs as a command and the panel stays responsive; the local - // sign-out happens when it answers (signedOutMsg), whatever the - // answer. Quitting before then leaves the member signed in here, - // which is consistent: nothing was revoked either. - if m.signingOut { - break - } - m.signingOut = true - m.setupMsg = "signing out…" - return m, revokeSession(m.client) - - // `s` and not `l`: l is already the vim spelling of "next tab". - case "s": - // Any tab, because the one a member is looking at when this is - // needed is whichever they walked into and got told to sign in. - if !m.showHelp && m.sess.Token == "" && !m.signingOut { - return m, m.startLogin() - } - - case "e": - // From any tab. It used to do nothing at all anywhere but Setup, - // which is the tab you have to already be on to know that - and - // Home tells everyone to press `e` from Home. - if !m.showHelp && m.loginStage == loginOff { - return m, m.startKeyEdit() - } - case "c": - if !m.showHelp && !m.configuring && (m.activeID() == tabSetup || m.wizard == wizardTools) { - // Pressing the key that configures everything and having - // nothing happen, with nothing said, is the worst of the - // three possible answers. - if m.sess.APIKey == "" { - m.setupMsg = "error: set your API key first — press e" - return m, nil - } - m.configuring = true - m.configured = nil - m.setupMsg = "" - key, tools := m.sess.APIKey, m.sess.EnabledTools - return m, tea.Batch(m.spin.Tick, func() tea.Msg { - msg, written, failed := configureTools(key, tools) - return configuredMsg{msg, written, failed} - }) - } - } - } - return m, nil -} - -func (m *model) maybeLoad() tea.Cmd { - id := m.activeID() - // The error belongs to the tab that asked. There is one m.err for all of - // them, and the tabs that load nothing - Home, About, one already in the - // cache - never cleared it, so walking back to Home after a tab failed - // drew that tab's error where Home should be. - m.err = nil - // Setup asks the platform one thing, once, and stays usable while it - // waits: no spinner, no error state, nothing that stops a member pasting - // a key on a train. - if id == tabSetup { - // Nothing to ask on a machine that has not logged in: the call would - // come back 401 and be swallowed. - if m.keyAsked || m.sess.Token == "" { - return nil - } - m.keyAsked = true - return m.fetchKeyStatus() - } - if id == tabHome || id == tabAbout { - return nil - } - // Costs tab derives from usage data — load that if needed - if id == tabCosts { - if _, ok := m.cache[tabUsage]; ok { - return nil - } - m.loading = true - m.err = nil - return tea.Batch(m.spin.Tick, m.fetchTab(tabUsage)) - } - if _, ok := m.cache[id]; ok { - return nil - } - m.loading = true - m.err = nil - return tea.Batch(m.spin.Tick, m.fetchTab(id)) -} - -// Whether the account has a key, and what it is called. Never the key -// itself: the platform hands that over once, at creation, and will not -// repeat it - so the Setup tab cannot fill the field in for a member, only -// tell them there is one to go and copy. -// Signing in from inside the panel. -// -// The flow used to be: read Home, quit the panel, run `nan auth login`, answer -// two prompts, start the panel again. Every one of those steps is somewhere to -// get stuck, and the prompts are the worst of them - they are a bare -// fmt.Print on stdin, and a terminal that renders a command as a block, or -// eats the Enter that would answer them, leaves a member with no way forward -// that reading the screen would show. -// -// The panel already owns the keyboard and already has a text input for the API -// key. So it asks the same two questions here, where the keystrokes certainly -// arrive, and the member never leaves the thing they just opened. -func (m *model) startLogin() tea.Cmd { - m.wizard = wizardEmail - m.loginStage = loginAskEmail - m.loginMsg = "" - // A machine set up with a platform token looks signed in to whoever set - // it up. The dashboard cannot use one, and asking for an email with no - // word about it reads as the token having been lost. - if m.sess.PlatformToken != "" || os.Getenv(session.TokenEnvVar) != "" { - m.loginMsg = tokenOnlyNotice - } - m.loginInput.SetValue("") - m.loginInput.Placeholder = "you@example.com" - m.loginInput.Prompt = "Email: " - // An email address is not a secret and hiding it only makes it harder to - // see a typo in the thing the link is about to be sent to. - m.loginInput.EchoMode = textinput.EchoNormal - return m.loginInput.Focus() -} - -// tokenOnlyNotice is what the sign-in says on a machine that has a platform -// token and no session. -const tokenOnlyNotice = "the platform token on this machine works for nan run and nan runs only; the dashboard needs the email sign-in" - -// What step 3 says while the cluster is looking at a key just saved. -const keyChecking = "checking it against the cluster…" - -// startKeyEdit opens the API key field, moving to the tab that shows it. -func (m *model) startKeyEdit() tea.Cmd { - if m.wizard != wizardOff { - m.wizard = wizardKey - } - if m.activeID() != tabSetup { - m.active = tabIndex(tabSetup) - m.scrollY = 0 - } - m.editingKey = true - m.keyInput.SetValue("") - m.setupMsg = "" - return m.keyInput.Focus() -} - -// resumeSetup asks for the first thing that is missing, and for nothing when -// nothing is. -// -// It runs when the panel opens and again after each step finishes, which is -// what turns three separate keys into one sequence: a fresh machine is asked -// for an account, then for a key, then left on the tab that configures the -// tools. Before this, all three were things the member had to know to press. -// -// It is a nudge and not a gate. Esc leaves any of them, and a member who came -// to look at their usage is not held hostage by a setup they did not ask for - -// the Setup tab has always worked with no session at all, and that stays true. -func (m *model) resumeSetup() tea.Cmd { - switch { - case m.sess.Token == "": - return m.startLogin() - case m.sess.APIKey == "": - return m.startKeyEdit() - } - return nil -} - -func (m *model) cancelLogin() { - m.wizard = wizardOff - m.loginStage = loginOff - m.loginBusy = false - m.loginInput.Blur() - m.loginInput.SetValue("") - m.loginMsg = "" -} - -func sendLink(email string) tea.Cmd { - return func() tea.Msg { return linkSentMsg{auth.RequestSignInLink(email)} } -} - -func signIn(pasted string) tea.Cmd { - return func() tea.Msg { - token, err := auth.TokenFromLink(strings.TrimSpace(pasted)) - if err != nil { - return signedInMsg{err} - } - sessionToken, err := auth.ExchangeToken(token) - if err != nil { - return signedInMsg{err} - } - current, err := session.Load() - if err != nil { - current = &session.Session{} - } - current.Token = sessionToken - if err := session.Save(current); err != nil { - return signedInMsg{err} - } - return signedInMsg{nil} - } -} - -func (m model) fetchKeyStatus() tea.Cmd { - client := m.client - return func() tea.Msg { - status, err := client.GetKeyStatus() - if errors.Is(err, api.ErrSessionExpired) { - return sessionExpiredMsg{token: client.Token()} - } - if err != nil { - // Silent on purpose: this is a hint, and a member with no - // connection still has a Setup tab that works. - return keyStatusMsg{nil} - } - return keyStatusMsg{status} - } -} - -// The one check that catches a mistyped key before it is copied into every -// tool on the machine. /v1/models is the endpoint the key itself opens, so -// a 401 here is exactly the 401 the tools would hit later. -func checkKey(apiKey string) tea.Cmd { - return func() tea.Msg { - ids, err := api.ListModels(apiKey) - if err != nil { - return keyCheckedMsg{err: err} - } - return keyCheckedMsg{models: len(ids)} - } -} - -// Whether this tab has nothing to ask for until the member logs in. -// -// Every tab below Home asks the platform for something, and the platform -// answers a request with no session `unauthorized`. That word reached the -// screen verbatim: a fresh install drew "unauthorized" over three of its six -// tabs, which says what the server decided and not one word about what to do -// about it. Reported from a real first run on Windows. -// -// The Models tab is the exception. An API key opens /v1/models on its own, so -// a member who pasted a key into Setup and never logged in still gets that -// one - which is the point of Setup working without a session at all. -func (m model) needsLogin(id tabID) bool { - if m.sess.Token != "" { - return false - } - if id == tabModels && m.sess.APIKey != "" { - return false - } - return id != tabHome && id != tabAbout && id != tabSetup -} - -// checkSession asks the platform whether the saved session still works, once, -// when the panel opens. Home draws from whether there is a token, and the -// token outlives the session on the server, so without this a member whose -// session had expired opened the panel onto a Home that said they were signed -// in. Anything but a refusal says nothing: offline is not signed out. -func (m model) checkSession() tea.Cmd { - client := m.client - if client == nil || m.sess.Token == "" { - return nil - } - return func() tea.Msg { - if _, err := client.GetMe(); errors.Is(err, api.ErrSessionExpired) { - return sessionExpiredMsg{token: client.Token(), atStart: true} - } - return nil - } -} - -// fetchTab is fetchTabData with the one answer every tab shares taken out of -// it: a refused session is not that tab's error, it is the panel's. -func (m model) fetchTab(id tabID) tea.Cmd { - fetch := m.fetchTabData(id) - var token string - if m.client != nil { - token = m.client.Token() - } - return func() tea.Msg { - msg := fetch() - if e, ok := msg.(fetchErrMsg); ok && errors.Is(e.err, api.ErrSessionExpired) { - return sessionExpiredMsg{token: token} - } - return msg - } -} - -func (m model) fetchTabData(id tabID) tea.Cmd { - client := m.client - apiKey := m.sess.APIKey - needsLogin := m.needsLogin(id) - return func() tea.Msg { - if needsLogin { - return fetchErrMsg{errNotSignedIn} - } - switch id { - case tabProfile: - data, err := client.GetMe() - if err != nil { - return fetchErrMsg{err} - } - return fetchedMsg{tab: tabProfile, data: data} - case tabUsage: - data, err := client.GetMetricsUsage() - if err != nil { - return fetchErrMsg{err} - } - return fetchedMsg{tab: tabUsage, data: data} - case tabModels: - // The ids a member can put in a request come from the inference - // API, and it wants the API key rather than the session. Without - // one there is still the platform's list, which answers with - // deployment names: routing aliases and models on their way out. - if apiKey != "" { - ids, err := api.ListModels(apiKey) - if err != nil { - return fetchErrMsg{err} - } - return fetchedMsg{tab: tabModels, data: ids} - } - data, err := client.GetAgentsModels() - if err != nil { - return fetchErrMsg{err} - } - return fetchedMsg{tab: tabModels, data: data} - } - return nil - } -} - -// ── view ────────────────────────────────────────────────────────────────────── - -const minWidth = 36 - -func (m model) View() string { - l := m.lay - - if l.w < minWidth { - return lipgloss.NewStyle().Foreground(cGray). - Render(fmt.Sprintf("Terminal too narrow (%d cols, need %d+)", l.w, minWidth)) - } - - if m.showHelp { - return lipgloss.Place(l.w, l.h, lipgloss.Center, lipgloss.Center, renderHelp()) - } - - var b strings.Builder - if m.wizard == wizardOff { - b.WriteString(renderTabBar(m.active, l) + "\n\n") - } else { - b.WriteString("\n") - } - - // Content area height: total minus tab-bar, blank, blank-before-footer, footer - contentH := l.h - 4 - if contentH < 1 { - contentH = 1 - } - - // The guided setup owns the screen: no tab bar to wander off into, and the - // banner at the top so the thing you just opened says what it is. - if m.wizard != wizardOff { - hint := "enter to continue esc to leave setup" - switch m.wizard { - case wizardTools: - hint = "↑/↓ pick space toggle c configure esc to leave setup" - case wizardDone: - hint = "esc or enter to open the panel" - } - body := strings.Split(strings.TrimRight(m.renderWizard(l), "\n"), "\n") - for len(body) < contentH { - body = append(body, "") - } - if len(body) > contentH { - body = body[:contentH] - } - b.WriteString(strings.Join(body, "\n")) - b.WriteString("\n" + lipgloss.NewStyle().Foreground(cGray).Render(l.indent+hint)) - return b.String() - } - - var content string - if m.loading { - content = l.indent + m.spin.View() + " Loading...\n" - } else if m.err != nil { - content = lipgloss.NewStyle().Foreground(cRed).Render(l.indent+"✗ "+m.err.Error()) + "\n" - } else { - id := m.activeID() - switch id { - case tabCosts: - if usageData, ok := m.cache[tabUsage]; ok { - content = renderCosts(usageData.(map[string]any), l) - } - case tabHome: - content = renderHome(l, m.sess.Token != "", m.sess.APIKey != "", m.mood()) - case tabAbout: - content = renderAbout(l) - case tabSetup: - content = m.renderSetup(l) - default: - if data, ok := m.cache[id]; ok { - switch id { - case tabProfile: - content = renderProfile(data.(map[string]any), l) - case tabUsage: - content = renderUsage(data.(map[string]any), l) - case tabModels: - usageData, _ := m.cache[tabUsage] - content = renderModels(data, usageData, l) - } - } - } - } - - // Scroll viewport - lines := strings.Split(strings.TrimRight(content, "\n"), "\n") - maxScroll := len(lines) - contentH - if maxScroll < 0 { - maxScroll = 0 - } - scrollY := m.scrollY - if scrollY > maxScroll { - scrollY = maxScroll - } - end := scrollY + contentH - if end > len(lines) { - end = len(lines) - } - visible := append([]string{}, lines[scrollY:end]...) - for len(visible) < contentH { - visible = append(visible, "") - } - b.WriteString(strings.Join(visible, "\n")) - - b.WriteString("\n") - hint := "←/→ tabs ↑/↓ scroll r refresh ? help q quit" - if l.w < 55 { - hint = "←/→ ↑/↓ r ? q" - } else if l.w < 72 { - hint = "←/→ tabs ↑/↓ scroll r ? help q" - } - b.WriteString(lipgloss.NewStyle().Foreground(cGray).Render(l.indent + hint)) - return b.String() -} - -func renderTabBar(active int, l layout) string { - narrow := l.w < 55 - var parts []string - for i, td := range tabDefs { - name := td.name - if narrow { - name = name[:3] // "Pro" "Usa" "Mod" - } - var s lipgloss.Style - switch { - case i == active: - s = lipgloss.NewStyle().Bold(true).Foreground(cWhite).Background(cBlueDim).Padding(0, 1) - case i == 0: - s = lipgloss.NewStyle().Foreground(cCyan).Padding(0, 1) - default: - s = lipgloss.NewStyle().Foreground(cGray).Padding(0, 1) - } - parts = append(parts, s.Render(name)) - } - sep := " " - if narrow { - sep = " " - } - return strings.Join(parts, sep) -} - -// ── profile renderer ────────────────────────────────────────────────────────── - -// renderProfile also carries the sign-out, because the account screen is where -// somebody goes looking for it. It was only behind `?`, which is a place you -// find something in if you already suspect it is there. -func renderProfile(data map[string]any, l layout) string { - var b strings.Builder - kStyle := lipgloss.NewStyle().Foreground(cGray).Width(l.keyW) - vStyle := lipgloss.NewStyle().Foreground(cText) - - for _, k := range sortedKeys(data) { - v := data[k] - if _, ok := v.(map[string]any); ok { - continue - } - val := censor(k, fmt.Sprintf("%v", v)) - b.WriteString(l.indent + kStyle.Render(humanKey(k)+":") + vStyle.Render(val) + "\n") - } - - b.WriteString("\n" + l.indent + - lipgloss.NewStyle().Foreground(cCyan).Bold(true).Render("o") + - lipgloss.NewStyle().Foreground(cGray).Render(" sign out of this account, twice to confirm") + "\n") - return b.String() -} - -// ── usage renderer ──────────────────────────────────────────────────────────── - -type modelStat struct { - name string - inputTokens float64 - outputTokens float64 -} - -type period struct { - label string - totalTokens float64 - models []modelStat -} - -func renderUsage(data map[string]any, l layout) string { - periods := []period{ - parsePeriod("Last 24 hours", data["last24h"]), - parsePeriod("Last 30 days", data["last30d"]), - parsePeriod("All time", data["allTime"]), - } - - var b strings.Builder - divider := lipgloss.NewStyle().Foreground(cDimGray). - Render(l.indent + strings.Repeat("─", l.w-len(l.indent)*2)) - sTitle := lipgloss.NewStyle().Bold(true).Foreground(cWhite) - sMuted := lipgloss.NewStyle().Foreground(cGray) - - first := true - for _, p := range periods { - if p.totalTokens == 0 { - continue - } - if !first { - b.WriteString(divider + "\n\n") - } - first = false - - b.WriteString(l.indent + sTitle.Render(p.label) + - " " + sMuted.Render(fmtTokens(p.totalTokens)+" total tokens") + "\n\n") - - for ci, ms := range p.models { - pct := (ms.inputTokens + ms.outputTokens) / p.totalTokens * 100 - color := modelColors[ci%len(modelColors)] - - nameStyle := lipgloss.NewStyle().Foreground(cGray).Width(l.nameW) - pctStyle := lipgloss.NewStyle().Foreground(color).Width(7) - - bar := renderBar(pct, l.barW, color) - b.WriteString(l.indent + nameStyle.Render(truncate(ms.name, l.nameW)) + - bar + " " + pctStyle.Render(fmt.Sprintf("%.1f%%", pct)) + "\n") - - if l.w >= 50 { - sub := lipgloss.NewStyle().Foreground(cDimGray). - Render(fmtTokens(ms.inputTokens) + " in " + fmtTokens(ms.outputTokens) + " out") - b.WriteString(l.indent + strings.Repeat(" ", l.nameW) + sub + "\n") - } - b.WriteString("\n") - } - } - return b.String() -} - -func parsePeriod(label string, raw any) period { - obj, ok := raw.(map[string]any) - if !ok { - return period{label: label} - } - p := period{label: label, totalTokens: toFloat(obj["totalTokens"])} - if list, ok := obj["byModel"].([]any); ok { - for _, item := range list { - m, ok := item.(map[string]any) - if !ok { - continue - } - p.models = append(p.models, modelStat{ - name: fmt.Sprintf("%v", m["model"]), - inputTokens: toFloat(m["inputTokens"]), - outputTokens: toFloat(m["outputTokens"]), - }) - } - } - return p -} - -func renderBar(pct float64, width int, color lipgloss.Color) string { - filled := int(pct / 100.0 * float64(width)) - filled = clamp(filled, 0, width) - fill := lipgloss.NewStyle().Background(color).Foreground(color).Render(strings.Repeat("█", filled)) - empty := lipgloss.NewStyle().Foreground(cDimGray).Render(strings.Repeat("░", width-filled)) - return fill + empty -} - -// ── models renderer ─────────────────────────────────────────────────────────── - -type modelInfo struct { - name string - mode string - tokens30d float64 - tokens24h float64 -} - -var modeLabel = map[string]string{ - "": "text generation", - "audio_speech": "audio speech", - "audio_transcription": "audio transcription", - "embedding": "embedding", -} - -var modeColor = map[string]lipgloss.TerminalColor{ - "": lipgloss.Color("#3B82F6"), - "audio_speech": lipgloss.Color("#8B5CF6"), - "audio_transcription": lipgloss.Color("#F59E0B"), - "embedding": lipgloss.Color("#10B981"), - // The kinds the catalogue uses, for the ids that come from /v1/models. - "chat": lipgloss.Color("#3B82F6"), - "chat · premium": lipgloss.Color("#A78BFA"), - "rerank": lipgloss.Color("#10B981"), - "text to speech": lipgloss.Color("#8B5CF6"), - "speech to text": lipgloss.Color("#F59E0B"), - "image": lipgloss.Color("#EC4899"), - // An id the cluster serves and this catalogue has never heard of. Worth - // showing rather than hiding: that is how a model nobody documented gets - // noticed. - "unknown": lipgloss.Color("#71717A"), -} - -// A colour for a mode this build does not know, so an id it has never seen -// still renders. -func badgeColor(mode string) lipgloss.TerminalColor { - if c, ok := modeColor[mode]; ok { - return c - } - return cGray -} - -func renderModels(data any, usageData any, l layout) string { - var models []modelInfo - - // []string is the id list from GET /v1/models: what goes in the `model` - // field of a request, which is what a member came to this tab to copy. - if ids, ok := data.([]string); ok { - for _, id := range ids { - mi := modelInfo{name: id, mode: "unknown"} - if m, found := catalog.Get(id); found { - mi.mode = string(m.Kind) - if m.Premium { - mi.mode += " · premium" - } - } - models = append(models, mi) - } - } else { - // The platform's list, when there is no API key to ask the other one. - var raw []any - switch v := data.(type) { - case map[string]any: - if list, ok := v["models"].([]any); ok { - raw = list - } - case []any: - raw = v - } - if raw == nil { - return l.indent + lipgloss.NewStyle().Foreground(cGray).Render("No models found.") + "\n" - } - for _, item := range raw { - obj, ok := item.(map[string]any) - if !ok { - continue - } - mi := modelInfo{ - name: fmt.Sprintf("%v", obj["name"]), - mode: fmt.Sprintf("%v", obj["mode"]), - } - if mi.mode == "" { - mi.mode = "" - } - models = append(models, mi) - } - } - if len(models) == 0 { - return l.indent + lipgloss.NewStyle().Foreground(cGray).Render("No models found.") + "\n" - } - - // Cross-reference with usage cache - if usageRaw, ok := usageData.(map[string]any); ok { - usageByModel := extractTokensByModel(usageRaw) - for i := range models { - models[i].tokens30d = usageByModel["30d"][models[i].name] - models[i].tokens24h = usageByModel["24h"][models[i].name] - } - } - - // Sort: most used first, then alphabetical - sort.Slice(models, func(i, j int) bool { - if models[i].tokens30d != models[j].tokens30d { - return models[i].tokens30d > models[j].tokens30d - } - return models[i].name < models[j].name - }) - - var b strings.Builder - nameW := clamp(l.w/3, 12, 22) - divider := lipgloss.NewStyle().Foreground(cDimGray). - Render(l.indent + strings.Repeat("─", clamp(l.w-6, 10, 50))) - - for i, mi := range models { - if i > 0 { - b.WriteString(divider + "\n") - } - - color := badgeColor(mi.mode) - label, ok := modeLabel[mi.mode] - if !ok { - label = mi.mode - } - - nameStyle := lipgloss.NewStyle().Bold(true).Foreground(cCyan).Width(nameW) - badgeStyle := lipgloss.NewStyle().Foreground(color) - dimStyle := lipgloss.NewStyle().Foreground(cDimGray) - usageStyle := lipgloss.NewStyle().Foreground(cGray) - - // Row 1: name + mode badge - b.WriteString(l.indent + nameStyle.Render(truncate(mi.name, nameW)) + - " " + badgeStyle.Render(label) + "\n") - - // Row 2: usage stats or "no recent usage" - if mi.tokens30d > 0 { - parts := []string{fmtTokens(mi.tokens30d) + " tokens (30d)"} - if mi.tokens24h > 0 { - parts = append(parts, fmtTokens(mi.tokens24h)+" (24h)") - } - b.WriteString(l.indent + strings.Repeat(" ", nameW) + - " " + usageStyle.Render(strings.Join(parts, " · ")) + "\n") - } else { - b.WriteString(l.indent + strings.Repeat(" ", nameW) + - " " + dimStyle.Render("no recent usage") + "\n") - } - b.WriteString("\n") - } - return b.String() -} - -// extractTokensByModel builds maps of total tokens per model for each period. -func extractTokensByModel(usage map[string]any) map[string]map[string]float64 { - out := map[string]map[string]float64{ - "24h": {}, - "30d": {}, - } - periods := map[string]string{"last24h": "24h", "last30d": "30d"} - for key, label := range periods { - if p, ok := usage[key].(map[string]any); ok { - if list, ok := p["byModel"].([]any); ok { - for _, item := range list { - if m, ok := item.(map[string]any); ok { - name := fmt.Sprintf("%v", m["model"]) - out[label][name] = toFloat(m["inputTokens"]) + toFloat(m["outputTokens"]) - } - } - } - } - } - return out -} - -// ── cost comparison renderer ────────────────────────────────────────────────── - -type providerPricing struct { - model string - provider string - inPer1M float64 // $ per 1M input tokens - outPer1M float64 // $ per 1M output tokens -} - -// Per 1M tokens, read off each vendor's own pricing page on 2026-09-14, and -// the Anthropic and OpenAI rows again on 2026-09-30 when both had newer -// models out: Opus 5.5 and Sonnet 5.5 for Opus 5 and Sonnet 5, GPT-6.1 Sol and -// GPT-6 Luna for their 5.6 namesakes. Terra stays at 5.6 because OpenAI has -// not shipped a GPT-6 one. The newer rows are cheaper than the ones they -// replace, and that is not the understating the paragraph below is about: -// each is the price of the model a member would reach for today. -// -// Ten rows rather than the six this started as, and the reason is the spread -// rather than the count. The tab multiplies a member's NaN token usage by each -// of these, so a table that carried only mid-range models answered only the -// mid-range question. From Luna at $0.10 in to Astra and Fable at $10, a -// reader can find the row that matches what they would actually have reached -// for instead of taking ours as the comparison. -// -// The six this replaced were five-sixths wrong, and all five in the same -// direction - the output price too low. GPT-5.5 was published at $20 against a -// real $30, Gemini 3.1 Pro at $8 against $12, Gemini 2.5 Flash at $0.35/$1.05 -// against $0.30/$2.50, and "GPT-5.4 Mini" was not a model anyone sells. A tab -// whose whole claim is "this is what you would have paid elsewhere" -// understating every competitor is the one direction it must not be wrong in. -// -// Some rows carry a condition the table cannot express, so each is taken at its -// lowest published rate and the comparison stays conservative: the GPT-6 and -// GPT-5.6 rows cost more above a 272k-token prompt, Gemini 3.1 Pro -// costs $4/$18 above a 200k-token prompt, and Gemini 3.8 Flash is on a -// promotional rate that doubles on 2027-01-01. TestGeminiFlashPromoHasNotExpired -// fails on that date so the number is changed rather than forgotten. -var pricingTable = []providerPricing{ - {"Claude Fable 5.1", "Anthropic", 10.00, 50.00}, - {"Claude Opus 5.5", "Anthropic", 4.00, 20.00}, - {"Claude Sonnet 5.5", "Anthropic", 2.00, 10.00}, - {"Claude Haiku 4.5", "Anthropic", 1.00, 5.00}, - {"GPT-6 Astra", "OpenAI", 10.00, 50.00}, - {"GPT-6.1 Sol", "OpenAI", 2.00, 10.00}, - {"GPT-5.6 Terra", "OpenAI", 2.00, 12.00}, - {"GPT-6 Luna", "OpenAI", 0.10, 0.50}, - {"Gemini 3.1 Pro", "Google", 2.00, 12.00}, - {"Gemini 3.8 Flash", "Google", 0.75, 3.75}, -} - -// The day Gemini 3.8 Flash stops being half price. -var geminiFlashPromoEnds = time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC) - -var providerColor = map[string]lipgloss.TerminalColor{ - "Anthropic": lipgloss.Color("#F97316"), - "OpenAI": lipgloss.Color("#10B981"), - "Google": lipgloss.Color("#3B82F6"), -} - -type periodTokens struct { - input float64 - output float64 -} - -func sumTokens(usage map[string]any, key string) periodTokens { - p, ok := usage[key].(map[string]any) - if !ok { - return periodTokens{} - } - list, _ := p["byModel"].([]any) - var pt periodTokens - for _, item := range list { - if m, ok := item.(map[string]any); ok { - pt.input += toFloat(m["inputTokens"]) - pt.output += toFloat(m["outputTokens"]) - } - } - return pt -} - -func calcCost(pt periodTokens, p providerPricing) float64 { - return pt.input/1_000_000*p.inPer1M + pt.output/1_000_000*p.outPer1M -} - -func fmtCost(v float64) string { - // Go has no `%,` verb: that format printed `$%!,(float64=1234.5).2f` on - // every figure over a thousand, which on the Costs tab is most of them. - if v >= 1000 { - return fmtCostAligned(v) - } - return fmt.Sprintf("$%.2f", v) -} - -// fmtCostAligned formats a dollar amount with comma separators. -func fmtCostAligned(v float64) string { - s := fmt.Sprintf("%.2f", v) - // insert thousands separators - dotIdx := len(s) - 3 - intPart := s[:dotIdx] - dec := s[dotIdx:] - var out []byte - for i, c := range intPart { - if i > 0 && (len(intPart)-i)%3 == 0 { - out = append(out, ',') - } - out = append(out, byte(c)) - } - return "$" + string(out) + dec -} - -func renderCosts(usage map[string]any, l layout) string { - t24 := sumTokens(usage, "last24h") - t30 := sumTokens(usage, "last30d") - tall := sumTokens(usage, "allTime") - sameAsTotal := tall.input == t30.input && tall.output == t30.output - showAll := l.w >= 72 && !sameAsTotal - - // ── pre-compute all cell strings (plain text, for width measurement) ── - type row struct { - plainLabel string // "Claude Sonnet 4.6 Anthropic" — no ANSI - styledLabel string // same text but colored - c24 string // "$38.21" - c30 string // "$876.11" - call string // "$876.11" - } - - // Below this the provider word is dropped: it is the widest part of the - // label and the least load-bearing, because each provider already has its - // own colour and its own block of rows. Keeping it is what ran the table - // off the side of a split pane. - showProvider := l.w >= 72 - - rows := make([]row, len(pricingTable)) - for i, p := range pricingTable { - pColor, ok := providerColor[p.provider] - if !ok { - pColor = cGray - } - plain := p.model - styled := lipgloss.NewStyle().Foreground(pColor).Bold(true).Render(p.model) - if showProvider { - plain = p.model + " " + p.provider - styled = lipgloss.NewStyle().Foreground(cWhite).Bold(true).Render(p.model) + - " " + lipgloss.NewStyle().Foreground(pColor).Render(p.provider) - } - rows[i] = row{ - plainLabel: plain, - styledLabel: styled, - c24: fmtCostAligned(calcCost(t24, p)), - c30: fmtCostAligned(calcCost(t30, p)), - call: fmtCostAligned(calcCost(tall, p)), - } - } - - // ── measure column widths from plain text ── - nameW := len("PROVIDER") - c24W := len("24H") - c30W := len("30 DAYS") - callW := len("ALL TIME") - for _, r := range rows { - if len(r.plainLabel) > nameW { - nameW = len(r.plainLabel) - } - if len(r.c24) > c24W { - c24W = len(r.c24) - } - if len(r.c30) > c30W { - c30W = len(r.c30) - } - if len(r.call) > callW { - callW = len(r.call) - } - } - nameW += 2 // right margin before first cost column - - // ── styles ── - hStyle := lipgloss.NewStyle().Foreground(cGray).Bold(true) - numStyle := lipgloss.NewStyle().Foreground(cGray) - totalStyle := lipgloss.NewStyle().Foreground(cWhite).Bold(true) - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(cWhite) - subStyle := lipgloss.NewStyle().Foreground(cGray) - - rpad := func(s string, w int) string { - p := w - len(s) - if p < 0 { - p = 0 - } - return strings.Repeat(" ", p) + s - } - lpad := func(s string, w int) string { - p := w - len(s) - if p < 0 { - p = 0 - } - return s + strings.Repeat(" ", p) - } - - var b strings.Builder - - // Header - b.WriteString(l.indent + titleStyle.Render("COST COMPARISON") + "\n") - // Measured rather than guessed: the long line is 75 columns and was - // switched on from 72 up, so the width that turned it on was also the - // width it ran off. Longest first, and the first one that fits wins. - subtitle := "" - for _, candidate := range []string{ - "Estimated cost based on your actual input/output tokens on other providers.", - "Estimated cost of your usage on other providers.", - "Estimated cost elsewhere.", - } { - subtitle = candidate - if lipgloss.Width(candidate)+lipgloss.Width(l.indent) <= l.w { - break - } - } - b.WriteString(l.indent + subStyle.Render(subtitle) + "\n\n") - - // Header row - header := hStyle.Render(lpad("PROVIDER", nameW)) - header += " " + hStyle.Render(rpad("24H", c24W)) - header += " " + hStyle.Render(rpad("30 DAYS", c30W)) - if showAll { - header += " " + hStyle.Render(rpad("ALL TIME", callW)) - } - b.WriteString(l.indent + header + "\n") - - divW := nameW + 2 + c24W + 2 + c30W - if showAll { - divW += 2 + callW - } - b.WriteString(l.indent + lipgloss.NewStyle().Foreground(cDimGray). - Render(strings.Repeat("─", divW)) + "\n\n") - - // Data rows — pad label using plain-text length, then right-align costs. - // - // One line each, with a blank line only where the provider changes. This - // used to put a blank line between every row, which read fine over six and - // stopped being a table at ten: two screens of alternating text and gap, - // impossible to run an eye down. The gaps that are left do some work - - // they group the rows by who charges them. - for i, r := range rows { - if i > 0 && pricingTable[i].provider != pricingTable[i-1].provider { - b.WriteString("\n") - } - labelPad := nameW - len(r.plainLabel) - if labelPad < 0 { - labelPad = 0 - } - line := r.styledLabel + strings.Repeat(" ", labelPad) - line += " " + numStyle.Render(rpad(r.c24, c24W)) - if showAll { - line += " " + numStyle.Render(rpad(r.c30, c30W)) - line += " " + totalStyle.Render(rpad(r.call, callW)) - } else { - line += " " + totalStyle.Render(rpad(r.c30, c30W)) - } - b.WriteString(l.indent + line + "\n") - } - b.WriteString("\n") - - // Footer — measure plain text width first to avoid border miscalculation - const notePlain = "NaN — Your usage is included in your membership. No per-token charges." - nanStyle := lipgloss.NewStyle().Foreground(cCyan).Bold(true) - note := nanStyle.Render("NaN") + - lipgloss.NewStyle().Foreground(cGray).Render(" — Your usage is included in your membership. No per-token charges.") - // Not len(): the em dash is one column and three bytes, so counting bytes - // drew the box two columns wider than its own text. And not the text width - // alone either: at 70 columns of content plus a border and the indent, the - // box ran off the side of any terminal narrower than about 74, which is - // where this tab gets read on half a laptop screen. The text wraps instead. - noteW := lipgloss.Width(notePlain) - if fits := l.w - lipgloss.Width(l.indent) - 4; noteW > fits { - noteW = fits - } - if noteW < 8 { - noteW = 8 - } - noteStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(cBlueDim). - Padding(0, 1). - Width(noteW) - b.WriteString(indentBlock(noteStyle.Render(note), l.indent) + "\n") - - return b.String() -} - -// ── censoring ───────────────────────────────────────────────────────────────── - -var sensitivePatterns = []string{ - "id", "token", "secret", "key", "password", - "email", "mail", "session", "auth", "hash", - "discord", "guild", "user", -} - -func isSensitive(key string) bool { - lower := strings.ToLower(key) - for _, p := range sensitivePatterns { - if strings.Contains(lower, p) { - return true - } - } - return false -} - -func censor(key, value string) string { - if !isSensitive(key) { - return value - } - const keep = 6 - runes := []rune(value) - if len(runes) <= keep { - return "..." - } - return string(runes[:keep]) + "..." -} - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func fmtTokens(n float64) string { - switch { - case n >= 1_000_000_000: - return fmt.Sprintf("%.1fB", n/1_000_000_000) - case n >= 1_000_000: - return fmt.Sprintf("%.1fM", n/1_000_000) - case n >= 1_000: - return fmt.Sprintf("%.1fK", n/1_000) - default: - return fmt.Sprintf("%.0f", n) - } -} - -func toFloat(v any) float64 { - switch n := v.(type) { - case float64: - return n - case int: - return float64(n) - case int64: - return float64(n) - } - return 0 -} - -func truncate(s string, max int) string { - runes := []rune(s) - if max < 4 || len(runes) <= max { - return s - } - return string(runes[:max-1]) + "…" -} - -func sortedKeys(m map[string]any) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -func humanKey(s string) string { - runes := []rune(s) - var out []rune - for i, r := range runes { - // A space before every capital turns userUUID into "User U U I D", - // which is the first line of the first tab. A run of capitals is one - // word, and it ends where a lowercase letter starts it a new one. - if i > 0 && isUpper(r) { - startsWord := !isUpper(runes[i-1]) - endsRun := i+1 < len(runes) && !isUpper(runes[i+1]) - if startsWord || endsRun { - out = append(out, ' ') - } - } - out = append(out, r) - } - s = string(out) - s = strings.ReplaceAll(s, "_", " ") - s = strings.ReplaceAll(s, "-", " ") - if len(s) == 0 { - return s - } - return strings.ToUpper(s[:1]) + s[1:] -} - -// ── setup renderer ─────────────────────────────────────────────────────────── - -type toolInfo struct { - name string - binary string - configPath string - installPath string // optional dir to check for installation (overrides configPath check) - installed bool - configured bool -} - -func detectTools() []toolInfo { - home, _ := os.UserHomeDir() - candidates := []toolInfo{ - { - name: "Factory AI", - binary: "droid", - configPath: filepath.Join(home, ".factory", "settings.json"), - }, - { - name: "OpenCode", - binary: "opencode", - configPath: filepath.Join(home, ".config", "opencode", "opencode.json"), - }, - { - name: "Pi", - binary: "pi", - configPath: filepath.Join(home, ".pi", "agent", "models.json"), - installPath: filepath.Join(home, ".pi"), - }, - { - name: "Codex", - binary: "codex", - configPath: filepath.Join(home, ".codex", "config.toml"), - }, - { - // Not a coding agent like the rest: it lives in the member's - // messaging channels. It is here because connecting it is the same - // two values, and because it is the one tool that tells us where - // its config is instead of making us guess. - name: "Hermes", - binary: "hermes", - configPath: hermesConfigPath(hermesHome()), - installPath: hermesHome(), - }, - } - for i := range candidates { - _, binErr := exec.LookPath(candidates[i].binary) - checkPath := candidates[i].configPath - if candidates[i].installPath != "" { - checkPath = candidates[i].installPath - } - _, pathErr := os.Stat(checkPath) - candidates[i].installed = binErr == nil || pathErr == nil - if candidates[i].installed { - candidates[i].configured = isNaNConfigured(candidates[i].name, candidates[i].configPath) - } - } - return candidates -} - -func isNaNConfigured(toolName, cfgPath string) bool { - data, err := os.ReadFile(cfgPath) - if err != nil { - return false - } - switch toolName { - case "Factory AI": - var cfg struct { - CustomModels []map[string]any `json:"customModels"` - } - if json.Unmarshal(data, &cfg) != nil { - return false - } - for _, m := range cfg.CustomModels { - if base, _ := m["baseUrl"].(string); strings.Contains(base, "api.nan.builders") { - return true - } - } - return false - case "OpenCode": - var cfg map[string]any - if json.Unmarshal(data, &cfg) != nil { - return false - } - providers, _ := cfg["provider"].(map[string]any) - nan, _ := providers["nan"].(map[string]any) - opts, _ := nan["options"].(map[string]any) - base, _ := opts["baseURL"].(string) - return strings.Contains(base, "api.nan.builders") - case "Pi": - var cfg map[string]any - if json.Unmarshal(data, &cfg) != nil { - return false - } - providers, _ := cfg["providers"].(map[string]any) - nan, _ := providers["nan"].(map[string]any) - base, _ := nan["baseUrl"].(string) - return strings.Contains(base, "api.nan.builders") - case "Codex": - return strings.Contains(string(data), "api.nan.builders") - } - return strings.Contains(string(data), "api.nan.builders") -} - -func (m model) toolEnabled(name string) bool { - if m.sess.EnabledTools == nil { - return true - } - if v, ok := m.sess.EnabledTools[name]; ok { - return v - } - return true -} - -// Writes the configs, and says which tools it wrote so the tab can tell a -// member how to use each one. -// -// This runs off the event loop. It used to be called straight out of the key -// handler, which meant the panel sat frozen for as long as it took - and with -// Hermes in the list that is four processes, several seconds, with no repaint -// and no key accepted. Reported as "se ha quedado paralizado y no sabia que -// pasaba", which is the only thing it could look like. -// How many of the tools it knows about are on this machine. -func installedTools() int { - n := 0 - for _, t := range detectTools() { - if t.installed { - n++ - } - } - return n -} - -// What went wrong for one tool, kept apart from the others so a failure in one -// does not read as a failure in all of them. -type toolFailure struct { - name string - reason string -} - -// firstLine of a reason, for a summary. A tool that is configured by running -// it - Hermes - hands back whatever it printed, which can be a paragraph. -func (f toolFailure) firstLine() string { - if i := strings.IndexAny(f.reason, "\r\n"); i >= 0 { - return strings.TrimSpace(f.reason[:i]) - } - return strings.TrimSpace(f.reason) -} - -func configureTools(apiKey string, enabledTools map[string]bool) (string, []string, []toolFailure) { - isEnabled := func(name string) bool { - if enabledTools == nil { - return true - } - if v, ok := enabledTools[name]; ok { - return v - } - return true - } - tools := detectTools() - var written []string - var failed []toolFailure - added, removed := 0, 0 - for _, t := range tools { - if !t.installed { - continue - } - if isEnabled(t.name) { - var err error - switch t.name { - case "Factory AI": - err = writeFactoryConfig(t.configPath, apiKey) - case "OpenCode": - err = writeOpencodeConfig(t.configPath, apiKey) - case "Pi": - err = writePiConfig(t.configPath, apiKey) - case "Codex": - err = writeCodexConfig(t.configPath, apiKey) - case "Hermes": - err = writeHermesConfig(filepath.Dir(t.configPath), apiKey) - } - if err == nil { - // Every writer above can decide it has nothing to change - a - // tool already pointing at the cluster is left alone - and - // that file is exactly the one that has been sitting there - // with a key in it since before this CLI started tightening - // the mode on its way past. So the mode is checked whether or - // not anything was written. - err = tightenKeyFiles(t) - } - if err != nil { - failed = append(failed, toolFailure{t.name, err.Error()}) - } else { - added++ - written = append(written, t.name) - } - } else if t.configured { - err := removeFromTool(t) - if err != nil { - failed = append(failed, toolFailure{t.name, err.Error()}) - } else { - removed++ - } - } - } - // One tool failing used to throw away everything that worked: the message - // became "error: " and the four configs that - // had just been written went unmentioned. A member with a broken Hermes - // was told the whole step had failed, and left on it with nothing to do - // but escape. - parts := []string{} - if added > 0 { - parts = append(parts, fmt.Sprintf("%d added", added)) - } - if removed > 0 { - parts = append(parts, fmt.Sprintf("%d removed", removed)) - } - for _, f := range failed { - parts = append(parts, f.name+" failed") - } - if len(parts) == 0 { - return "nothing to sync", written, nil - } - return strings.Join(parts, " · "), written, failed -} - -// The files that end up holding the key, per tool. Hermes is the odd one: its -// config.yaml carries the reference and its .env carries the secret. -func keyFilesFor(t toolInfo) []string { - if t.name == "Hermes" { - return []string{hermesEnvPath(filepath.Dir(t.configPath))} - } - return []string{t.configPath} -} - -// A config written before this CLI wrote through a temp file kept whatever -// mode the tool gave it, which for most of them is 0644. Rewriting it is not -// always on the table - the writers stop early where there is nothing to -// change - so the mode is put right on its own. -func tightenKeyFiles(t toolInfo) error { - if runtime.GOOS == "windows" { - // Not the mechanism there: the file inherits the ACL of the profile - // directory it sits in. - return nil - } - for _, path := range keyFilesFor(t) { - info, err := os.Stat(path) - if os.IsNotExist(err) { - continue - } - if err != nil { - return err - } - if info.Mode().Perm()&0o077 == 0 { - continue - } - if err := os.Chmod(path, 0o600); err != nil { - return err - } - } - return nil -} - -func removeFromTool(t toolInfo) error { - switch t.name { - case "Factory AI": - return removeFactoryConfig(t.configPath) - case "OpenCode": - return removeOpencodeConfig(t.configPath) - case "Pi": - return removePiConfig(t.configPath) - case "Codex": - return removeCodexConfig(t.configPath) - case "Hermes": - return removeHermesConfig(filepath.Dir(t.configPath)) - } - return nil -} - -// RemoveNanFromTools takes the key back out of every tool this CLI wrote it -// into, and says which ones it managed and which it could not. -// -// Exported because signing out happens in two places - `o` in the panel and -// `nan auth logout` - and deleting session.json is only half of what a member -// means by it. The key was copied into as many as five files that have nothing -// to do with the session any more, and it goes on working from there: a -// machine handed back, a laptop sold, a shared account left behind. -func RemoveNanFromTools() (removed []string, failed []string) { - for _, t := range detectTools() { - // Installed is the only gate. `configured` is read off the tool's own - // config file, and the case that matters most here is the one where - // that file is not the whole story: a run that wrote Hermes' .env and - // then failed before its config.yaml left a key on disk that this - // would have walked straight past. Every remover below is a no-op - // where there is nothing of ours to take out. - if !t.installed { - continue - } - if err := removeFromTool(t); err != nil { - failed = append(failed, t.name+" ("+err.Error()+")") - continue - } - if t.configured { - removed = append(removed, t.name) - } - } - return removed, failed -} - -// finishSignOut is the local half of signing out, once the platform has -// answered (revokeErr nil when it confirmed the session is over). -func (m model) finishSignOut(revokeErr error) (tea.Model, tea.Cmd) { - if err := session.Delete(); err != nil { - m.setupMsg = "error: " + err.Error() - return m, nil - } - // Everything the session was holding goes with it: the key lives in - // the same file, and the tabs are full of answers that were true - // for somebody else. - // - // And the key does not only live there. Every tool configured from - // this panel got a copy, where deleting session.json leaves it - // working - so a member who signs out because they are handing the - // machine over would have been signed out of everything except the - // cluster their key bills. - removed, failed := RemoveNanFromTools() - m.sess = &session.Session{} - m.client = api.New("") - m.cache = make(map[tabID]any) - m.keyStatus, m.keyAsked, m.keyCheck = nil, false, "" - m.configured, m.setupMsg = nil, signOutMessage(removed, failed) - if revokeErr != nil { - m.setupMsg += " · " + revokeNotConfirmed(revokeErr) - } - m.err = nil - return m, m.resumeSetup() -} - -// revokeNotConfirmed says the session may outlive the local sign-out. -func revokeNotConfirmed(err error) string { - return "the platform did not confirm the sign-out (" + err.Error() + "): the session stays valid until it expires" -} - -// What to say afterwards. Signing out and leaving the key behind in four other -// files is worth a sentence either way. -func signOutMessage(removed, failed []string) string { - if len(failed) > 0 { - return "error: signed out, but the key is still in " + strings.Join(failed, ", ") - } - if len(removed) > 0 { - return "signed out · key removed from " + strings.Join(removed, ", ") - } - return "signed out" -} - -func factoryCustomID(displayName string, index int) string { - return fmt.Sprintf("custom:%s-%d", strings.ReplaceAll(displayName, " ", "-"), index) -} - -// ── writing a file that carries a key ──────────────────────────────────────── - -// writeConfigFile writes a tool's config the way a file holding an API key has -// to be written, which is not what os.WriteFile does on its own. -// -// Its mode argument applies only where the file is CREATED, and the usual case -// here is the opposite one: ~/.codex/config.toml and the rest already exist, -// written by the tool itself, commonly 0644. The key went into them and the -// 0o600 in the call did nothing whatsoever - the file kept the mode it had, -// readable by every account on the machine. -// -// And a plain write truncates first: interrupted halfway it leaves a member's -// config - every provider in it, not only ours - cut in two. So the bytes go -// to a temp file in the same directory, which is created 0600, and that file -// is renamed over the destination. The rename happens once or not at all, and -// it carries the temp file's mode with it, which is where the 0600 sticks. -func writeConfigFile(path string, data []byte) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - // Managed dotfiles are symlinks into a repo. Renaming over the link would - // replace it with a regular file and quietly detach the config from the - // thing that manages it, so the write follows the link first. - if resolved, err := filepath.EvalSymlinks(path); err == nil { - path = resolved - } - - tmp, err := os.CreateTemp(filepath.Dir(path), ".nan-*.tmp") - if err != nil { - return err - } - name := tmp.Name() - defer os.Remove(name) // does nothing once the rename has taken it - - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return err - } - // On Windows the mode bits are not the mechanism - the file inherits the - // ACL of the profile directory it sits in - so a filesystem that will not - // take the chmod is no reason to refuse to write the config at all. - if err := tmp.Chmod(0o600); err != nil && runtime.GOOS != "windows" { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Rename(name, path); err != nil { - // Windows will not replace a file another process is holding open, - // where the plain write this replaced would have gone through. The - // mode bits are not the mechanism there in any case, so falling back - // to that write leaves nothing worse than what came before it. On - // every other platform a rename that failed is a failure. - if runtime.GOOS == "windows" { - return os.WriteFile(path, data, 0o600) - } - return err - } - return nil -} - -// readJSONConfig reads a tool's config, and refuses to guess at one it cannot -// parse. -// -// These files are shared: opencode.json and Pi's models.json hold every -// provider a member has, other vendors' API keys included. The readers here -// used to drop the unmarshal error on the floor, which meant a file this CLI -// could not parse - a stray comma, a half-finished hand edit, a schema the -// tool has since moved on to - was read as nothing and then written again from -// scratch, taking every other provider in it along with it. Not being able to -// read the file is precisely the case where it must not be touched. -func readJSONConfig(path string) (map[string]any, error) { - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return map[string]any{}, nil - } - if err != nil { - return nil, err - } - if strings.TrimSpace(string(data)) == "" { - return map[string]any{}, nil - } - var cfg map[string]any - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("%s does not parse as JSON, so it was left exactly as it is: %w", - filepath.Base(path), err) - } - if cfg == nil { - cfg = map[string]any{} - } - return cfg, nil -} - -func writeFactoryConfig(cfgPath, apiKey string) error { - // A map, so unknown top-level fields (logoAnimation, etc.) survive. - cfg, err := readJSONConfig(cfgPath) - if err != nil { - return err - } - - // Extract existing customModels - var models []map[string]any - if raw, ok := cfg["customModels"].([]any); ok { - for _, item := range raw { - if m, ok := item.(map[string]any); ok { - models = append(models, m) - } - } - } - - // Index NaN model IDs already present - existingIDs := map[string]bool{} - for _, m := range models { - if base, _ := m["baseUrl"].(string); strings.Contains(base, "api.nan.builders") { - if id, _ := m["model"].(string); id != "" { - existingIDs[id] = true - } - } - } - - // Append only missing models - added := false - for _, nm := range catalog.ChatModels() { - if !existingIDs[nm.ID] { - idx := len(models) - display := nm.Name + " (NaN)" - models = append(models, map[string]any{ - "model": nm.ID, - "id": factoryCustomID(display, idx), - "index": idx, - "baseUrl": "https://api.nan.builders/v1", - "apiKey": apiKey, - "displayName": display, - "noImageSupport": !nm.Accepts(catalog.InputImage), - "provider": "openai", - }) - added = true - } - } - if !added { - return nil - } - cfg["customModels"] = models - - // Leave it pointing at the model the quickstart starts everyone with. It - // used to be qwen3.6, which the docs now call the previous generation. - if _, ok := cfg["sessionDefaultSettings"]; !ok { - for _, m := range models { - if id, _ := m["model"].(string); id == catalog.Default { - cfg["sessionDefaultSettings"] = map[string]any{ - "model": m["id"], - "reasoningEffort": "none", - "autonomyMode": "normal", - } - break - } - } - } - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return writeConfigFile(cfgPath, data) -} - -func writeOpencodeConfig(cfgPath, apiKey string) error { - cfg, err := readJSONConfig(cfgPath) - if err != nil { - return err - } - - providers, _ := cfg["provider"].(map[string]any) - if providers == nil { - providers = map[string]any{} - } - - // `limit` is not decoration: without it opencode falls back to its own - // guess at the window and compacts a 1M-token session as if it were a - // small one. An unknown key (this used to be written as `contextWindow`) - // raises nothing anyone sees, which is why nan.builders/docs/opencode - // spells the field out and why this writes it. - nanModels := map[string]any{} - for _, m := range catalog.ChatModels() { - nanModels[m.ID] = map[string]any{ - "name": m.Name, - "limit": map[string]any{ - "context": m.Context, - "output": m.Output, - }, - "modalities": map[string]any{ - "input": m.Inputs, - "output": []string{"text"}, - }, - } - } - - if nan, ok := providers["nan"].(map[string]any); ok { - if opts, ok := nan["options"].(map[string]any); ok { - if base, _ := opts["baseURL"].(string); strings.Contains(base, "api.nan.builders") { - // Already configured — merge any missing models - existing, _ := nan["models"].(map[string]any) - if existing == nil { - existing = map[string]any{} - } - changed := false - for id, m := range nanModels { - found, ok := existing[id].(map[string]any) - if !ok { - existing[id] = m - changed = true - continue - } - // An entry written by an older version of this CLI has no - // `limit` at all. Filling it in is the only way those - // configs ever stop compacting early; anything the member - // set themselves is left exactly as it is. - for _, field := range []string{"limit", "modalities"} { - if _, present := found[field]; !present { - found[field] = m.(map[string]any)[field] - changed = true - } - } - } - if !changed { - return nil - } - nan["models"] = existing - providers["nan"] = nan - cfg["provider"] = providers - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return writeConfigFile(cfgPath, data) - } - } - } - - if _, ok := cfg["$schema"]; !ok { - cfg["$schema"] = "https://opencode.ai/config.json" - } - providers["nan"] = map[string]any{ - "npm": "@ai-sdk/openai-compatible", - "name": "NaN", - "options": map[string]any{ - "baseURL": "https://api.nan.builders/v1", - "apiKey": apiKey, - }, - "models": nanModels, - } - cfg["provider"] = providers - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return writeConfigFile(cfgPath, data) -} - -// Pi takes a provider two ways: a models.json, which is data, or an extension -// that calls pi.registerProvider, which is code. Both are current in 0.85.1. -// This used to write the extension - a TypeScript file Pi loads and runs - and -// now writes the data, which is what nan.builders/docs/pi publishes: Pi -// validates models.json against its own schema and names the field that is -// wrong, nothing this CLI writes has to execute on a member's machine, and the -// two ways of setting Pi up stop being two things to keep in step. -func writePiConfig(cfgPath, apiKey string) error { - // Unlike the extension file, models.json is shared: other providers live in - // it and none of them are ours to touch. - cfg, err := readJSONConfig(cfgPath) - if err != nil { - return err - } - - providers, _ := cfg["providers"].(map[string]any) - if providers == nil { - providers = map[string]any{} - } - - models := make([]map[string]any, 0, len(catalog.ChatModels())) - for _, m := range catalog.ChatModels() { - models = append(models, map[string]any{ - "id": m.ID, - "name": m.Name, - "input": piInputs(m), - "reasoning": m.Reasoning, - "contextWindow": m.Context, - "maxTokens": m.Output, - }) - } - - providers["nan"] = map[string]any{ - "name": "NaN", - "baseUrl": "https://api.nan.builders/v1", - "apiKey": apiKey, - "api": "openai-completions", - "compat": map[string]any{"supportsDeveloperRole": true}, - "models": models, - } - cfg["providers"] = providers - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - if err := writeConfigFile(cfgPath, data); err != nil { - return err - } - return writePiDefaults(piSettingsPath(cfgPath)) -} - -// Pi reads the provider it calls from a second file, and until this existed -// the CLI wrote only the first one. nan.builders/docs/pi marks this step "not -// optional" for a reason: with models.json alone Pi goes on calling its -// factory provider, and what the member sees is a 401 that names neither file. -func writePiDefaults(settingsPath string) error { - settings, err := readJSONConfig(settingsPath) - if err != nil { - return err - } - - // A member who already picked a default picked it, and ours is one more - // provider they can switch to from inside Pi. This only fills the gap that - // leaves a fresh install calling nothing. - if _, chosen := settings["defaultProvider"]; chosen { - return nil - } - settings["defaultProvider"] = "nan" - settings["defaultModel"] = catalog.Coding - - out, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return err - } - return writeConfigFile(settingsPath, out) -} - -// settings.json sits next to models.json in Pi's agent directory. -func piSettingsPath(cfgPath string) string { - return filepath.Join(filepath.Dir(cfgPath), "settings.json") -} - -// Pi's schema takes "text" and "image" and nothing else, so mimo-v2.5 goes in -// without its audio: a third value fails validation, and Pi then refuses the -// whole file with every other provider in it. -func piInputs(m catalog.Model) []string { - out := make([]string, 0, len(m.Inputs)) - for _, in := range m.Inputs { - if in == catalog.InputText || in == catalog.InputImage { - out = append(out, in) - } - } - return out -} - -// Deleting the file is no longer an option: models.json is where every Pi -// provider lives, and the extension it replaced was a file of ours alone. -func removePiConfig(cfgPath string) error { - data, err := os.ReadFile(cfgPath) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return err - } - var cfg map[string]any - if json.Unmarshal(data, &cfg) != nil { - return nil - } - providers, _ := cfg["providers"].(map[string]any) - if _, ours := providers["nan"]; !ours { - return nil - } - delete(providers, "nan") - - // A default pointing at a provider that is no longer in models.json is - // worse than no default: Pi starts and fails on the first message. - if err := removePiDefaults(piSettingsPath(cfgPath)); err != nil { - return err - } - - // A models.json with nothing left in it is not a config Pi needs to read. - if len(providers) == 0 && len(cfg) == 1 { - return os.Remove(cfgPath) - } - cfg["providers"] = providers - out, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return writeConfigFile(cfgPath, out) -} - -// Only the default we wrote, and only while it still points at us: anything -// the member set themselves is theirs. -func removePiDefaults(settingsPath string) error { - data, err := os.ReadFile(settingsPath) - if err != nil { - return nil - } - var settings map[string]any - if json.Unmarshal(data, &settings) != nil { - return nil - } - if settings["defaultProvider"] != "nan" { - return nil - } - delete(settings, "defaultProvider") - delete(settings, "defaultModel") - - // The file existed only to hold our default, so it goes with it. - if len(settings) == 0 { - return os.Remove(settingsPath) - } - out, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return err - } - return writeConfigFile(settingsPath, out) -} - -func writeCodexConfig(cfgPath, apiKey string) error { - data, _ := os.ReadFile(cfgPath) - codexModel, _ := catalog.Get(catalog.Coding) - - if strings.Contains(string(data), "api.nan.builders") { - // Already pointing at the cluster, so there is nothing to add - but - // the section may be one we wrote back when it said wire_api = - // "chat", and Codex 0.154 will not load a config with that value at - // all: it prints the error and exits before the TUI is up. A member - // whose Codex does not open is the last member who can be asked to - // disconnect and reconnect it, so the repair happens here. - // - // The same member is also the one most likely to be carrying a - // model_context_window we appended once per run, back when it went to - // the end of the file. Two of them is a duplicate key, and Codex - // refuses the whole file for it - the desktop app too, which opens on - // an error dialog and nothing else. Repairing the wire_api alone left - // that member exactly as stuck as before, so the pruning runs first - // and puts the key back where it is read from. - repaired, pruned := codexContextWindowsPruned(data, codexModel.Context) - if pruned { - repaired = []byte(withCodexContextWindow(string(repaired), codexModel.Context)) - } - rewired, changed := codexWireAPIRepaired(repaired) - if pruned || changed { - if err := writeConfigFile(cfgPath, rewired); err != nil { - return err - } - } - return writeCodexProfiles(filepath.Dir(cfgPath)) - } - - // If no existing config, write a complete starter config. - if len(data) == 0 { - content := fmt.Sprintf(`model = %q -model_provider = "nan" -model_context_window = %d - -[model_providers.nan] -name = "NaN" -base_url = "https://api.nan.builders/v1" -experimental_bearer_token = %q -wire_api = "responses" -`, codexModel.ID, codexModel.Context, apiKey) - if err := writeConfigFile(cfgPath, []byte(content)); err != nil { - return err - } - return writeCodexProfiles(filepath.Dir(cfgPath)) - } - - // Existing config: only append the provider section; preserve user's model/provider choices. - section := fmt.Sprintf(` -[model_providers.nan] -name = "NaN" -base_url = "https://api.nan.builders/v1" -experimental_bearer_token = %q -wire_api = "responses" -`, apiKey) - content := strings.TrimRight(string(data), "\n") + "\n" + section - if err := writeConfigFile(cfgPath, []byte(withCodexContextWindow(content, codexModel.Context))); err != nil { - return err - } - return writeCodexProfiles(filepath.Dir(cfgPath)) -} - -// One file per chat model, so every model on the cluster is a flag away with -// the window it is actually served at. -// -// `codex --model ` switches the model and nothing else, and the -// model_context_window in config.toml stays where it was: point it at a -// 262,144-token model with a million-token window declared and Codex compacts -// against a window that is not there. Codex 0.155 layers -// $CODEX_HOME/.config.toml over the base config for `--profile `, -// which is the one place a per-model window can live without a second copy of -// the member's provider, MCP servers and permissions. -// -// No API key goes in them: the provider section in config.toml is what -// carries it, and a key in eight files is a key to rotate in eight files. -// -// They also switch off the two tools Codex adds on its own. Codex 0.159 -// sends the sub-agent tools as a "namespace" tool and web search as a -// "web_search" one, and with either in the request the cluster's /responses -// stops streaming items for qwen3.8-flash and mimo: all that comes back is -// response.completed, which Codex does not read an answer from, so the turn -// ends with no text and no tool call. Neither tool has anywhere to go on the -// cluster anyway. Here and not in config.toml, because these files are ours -// and leave with the disconnect - a web_search = "disabled" at the root -// would stay behind and take search from the member's OpenAI sessions too. -func writeCodexProfiles(codexHome string) error { - for _, m := range catalog.ChatModels() { - body := fmt.Sprintf(`# Written by the NaN CLI. Disconnecting Codex removes it. -model = %q -model_provider = "nan" -model_context_window = %d -web_search = "disabled" - -[features] -multi_agent = false -`, m.ID, m.Context) - path := filepath.Join(codexHome, codexProfileName(m.ID)+".config.toml") - if err := writeConfigFile(path, []byte(body)); err != nil { - return err - } - } - return nil -} - -// Codex takes "a plain name" for --profile and rejects a dot in it outright, -// so the ids that carry a version number cannot be used as they are spelled: -// glm5.3-flash is refused, glm53-flash is not. The nan- prefix is what makes -// the file ours to delete later, and what keeps it clear of a profile the -// member wrote themselves. -func codexProfileName(id string) string { - return "nan-" + strings.ReplaceAll(id, ".", "") -} - -// Takes back only the files this CLI wrote, by the prefix it wrote them under. -func removeCodexProfiles(codexHome string) error { - entries, err := os.ReadDir(codexHome) - if err != nil { - return nil - } - for _, e := range entries { - name := e.Name() - if !e.IsDir() && strings.HasPrefix(name, "nan-") && strings.HasSuffix(name, ".config.toml") { - if err := os.Remove(filepath.Join(codexHome, name)); err != nil && !os.IsNotExist(err) { - return err - } - } - } - return nil -} - -// Codex has no metadata for a model on this cluster, so without -// model_context_window it compacts against a window it guessed. (It prints -// the "metadata not found" warning either way - that fires before it reads -// the override.) -// -// The key is a root key, so it belongs above the first table header. -// Appended at the end of the file the way the section around it is, it lands -// inside whatever table came last - for a member with [projects.*] entries it -// became a key of the last project they trusted, and Codex never looked -// there. -func withCodexContextWindow(content string, window int) string { - declared := fmt.Sprintf("model_context_window = %d", window) - lines := strings.Split(content, "\n") - for i, raw := range lines { - trimmed := strings.TrimSpace(raw) - // Everything above the first table header is the root table, so this - // only ever reads the member's root keys. - if strings.HasPrefix(trimmed, "[") { - head := strings.TrimRight(strings.Join(lines[:i], "\n"), "\n") - tail := strings.Join(lines[i:], "\n") - if head == "" { - return declared + "\n\n" + tail - } - return head + "\n" + declared + "\n\n" + tail - } - if key, _, ok := strings.Cut(trimmed, "="); ok && strings.TrimSpace(key) == "model_context_window" { - return content // the member declared one of their own; it stands - } - } - return strings.TrimRight(content, "\n") + "\n" + declared + "\n" -} - -// [projects."/home/member/repo"] and the [projects] table above it. This is -// the only shape the appending bug could put a key into: it wrote at the end -// of the file, and what sits at the end of a config.toml is the last project -// the member trusted. -func isCodexProjectsHeader(trimmed string) bool { - inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]")) - return inner == "projects" || strings.HasPrefix(inner, "projects.") -} - -// Takes out every model_context_window this CLI is responsible for putting -// somewhere it does not belong, and leaves at most one standing. -// -// Two of them anywhere in the file is a duplicate key, and Codex does not -// load a config with one: `codex` exits on the error and the desktop app -// opens an error dialog instead of a window. That is how a member ends up -// unable to use Codex at all, for a key that only ever existed to stop it -// compacting against a guessed window. -// -// Inside a table the key is not a root key at all - it reads as a key of -// whatever table came last, and the table it landed in was a [projects.*] -// the member trusted, because that is what a config.toml ends with. One -// carrying our own value under a [projects.*] header is ours, appended by a -// version that wrote it at the end of the file, and it goes. -// -// Under any other header it stays, whatever its value. The member's own -// tables are somewhere our appending bug could never reach, and [profiles.*] -// is now a place a window belongs: this CLI writes one per model, and the -// value it writes there is the same number the coding model declares. Judging -// those by value alone took a window the member had set on a profile of their -// own and moved it to the root, quietly changing what that profile does. -// -// In the root table the first one stands and any repeat after it is the -// duplicate - unless we are taking our own work back out, where the first one -// goes too if it reads as ours. See codexContextWindowsCleared. -// -// Done line by line for the same reason the wire_api repair is: a round trip -// through a TOML library reflows a document the member also edits by hand. -func codexContextWindowsPruned(data []byte, window int) ([]byte, bool) { - return codexContextWindowsSwept(data, window, true) -} - -// The disconnect half. Everything above still holds, and the root table stops -// being off limits: the key we put there went in beside the provider section -// and means nothing without it, so leaving it behind hands back a file -// declaring a window for a model the member no longer has. -// -// Ours is the one whose value is the window we write. A root key that says -// anything else is the member's - withCodexContextWindow steps aside for it -// on the way in, and this steps around it on the way out. The one case both -// halves get wrong is a member who wrote our exact number themselves before -// we ever ran: connecting leaves their key alone and disconnecting takes it -// for ours. Telling those apart needs a mark on the line, which the keys -// already out there do not carry. -func codexContextWindowsCleared(data []byte, window int) ([]byte, bool) { - return codexContextWindowsSwept(data, window, false) -} - -func codexContextWindowsSwept(data []byte, window int, keepRoot bool) ([]byte, bool) { - ours := strconv.Itoa(window) - lines := strings.Split(string(data), "\n") - out := make([]string, 0, len(lines)) - inTable, inProjects, keptRoot, pruned := false, false, false, false - for i := 0; i < len(lines); i++ { - line := lines[i] - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") { - inTable = true - inProjects = isCodexProjectsHeader(trimmed) - out = append(out, line) - continue - } - key, value, ok := strings.Cut(trimmed, "=") - if !ok || strings.TrimSpace(key) != "model_context_window" { - out = append(out, line) - continue - } - mine := strings.TrimSpace(value) == ours - drop := false - switch { - case inProjects: - drop = mine - // Under any other header it is the member's, and the root cases - // below are not about it. - case inTable: - drop = false - case !keepRoot && mine: - drop = true - case keptRoot: - drop = true - default: - keptRoot = true - } - if !drop { - out = append(out, line) - continue - } - pruned = true - // The line was written with a blank line on either side of it. Left - // alone both survive it and the file gains a gap where a key used to - // be, which is a diff the member did not ask for on a file they read. - if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" && - i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "" { - i++ - } - } - if !pruned { - return data, false - } - return []byte(strings.Join(out, "\n")), true -} - -// Brings our own [model_providers.nan] section up to the only wire protocol -// Codex still speaks, and touches nothing else in the file - not another -// provider's wire_api, not the member's own keys, not a byte of the layout. -// -// The section used to say wire_api = "chat" on purpose: the cluster's -// /responses endpoint answered in one terminal event, so "responses" meant -// the whole reply arriving at once at the end instead of streaming. The -// endpoint streams deltas now, and Codex 0.154 dropped "chat" entirely, so -// the reason for the old value is gone twice over. -// -// Done by hand rather than through a TOML library because a round trip -// through one reflows the whole document - a member's comments, their -// spacing, their quoting - to satisfy a change of one word on one line. -func codexWireAPIRepaired(data []byte) ([]byte, bool) { - lines := strings.Split(string(data), "\n") - inNaN, changed := false, false - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") { - inNaN = isCodexNaNProviderHeader(trimmed) - continue - } - if !inNaN { - continue - } - key, value, ok := strings.Cut(trimmed, "=") - if !ok || strings.TrimSpace(key) != "wire_api" { - continue - } - if strings.Trim(strings.TrimSpace(value), `"'`) != "chat" { - continue - } - indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] - lines[i] = indent + `wire_api = "responses"` - changed = true - } - if !changed { - return data, false - } - return []byte(strings.Join(lines, "\n")), true -} - -// [model_providers.nan], and the quoted spelling of it that TOML also allows. -func isCodexNaNProviderHeader(line string) bool { - name := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) - if !strings.HasPrefix(name, "model_providers.") { - return false - } - return strings.Trim(strings.TrimPrefix(name, "model_providers."), `"'`) == "nan" -} - -// ── Hermes ─────────────────────────────────────────────────────────────────── -// -// Every other tool here is configured by writing its file. Hermes is not, -// because its config.yaml is a long commented document the member also edits -// by hand, and because it ships the command to do it: `hermes config set` -// writes a key without flattening the comments around it and rejects a key it -// does not know. Reproducing that schema in Go would buy nothing and would -// start drifting the day Hermes moves a field. -// -// Two things the tool knows and the docs page does not say. The path in -// nan.builders/docs/hermes is the Unix one; on Windows Hermes keeps its home -// under LOCALAPPDATA. And with the `custom` provider Hermes asks the endpoint -// what it serves, so there is no model list to write here and none to keep in -// step with the cluster - only which model to open with. - -// Swapped in tests, so nothing here spawns a process or goes near the -// member's own Hermes. HERMES_HOME is passed on every call rather than -// inherited: the writer configures the install it detected, not whichever one -// the environment happens to point at. -var runHermesConfig = func(home string, args ...string) error { - cmd := exec.Command("hermes", append([]string{"config"}, args...)...) - cmd.Env = append(os.Environ(), "HERMES_HOME="+home) - out, err := cmd.CombinedOutput() - if err != nil { - return hermesConfigError(args, string(out)) - } - return nil -} - -// The verb and the key it was called with, and never the value. -// -// This used to join every argument into the message, and the message is -// rendered in the panel: one `set` that Hermes refused printed the member's -// API key onto their screen, into their scrollback, and into the issue they -// then pasted all of it into. Nothing past the key belongs in an error. -func hermesConfigError(args []string, out string) error { - said := args - if len(said) > 2 { - said = said[:2] - } - return fmt.Errorf("hermes config %s: %s", strings.Join(said, " "), strings.TrimSpace(out)) -} - -// Reading a value back, for the one thing that has to be confirmed rather than -// assumed. Swapped in tests alongside the writer above. -var readHermesConfig = func(home string, args ...string) (string, error) { - cmd := exec.Command("hermes", append([]string{"config"}, args...)...) - cmd.Env = append(os.Environ(), "HERMES_HOME="+home) - out, err := cmd.Output() - if err != nil { - var exit *exec.ExitError - if errors.As(err, &exit) { - return "", hermesConfigError(args, string(exit.Stderr)) - } - return "", hermesConfigError(args, err.Error()) - } - return strings.TrimSpace(string(out)), nil -} - -// The resolution Hermes itself uses: HERMES_HOME, then the platform default. -func hermesHome() string { - if home := strings.TrimSpace(os.Getenv("HERMES_HOME")); home != "" { - return home - } - if runtime.GOOS == "windows" { - if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" { - return filepath.Join(local, "hermes") - } - home, _ := os.UserHomeDir() - return filepath.Join(home, "AppData", "Local", "hermes") - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".hermes") -} - -func hermesConfigPath(home string) string { - return filepath.Join(home, "config.yaml") -} - -// Hermes keeps its secrets in a .env beside its config - it has a `hermes -// config env-path` for exactly this - and expands ${VAR} in any config value -// against it when it loads. So the config gets the placeholder and the .env -// gets the key. -const ( - hermesEnvFile = ".env" - hermesKeyVar = "NAN_API_KEY" - hermesKeyRef = "${" + hermesKeyVar + "}" - hermesKeyNote = "# Written by nan-cli. config.yaml points at it as " + hermesKeyRef + "." -) - -func hermesEnvPath(home string) string { - return filepath.Join(home, hermesEnvFile) -} - -func writeHermesConfig(home, apiKey string) error { - // The key goes into Hermes' .env, and the config points at it. Not because - // the config is a worse place to keep it - same machine, same member, same - // 0600 - but because of the way it would have to get there. - // - // Every value here is written by running `hermes config set `, - // and an argument is not private: on Linux /proc//cmdline is readable - // by other accounts on the box, and `ps` hands it to anything running as - // the member. A key passed that way is exposed for as long as the process - // lives, to readers this CLI never meant to hand it to, and there is no - // taking it back afterwards. The placeholder is not a secret and can go - // through argv; the key is written straight to disk and appears in no - // argument list at all. - if err := writeHermesEnvKey(hermesEnvPath(home), apiKey); err != nil { - return err - } - settings := [][2]string{ - {"model.provider", "custom"}, - {"model.base_url", "https://api.nan.builders/v1"}, - {"model.api_key", hermesKeyRef}, - {"model.default", catalog.Coding}, - } - for _, s := range settings { - if err := runHermesConfig(home, "set", s[0], s[1]); err != nil { - return err - } - } - return confirmHermesResolvesTheKey(home, apiKey) -} - -// Everything above rests on Hermes expanding ${VAR} in a config value against -// its own .env, which it does today and which nothing here controls. Asking it -// what it ended up with is the only way to know it happened: a build that -// stopped expanding would leave the literal ${NAN_API_KEY} in place as the -// key, and what a member would see is a 401 from the cluster naming nothing in -// particular. -// -// The answer is the key itself, so it is compared and dropped on the spot. It -// does not go into a message, a log or an error. -func confirmHermesResolvesTheKey(home, apiKey string) error { - resolved, err := readHermesConfig(home, "get", "model.api_key") - if err != nil { - // No answer is not a wrong answer. An older Hermes without this - // subcommand, or one that could not be run twice, says nothing either - // way about what it will do with the config - and failing the whole - // step over a question we could not ask would be its own bug. - return nil - } - if resolved != apiKey { - return fmt.Errorf("Hermes did not resolve %s from its .env, so it has no usable key - "+ - "nan.builders/docs/hermes has the manual steps", hermesKeyRef) - } - return nil -} - -// One variable set in the .env, and the rest of the file - a long commented -// document with the member's other providers in it - left as it was. -func writeHermesEnvKey(envPath, apiKey string) error { - var lines []string - if data, err := os.ReadFile(envPath); err == nil { - if body := strings.TrimRight(string(data), "\n"); body != "" { - lines = strings.Split(body, "\n") - } - } else if !os.IsNotExist(err) { - return err - } - - entry := hermesKeyVar + "=" + apiKey - replaced := false - for i, line := range lines { - if isHermesKeyLine(line) { - lines[i] = entry - replaced = true - } - } - if !replaced { - if len(lines) > 0 { - lines = append(lines, "") - } - lines = append(lines, hermesKeyNote, entry) - } - return writeConfigFile(envPath, []byte(strings.Join(lines, "\n")+"\n")) -} - -// The assignment, and not a commented-out example of the same name: blanking -// one of those would leave the real one further down the file still winning. -func isHermesKeyLine(line string) bool { - name, _, ok := strings.Cut(line, "=") - return ok && strings.TrimSpace(name) == hermesKeyVar -} - -// The same four keys and nothing else: a member's channels, skills and -// persona live in this file too. -func removeHermesConfig(home string) error { - // Four processes, so only where there is something to unset. The .env - // below is checked either way: it is written before the first of these - // calls, so a run that failed part-way through leaves the key there and - // nothing in config.yaml to say so. - if cfg, err := os.ReadFile(hermesConfigPath(home)); err == nil && strings.Contains(string(cfg), "api.nan.builders") { - for _, key := range []string{"model.api_key", "model.base_url", "model.default", "model.provider"} { - if err := runHermesConfig(home, "unset", key); err != nil { - return err - } - } - } - // And the key the config was pointing at. Unsetting model.api_key drops - // the reference, which leaves the secret itself sitting in the .env. - return removeHermesEnvKey(hermesEnvPath(home)) -} - -func removeHermesEnvKey(envPath string) error { - data, err := os.ReadFile(envPath) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return err - } - - var kept []string - dropped := false - for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { - if isHermesKeyLine(line) || line == hermesKeyNote { - dropped = true - continue - } - kept = append(kept, line) - } - if !dropped { - return nil - } - // A .env that held nothing but our two lines was ours to begin with. - if len(kept) == 0 { - return os.Remove(envPath) - } - return writeConfigFile(envPath, []byte(strings.Join(kept, "\n")+"\n")) -} - -func removeCodexConfig(cfgPath string) error { - // Before either way out below, because the profile files are ours whatever - // state config.toml is in. A member who edited our provider section out by - // hand, or deleted the file, used to keep all seven of them - each one - // naming model_provider = "nan", which by then resolves to nothing, so - // `codex -p nan-qwen36` fails and no tool on the machine admits to having - // put them there. - // - // The error waits until the end. A profile that will not delete - open in - // an editor, locked by a running Codex - is a cosmetic failure, and - // returning it here would leave experimental_bearer_token sitting in - // config.toml after the member asked us to take it out. - profileErr := removeCodexProfiles(filepath.Dir(cfgPath)) - - data, err := os.ReadFile(cfgPath) - if err != nil { - return profileErr - } - // Nothing of ours in it, so nothing to rewrite: this is called on every - // sign-out, against a config.toml that may never have been ours at all. - if !strings.Contains(string(data), "[model_providers.nan]") { - return profileErr - } - lines := strings.Split(string(data), "\n") - var out []string - inNanSection := false - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "[model_providers.nan]" { - inNanSection = true - continue - } - if inNanSection { - // End of the nan section when a new section header appears. - if strings.HasPrefix(trimmed, "[") { - inNanSection = false - } else { - continue - } - } - out = append(out, line) - } - result := strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n" - // The section is gone, and so is the reason for the model_context_window - // that went in beside it. Left behind it is a key the member never wrote, - // naming a window no model of theirs has - and if there are two of them - // it is a file their Codex does not open, now with nothing left in it to - // say who did that or which tool to disconnect to undo it. - codexModel, _ := catalog.Get(catalog.Coding) - if cleared, changed := codexContextWindowsCleared([]byte(result), codexModel.Context); changed { - result = string(cleared) - } - if strings.TrimSpace(result) == "" { - if err := os.Remove(cfgPath); err != nil { - return err - } - return profileErr - } - if err := writeConfigFile(cfgPath, []byte(result)); err != nil { - return err - } - return profileErr -} - -func removeFactoryConfig(cfgPath string) error { - data, err := os.ReadFile(cfgPath) - if err != nil { - return nil - } - var cfg map[string]any - if err := json.Unmarshal(data, &cfg); err != nil { - return err - } - - raw, _ := cfg["customModels"].([]any) - var filtered []map[string]any - var removedIDs []string - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if base, _ := m["baseUrl"].(string); strings.Contains(base, "api.nan.builders") { - if id, _ := m["id"].(string); id != "" { - removedIDs = append(removedIDs, id) - } - } else { - filtered = append(filtered, m) - } - } - if len(removedIDs) == 0 { - return nil - } - cfg["customModels"] = filtered - - // Clear sessionDefaultSettings.model if it pointed to a removed NaN model - if sds, ok := cfg["sessionDefaultSettings"].(map[string]any); ok { - if model, _ := sds["model"].(string); model != "" { - for _, rid := range removedIDs { - if model == rid { - delete(sds, "model") - cfg["sessionDefaultSettings"] = sds - break - } - } - } - } - - out, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return writeConfigFile(cfgPath, out) -} - -func removeOpencodeConfig(cfgPath string) error { - data, err := os.ReadFile(cfgPath) - if err != nil { - return nil - } - var cfg map[string]any - if err := json.Unmarshal(data, &cfg); err != nil { - return err - } - providers, _ := cfg["provider"].(map[string]any) - if providers == nil { - return nil - } - if _, ok := providers["nan"]; !ok { - return nil // nothing to remove - } - delete(providers, "nan") - cfg["provider"] = providers - out, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return writeConfigFile(cfgPath, out) -} - -// The sign-in questions, drawn where the tab content would be. -// The guided setup: the banner, the four steps with the one you are on marked, -// and whatever that step needs underneath. -// -// It replaced a sign-in screen that showed "Step 1 of 2" and then handed you -// back to the panel to find the other two yourself. Four numbered lines cost -// almost nothing and answer "how much is left", which is the question someone -// halfway through a setup is actually holding. -func (m model) renderWizard(l layout) string { - title := lipgloss.NewStyle().Bold(true).Foreground(cWhite) - dim := lipgloss.NewStyle().Foreground(cDimGray) - done := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) - now := lipgloss.NewStyle().Foreground(cCyan).Bold(true) - - var b strings.Builder - b.WriteString(l.indent + title.Render("Setting up") + "\n\n") - - at := m.wizard.index() - for i, step := range wizardSteps { - marker, style := dim.Render(" "), dim - switch { - case i < at || m.wizard == wizardDone: - marker, style = done.Render("✓ "), dim - case i == at: - marker, style = now.Render("▶ "), now - } - b.WriteString(l.indent + marker + - style.Render(fmt.Sprintf("%d. %s", step.n, step.title)) + "\n") - } - b.WriteString("\n") - - switch m.wizard { - case wizardEmail, wizardLink: - b.WriteString(l.indent + m.loginInput.View() + "\n") - if m.loginMsg != "" { - b.WriteString("\n" + m.wrapped(l, m.loginMsg) + "\n") - } - case wizardKey: - if m.editingKey { - b.WriteString(l.indent + m.keyInput.View() + "\n") - } - if m.keyCheck != "" { - b.WriteString("\n" + m.wrapped(l, m.keyCheck) + "\n") - } - case wizardTools, wizardDone: - if m.keyCheck != "" { - b.WriteString(l.indent + m.wrapped(l, m.keyCheck) + "\n\n") - } - tools := detectTools() - cursor := m.setupCursor - if len(tools) > 0 && cursor >= len(tools) { - cursor = len(tools) - 1 - } - b.WriteString(m.renderToolList(l, tools, cursor)) - if m.configuring { - b.WriteString("\n" + l.indent + m.spin.View() + - dim.Render(" writing the configs - a few seconds") + "\n") - } else if m.setupMsg != "" { - b.WriteString("\n" + m.wrapped(l, m.setupMsg) + "\n") - } - b.WriteString(m.renderFailures(l)) - } - - // The banner goes on top only if it leaves room for the rest. It used to go - // first unconditionally, and View cuts what does not fit from the bottom - - // so on a short terminal the part that went was the field for the key, and - // step 3 asked for one with nowhere to type it. The mascot goes first, then - // the wordmark; the step you are on never does. - body := b.String() - room := l.h - 4 - strings.Count(body, "\n") - if l.w >= BannerWidthPlain+4 { - withMascot := l.w >= BannerWidth+4 && l.h >= BannerRoom - banner := Banner(l.indent, m.wizard.mood(), withMascot) + "\n" - if withMascot && strings.Count(banner, "\n") > room { - withMascot = false - banner = Banner(l.indent, m.wizard.mood(), false) + "\n" - } - if strings.Count(banner, "\n") <= room { - body = banner + body - } - } - return body -} - -// What would not configure, and what to do about it. -// -// One tool failing used to read as the whole step failing, and left a member -// on the last screen of a setup with nothing to do but press escape. The rest -// of the tools were configured and nothing said so. -func (m model) renderFailures(l layout) string { - if len(m.failures) == 0 { - return "" - } - bad := lipgloss.NewStyle().Foreground(cRed) - dim := lipgloss.NewStyle().Foreground(cDimGray) - - var b strings.Builder - b.WriteString("\n") - for _, f := range m.failures { - b.WriteString(m.wrapped(l, "error: "+f.name+" — "+f.firstLine()) + "\n") - } - _ = bad - b.WriteString("\n" + l.indent + dim.Render( - "The rest went in. Fix that one and press c again, or carry on without it - "+ - "its page on nan.builders has the manual steps.") + "\n") - return b.String() -} - -// A message wrapped to the panel, coloured by whether it is one. A sign-in -// link is longer than any terminal. -func (m model) wrapped(l layout, msg string) string { - style := lipgloss.NewStyle().Foreground(cCyan) - if strings.HasPrefix(msg, "error") { - style = lipgloss.NewStyle().Foreground(cRed) - } - return indentBlock(lipgloss.NewStyle(). - Width(l.w-lipgloss.Width(l.indent)-1). - Render(style.Render(msg)), l.indent) -} - -// How to actually use each tool once its config is written. The panel said -// "4 added" and stopped there, which answers what it did and not the question -// a member is left holding: and now what. These are the steps each tool page -// on nan.builders publishes under "check that it works". -var nextStepFor = map[string][2]string{ - "OpenCode": {"opencode", "then /models inside it, and pick a NaN one"}, - "Codex": {"codex", "already aimed at the cluster; codex --model to switch"}, - "Pi": {"pi", "NaN is already its default provider"}, - "Factory AI": {"droid", "pick a model with (NaN) in its name"}, - "Hermes": {"hermes doctor", "says whether the provider answers, then talk to it"}, -} - -// Pads to a column width. renderCosts keeps its own; this is package-level -// because a second renderer now needs the same thing. -func lpadTo(v string, w int) string { - if len(v) >= w { - return v - } - return v + strings.Repeat(" ", w-len(v)) -} - -// renderToolList draws the tools and their state. Pulled out of the Setup -// tab because the guided setup shows the same list as its last step, and two -// copies of a list with a cursor in it would drift the first time either moved. -func (m model) renderToolList(l layout, tools []toolInfo, cursor int) string { - dimStyle := lipgloss.NewStyle().Foreground(cDimGray) - okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) - warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) - var b strings.Builder - nameW := 14 - for _, t := range tools { - if len(t.name) > nameW { - nameW = len(t.name) - } - } - - checkStyle := lipgloss.NewStyle().Foreground(cCyan) - cursorStyle := lipgloss.NewStyle().Foreground(cCyan).Bold(true) - - for i, t := range tools { - isCursor := i == cursor - enabled := m.toolEnabled(t.name) - - cur := " " - if isCursor { - cur = cursorStyle.Render("▶") + " " - } - - var check string - if !t.installed { - check = dimStyle.Render("[ ]") - } else if enabled { - check = checkStyle.Render("[✓]") - } else { - check = dimStyle.Render("[ ]") - } - - nameStyle := lipgloss.NewStyle().Foreground(cText).Width(nameW) - if isCursor { - nameStyle = nameStyle.Foreground(cWhite) - } - - var status string - if !t.installed { - status = dimStyle.Render("not installed") - } else if t.configured { - status = okStyle.Render("✓ configured with NaN") - } else { - status = warnStyle.Render("○ not configured") - } - - b.WriteString(l.indent + cur + check + " " + nameStyle.Render(t.name) + " " + status + "\n") - } - return b.String() -} - -func (m model) renderSetup(l layout) string { - var b strings.Builder - - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(cWhite) - labelStyle := lipgloss.NewStyle().Foreground(cGray).Width(l.keyW) - dimStyle := lipgloss.NewStyle().Foreground(cDimGray) - accentStyle := lipgloss.NewStyle().Foreground(cCyan) - okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) - warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) - errStyle := lipgloss.NewStyle().Foreground(cRed) - - // ── API Key ────────────────────────────────────────────────────────────── - b.WriteString(l.indent + titleStyle.Render("API Key") + "\n\n") - - if m.editingKey { - b.WriteString(l.indent + m.keyInput.View() + "\n") - b.WriteString(l.indent + dimStyle.Render("Enter to save · Esc to cancel") + "\n") - } else if m.sess.APIKey != "" { - b.WriteString(l.indent + labelStyle.Render("Key:") + - accentStyle.Render(strings.Repeat("•", 24)) + - " " + dimStyle.Render("e to edit") + "\n") - // The one thing a member needs and had nowhere to read: what to do if - // this key has been somewhere it should not have been. Nothing in this - // CLI can revoke it - the platform issues and retires keys - so the - // most useful thing the tab can do is name the steps and where the - // first one happens. - b.WriteString(l.indent + dimStyle.Render( - "if anyone has seen it: replace it at cloud.nan.builders, "+ - "paste the new one here (e), then press c") + "\n") - } else { - b.WriteString(l.indent + warnStyle.Render("No API key set") + - " " + dimStyle.Render("e to set") + "\n") - } - - // What the platform says about the account, once it has answered. The key - // itself never comes back from there, so the most this can do is say - // whether there is one to copy and where from - which still beats leaving - // a member staring at an empty field wondering what to paste. - if m.keyStatus != nil && m.sess.APIKey == "" { - hint := "this account has no key yet - create one at cloud.nan.builders" - if m.keyStatus.Exists { - name := m.keyStatus.Alias - if name == "" { - name = m.keyStatus.Name - } - hint = "your account has a key" - if name != "" { - hint += " (" + name + ")" - } - hint += " - copy it from cloud.nan.builders" - } - b.WriteString(l.indent + dimStyle.Render(hint) + "\n") - } - if m.keyCheck != "" { - style := okStyle - if strings.HasPrefix(m.keyCheck, "error") { - style = errStyle - } - b.WriteString(l.indent + style.Render(m.keyCheck) + "\n") - } - - // ── Tools ──────────────────────────────────────────────────────────────── - b.WriteString("\n" + l.indent + titleStyle.Render("Tools") + "\n") - if m.configuring { - // Several seconds, because Hermes is configured by running Hermes. - // Saying so beats a panel that looks hung. - b.WriteString(l.indent + m.spin.View() + - dimStyle.Render(" writing the configs - a few seconds, Hermes is asked rather than written") + "\n") - } - if m.setupMsg != "" { - style := okStyle - if strings.HasPrefix(m.setupMsg, "error") { - style = errStyle - } - b.WriteString(l.indent + style.Render(m.setupMsg) + "\n") - } - - b.WriteString(m.renderFailures(l)) - - // And now what. The answer used to be nowhere in the panel. - if len(m.configured) > 0 { - b.WriteString("\n" + l.indent + titleStyle.Render("Now open them") + "\n\n") - width := 0 - for _, name := range m.configured { - if step, ok := nextStepFor[name]; ok && len(step[0]) > width { - width = len(step[0]) - } - } - for _, name := range m.configured { - step, ok := nextStepFor[name] - if !ok { - continue - } - b.WriteString(l.indent + accentStyle.Render(lpadTo(step[0], width+2)) + - dimStyle.Render(step[1]) + "\n") - } - } - b.WriteString("\n") - - tools := detectTools() - cursor := m.setupCursor - if len(tools) > 0 && cursor >= len(tools) { - cursor = len(tools) - 1 - } - - b.WriteString(m.renderToolList(l, tools, cursor)) - - b.WriteString("\n") - if m.sess.APIKey == "" { - b.WriteString(l.indent + dimStyle.Render("Set an API key first (e).") + "\n") - } else { - keys := "↑/↓ navigate space toggle c configure selected" - if m.sess.Token != "" { - keys += " o sign out" - } - b.WriteString(l.indent + dimStyle.Render(keys) + "\n") - } - - return b.String() -} - -// ── about renderer ─────────────────────────────────────────────────────────── - -const Version = "0.1.29" - -func renderAbout(l layout) string { - var b strings.Builder - - logoStyle := lipgloss.NewStyle().Bold(true).Foreground(cCyan) - dimStyle := lipgloss.NewStyle().Foreground(cDimGray) - labelStyle := lipgloss.NewStyle().Foreground(cGray).Width(l.keyW) - linkStyle := lipgloss.NewStyle().Foreground(cBlue) - accentStyle := lipgloss.NewStyle().Foreground(cCyan) - sectionStyle := lipgloss.NewStyle().Foreground(cGray).Bold(true) - - // The banner needs room for the art with the text beside it; narrower than - // that it would wrap into nonsense, so the plain line stays. - banner := l.w >= BannerWidthPlain+4 - if banner { - b.WriteString(Banner(l.indent, moodNormal, l.w >= BannerWidth+4 && l.h >= BannerRoom) + "\n") - } else { - b.WriteString(l.indent + logoStyle.Render("nan") + - " " + dimStyle.Render("v"+Version) + "\n") - b.WriteString(l.indent + dimStyle.Render("nan.builders cloud CLI") + "\n\n") - } - - b.WriteString(l.indent + sectionStyle.Render("Links") + "\n\n") - b.WriteString(l.indent + labelStyle.Render("Platform:") + - linkStyle.Render("https://nan.builders") + "\n") - b.WriteString(l.indent + labelStyle.Render("Cloud:") + - linkStyle.Render("https://cloud.nan.builders") + "\n\n") - - // The banner already says who made it and who keeps it; repeating it four - // rows below is just the same line twice. - if !banner { - b.WriteString(l.indent + sectionStyle.Render("Maintainer") + "\n\n") - b.WriteString(l.indent + labelStyle.Render("Author:") + - accentStyle.Render("@Nxssie") + "\n\n") - } - - b.WriteString(l.indent + sectionStyle.Render("Session") + "\n\n") - b.WriteString(l.indent + labelStyle.Render("Config:") + - dimStyle.Render(session.Path()) + "\n") - - return b.String() -} - -// ── help overlay ───────────────────────────────────────────────────────────── - -func renderHelp() string { - shortcuts := []struct{ key, desc string }{ - {"←/→ h/l Tab", "switch tabs"}, - {"↑/↓ k/j", "scroll"}, - {"r", "refresh current tab"}, - {"s", "sign in, when there is no session"}, - {"e", "edit API key (Setup tab)"}, - {"space", "tick or untick the tool under the cursor (Setup tab)"}, - {"c", "configure the ticked tools (Setup tab)"}, - {"o", "sign out, twice to confirm"}, - {"?", "toggle this help"}, - {"q / Esc", "quit"}, - } - - titleStyle := lipgloss.NewStyle().Foreground(cWhite).Bold(true) - descStyle := lipgloss.NewStyle().Foreground(cGray) - - keyW := 0 - for _, s := range shortcuts { - if w := lipgloss.Width(s.key); w > keyW { - keyW = w - } - } - keyW += 3 - - rows := []string{titleStyle.Render("Keyboard Shortcuts"), ""} - for _, s := range shortcuts { - keyCol := lipgloss.NewStyle().Foreground(cCyan).Bold(true).Width(keyW).Render(s.key) - rows = append(rows, keyCol+descStyle.Render(s.desc)) - } - - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(cBlueDim). - Padding(1, 2). - Render(strings.Join(rows, "\n")) -} - -// ── entry point ─────────────────────────────────────────────────────────────── - -func Run() error { - // A machine that has never logged in still gets the TUI: the Setup tab, - // which is the one that configures the tools, needs the API key and nothing - // else. Returning ErrNotLoggedIn here meant a fresh install could not open - // the dashboard at all, and the only way in was `nan auth login --token` - // with any string whatsoever. - sess, err := session.Load() - if err != nil { - if !errors.Is(err, session.ErrNotLoggedIn) { - return err - } - sess = &session.Session{} - } - client := api.New(sess.Token) - m := newModel(client, sess) - // Home asks the API for nothing, so there is nothing to wait for: starting - // on `loading` would spin forever over a tab that is already drawn. The - // data tabs set it themselves in maybeLoad when you walk into them. - _, err = tea.NewProgram(m, tea.WithAltScreen()).Run() - return err -} - -func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' } - -// indentBlock indents EVERY line. `indent + Render(...)` only moves the first -// one, which on a bordered box leaves the top edge two columns right of the -// sides. -func indentBlock(block, indent string) string { - lines := strings.Split(block, "\n") - for i, line := range lines { - lines[i] = indent + line - } - return strings.Join(lines, "\n") -} diff --git a/internal/ui/badges.go b/internal/ui/badges.go new file mode 100644 index 0000000..fd1420f --- /dev/null +++ b/internal/ui/badges.go @@ -0,0 +1,52 @@ +package ui + +import ( + "strings" +) + +// Turning the platform's numbers into what is drawn: a change, a share of a +// limit, a count against one. Here rather than in the commands and again in +// the dashboard, because the same figure in two different colours is the +// same class of bug as two different violets. + +// Delta renders the backend's pre-formatted change string — "+12.4%", "-3%" +// — which arrives already computed. Only the sign is interpreted, and only +// to colour it: a delta with no direction is not pointed at. +// +// No change at all renders as nothing. The backend sends "0" for that, and a +// lone zero sitting after a figure reads as a second, unexplained figure — +// "Active keys 4 0" is a worse line than "Active keys 4". +func Delta(s string) string { + switch strings.TrimSpace(s) { + // The platform sends a dash, em or en, when there is no earlier period + // to compare with. That is "no delta", and printing the character + // would put the one glyph this CLI never prints on its front page. + case "", "0", "0%", "+0", "+0%", "-0", "-0%", "-", string(rune(0x2014)), string(rune(0x2013)): + return "" + } + if s[0] == '+' { + return Accent(s) + } + return Faint(s) +} + +// Share is a fraction that refuses to divide by zero. A limit of zero means +// "no limit known", and drawing that as a full bar would be alarming and +// wrong in the same breath. +func Share(used, limit float64) float64 { + if limit <= 0 { + return 0 + } + if used < 0 { + return 0 + } + return used / limit +} + +// CountOf is "3/5", or just "3" when there is no ceiling to compare against. +func CountOf(used, limit int) string { + if limit <= 0 { + return Count(float64(used)) + } + return Count(float64(used)) + "/" + Count(float64(limit)) +} diff --git a/internal/ui/banner.go b/internal/ui/banner.go new file mode 100644 index 0000000..83affde --- /dev/null +++ b/internal/ui/banner.go @@ -0,0 +1,152 @@ +package ui + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// The banner: the mascot, NaN in block letters, and the few words that say +// what this is. +// +// The glyphs of the wordmark are two different things and are coloured as +// such. `█` is the body of the letter, in the accent, because a block letter +// is a fill; `╔ ╗ ╚ ╝ ═ ║` are its edge, in a deeper violet, which is what +// gives the letterform depth on a terminal instead of a flat slab. + +// The middle letter sits one row lower than the two N's, which is what makes +// it read as the lowercase 'a' of NaN instead of a third capital. +var wordmark = []string{ + "███╗ ██╗ ███╗ ██╗", + "████╗ ██║ █████╗ ████╗ ██║", + "██╔██╗ ██║ ██╔══██╗ ██╔██╗ ██║", + "██║╚██╗██║ ███████║ ██║╚██╗██║", + "██║ ╚████║ ██╔══██║ ██║ ╚████║", + "╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝", +} + +const ( + wordmarkWidth = 30 + wordmarkRows = 6 + sideGap = 6 + // Between the mascot and the wordmark. Wider than it looks like it needs + // to be, because the mascot is a solid shape and the letters are not: the + // same gap reads as crowding. + mascotGap = 4 +) + +// BannerRoom is the terminal height from which the mascot is worth its rows. +// Home is a landing screen: one that needs scrolling on a 24-row terminal +// has already failed at the only thing it does, and the mascot is the part +// that goes. +const BannerRoom = 24 + MascotHeight - wordmarkRows + 1 + +// paint colours one wordmark row. +func paint(row string) string { + body := lipgloss.NewStyle().Foreground(Current().Accent) + edge := lipgloss.NewStyle().Foreground(Current().AccentDeep) + + var b strings.Builder + for _, r := range row { + switch r { + case '█': + b.WriteString(body.Render(string(r))) + case ' ': + b.WriteString(" ") + default: + b.WriteString(edge.Render(string(r))) + } + } + return b.String() +} + +func brandName() lipgloss.Style { + return lipgloss.NewStyle().Foreground(Current().AccentText).Bold(true) +} + +// bannerSide is what goes beside the wordmark, row by row, so the text sits +// on the letters' baseline instead of floating above them. +func bannerSide(version string) []string { + name := brandName() + dim := lipgloss.NewStyle().Foreground(Current().TextDim) + return []string{ + Faint("welcome to"), + name.Render("nan.builders"), + Faint("cloud CLI · v" + version), + "", + dim.Render("created by ") + name.Render("@Nxssie"), + dim.Render("maintained by ") + name.Render("Helmcode Team"), + } +} + +// BannerWidth is the columns a full banner needs, the indent aside. +func BannerWidth(version string) int { + return MascotWidth + mascotGap + BannerWidthCompact(version) +} + +// BannerWidthCompact is the same without the mascot, which is what a narrow +// terminal gets. +func BannerWidthCompact(version string) int { + return wordmarkWidth + sideGap + widest(bannerSide(version)) +} + +// widest measures the longest of a set of rendered lines, ignoring the escape +// sequences in them. +func widest(lines []string) int { + w := 0 + for _, l := range lines { + if n := lipgloss.Width(l); n > w { + w = n + } + } + return w +} + +// Banner draws the mascot, then the wordmark, then the text. The mascot is +// eleven rows and the wordmark six, so the letters are centred against it +// rather than sat on its top edge, which reads as two unrelated drawings. +// +// With withMascot false it is the wordmark and the text alone: on a terminal +// too narrow or too short for all three, the mascot is the part that goes. +func Banner(version, indent string, mood Mood, withMascot bool) string { + side := bannerSide(version) + row := func(i int) string { + line := paint(wordmark[i]) + if side[i] != "" { + line += strings.Repeat(" ", sideGap) + side[i] + } + return line + } + + var b strings.Builder + if !withMascot { + for i := range wordmark { + b.WriteString(strings.TrimRight(indent+row(i), " ") + "\n") + } + return b.String() + } + + art := Mascot(mood) + top := (len(art) - len(wordmark)) / 2 + for i := range art { + line := indent + art[i] + strings.Repeat(" ", mascotGap) + if w := i - top; w >= 0 && w < len(wordmark) { + line += row(w) + } + b.WriteString(strings.TrimRight(line, " ") + "\n") + } + return b.String() +} + +// PlainBanner is for a terminal too narrow for the wordmark: the name and +// what this is, in two lines. +func PlainBanner(version, indent string) string { + return indent + brandName().Render("nan.builders") + "\n" + + indent + Faint("cloud CLI · v"+version) + "\n" +} + +// CompactBanner is one line: what a command that prints a result and exits +// can afford to spend on saying whose result it is. +func CompactBanner(version string) string { + return brandName().Render("nan.builders") + " " + Faint("cloud CLI · v"+version) + "\n" +} diff --git a/internal/ui/banner_test.go b/internal/ui/banner_test.go new file mode 100644 index 0000000..b9d7b7e --- /dev/null +++ b/internal/ui/banner_test.go @@ -0,0 +1,68 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// Six faces, one head: the moods differ only in the eyes, so the silhouette +// of every face matches the normal one pixel for pixel outside the window. +func TestEveryFaceIsTheSameHead(t *testing.T) { + normal := mascotFaces[MoodNormal] + for mood, face := range mascotFaces { + if len(face) != len(normal) { + t.Fatalf("mood %d is %d rows, the head is %d", mood, len(face), len(normal)) + } + for y := range face { + if len(face[y]) != MascotWidth { + t.Fatalf("mood %d row %d is %d wide", mood, y, len(face[y])) + } + for x := range face[y] { + if (face[y][x] == '#') != (normal[y][x] == '#') { + t.Errorf("mood %d changes the head at %d,%d", mood, x, y) + } + } + } + } +} + +// Two pixels a cell: 21 rows of pixels are 11 lines on screen. +func TestTheMascotIsHalfAsTallOnScreen(t *testing.T) { + SetTheme(NewTheme(true)) + for mood := range mascotFaces { + rows := Mascot(mood) + if len(rows) != MascotHeight { + t.Errorf("mood %d draws %d lines, want %d", mood, len(rows), MascotHeight) + } + for _, r := range rows { + if w := ansi.StringWidth(r); w != MascotWidth { + t.Errorf("mood %d draws a line %d wide", mood, w) + } + } + } +} + +// The banner fits the width it says it needs, and carries the names. +func TestTheBannerFitsAndCarriesTheNames(t *testing.T) { + SetTheme(NewTheme(true)) + for _, withMascot := range []bool{true, false} { + out := Banner("1.2.3", "", MoodNormal, withMascot) + want := BannerWidthCompact("1.2.3") + if withMascot { + want = BannerWidth("1.2.3") + } + for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + if w := ansi.StringWidth(l); w > want { + t.Errorf("a banner line is %d wide, BannerWidth says %d", w, want) + } + } + plain := ansi.Strip(out) + for _, name := range []string{"nan.builders", "v1.2.3", "@Nxssie", "Helmcode Team"} { + if !strings.Contains(plain, name) { + t.Errorf("the banner does not carry %q", name) + } + } + } +} diff --git a/internal/ui/components.go b/internal/ui/components.go new file mode 100644 index 0000000..10f5a3a --- /dev/null +++ b/internal/ui/components.go @@ -0,0 +1,398 @@ +package ui + +import ( + "image/color" + "math" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/progress" + "charm.land/lipgloss/v2" + ltable "charm.land/lipgloss/v2/table" + "github.com/charmbracelet/x/ansi" +) + +// The pieces every screen is built from. +// +// They exist so the plain commands and the dashboard draw the same thing: +// a key/value row in `helmcode keys list` and one inside the Keys tab are the +// same two lines of code, and a change to how Helmcode looks is a change in +// one place rather than eleven. + +// Eyebrow is the brand's section marker: `// SECTION`, upper case, in the +// accent, with the letters held apart. +// +// The brandbook calls the wide tracking on this a signature and asks for +// 0.10em; a terminal has one cell per character and no tracking at all, so +// the spacing is faked with a space between letters. It is the one place +// that trick is worth it — it is what makes a heading read as Helmcode's +// rather than as any CLI's bold text. +func Eyebrow(s string) string { + t := Current() + slash := lipgloss.NewStyle().Foreground(t.AccentText) + label := lipgloss.NewStyle().Foreground(t.AccentText) + return slash.Render("// ") + label.Render(track(strings.ToUpper(s))) +} + +// track puts a space between characters, and two between words, so a word +// break survives the letters being spread out. +func track(s string) string { + var b strings.Builder + for i, r := range s { + if i > 0 { + b.WriteByte(' ') + } + b.WriteRune(r) + } + return b.String() +} + +// Rule is a hairline. The brandbook wants 0.5px and a terminal's thinnest +// line is one cell, so the weight it cannot give is taken out of the colour +// instead: the line is drawn in the border tone, never in text. +func Rule(width int) string { + if width < 1 { + return "" + } + return lipgloss.NewStyle().Foreground(Current().Line).Render(strings.Repeat("─", width)) +} + +// Title is a section heading inside a screen. +func Title(s string) string { + return lipgloss.NewStyle().Foreground(Current().Text).Bold(true).Render(sanitize(s)) +} + +// Label is the muted half of a key/value pair. +func Label(s string) string { + return lipgloss.NewStyle().Foreground(Current().TextDim).Render(sanitize(s)) +} + +// Value is an ordinary value: a name, a date, a sentence. +func Value(s string) string { + return lipgloss.NewStyle().Foreground(Current().Text).Render(sanitize(s)) +} + +// Mono is a technical value — an id, a key prefix, a count. The brandbook +// reserves its mono track for exactly these, and in a terminal everything is +// already monospaced, so what carries the distinction here is the colour. +func Mono(s string) string { + return lipgloss.NewStyle().Foreground(Current().Mono).Render(sanitize(s)) +} + +// Faint is the legible floor: hints, empty states, things that are present +// but not being pointed at. +func Faint(s string) string { + return lipgloss.NewStyle().Foreground(Current().TextFaint).Render(sanitize(s)) +} + +// Accent is for a figure being pointed at. Text, so it takes the readable +// indigo and not the fill. +func Accent(s string) string { + return lipgloss.NewStyle().Foreground(Current().AccentText).Render(sanitize(s)) +} + +// KV renders `label value`, with the label padded to a shared column so a +// run of them line up. +// +// The trailing trim is for values assembled from optional parts — a figure +// and a delta that turned out to be empty — which otherwise leave plain +// spaces at the end of the line. It cannot damage a tag: a filled badge's +// padding lives INSIDE its colour escape, where this cannot reach it, which +// is exactly right because there the space is the box. +func KV(label, value string, column int) string { + pad := column - lipgloss.Width(label) + if pad < 1 { + pad = 1 + } + return strings.TrimRight(Label(label)+strings.Repeat(" ", pad)+value, " ") +} + +// KVBlock renders a run of label/value rows: a definition list. +// +// A borderless two-column table rather than hand-measured padding. The +// alignment is then the library's problem, which is where it belongs, and a +// value that is too wide wraps under its own column instead of pushing the +// line off the screen. +// +// Both front ends draw this, so it lives here rather than once in the command +// layer and again in the dashboard: the day a label column changes width, it +// should change in both or the two stop looking like one program. +func KVBlock(rows [][2]string, indent string) string { + if len(rows) == 0 { + return "" + } + label := lipgloss.NewStyle().Foreground(Current().TextDim).PaddingRight(3) + + tbl := ltable.New(). + Border(lipgloss.HiddenBorder()). + BorderTop(false).BorderBottom(false). + BorderLeft(false).BorderRight(false). + BorderColumn(false).BorderRow(false).BorderHeader(false). + StyleFunc(func(_, col int) lipgloss.Style { + if col == 0 { + return label + } + return lipgloss.NewStyle() + }) + + for _, r := range rows { + tbl = tbl.Row(r[0], r[1]) + } + return Indent(TrimLines(tbl.String()), indent) + "\n" +} + +// LabelColumn measures the label column a run of KV rows needs, so the caller +// does not have to guess and then be wrong when a label changes. +func LabelColumn(labels ...string) int { + widest := 0 + for _, l := range labels { + if w := lipgloss.Width(l); w > widest { + widest = w + } + } + return widest + 3 +} + +// Bar is a share of a ceiling, drawn by bubbles/progress. +// +// This was hand-rolled — repeat a block character, repeat a dash — which is +// the same picture without the gradient, without the fractional last cell, +// and with a NaN bug that had to be found and fixed by hand. The library is +// already a dependency for the dashboard; there is no reason for the commands +// to draw their own. +// +// Rendering the track matters as much as the fill: a bar drawn only up to its +// value says how much, but not out of how much, and every bar in this CLI is +// a share of something with a ceiling — a quota, a key limit, a month's +// tokens. +func Bar(fraction float64, width int, alarm bool) string { + if width < 1 { + return "" + } + // NaN satisfies neither comparison, so without this it reaches the + // renderer as a very large negative and panics. + if math.IsNaN(fraction) || fraction < 0 { + fraction = 0 + } + if fraction > 1 { + fraction = 1 + } + + t := Current() + // The brand's two indigos, blended along the bar — and the traffic-light + // red when the thing being measured is nearly spent, which is the one + // use the brandbook allows it. + start, end := t.Accent, t.AccentText + if alarm { + start, end = t.Red, t.Yellow + } + + p := progress.New( + progress.WithColors(start, end), + progress.WithoutPercentage(), + progress.WithFillCharacters('█', '─'), + progress.WithWidth(width), + ) + return p.ViewAs(fraction) +} + +// levelSteps is how finely the usage scale is cut: one colour per tenth. +const levelSteps = 11 + +// Level is the colour of a share of an allowance: green while there is +// plenty left, yellow around the middle, red as it runs out. A scale rather +// than an alarm switched on at 90%, so how spent something is can be read +// from the colour alone, and two caps at 20% and 70% no longer look alike. +// +// Traffic-light colours are the one thing the brandbook keeps for severity +// of data, and how much of a cap is gone is exactly that. A share of the +// organisation's traffic is not, and keeps the brand's indigo (Bar). +func Level(fraction float64) color.Color { + if math.IsNaN(fraction) || fraction < 0 { + fraction = 0 + } + t := Current() + ramp := lipgloss.Blend1D(levelSteps, t.Green, t.Yellow, t.Red) + return ramp[min(int(math.Round(math.Min(fraction, 1)*float64(levelSteps-1))), levelSteps-1)] +} + +// LevelBar is a bar in the usage scale: its fill in Level's colour for how +// full it is, so the length and the colour say the same thing twice. +func LevelBar(fraction float64, width int) string { + if width < 1 { + return "" + } + if math.IsNaN(fraction) || fraction < 0 { + fraction = 0 + } + c := Level(fraction) + p := progress.New( + progress.WithColors(c, c), + progress.WithoutPercentage(), + progress.WithFillCharacters('█', '─'), + progress.WithWidth(width), + ) + return p.ViewAs(math.Min(fraction, 1)) +} + +// LevelPercent is a share of an allowance, as a figure in its Level colour. +func LevelPercent(fraction float64) string { + return lipgloss.NewStyle().Foreground(Level(fraction)).Render(Percent(fraction)) +} + +// TagKind is how loudly a tag is drawn. +type TagKind int + +const ( + // TagNeutral is the default, and what most tags should be: the + // brandbook reserves the accent for what is actually being highlighted, + // and a screen where every tag shouts highlights nothing. + TagNeutral TagKind = iota + TagAccent + TagFilled + TagDanger + TagWarn + TagOK +) + +// Tag is a small upper-case badge: a role, a status, a tier. +func Tag(s string, kind TagKind) string { + t := Current() + // Only the filled tag is padded. On every other kind the padding is two + // stray spaces that push the tag out of the column it sits in; on the + // filled one it is the box itself. + style := lipgloss.NewStyle() + switch kind { + case TagAccent: + style = style.Foreground(t.AccentText) + case TagFilled: + style = style.Padding(0, 1).Foreground(lipgloss.Color("#0a0a0a")).Background(t.Accent) + case TagDanger: + style = style.Foreground(t.Red) + case TagWarn: + style = style.Foreground(t.Yellow) + case TagOK: + style = style.Foreground(t.Green) + default: + style = style.Foreground(t.TextFaint) + } + return style.Render(strings.ToUpper(sanitize(s))) +} + +// Error is how a failure is printed, everywhere. +func Error(err error) string { + return lipgloss.NewStyle().Foreground(Current().Red).Render("✗ ") + Value(err.Error()) +} + +// Success is its counterpart, for a command that changed something. +func Success(msg string) string { + return lipgloss.NewStyle().Foreground(Current().Green).Render("✓ ") + Value(msg) +} + +// Warn is for something that worked but should be looked at. +func Warn(msg string) string { + return lipgloss.NewStyle().Foreground(Current().Yellow).Render("! ") + Value(msg) +} + +// Fingerprint is the most of a secret anyone needs to see: enough to tell two +// keys apart, which is the only question a screen ever asks of one, and not +// enough to use. Nothing in this CLI prints a whole key except the single +// moment it is issued. +func Fingerprint(secret string) string { + if len(secret) <= 12 { + return strings.Repeat("·", len(secret)) + } + return secret[:8] + "…" + secret[len(secret)-4:] +} + +// Truncate shortens a string to fit a column, with an ellipsis when it had +// to cut. +// +// Delegated to ansi.Truncate, which understands escape sequences. Walking +// runes and measuring each one — which is what this did — cuts a styled +// string INSIDE its escape sequence: `Truncate(ui.Value("production-api"), 10)` +// returned "\x1b[38;2;255…", a broken sequence and no visible text, which on +// a real terminal swallows whatever is printed after it. +// +// That is not a hypothetical: three of the four callers pass strings this +// package has already styled — the sidebar rows, the status line and the +// footer — and they all truncate exactly when the terminal is narrow. +func Truncate(s string, width int) string { + if width < 1 { + return "" + } + if ansi.StringWidth(s) <= width { + return s + } + if width == 1 { + return "…" + } + return ansi.Truncate(s, width, "…") +} + +// Pad right-pads to a display width, for hand-built columns. +func Pad(s string, width int) string { + gap := width - lipgloss.Width(s) + if gap <= 0 { + return s + } + return s + strings.Repeat(" ", gap) +} + +// PadLeft is the same for figures, which line up on the right. +func PadLeft(s string, width int) string { + gap := width - lipgloss.Width(s) + if gap <= 0 { + return s + } + return strings.Repeat(" ", gap) + s +} + +// TrimLines removes the trailing whitespace a table leaves on every row. +// +// lipgloss pads each cell to its column, which is what makes the columns line +// up and also what leaves a run of spaces at the end of every line. Nobody +// sees them on screen and everybody sees them in a diff, in a paste, and in +// `git add` on a file somebody redirected this into. +func TrimLines(block string) string { + lines := strings.Split(block, "\n") + for i, l := range lines { + lines[i] = strings.TrimRight(l, " ") + } + return strings.Join(lines, "\n") +} + +// Indent shifts a whole rendered block, keeping blank lines blank so the +// indent does not become trailing whitespace on an empty row. +func Indent(block, indent string) string { + lines := strings.Split(block, "\n") + for i, l := range lines { + if l != "" { + lines[i] = indent + l + } + } + return strings.Join(lines, "\n") +} + +// HelpStyles dresses bubbles/help in the brand. +// +// The keys carry the accent because they are the part you act on; the +// descriptions sit at the muted level, and the separator is the hairline +// tone. Without this the help is the one part of the dashboard rendered in +// somebody else's palette. +func HelpStyles() help.Styles { + t := Current() + key := lipgloss.NewStyle().Foreground(t.AccentText) + desc := lipgloss.NewStyle().Foreground(t.TextFaint) + sep := lipgloss.NewStyle().Foreground(t.Line) + + return help.Styles{ + Ellipsis: sep, + ShortKey: key, + ShortDesc: desc, + ShortSeparator: sep, + FullKey: key, + FullDesc: desc, + FullSeparator: sep, + } +} diff --git a/internal/ui/dashes_test.go b/internal/ui/dashes_test.go new file mode 100644 index 0000000..1ef0a68 --- /dev/null +++ b/internal/ui/dashes_test.go @@ -0,0 +1,73 @@ +package ui + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// No em or en dashes in anything this program prints. +// +// They are a typographic nicety that a terminal is a bad place for: the +// character is not in every font a person might have set, it is not on +// anybody's keyboard when they want to grep the output for a line, and it +// survives a copy-paste into a shell far less well than a hyphen. This +// program had seventy-odd of them. +// +// The check is over string LITERALS only, parsed rather than grepped, so +// prose in the comments is left alone — it is not what anybody sees. +// Built rather than written out, so this file does not report itself. +var dashes = string([]rune{0x2014, 0x2013}) + +func TestNothingPrintedHasAnEmDash(t *testing.T) { + root := filepath.Join("..", "..") + + var found []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if name := d.Name(); name == ".git" || name == "assets" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + // A file that does not parse is the compiler's problem, not this + // test's. + return nil //nolint:nilerr // not this test's failure to report + } + ast.Inspect(f, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + if strings.ContainsAny(lit.Value, dashes) { + found = append(found, fmt.Sprintf("%s:%d %s", + filepath.ToSlash(path), fset.Position(lit.Pos()).Line, lit.Value)) + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walking the repository: %v", err) + } + + if len(found) > 0 { + t.Errorf("%d string literals carry an em or en dash; use a hyphen:\n %s", + len(found), strings.Join(found, "\n ")) + } +} diff --git a/internal/ui/eyebrow_motion.go b/internal/ui/eyebrow_motion.go new file mode 100644 index 0000000..7dc0111 --- /dev/null +++ b/internal/ui/eyebrow_motion.go @@ -0,0 +1,43 @@ +package ui + +import ( + "math" + "strings" + + "charm.land/lipgloss/v2" +) + +// EyebrowTyped draws the eyebrow partway through being typed out: the two +// slashes first, then the letters, with a cursor ahead of them that blinks +// while it types and goes once it is done. For a dashboard that animates +// what it has just put on screen. +// +// progress runs from 0 to 1, driven by a spring; frame counts animation +// ticks, for the blink. Chosen over a sweep of light, letters closing up +// and a decode, side by side in the dashboard: typing is the one that +// belongs to a terminal. +func EyebrowTyped(s string, progress float64, frame int) string { + if progress >= 1 { + return Eyebrow(s) + } + t := Current() + accent := lipgloss.NewStyle().Foreground(t.AccentText) + label := []rune(track(strings.ToUpper(s))) + + // A spring spends its last stretch creeping the final percent to 1, + // which would leave the last letter missing for most of the settling. + // Running a fifth ahead finishes the typing as the spring lands. + p := math.Min(math.Max(progress, 0)*1.2, 1) + n := int(p * float64(len(label)+2)) + slashes := min(n, 2) + typed := min(max(n-2, 0), len(label)) + + out := accent.Render(strings.Repeat("/", slashes)) + if slashes == 2 { + out += " " + accent.Render(string(label[:typed])) + } + if frame/6%2 == 0 { + out += lipgloss.NewStyle().Foreground(t.Accent).Render("▌") + } + return out +} diff --git a/internal/ui/eyebrow_motion_test.go b/internal/ui/eyebrow_motion_test.go new file mode 100644 index 0000000..2f555a0 --- /dev/null +++ b/internal/ui/eyebrow_motion_test.go @@ -0,0 +1,41 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// Typing lands exactly on the eyebrow at rest, cursor gone - a heading that +// settles into something slightly different is one that changes after you +// have read it - and has finished typing a little before the spring lands. +func TestTypingLandsOnTheEyebrow(t *testing.T) { + SetTheme(NewTheme(true)) + for _, title := range []string{"Keys", "What you can do"} { + rest := ansi.Strip(Eyebrow(title)) + if got := ansi.Strip(EyebrowTyped(title, 1, 0)); got != rest { + t.Errorf("typing settles on %q, want %q", got, rest) + } + if got := strings.TrimSuffix(ansi.Strip(EyebrowTyped(title, 0.9, 0)), "▌"); got != rest { + t.Errorf("at 0.9 it reads %q, want it typed", got) + } + } +} + +// The slashes come first, and the letters follow in order. +func TestTypingGoesInOrder(t *testing.T) { + SetTheme(NewTheme(true)) + rest := ansi.Strip(Eyebrow("Keys")) + prev := "" + for p := 0.0; p <= 1; p += 0.05 { + got := strings.TrimSuffix(ansi.Strip(EyebrowTyped("Keys", p, 1)), "▌") + if !strings.HasPrefix(rest, got) { + t.Fatalf("at %.2f it reads %q, which is not the start of %q", p, got, rest) + } + if len(got) < len(prev) { + t.Fatalf("at %.2f it went back from %q to %q", p, prev, got) + } + prev = got + } +} diff --git a/internal/ui/form.go b/internal/ui/form.go new file mode 100644 index 0000000..c8de37e --- /dev/null +++ b/internal/ui/form.go @@ -0,0 +1,187 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" +) + +// Forms and prompts, in the brand's colours. +// +// Everything a person types at this CLI goes through huh rather than through +// bufio.Scanner: the email at sign-in, the link pasted back, the key in +// Setup, the tools it applies to, and every confirmation before something +// irreversible. A hand-rolled prompt has no validation, no cursor, no way +// back, and reimplements badly what a form library already does — and it +// looks like a different program from the dashboard two commands away. + +// FormTheme is huh dressed as Helmcode. +// +// Built on ThemeBase rather than ThemeCharm, because ThemeCharm is another +// brand's palette and the point is not to ship it. The focused field's thick +// left border is kept and coloured with the accent, which is exactly the +// brandbook's terminal-block treatment: a surface with a 3px accent rule down +// its left edge. +func FormTheme() huh.Theme { + return huh.ThemeFunc(func(isDark bool) *huh.Styles { + t := NewTheme(isDark) + s := huh.ThemeBase(isDark) + + s.Focused.Base = s.Focused.Base.BorderForeground(t.Accent) + s.Focused.Card = s.Focused.Base + s.Focused.Title = lipgloss.NewStyle().Foreground(t.Text).Bold(true) + s.Focused.Description = lipgloss.NewStyle().Foreground(t.TextDim) + s.Focused.ErrorIndicator = s.Focused.ErrorIndicator.Foreground(t.Red) + s.Focused.ErrorMessage = s.Focused.ErrorMessage.Foreground(t.Red) + + s.Focused.SelectSelector = s.Focused.SelectSelector.Foreground(t.AccentText) + s.Focused.MultiSelectSelector = s.Focused.MultiSelectSelector.Foreground(t.AccentText) + s.Focused.SelectedOption = lipgloss.NewStyle().Foreground(t.AccentText) + s.Focused.SelectedPrefix = s.Focused.SelectedPrefix.Foreground(t.AccentText) + s.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(t.TextDim) + s.Focused.UnselectedPrefix = s.Focused.UnselectedPrefix.Foreground(t.TextFaint) + s.Focused.Option = lipgloss.NewStyle().Foreground(t.Text) + + s.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.Accent) + s.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.TextFaint) + s.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.AccentText) + s.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(t.Text) + + // The one filled moment: the button you are about to press. + s.Focused.FocusedButton = s.Focused.FocusedButton. + Foreground(lipgloss.Color("#0a0a0a")).Background(t.Accent) + s.Focused.BlurredButton = s.Focused.BlurredButton. + Foreground(t.TextDim).Background(t.Surface) + + // Blurred keeps the same shapes with the border hidden, so a field + // does not move by a column when it takes focus. + blurred := s.Focused + blurred.Base = s.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + blurred.Card = blurred.Base + blurred.Title = lipgloss.NewStyle().Foreground(t.TextDim) + blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.TextFaint) + blurred.TextInput.Text = lipgloss.NewStyle().Foreground(t.TextDim) + blurred.MultiSelectSelector = lipgloss.NewStyle().SetString(" ") + blurred.NextIndicator = lipgloss.NewStyle() + blurred.PrevIndicator = lipgloss.NewStyle() + s.Blurred = blurred + + return s + }) +} + +// NewForm builds a form already wearing the theme and sized to the terminal, +// so no caller has to remember to do either. Help is off: every form here is +// one field, and the dashboard's own footer says what the keys do. +// +// The width is not cosmetic. huh leaves a field's text input at width zero +// unless the form is given one, and bubbles' text input draws only the first +// character of its placeholder at width zero - so "you@example.com" arrives +// as a lone "y" sitting under the cursor, which reads as a stray keystroke +// rather than as a hint, and the prompt stops saying what it wants. +// +// Nor is the height. huh sizes a group when it is built, before it has a +// width, so it measures every title and description as one line and then +// draws them wrapped: a description that wraps pushes the input out of the +// bottom of its own box. It measures again on a window-size message, and a +// form embedded in the dashboard never sees one. Fitting it here, once, is +// that message. +func NewForm(groups ...*huh.Group) *huh.Form { + f := huh.NewForm(groups...).WithTheme(FormTheme()).WithShowHelp(false) + w, h := TerminalSize() + FitForm(f, w, h) + return f +} + +// FitForm resizes a form to a terminal of w by h, rewrapping its text. +// +// huh wraps titles and descriptions itself, at the width it is given, so the +// text is passed to it as plain sentences and this is all a resize takes. +// A dashboard calls it on every WindowSizeMsg while a form is up; a form run +// on its own gets that message from its own program. +func FitForm(f *huh.Form, w, h int) { + if f == nil || w <= 0 || h <= 0 { + return + } + f.WithWidth(FormWidthFor(w)) + f.Update(tea.WindowSizeMsg{Width: w, Height: h}) +} + +// Picker is a MultiSelect that keeps itself tall enough to show its options. +// +// Not optional. huh's MultiSelect, given a description and no height, draws +// the title and the description and none of the options: it sizes its list +// from the options alone, then takes the title and the description out of +// that. Setup's tool picker came out as a question with nothing to tick, in +// the dashboard and in `helmcode setup` alike. +// +// A fixed height is not the fix either, because the description wraps to a +// different number of lines at every width: too short and an option falls +// off the bottom, too tall and a gap opens under the last one. So the height +// is worked out again every time huh hands the field a new width, which is +// the one moment the answer changes. +type Picker struct { + *huh.MultiSelect[string] + title, description string + options int +} + +// NewPicker builds a Picker. Key and Value are on it rather than left to the +// embedded field, whose own versions return the bare MultiSelect and would +// drop the wrapper on the floor. +func NewPicker(title, description string, options []huh.Option[string]) *Picker { + return &Picker{ + MultiSelect: huh.NewMultiSelect[string](). + Title(title). + Description(description). + Options(options...), + title: title, + description: description, + options: len(options), + } +} + +// Key sets the key the form reads the answer back by. +func (p *Picker) Key(key string) *Picker { + p.MultiSelect.Key(key) + return p +} + +// Value binds the answer to v. +func (p *Picker) Value(v *[]string) *Picker { + p.MultiSelect.Value(v) + return p +} + +// WithWidth is where huh tells the field its width, so it is where the +// height is settled. +func (p *Picker) WithWidth(width int) huh.Field { + p.Height(ChoiceHeight(p.title, p.description, p.options, width)) + p.MultiSelect.WithWidth(width) + return p +} + +// Update keeps the wrapper: huh stores whatever a field's Update returns, and +// the embedded one returns itself. +func (p *Picker) Update(msg tea.Msg) (huh.Model, tea.Cmd) { + _, cmd := p.MultiSelect.Update(msg) + return p, cmd +} + +// ChoiceHeight is the height a MultiSelect needs at a given field width: its +// title and its description as huh will wrap them, and one row per option. +// Measured, that is exactly it, with no margin. +func ChoiceHeight(title, description string, options, width int) int { + text := width - FormTheme().Theme(true).Focused.Base.GetHorizontalFrameSize() + if text < 1 { + text = 1 + } + h := options + for _, s := range []string{title, description} { + if s != "" { + // The same call, with the same break characters, huh wraps with. + h += lipgloss.Height(lipgloss.Wrap(s, text, ",.-; ")) + } + } + return h +} diff --git a/internal/ui/form_test.go b/internal/ui/form_test.go new file mode 100644 index 0000000..e97a59c --- /dev/null +++ b/internal/ui/form_test.go @@ -0,0 +1,161 @@ +package ui + +import ( + "strings" + "testing" + + "charm.land/huh/v2" + "github.com/charmbracelet/x/ansi" +) + +// A placeholder that does not render is worse than no placeholder: what +// reaches the screen is the first character of it, sitting under the cursor, +// which reads as a stray keystroke rather than as a hint — and the prompt +// stops saying what it wants. +// +// It happens whenever the form is not given a width: huh leaves the field's +// text input at zero, and bubbles' text input draws one character at zero. +// Every form in this program was built without one. +func TestAPlaceholderIsDrawnInFull(t *testing.T) { + SetTheme(NewTheme(true)) + + form := NewForm(huh.NewGroup( + huh.NewInput(). + Title("Sign in to Helmcode"). + Placeholder("you@example.com"), + )).WithShowHelp(false) + form.Init() + + got := ansi.Strip(form.View()) + if !strings.Contains(got, "you@example.com") { + t.Errorf("the placeholder is not drawn in full:\n%s", got) + } +} + +// And the form is not stretched across the whole terminal. A one-line +// question does not get better for being a hundred columns wide. +func TestAFormIsNotAsWideAsTheTerminal(t *testing.T) { + SetTheme(NewTheme(true)) + t.Setenv("COLUMNS", "200") + + form := NewForm(huh.NewGroup( + huh.NewInput().Title("Sign in to Helmcode"), + )).WithShowHelp(false) + form.Init() + + for _, line := range strings.Split(ansi.Strip(form.View()), "\n") { + if w := len([]rune(line)); w > formWidth+4 { + t.Errorf("a form line is %d columns wide in a 200-column terminal: %q", w, line) + } + } +} + +// In a terminal too narrow for the cap, the form follows the terminal rather +// than overflowing it. +func TestAFormFitsANarrowTerminal(t *testing.T) { + SetTheme(NewTheme(true)) + t.Setenv("COLUMNS", "44") + + if got := FormWidth(); got > 40 { + t.Errorf("FormWidth is %d in a 44-column terminal", got) + } +} + +// A description too long for one line must not cost the field. +// +// huh sizes a group before it knows its width, measuring every description +// as one line, and draws it wrapped: a description that wraps pushes the +// input out of the bottom of its box, leaving a question with no field under +// it and no cursor. NewForm fits the form once built, which re-measures it. +func TestALongDescriptionDoesNotCostTheField(t *testing.T) { + SetTheme(NewTheme(true)) + t.Setenv("COLUMNS", "50") + + form := NewForm(huh.NewGroup( + huh.NewInput(). + Title("Sign in to Helmcode"). + Description("Your email address - a link goes there. Helmcode has no password, " + + "and this sentence is deliberately far too long for one line."). + Placeholder("you@example.com"), + )) + form.Init() + + got := ansi.Strip(form.View()) + if !strings.Contains(got, "you@example.com") { + t.Errorf("the input is not drawn under a description that wraps:\n%s", got) + } +} + +// The text follows the terminal: a wide one gets long lines, a narrow one +// short lines, and a resize while the form is up rewraps it. +func TestAFormRewrapsWhenTheTerminalResizes(t *testing.T) { + SetTheme(NewTheme(true)) + t.Setenv("COLUMNS", "120") + + desc := strings.TrimSpace(strings.Repeat("words to wrap ", 12)) + form := NewForm(huh.NewGroup( + huh.NewInput().Title("Your key").Description(desc).Placeholder("sk-hke_"), + )) + form.Init() + + lines := func() int { return strings.Count(ansi.Strip(form.View()), "\n") + 1 } + widest := func() int { + w := 0 + for _, l := range strings.Split(ansi.Strip(form.View()), "\n") { + w = max(w, ansi.StringWidth(strings.TrimRight(l, " "))) + } + return w + } + + wide, wideLines := widest(), lines() + if wide <= 60 { + t.Errorf("in a 120-column terminal the widest line is %d: the form is not using the width", wide) + } + + FitForm(form, 50, 40) + if w := widest(); w > 50 { + t.Errorf("after narrowing to 50 columns a line is %d wide", w) + } + if lines() <= wideLines { + t.Errorf("narrowing did not rewrap: %d lines, the same as at 120", lines()) + } + if got := ansi.Strip(form.View()); !strings.Contains(got, "sk-hke_") { + t.Errorf("after narrowing, the input is gone:\n%s", got) + } +} + +// A MultiSelect with a description and no height draws its question and +// none of its answers. Setup's tool picker shipped that way: "Which tools +// should use Helmcode?" over an empty space, with nothing to tick. And the +// height it needs changes with the width, so it has to hold at every width, +// including after a resize. +func TestAPickerShowsEveryOptionAtEveryWidth(t *testing.T) { + SetTheme(NewTheme(true)) + t.Setenv("COLUMNS", "120") + + names := []string{"pi", "Crush", "OpenCode"} + options := make([]huh.Option[string], 0, len(names)) + for _, n := range names { + options = append(options, huh.NewOption(n, n)) + } + form := NewForm(huh.NewGroup(NewPicker( + "Which tools should use Helmcode?", + "Only installed tools are listed. Unticking one removes the key from its config.", + options, + ))) + form.Init() + + for _, w := range []int{120, 44, 80} { + FitForm(form, w, 40) + got := ansi.Strip(form.View()) + for _, n := range names { + if !strings.Contains(got, n) { + t.Errorf("at %d columns the picker does not show %q:\n%s", w, n, got) + } + } + // Nor a gap under the last option: the height is exact. + if trimmed := strings.TrimRight(got, " \n"); !strings.HasSuffix(trimmed, "OpenCode") { + t.Errorf("at %d columns something follows the last option:\n%s", w, got) + } + } +} diff --git a/internal/ui/format.go b/internal/ui/format.go new file mode 100644 index 0000000..1b767ae --- /dev/null +++ b/internal/ui/format.go @@ -0,0 +1,225 @@ +package ui + +import ( + "math" + "os" + "strconv" + "strings" + "time" +) + +// Turning the control plane's figures into something a person reads at a +// glance. These live here rather than in each screen because a token count +// that is abbreviated one way in the dashboard and another in `usage` is the +// same bug as two different shades of indigo. + +// Tokens abbreviates a count: 4.12B, 41.2B, 412.4M. Below a thousand the +// exact number is short enough to read and interesting enough to want, so it +// is printed in full. +// +// Three figures, and the trailing zeros are KEPT. An earlier version showed +// one decimal and dropped it when it was a zero, on the reasoning that +// "12.0M" reads as a rounding artefact where "12M" reads as a measurement. +// That is true of 12.0 and false of everything near it: 4,040,000,000 came +// out as "4B", which does not look abbreviated at all — it looks like a +// round number, and a reader has no way to tell the two apart. Showing +// "4.04B" costs three characters and stops the figure lying about its own +// precision. +func Tokens(n float64) string { + switch { + case math.IsNaN(n) || math.IsInf(n, 0): + return "-" + case n < 0: + return "-" + Tokens(-n) + case n >= 1e9: + return precise(n/1e9) + "B" + case n >= 1e6: + return precise(n/1e6) + "M" + case n >= 1e3: + return precise(n/1e3) + "K" + default: + return strconv.FormatFloat(n, 'f', -1, 64) + } +} + +// precise renders a value in [1, 1000) with always at least one decimal, and +// two below ten. +// +// Not "three significant figures", which was the first attempt: it turns +// 412.4M into "412M" and loses a digit this screen used to show. The rule is +// that no range gets less precision than it had, and the small end — where a +// single decimal is coarsest, because 4.1 covers a span of a hundred million +// tokens — gets one more. +func precise(v float64) string { + if v < 10 { + return strconv.FormatFloat(v, 'f', 2, 64) + } + return strconv.FormatFloat(v, 'f', 1, 64) +} + +// Count formats a plain integer with thousands separators. +func Count(n float64) string { + if math.IsNaN(n) || math.IsInf(n, 0) { + return "-" + } + return group(strconv.FormatFloat(math.Round(n), 'f', 0, 64)) +} + +func group(digits string) string { + neg := strings.HasPrefix(digits, "-") + digits = strings.TrimPrefix(digits, "-") + var parts []string + for len(digits) > 3 { + parts = append([]string{digits[len(digits)-3:]}, parts...) + digits = digits[:len(digits)-3] + } + parts = append([]string{digits}, parts...) + out := strings.Join(parts, ",") + if neg { + return "-" + out + } + return out +} + +// Date renders an RFC 3339 timestamp as a day. Anything unparseable is +// passed through untouched: the raw value is more use to somebody reporting +// a bug than a shrug is. +func Date(ts string) string { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + if ts == "" { + return "-" + } + return ts + } + return t.Local().Format("2006-01-02") +} + +// Ago is how long ago something was, in the roughest unit that is still +// true. A key's last use is a question of "recently or not", and an exact +// timestamp makes that harder to answer rather than easier. +func Ago(ts string) string { + if ts == "" { + return "never" + } + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return ts + } + d := time.Since(t) + switch { + case d < 0: + // A clock skewed forwards, or a timestamp from the future. Saying + // "in 3 minutes" about the past is worse than saying "just now". + return "just now" + case d < time.Minute: + return "just now" + case d < time.Hour: + return plural(int(d.Minutes()), "minute") + " ago" + case d < 24*time.Hour: + return plural(int(d.Hours()), "hour") + " ago" + case d < 30*24*time.Hour: + return plural(int(d.Hours()/24), "day") + " ago" + case d < 365*24*time.Hour: + return plural(int(d.Hours()/24/30), "month") + " ago" + default: + return plural(int(d.Hours()/24/365), "year") + " ago" + } +} + +// Until is Ago's counterpart, for a deadline that has not passed yet. +func Until(ts string) string { + if ts == "" { + return "-" + } + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return ts + } + d := time.Until(t) + if d <= 0 { + return "expired" + } + switch { + case d < time.Hour: + return "in " + plural(int(d.Minutes()), "minute") + case d < 24*time.Hour: + return "in " + plural(int(d.Hours()), "hour") + default: + return "in " + plural(int(d.Hours()/24), "day") + } +} + +func plural(n int, unit string) string { + if n == 1 { + return "1 " + unit + } + return strconv.Itoa(n) + " " + unit + "s" +} + +// Latency renders milliseconds the way a latency is discussed: under a +// second in ms, above it in seconds. +func Latency(ms float64) string { + if ms <= 0 { + return "-" + } + if ms < 1000 { + return strconv.FormatFloat(math.Round(ms), 'f', 0, 64) + "ms" + } + // Same three significant figures as Tokens, for the same reason: "1.8s" + // and "1.84s" cost one character apart and only one of them tells you + // whether the figure moved. + return precise(ms/1000) + "s" +} + +// HomePath shortens a path under the user's home directory to `~/…`. +// +// For display only — never for anything that will be opened, because `~` is +// the shell's expansion and not the filesystem's. Paths on these screens are +// there to be recognised ("yes, that is my Crush config") and to be pasted +// into an editor, and both work better at a third of the length. It also +// keeps the home directory out of anything somebody screenshots. +// +// Anything that is not under the home directory, or a home that cannot be +// determined, comes back untouched: a wrong abbreviation is worse than none. +func HomePath(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" || home == "/" { + return path + } + if path == home { + return "~" + } + if rest, found := strings.CutPrefix(path, home+string(os.PathSeparator)); found { + return "~" + string(os.PathSeparator) + rest + } + return path +} + +// When parses a control-plane timestamp for ordering. +// +// The zero time for anything unparseable or absent, which sorts before every +// real date — so "never used" collects at one end of a column rather than +// scattering through it. Display code keeps its own formatting; this exists +// only so a column of dates sorts as dates and not as the strings they were +// rendered into. +func When(ts string) time.Time { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return time.Time{} + } + return t +} + +// Percent renders a fraction as a percentage, with a decimal below ten so a +// cap at 4.8% does not read as the same thing as one at 5%. +func Percent(f float64) string { + if math.IsNaN(f) || math.IsInf(f, 0) { + return "-" + } + p := f * 100 + if p < 10 { + return strconv.FormatFloat(p, 'f', 1, 64) + "%" + } + return strconv.FormatFloat(p, 'f', 0, 64) + "%" +} diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go new file mode 100644 index 0000000..2963c84 --- /dev/null +++ b/internal/ui/format_test.go @@ -0,0 +1,155 @@ +package ui + +import ( + "path/filepath" + "testing" + "time" +) + +func TestTokens(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "0"}, + {999, "999"}, + {1000, "1.00K"}, + {1250, "1.25K"}, + {412_400_000, "412.4M"}, + // Trailing zeros are KEPT. Dropping them was the old behaviour and + // it made a figure lie about its own precision: 4,040,000,000 came + // out as "4B", indistinguishable from a round four billion. + {12_000_000, "12.0M"}, + {2_500_000_000, "2.50B"}, + {4_040_000_000, "4.04B"}, + {4_120_000_000, "4.12B"}, + // Two decimals below ten, one above: no range shows less than it + // used to, and the small end - where one decimal spans a hundred + // million tokens - shows more. + {41_230_000_000, "41.2B"}, + {412_300_000_000, "412.3B"}, + {-1500, "-1.50K"}, + } + for _, c := range cases { + if got := Tokens(c.in); got != c.want { + t.Errorf("Tokens(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestTokensSurvivesNonsense(t *testing.T) { + // A control plane sending a NaN should produce an em-dash, not the + // literal "NaN" in the middle of a table. + if got := Tokens(nan()); got != "-" { + t.Errorf("Tokens(NaN) = %q, want an em-dash", got) + } +} + +func nan() float64 { var z float64; return z / z } //nolint:staticcheck // deliberate NaN + +func TestCountGroups(t *testing.T) { + cases := map[float64]string{0: "0", 999: "999", 1000: "1,000", 18422: "18,422", 1234567: "1,234,567"} + for in, want := range cases { + if got := Count(in); got != want { + t.Errorf("Count(%v) = %q, want %q", in, got, want) + } + } +} + +func TestAgo(t *testing.T) { + now := time.Now().UTC() + cases := []struct { + ts string + want string + }{ + {"", "never"}, + {now.Add(-30 * time.Second).Format(time.RFC3339), "just now"}, + {now.Add(-5 * time.Minute).Format(time.RFC3339), "5 minutes ago"}, + {now.Add(-1 * time.Hour).Format(time.RFC3339), "1 hour ago"}, + {now.Add(-48 * time.Hour).Format(time.RFC3339), "2 days ago"}, + // A clock skewed forward must not produce "in 3 minutes ago". + {now.Add(10 * time.Minute).Format(time.RFC3339), "just now"}, + } + for _, c := range cases { + if got := Ago(c.ts); got != c.want { + t.Errorf("Ago(%q) = %q, want %q", c.ts, got, c.want) + } + } +} + +func TestAgoPassesThroughGarbage(t *testing.T) { + // The raw value is more use to somebody reporting a bug than a shrug. + if got := Ago("not a timestamp"); got != "not a timestamp" { + t.Errorf("Ago(garbage) = %q, want it passed through", got) + } +} + +func TestUntil(t *testing.T) { + now := time.Now().UTC() + if got := Until(now.Add(-time.Hour).Format(time.RFC3339)); got != "expired" { + t.Errorf("a past deadline should be %q, got %q", "expired", got) + } + if got := Until(now.Add(23 * time.Hour).Format(time.RFC3339)); got != "in 22 hours" && got != "in 23 hours" { + t.Errorf("Until(+23h) = %q", got) + } +} + +func TestLatency(t *testing.T) { + // Seconds carry the same precision as Tokens: "1.8s" and "1.84s" cost + // one character apart and only one of them shows a figure moving. + cases := map[float64]string{0: "-", 420: "420ms", 999: "999ms", 1840: "1.84s", 12_300: "12.3s"} + for in, want := range cases { + if got := Latency(in); got != want { + t.Errorf("Latency(%v) = %q, want %q", in, got, want) + } + } +} + +func TestTruncateMeasuresDisplayWidth(t *testing.T) { + SetTheme(NewTheme(true)) + if got := Truncate("production-api", 40); got != "production-api" { + t.Errorf("a string that fits must come back untouched, got %q", got) + } + got := Truncate("a-very-long-api-key-name-indeed", 10) + if len([]rune(got)) != 10 { + t.Errorf("Truncate to 10 gave %q (%d runes)", got, len([]rune(got))) + } + if got[len(got)-len("…"):] != "…" { + t.Errorf("a truncated string should end in an ellipsis, got %q", got) + } +} + +// Paths on screen are shortened to `~`, which is both shorter and one less +// thing to redact out of a screenshot. Anything outside the home directory is +// left alone: a wrong abbreviation is worse than none. +func TestHomePathShortensOnlyWhatIsUnderHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // where Windows keeps it + + cases := []struct{ in, want string }{ + {filepath.Join(home, ".config", "helmcode", "session.json"), filepath.Join("~", ".config", "helmcode", "session.json")}, + {home, "~"}, + {"/etc/helmcode.json", "/etc/helmcode.json"}, + // A directory that merely STARTS with the home path is a different + // directory, and abbreviating it would name a file that is not there. + {filepath.Join(home+"-backup", "config.json"), filepath.Join(home+"-backup", "config.json")}, + {"", ""}, + } + for _, c := range cases { + if got := HomePath(c.in); got != c.want { + t.Errorf("HomePath(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// The platform sends a dash when there is nothing to compare with. That is +// no delta, and must not reach the screen as an em dash. +func TestADashDeltaIsNoDelta(t *testing.T) { + em, en := string(rune(0x2014)), string(rune(0x2013)) // not literals: see dashes_test.go + for _, d := range []string{em, en, "-", " " + em + " "} { + if got := Delta(d); got != "" { + t.Errorf("Delta(%q) = %q, want nothing", d, got) + } + } +} diff --git a/internal/ui/loader.go b/internal/ui/loader.go new file mode 100644 index 0000000..9d45442 --- /dev/null +++ b/internal/ui/loader.go @@ -0,0 +1,106 @@ +package ui + +import ( + "image/color" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// The loading indicator: a band of light sweeping across a word. +// +// A one-character spinner says "something is happening" and nothing else, and +// at the size of a dashboard it is easy to miss entirely. A word that moves +// says the same thing at the size of the sentence it is in, reads at a glance +// from across a desk, and — the part that matters here — is made of the +// brand's own two indigos rather than of whatever a spinner character +// inherits. +// +// It is a sweep rather than a pulse because a pulse and a frozen program look +// the same at the moment you glance at them. Motion along an axis does not. + +// LoaderFPS is how often the band moves. Slow enough not to strobe, fast +// enough to read as motion rather than as a redraw. +const LoaderFPS = time.Second / 16 + +// Loader is an animated label. Embed it, forward its messages, call View. +type Loader struct { + label string + phase int + ramp []color.Color +} + +// LoaderTickMsg advances the animation. +type LoaderTickMsg struct{} + +// NewLoader builds one. The ramp is computed once: blending colours is the +// expensive part, and it does not change between frames. +func NewLoader(label string) Loader { + t := Current() + return Loader{ + label: label, + // Dim at the edges, BRIGHTEST at the peak. The order matters and is + // easy to get backwards: with the fill indigo last, the travelling + // band comes out darker than the characters beside it, which reads + // as a hole moving along the word rather than a light. + ramp: lipgloss.Blend1D(rampSteps, t.TextFaint, t.Accent, t.AccentText), + } +} + +const rampSteps = 12 + +// SetLabel changes the word without restarting the animation, so a loader +// that goes from "Loading" to "Checking the key" does not stutter. +func (l Loader) SetLabel(label string) Loader { + l.label = label + return l +} + +// Tick starts (or continues) the animation. +func (l Loader) Tick() tea.Cmd { + return tea.Tick(LoaderFPS, func(time.Time) tea.Msg { return LoaderTickMsg{} }) +} + +// Update advances one frame. +func (l Loader) Update(msg tea.Msg) (Loader, tea.Cmd) { + if _, ok := msg.(LoaderTickMsg); !ok { + return l, nil + } + l.phase++ + return l, l.Tick() +} + +// View renders the label with the band where it currently is. +func (l Loader) View() string { + runes := []rune(l.label) + if len(runes) == 0 { + return "" + } + + // The band travels a little past each end, so it leaves and re-enters + // rather than bouncing off the last character. + const overshoot = 6 + span := len(runes) + overshoot + head := l.phase % span + + var b strings.Builder + for i, r := range runes { + distance := i - head + if distance < 0 { + distance = -distance + } + // Far from the band is the dim end of the ramp; on it is the bright + // end. Anything past the ramp's length is simply the dimmest. + step := rampSteps - 1 - distance*2 + if step < 0 { + step = 0 + } + b.WriteString(lipgloss.NewStyle().Foreground(l.ramp[step]).Render(string(r))) + } + return b.String() +} + +// Width is what the loader occupies, for laying out around it. +func (l Loader) Width() int { return lipgloss.Width(l.label) } diff --git a/internal/tui/mascot.go b/internal/ui/mascot.go similarity index 81% rename from internal/tui/mascot.go rename to internal/ui/mascot.go index ef342c1..46c7b75 100644 --- a/internal/tui/mascot.go +++ b/internal/ui/mascot.go @@ -1,9 +1,9 @@ -package tui +package ui import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // The mascot, drawn as pixels rather than as characters. @@ -12,27 +12,28 @@ import ( // `▀` is two square pixels stacked. Printing `▀` with a foreground AND a // background colour paints both of them independently, which doubles the // vertical resolution and is the difference between a picture of the hexagon -// and a pile of box-drawing characters arranged to suggest one. The first four -// attempts at this were the second thing. +// and a pile of box-drawing characters arranged to suggest one. // // 18 by 21 pixels, so 18 columns by 11 rows on screen. -type mascotMood int +// Mood is the mascot's face. The expressions are not decoration: they answer +// "what is going on" from across the room. +type Mood int const ( - moodNormal mascotMood = iota - moodAsleep - moodThinking - moodHappy - moodSad - moodSurprised + MoodNormal Mood = iota + MoodAsleep + MoodThinking + MoodHappy + MoodSad + MoodSurprised ) const ( // The face window, darker than any background the terminal is likely to // have, so the head reads as a solid object with a hole in it. - mascotDark = "#1A0B33" - mascotEye = "#EDE7FF" + mascotDarkHex = "#1A0B33" + mascotEyeHex = "#EDE7FF" ) // MascotWidth and MascotHeight are what it occupies on screen, in cells. @@ -41,36 +42,17 @@ const ( MascotHeight = 11 ) -// mood picks the face from what the panel is actually doing. The expressions -// are not decoration: they answer "what is going on" from across the room, -// which is the question this whole tab kept failing to answer in words. -func (m model) mood() mascotMood { - switch { - case m.configuring, m.loading: - return moodThinking - case m.err != nil || strings.HasPrefix(m.setupMsg, "error"): - return moodSad - case m.loginStage != loginOff: - return moodSurprised - case m.sess.Token == "": - return moodAsleep - case m.sess.APIKey != "": - return moodHappy - } - return moodNormal -} - -// renderMascot paints a grid two pixels to a cell. -func renderMascot(mood mascotMood) []string { +// Mascot paints a face two pixels to a cell, one string per row. +func Mascot(mood Mood) []string { grid, ok := mascotFaces[mood] if !ok { - grid = mascotFaces[moodNormal] + grid = mascotFaces[MoodNormal] } colour := map[byte]string{ - '#': brandViolet, - ':': mascotDark, - 'O': mascotEye, + '#': accentHex, + ':': mascotDarkHex, + 'O': mascotEyeHex, } out := make([]string, 0, (len(grid)+1)/2) @@ -83,8 +65,7 @@ func renderMascot(mood mascotMood) []string { var row strings.Builder for x := 0; x < len(top); x++ { t, tok := colour[top[x]] - b := "" - bok := false + b, bok := "", false if x < len(bottom) { b, bok = colour[bottom[x]] } @@ -119,8 +100,8 @@ func renderMascot(mood mascotMood) []string { // up, which is how the first four attempts went, it reads as a blob. // // `#` body `:` the dark face window `O` eye `.` nothing -var mascotFaces = map[mascotMood][]string{ - moodNormal: { +var mascotFaces = map[Mood][]string{ + MoodNormal: { "........##........", "......######......", "....##########....", @@ -143,7 +124,7 @@ var mascotFaces = map[mascotMood][]string{ "......######......", "........##........", }, - moodAsleep: { + MoodAsleep: { "........##........", "......######......", "....##########....", @@ -166,7 +147,7 @@ var mascotFaces = map[mascotMood][]string{ "......######......", "........##........", }, - moodThinking: { + MoodThinking: { "........##........", "......######......", "....##########....", @@ -189,7 +170,7 @@ var mascotFaces = map[mascotMood][]string{ "......######......", "........##........", }, - moodHappy: { + MoodHappy: { "........##........", "......######......", "....##########....", @@ -212,7 +193,7 @@ var mascotFaces = map[mascotMood][]string{ "......######......", "........##........", }, - moodSad: { + MoodSad: { "........##........", "......######......", "....##########....", @@ -235,7 +216,7 @@ var mascotFaces = map[mascotMood][]string{ "......######......", "........##........", }, - moodSurprised: { + MoodSurprised: { "........##........", "......######......", "....##########....", diff --git a/internal/ui/sanitize.go b/internal/ui/sanitize.go new file mode 100644 index 0000000..f12b183 --- /dev/null +++ b/internal/ui/sanitize.go @@ -0,0 +1,58 @@ +package ui + +import "strings" + +// Everything the control plane says eventually reaches a terminal, and a +// terminal executes what it is sent. +// +// A key name, a member's address, an organisation name and an error code are +// all strings somebody else chose. Nothing between the JSON decoder and the +// screen strips control characters on its own: Lip Gloss only wraps a string +// in colour, and colorprofile forwards anything that is not an SGR sequence +// untouched when the destination IS a terminal — so the one place the +// sanitising would happen for free is the pipe, where it does not matter. +// +// The attack that follows is not theoretical and does not need a compromised +// server. A member of the organisation names a key +// +// prod\r\x1b[2K… something reassuring +// +// and the next admin to run `helmcode members remove` is shown a confirmation +// that has been rewritten after it was drawn, for a different thing than the +// one they are about to destroy. Cursor movement, screen clears and the +// window-title sequence are all available the same way. +// +// So it is stripped here, in the leaf text primitives, which is the last +// point every path goes through: the commands, the dashboard, the prompts and +// the error messages. It is deliberately NOT applied to Truncate or Pad, +// which are handed strings this package has already styled and whose escapes +// are ours. +func sanitize(s string) string { + // The common case is a name with nothing wrong with it, so check before + // building anything. + if !hasControl(s) { + return s + } + return strings.Map(func(r rune) rune { + switch { + case r == '\t': + // A tab is not dangerous and turning it into nothing would + // silently join two words. + return ' ' + case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f: + // C0, DEL and C1. C1 matters because 0x9b is CSI in its own + // right on terminals that honour eight-bit controls. + return -1 + } + return r + }, s) +} + +func hasControl(s string) bool { + for _, r := range s { + if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + return true + } + } + return false +} diff --git a/internal/ui/sanitize_test.go b/internal/ui/sanitize_test.go new file mode 100644 index 0000000..0b8ff67 --- /dev/null +++ b/internal/ui/sanitize_test.go @@ -0,0 +1,144 @@ +package ui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" +) + +// Names, addresses and error codes are chosen by somebody else. A terminal +// executes what it is sent, so what reaches one must not contain the +// sequences that move the cursor, clear the screen or rename the window. +func TestControlCharactersNeverReachTheTerminal(t *testing.T) { + SetTheme(NewTheme(true)) + + // The realistic shape: a member names a key so that the confirmation an + // admin is shown gets rewritten after it is drawn. + hostile := "prod\x1b[2K\rharmless-looking\x1b]0;pwned\x07\x9b31m" + + for name, got := range map[string]string{ + "Value": Value(hostile), + "Mono": Mono(hostile), + "Faint": Faint(hostile), + "Accent": Accent(hostile), + "Label": Label(hostile), + "Title": Title(hostile), + "Tag": Tag(hostile, TagNeutral), + } { + for _, forbidden := range []string{"\x1b[2K", "\x1b]0;", "\r", "\x07", "\x9b"} { + if strings.Contains(got, forbidden) { + t.Errorf("%s passed %q through to the terminal:\n%q", name, forbidden, got) + } + } + // The legible part of the name still has to survive, or the + // sanitising has eaten the thing somebody needs to read. Tag upper + // cases what it is given, so compare case-insensitively. + if !strings.Contains(strings.ToLower(got), "harmless-looking") { + t.Errorf("%s dropped the legible part of the name: %q", name, got) + } + } +} + +// Errors are printed too, and the one carrying a control-plane string is +// reachable before anybody has signed in. +func TestErrorAndFriendsAreSanitised(t *testing.T) { + SetTheme(NewTheme(true)) + hostile := "no\x1b[2Jthing" + + for name, got := range map[string]string{ + "Error": Error(&staticErr{hostile}), + "Success": Success(hostile), + "Warn": Warn(hostile), + } { + if strings.Contains(got, "\x1b[2J") { + t.Errorf("%s passed a screen-clear through: %q", name, got) + } + } +} + +type staticErr struct{ s string } + +func (e *staticErr) Error() string { return e.s } + +// A tab is not dangerous, and deleting it would silently join two words. +func TestTabsBecomeSpacesRatherThanNothing(t *testing.T) { + SetTheme(NewTheme(true)) + if got := Value("a\tb"); !strings.Contains(got, "a b") { + t.Errorf("a tab should become a space, got %q", got) + } +} + +// A byte that is not valid UTF-8 comes back as the replacement character +// rather than being passed on. That is worth having on purpose as well as by +// accident: malformed UTF-8 reaching lipgloss.Render is the shape of +// GO-2026-5970, the x/text infinite loop. +func TestInvalidUTF8IsReplaced(t *testing.T) { + if got := sanitize("ok\x9bhere"); strings.ContainsRune(got, 0x9b) { + t.Errorf("a raw C1 byte survived: %q", got) + } +} + +func TestSanitiseLeavesOrdinaryTextAlone(t *testing.T) { + for _, s := range []string{"production-api", "cristian@acme.com", "Acme Industrial", "€1,497", "已完成"} { + if got := sanitize(s); got != s { + t.Errorf("sanitize(%q) changed it to %q", s, got) + } + } +} + +// Truncate used to drop one rune and re-measure the whole remainder, which is +// quadratic: a name the length of the client's 8 MiB response cap took the +// better part of a minute, with no timeout in front of it because the request +// had already finished. +func TestTruncateIsNotQuadratic(t *testing.T) { + SetTheme(NewTheme(true)) + long := strings.Repeat("a", 200_000) + + done := make(chan string, 1) + go func() { done <- Truncate(long, 20) }() + + select { + case got := <-done: + if got != strings.Repeat("a", 19)+"…" { + t.Errorf("Truncate gave %q", got) + } + case <-time.After(2 * time.Second): + t.Fatal("Truncate took more than two seconds on a 200k string - it is quadratic again") + } +} + +func TestBarSurvivesNonsense(t *testing.T) { + SetTheme(NewTheme(true)) + // NaN satisfies neither `< 0` nor `> 1`, so before the guard this + // underflowed the cell count and panicked inside strings.Repeat. + for _, f := range []float64{nan(), -1, 0, 0.5, 1, 2} { + if got := Bar(f, 10, false); got == "" { + t.Errorf("Bar(%v) rendered nothing", f) + } + } +} + +func TestTruncateDoesNotCutInsideAnEscapeSequence(t *testing.T) { + SetTheme(NewTheme(true)) + styled := Value("production-api") + Faint(" never used") + + for _, width := range []int{1, 2, 5, 10, 16, 20, 25} { + got := Truncate(styled, width) + + // Every escape opened must be closed: an odd count means a cut + // landed in the middle of one. + if strings.Count(got, "\x1b") != strings.Count(got, "m") { + t.Errorf("width %d left a broken escape sequence: %q", width, got) + } + // And it must still be the width that was asked for. + if w := ansi.StringWidth(got); w > width { + t.Errorf("width %d produced %d columns: %q", width, w, got) + } + // The visible text has to survive down to the ellipsis. + if visible := ansi.Strip(got); visible == "" { + t.Errorf("width %d rendered nothing visible: %q", width, got) + } + } +} diff --git a/internal/ui/sort.go b/internal/ui/sort.go new file mode 100644 index 0000000..68f25fa --- /dev/null +++ b/internal/ui/sort.go @@ -0,0 +1,144 @@ +package ui + +// Sorting, as a piece of state rather than a piece of machinery. +// +// What a table needs to be sortable is smaller than it looks: which column +// is active, and which way. The comparison itself belongs to whoever owns +// the data, because only they still have it as numbers and timestamps. By +// the time a row reaches a table it is strings, and sorting strings puts +// "900M" above "4.12B" and 2026-01-02 above 2025-12-31. +// +// So this holds the state, renders the marker, and stays out of the way. +// Both dashboards embed it, which is what stops them disagreeing about what +// pressing 2 does. + +// Sort is which column a table is ordered by, and in which direction. +// +// The zero value sorts by the first column, ascending: the name column, A to +// Z. A figures column, once picked, opens descending instead, because the +// interesting end of a list of figures is the top. +type Sort struct { + col int + // desc rather than asc, so the zero value is column zero ascending - + // the name column, A to Z - which is what a table that has never been + // sorted should show. + desc bool + cols int +} + +// NewSort returns the ordering for a table of n columns, starting on the +// column the caller considers the natural one. +func NewSort(columns, start int) Sort { + s := Sort{cols: columns} + if start > 0 && start < columns { + // Set directly rather than through By: By treats "the column you are + // already on" as a request to reverse, so initialising through it + // would hand back a table sorted the wrong way round. + s.col = start + s.desc = true + } + return s +} + +// Column is the index currently sorted on. +func (s Sort) Column() int { return s.col } + +// Ascending reports the direction. +func (s Sort) Ascending() bool { return !s.desc } + +// Columns is how many columns this ordering knows about. +func (s Sort) Columns() int { return s.cols } + +// By sorts on a column. Asking for the column already active flips the +// direction instead, which is what a second press of the same key means +// everywhere else. +func (s Sort) By(col int) Sort { + if s.cols <= 0 { + return s + } + if col < 0 || col >= s.cols { + return s + } + if col == s.col { + s.desc = !s.desc + return s + } + s.col = col + // A new column starts descending, except the first one: column zero is + // the name, and a name list reads A to Z. + s.desc = col != 0 + return s +} + +// Next moves to the next column, wrapping. Direction resets with the column. +func (s Sort) Next() Sort { + if s.cols <= 0 { + return s + } + return s.By((s.col + 1) % s.cols) +} + +// Prev moves to the previous column, wrapping. +func (s Sort) Prev() Sort { + if s.cols <= 0 { + return s + } + return s.By((s.col - 1 + s.cols) % s.cols) +} + +// Flip reverses the direction without changing the column. +func (s Sort) Flip() Sort { + s.desc = !s.desc + return s +} + +// Resize keeps the ordering valid when a table changes shape — a column +// dropped on a narrow terminal, say. An index that no longer exists falls +// back to the first column rather than sorting on nothing. +func (s Sort) Resize(columns int) Sort { + s.cols = columns + if s.col >= columns { + s.col = 0 + s.desc = false + } + return s +} + +// Marker is the arrow for a column: the direction on the active one, and +// nothing on the rest. +// +// Pointing the way the values run rather than the way a file manager's +// arrows point: descending is largest first, so the arrow points down. +func (s Sort) Marker(col int) string { + if col != s.col { + return "" + } + if s.desc { + return "▼" + } + return "▲" +} + +// Title is a column heading with its marker, for a table that renders its +// own headers. +func (s Sort) Title(col int, title string) string { + if m := s.Marker(col); m != "" { + return title + " " + m + } + return title +} + +// Less wraps an ascending comparison with the current direction, for handing +// to sort.SliceStable: +// +// sort.SliceStable(keys, s.Less(func(a, b int) bool { ... })) +// +// Callers write one ascending comparison per column and never think about +// the direction again. Stable, so rows that tie keep the order they had and +// the list does not reshuffle under the cursor on every keypress. +func (s Sort) Less(less func(a, b int) bool) func(a, b int) bool { + if !s.desc { + return less + } + return func(a, b int) bool { return less(b, a) } +} diff --git a/internal/ui/sort_test.go b/internal/ui/sort_test.go new file mode 100644 index 0000000..27af2ca --- /dev/null +++ b/internal/ui/sort_test.go @@ -0,0 +1,129 @@ +package ui + +import ( + "sort" + "testing" +) + +func TestTheSameKeyTwiceFlipsTheDirection(t *testing.T) { + s := NewSort(4, 0) + if !s.Ascending() { + t.Error("the name column should start A to Z") + } + + s = s.By(0) + if s.Ascending() { + t.Error("asking for the active column again did not flip the direction") + } + s = s.By(0) + if !s.Ascending() { + t.Error("a third press did not flip it back") + } +} + +// A figures column opens on its biggest row; a name column opens at A. Both +// are what somebody means by "sort by this", and they are opposite. +func TestANewColumnPicksTheUsefulDirection(t *testing.T) { + s := NewSort(4, 0) + + s = s.By(2) + if s.Column() != 2 { + t.Fatalf("sorting by column 2 left the sort on %d", s.Column()) + } + if s.Ascending() { + t.Error("a figures column should open largest first") + } + + if s = s.By(0); !s.Ascending() { + t.Error("the name column should open A to Z") + } +} + +func TestNextWrapsAndOutOfRangeIsIgnored(t *testing.T) { + s := NewSort(3, 0) + got := []int{} + for i := 0; i < 4; i++ { + s = s.Next() + got = append(got, s.Column()) + } + want := []int{1, 2, 0, 1} + for i := range want { + if got[i] != want[i] { + t.Fatalf("Next() walked %v, want %v", got, want) + } + } + + before := NewSort(3, 1) + if after := before.By(9); after.Column() != before.Column() || after.Ascending() != before.Ascending() { + t.Error("a column that does not exist changed the ordering") + } + if after := before.By(-1); after.Column() != before.Column() { + t.Error("a negative column changed the ordering") + } +} + +// Columns get dropped when the terminal is narrow. An ordering left pointing +// at a column that is no longer there would sort on nothing. +func TestResizeRescuesAnOrderingThatLostItsColumn(t *testing.T) { + s := NewSort(5, 4) + if s.Column() != 4 { + t.Fatalf("sort starts on %d", s.Column()) + } + + s = s.Resize(3) + if s.Column() != 0 { + t.Errorf("after losing column 4 the sort is on %d, want the first column", s.Column()) + } + if s.Columns() != 3 { + t.Errorf("Resize did not record the new width: %d", s.Columns()) + } + + // A column that survives is left alone. + keep := NewSort(5, 1).Resize(3) + if keep.Column() != 1 { + t.Errorf("a surviving column was moved to %d", keep.Column()) + } +} + +// The marker is on the active column only, and points the way the values +// run: descending is largest first, so the arrow points down. +func TestTheMarkerIsOnOneColumn(t *testing.T) { + s := NewSort(3, 1) // column 1, descending + + if got := s.Marker(1); got != "▼" { + t.Errorf("the active descending column is marked %q", got) + } + if got := s.Marker(0); got != "" { + t.Errorf("an inactive column is marked %q", got) + } + if got := s.Flip().Marker(1); got != "▲" { + t.Errorf("ascending is marked %q", got) + } + if got := s.Title(1, "Tokens"); got != "Tokens ▼" { + t.Errorf("Title = %q", got) + } + if got := s.Title(0, "Name"); got != "Name" { + t.Errorf("an inactive title should be bare, got %q", got) + } +} + +// Less carries the direction so callers write one ascending comparison per +// column. Sorting the rendered strings instead is the bug this exists to +// avoid: "900M" sorts above "4.12B". +func TestLessCarriesTheDirection(t *testing.T) { + tokens := []float64{4.12e9, 900e6, 41.2e9} + + asc := NewSort(2, 0) // column 0 ascending + got := append([]float64(nil), tokens...) + sort.SliceStable(got, asc.Less(func(a, b int) bool { return got[a] < got[b] })) + if got[0] != 900e6 || got[2] != 41.2e9 { + t.Errorf("ascending gave %v", got) + } + + desc := asc.Flip() + got = append([]float64(nil), tokens...) + sort.SliceStable(got, desc.Less(func(a, b int) bool { return got[a] < got[b] })) + if got[0] != 41.2e9 || got[2] != 900e6 { + t.Errorf("descending gave %v", got) + } +} diff --git a/internal/ui/table.go b/internal/ui/table.go new file mode 100644 index 0000000..0f115cf --- /dev/null +++ b/internal/ui/table.go @@ -0,0 +1,223 @@ +package ui + +import ( + "strings" + + "charm.land/lipgloss/v2" + ltable "charm.land/lipgloss/v2/table" +) + +// Tables, rendered by lipgloss/table. +// +// This used to be hand-rolled — measure every cell, pad to the widest, join +// with two spaces — which is a worse version of something the library already +// does. It also only knew how to TRUNCATE a cell that did not fit, where +// lipgloss distributes the width across the columns and wraps. +// +// What is kept is the shape the brandbook asks for, which is not the +// library's default: no grid. Vertical rules between every column and a box +// around the whole thing is the look this design explicitly is not. One +// hairline under the header, air between the columns, and nothing else — the +// separator between things here is space, and a line only inside a component. + +// Align says which way a column's cells sit in their width. +type Align int + +const ( + // Left is the default: names, addresses, anything read as words. + Left Align = iota + // Right is for figures, which line up on their last digit. + Right +) + +// Column is one column of a Table. +type Column struct { + Title string + Align Align + // Max caps the column's width; 0 lets it take what it needs. Use it on + // the column that would otherwise eat the terminal — a key name, an + // email — so the ones after it stay on screen. + Max int + // DropBelow marks the column as one that may be dropped when the table + // will not fit, and says how readily: the HIGHER the number, the sooner + // it goes. Zero keeps it always. + // + // Read it as "below this width I am not worth my columns", which is also + // how the numbers are chosen — but it is an ORDER, not a threshold. A + // column is only ever dropped because the table overruns the terminal, + // and they go one at a time until what is left fits, so a table that + // happens to be narrow keeps everything however tight the terminal is. + // + // Dropping beats wrapping. Given 60 columns and five columns to fit, + // lipgloss will make them all fit — by wrapping every cell over three + // lines, which is technically correct and unreadable. Losing the column + // that matters least keeps the ones that matter legible, and a narrow + // terminal is exactly when legibility is scarce. + DropBelow int +} + +// Table lays out rows under headers. +// +// Cells are pre-rendered strings, styling included, because the caller knows +// which figure is worth an accent and this does not. +type Table struct { + Columns []Column + Rows [][]string + // Indent goes before every line. + Indent string + // Width caps the whole table. Zero lets it size to its content. + Width int +} + +// Render returns the table, or an empty string when there are no rows: an +// empty table drawn with its headers looks like a failure to load, and +// callers have a better sentence for "there are none of these yet". +func (t Table) Render() string { + if len(t.Rows) == 0 { + return "" + } + + // The natural size first. Width is a CEILING, not a target — lipgloss + // treats it as an exact width and shares the slack out between the + // columns, which on three short columns in a hundred-column terminal + // leaves the header and its rule stretched across the screen with the + // words huddled at the left of each third. + rendered := t.build().String() + if t.Width <= 0 || lipgloss.Width(rendered) <= t.Width { + return Indent(TrimLines(rendered), t.Indent) + "\n" + } + + // It does not fit. Drop the columns that earn their place least, one at + // a time, and stop as soon as the rest fit — dropping every candidate at + // once throws away columns that would have fitted once the first one + // went. + t = t.dropColumns() + + tbl := t.build() + rendered = tbl.String() + if lipgloss.Width(rendered) > t.Width { + // Still over, with nothing left worth dropping: let lipgloss squeeze + // what remains. Wrapped cells are bad; cells off the right-hand edge + // of the terminal are worse. + rendered = tbl.Width(t.Width).String() + } + return Indent(TrimLines(rendered), t.Indent) + "\n" +} + +// build makes the lipgloss table for the columns as they currently stand. +// +// What is kept from the library's defaults is almost nothing: the grid — a +// box round the whole thing and a rule between every column — is the look +// this design explicitly is not. One hairline under the header, air between +// the columns, and nothing else. +func (t Table) build() *ltable.Table { + headers := make([]string, 0, len(t.Columns)) + for _, c := range t.Columns { + headers = append(headers, strings.ToUpper(c.Title)) + } + + return ltable.New(). + Headers(headers...). + Rows(t.Rows...). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(Current().Line)). + BorderTop(false).BorderBottom(false). + BorderLeft(false).BorderRight(false). + BorderColumn(false).BorderRow(false). + BorderHeader(true). + StyleFunc(t.styleFunc()) +} + +// dropColumns removes columns until the table fits, least valuable first. +// +// `DropBelow` orders them: the column willing to go at the widest terminal is +// the one worth least, so it goes first. It stops the moment the rest fit, +// which is what keeps "created by" on screen at ninety columns and only loses +// it at sixty. +func (t Table) dropColumns() Table { + for len(t.Columns) > 1 { + // The column willing to go at the widest terminal is the one worth + // least, so it goes first. + worst, widest := -1, 0 + for i, c := range t.Columns { + if c.DropBelow > widest { + worst, widest = i, c.DropBelow + } + } + if worst < 0 { + return t // nothing left that is allowed to go + } + + t = t.without(worst) + if lipgloss.Width(t.build().String()) <= t.Width { + return t + } + } + return t +} + +// without returns the table with column i, and the matching cell of every +// row, taken out. +func (t Table) without(i int) Table { + columns := make([]Column, 0, len(t.Columns)-1) + columns = append(columns, t.Columns[:i]...) + columns = append(columns, t.Columns[i+1:]...) + + rows := make([][]string, 0, len(t.Rows)) + for _, row := range t.Rows { + if i >= len(row) { + rows = append(rows, row) + continue + } + trimmed := make([]string, 0, len(row)-1) + trimmed = append(trimmed, row[:i]...) + trimmed = append(trimmed, row[i+1:]...) + rows = append(rows, trimmed) + } + + t.Columns, t.Rows = columns, rows + return t +} + +// styleFunc carries the per-column alignment and the header's colour. +// +// It is also where the width cap lives: lipgloss applies MaxWidth per cell, +// which truncates that cell rather than letting one long value push every +// other column off the screen. +func (t Table) styleFunc() ltable.StyleFunc { + theme := Current() + header := lipgloss.NewStyle().Foreground(theme.TextFaint).Padding(0, 1) + cell := lipgloss.NewStyle().Padding(0, 1) + + return func(row, col int) lipgloss.Style { + s := cell + if row == ltable.HeaderRow { + s = header + } + if col < len(t.Columns) { + if t.Columns[col].Align == Right { + s = s.Align(lipgloss.Right) + } + if limit := t.Columns[col].Max; limit > 0 { + s = s.MaxWidth(limit + 2) // +2 for the cell's own padding + } + } + return s + } +} + +// TableOrEmpty renders a table, or the given sentence when it has no rows. +// +// The empty case is a sentence rather than bare headers on purpose: headers +// with nothing under them read as a failure to load, and "no API keys yet" is +// both true and actionable. An empty `empty` means draw nothing at all, for a +// section that simply does not apply. +func TableOrEmpty(t Table, empty string) string { + if rendered := t.Render(); rendered != "" { + return rendered + } + if empty == "" { + return "" + } + return t.Indent + Faint(empty) + "\n" +} diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go new file mode 100644 index 0000000..984ed19 --- /dev/null +++ b/internal/ui/table_test.go @@ -0,0 +1,137 @@ +package ui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +// The width a table is given is a CEILING. These are about what happens on +// either side of it, because both failures are invisible in a unit test that +// only checks the words are present: a table stretched to fill a wide +// terminal still contains every cell, and so does one wrapped over three +// lines per row. + +func keysTable(width int) Table { + return Table{ + Width: width, + Columns: []Column{ + {Title: "name"}, + {Title: "key"}, + {Title: "last used"}, + {Title: "created", DropBelow: 96}, + {Title: "by", Max: 28, DropBelow: 78}, + }, + Rows: [][]string{ + {"production-api", "hke_9f3a…7c21", "just now", "2026-05-27", "cristian@acme.com"}, + {"ci-runner", "hke_2b8d…441e", "2 days ago", "2026-05-27", "cristian@acme.com"}, + }, + } +} + +func widestLine(s string) int { + w := 0 + for _, line := range strings.Split(s, "\n") { + if n := lipgloss.Width(line); n > w { + w = n + } + } + return w +} + +// A table narrower than the terminal is left at its natural size. Passing the +// width to lipgloss as-is makes it an exact width, and it shares the slack +// out between the columns — which leaves the rule stretched across the screen +// with the words huddled at the left of each column. +func TestATableDoesNotStretchToFillTheTerminal(t *testing.T) { + SetTheme(NewTheme(true)) + + narrow := Table{ + Width: 200, + Columns: []Column{{Title: "email"}, {Title: "role"}}, + Rows: [][]string{{"dev@acme.com", "MEMBER"}}, + } + + if w := widestLine(narrow.Render()); w > 40 { + t.Errorf("a two-column table given 200 columns rendered %d wide:\n%s", w, narrow.Render()) + } +} + +// Nothing is dropped while the table still fits. The old version compared +// each column's DropBelow against the terminal width on its own, so a table +// that fitted comfortably in 80 columns still lost two of them. +func TestColumnsSurviveWhileTheTableFits(t *testing.T) { + SetTheme(NewTheme(true)) + + out := keysTable(80).Render() + for _, want := range []string{"CREATED", "BY"} { + if !strings.Contains(out, want) { + t.Errorf("%s was dropped at 80 columns even though the table fits:\n%s", want, out) + } + } + if w := widestLine(out); w > 80 { + t.Errorf("the table is %d wide with a ceiling of 80", w) + } +} + +// When it does not fit, columns go one at a time, most droppable first, and +// it stops as soon as the rest fit. Dropping every candidate at once throws +// away columns that would have fitted once the first one went. +func TestColumnsGoOneAtATimeMostDroppableFirst(t *testing.T) { + SetTheme(NewTheme(true)) + + out := keysTable(70).Render() + if strings.Contains(out, "CREATED") { + t.Errorf("the most droppable column survived at 70 columns:\n%s", out) + } + if !strings.Contains(out, "BY") { + t.Errorf("a column was dropped that was not needed to make it fit:\n%s", out) + } + if w := widestLine(out); w > 70 { + t.Errorf("the table is %d wide with a ceiling of 70", w) + } + + // Tighter still, and the second one goes too. + if out := keysTable(50).Render(); strings.Contains(out, "BY") { + t.Errorf("both droppable columns should be gone at 50 columns:\n%s", out) + } +} + +// A column with no DropBelow is never dropped, however tight it gets — it is +// the caller saying the table is meaningless without it. At a width like this +// the remaining columns are squeezed and their headers truncated, which is +// the right answer: unreadable beats absent when the column is load-bearing. +func TestColumnsWithoutDropBelowAreNeverDropped(t *testing.T) { + SetTheme(NewTheme(true)) + + out := keysTable(30).Render() + // Prefixes, because at 30 columns lipgloss truncates the headers + // themselves ("LAST USED" arrives as "LAST US…"). + for _, want := range []string{"NAME", "KEY", "LAST"} { + if !strings.Contains(out, want) { + t.Errorf("%s was dropped, but it has no DropBelow:\n%s", want, out) + } + } + // And the two that ARE droppable went, rather than everything being + // squeezed together. + for _, gone := range []string{"CREATED", "cristian@acme.com"} { + if strings.Contains(out, gone) { + t.Errorf("%s survived at 30 columns:\n%s", gone, out) + } + } +} + +// No rows means no table. Headers over nothing read as a failure to load, and +// the caller has a better sentence for "there are none of these yet". +func TestAnEmptyTableRendersNothing(t *testing.T) { + SetTheme(NewTheme(true)) + + empty := Table{Columns: []Column{{Title: "email"}}} + if got := empty.Render(); got != "" { + t.Errorf("an empty table rendered %q", got) + } + if got := TableOrEmpty(empty, "no keys yet"); !strings.Contains(got, "no keys yet") { + t.Errorf("TableOrEmpty dropped its sentence: %q", got) + } +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..f22f62d --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,101 @@ +// Package ui holds everything this CLI paints: the NaN palette, the +// wordmark and the mascot, and the small set of components that the plain +// commands and the dashboard both draw with. +// +// The values come from nan.builders' own tokens: one violet accent for +// fills, a lighter violet for text, and the traffic-light colours kept for +// the state of data. Everything else is neutral, and adapts to a light or a +// dark terminal. +package ui + +import ( + "image/color" + "os" + + "charm.land/lipgloss/v2" +) + +// The NaN violet. `#7D39EB` is --color-violet on nan.builders, the single +// accent of the system, and its token file says it is for FILLS: bars, the +// wordmark, the mascot. Text in the accent uses `#9B6BF0`, --color-violet-2, +// which is the one that clears contrast against a dark background, and a +// darker violet on a light one. +const ( + accentHex = "#7D39EB" + accentTextHex = "#9B6BF0" + accentTextLightHex = "#6D28D9" + // The edge of the wordmark's letters, darker than the body so the block + // letters read with depth instead of as a flat slab. + accentDeepHex = "#3B1578" +) + +// The traffic light, for the state of data and nothing decorative. +const ( + statusRedHex = "#EF4444" + statusYellowHex = "#F59E0B" + statusGreenHex = "#10B981" +) + +// Theme is the resolved palette for one terminal. +// +// The accent holds its ground and the neutrals adapt: plenty of people run a +// terminal on white, and a CLI drawn only for black is unreadable for them. +type Theme struct { + Accent color.Color // fills, bars, the wordmark, the mascot. Never small text. + AccentDeep color.Color // the wordmark's edges + AccentText color.Color // anything accented that is read rather than seen. + Text color.Color // primary + TextDim color.Color // secondary — descriptions, subtitles + TextFaint color.Color // tertiary — the legible floor, nothing below it + Mono color.Color // technical values: ids, keys, numbers + Line color.Color // hairlines and separators + Surface color.Color // panels the terminal paints behind content + + Red color.Color + Yellow color.Color + Green color.Color +} + +// NewTheme resolves the palette for a dark or a light terminal. +// +// The neutrals are the zinc and slate greys nan.builders draws with, and the +// hairlines and surfaces are the deep violet it uses for panels, so the +// accent is never the only thing on screen that says NaN. +func NewTheme(dark bool) Theme { + pick := lipgloss.LightDark(dark) + hex := func(light, dark string) color.Color { + return pick(lipgloss.Color(light), lipgloss.Color(dark)) + } + return Theme{ + Accent: lipgloss.Color(accentHex), + AccentDeep: lipgloss.Color(accentDeepHex), + AccentText: hex(accentTextLightHex, accentTextHex), + Text: hex("#18181B", "#FFFFFF"), + TextDim: hex("#52525B", "#A1A1AA"), + TextFaint: hex("#71717A", "#71717A"), + Mono: hex("#374151", "#CBD5E1"), + Line: hex("#DDD6FE", "#2E1065"), + Surface: hex("#F5F3FF", "#1A0B33"), + Red: lipgloss.Color(statusRedHex), + Yellow: lipgloss.Color(statusYellowHex), + Green: lipgloss.Color(statusGreenHex), + } +} + +// theme is resolved once, on first use, because asking the terminal about its +// background is a round trip on some emulators and the answer cannot change +// under a running process. +var theme *Theme + +// Current is the palette for this terminal. +func Current() Theme { + if theme == nil { + t := NewTheme(lipgloss.HasDarkBackground(os.Stdin, os.Stdout)) + theme = &t + } + return *theme +} + +// SetTheme forces a palette. Tests use it to render both backgrounds without +// a terminal attached; nothing else should need it. +func SetTheme(t Theme) { theme = &t } diff --git a/internal/ui/width.go b/internal/ui/width.go new file mode 100644 index 0000000..a82cd00 --- /dev/null +++ b/internal/ui/width.go @@ -0,0 +1,84 @@ +package ui + +import ( + "os" + "strconv" + + "github.com/charmbracelet/x/term" +) + +// How wide the terminal is, asked once and in one place. +// +// Two things need this and they used to disagree: the printer, which sizes +// tables, and the forms, which have to be given a width or huh leaves their +// text input at zero — and a text input with no width draws only the first +// character of its placeholder, so "you@example.com" arrives as a lone "y" +// under the cursor and the prompt stops saying what it wants. + +const ( + // pipeWidth is what something that is not a terminal gets. Wide enough + // for the tables here, narrow enough to survive being pasted into an + // issue. + pipeWidth = 100 + // minWidth is the floor. Below this nothing useful fits, and a table + // squeezed into 20 columns is worse than one that overflows a little. + minWidth = 40 + // formWidth is as wide as a form is ever drawn. Below it a form takes + // the terminal, margins aside; above it, the lines of a description + // would be longer than anybody reads comfortably in one sweep of the + // eye, and a prompt spanning an ultrawide screen reads as the program + // having taken it over. + formWidth = 100 + // pipeHeight is the height assumed when there is no terminal to ask. + pipeHeight = 40 +) + +// TerminalWidth is the width output should be drawn to. +// +// COLUMNS first: it is the standard way to say how wide the output should +// be, it is what `COLUMNS=60 helmcode keys` means, and it is the only handle +// anybody has when the destination is not a terminal at all. +func TerminalWidth() int { + if v := os.Getenv("COLUMNS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return clampWidth(n) + } + } + if term.IsTerminal(os.Stdout.Fd()) { + if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 { + return clampWidth(w) + } + } + return pipeWidth +} + +// TerminalSize is the terminal's width, as TerminalWidth has it, and its +// height. +func TerminalSize() (width, height int) { + width, height = TerminalWidth(), pipeHeight + if term.IsTerminal(os.Stdout.Fd()) { + if _, h, err := term.GetSize(os.Stdout.Fd()); err == nil && h > 0 { + height = h + } + } + return width, height +} + +// FormWidth is how wide to draw a form in this terminal. +func FormWidth() int { return FormWidthFor(TerminalWidth()) } + +// FormWidthFor is how wide to draw a form in a terminal w columns wide: the +// terminal with a margin down each side, up to formWidth. +func FormWidthFor(w int) int { + if w -= 4; w < formWidth { + return max(w, minWidth-4) + } + return formWidth +} + +func clampWidth(w int) int { + if w < minWidth { + return minWidth + } + return w +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..26d0e00 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,233 @@ +// Package update knows whether this copy of the CLI is out of date, how to +// update it, and what has changed since the last time it looked: any model +// the member's key can call that it could not before. +// +// All of it is remembered in one small file in the user's cache - not the +// session, because none of it is a credential and all of it can be thrown +// away. Losing it costs one repeated "new model" and one extra version +// check; nothing else depends on it. +package update + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/nxssie/nan-cli/internal/build" +) + +// CheckEvery is how often the latest version is asked for: often enough to +// hear about a release within a day, rarely enough that almost no run pays +// for a request it did not need. +const CheckEvery = 24 * time.Hour + +// State is what is remembered between runs. +type State struct { + // KnownModels is every model the organisation could use last time, to + // tell which ones are new. Empty until the first look. + KnownModels []string `json:"knownModels,omitempty"` + // NewModels is what the last comparison found, kept until the list is + // opened, so a model announced once is not lost on the next run. + NewModels []string `json:"newModels,omitempty"` + // Latest is the newest release seen, and when it was asked for. + Latest string `json:"latest,omitempty"` + CheckedAt string `json:"checkedAt,omitempty"` +} + +// Path is the state file. +func Path() string { + // XDG_CACHE_HOME when set, on every platform, as the session does with + // XDG_CONFIG_HOME; otherwise the OS's cache directory. + if xdg := os.Getenv("XDG_CACHE_HOME"); filepath.IsAbs(xdg) { + return filepath.Join(xdg, build.Name, "state.json") + } + dir, err := os.UserCacheDir() + if err != nil { + dir = os.TempDir() + } + return filepath.Join(dir, build.Name, "state.json") +} + +// Load reads the state; a missing or unreadable file is an empty one. +func Load() State { + var s State + //nolint:gosec // G304: this package's own file + data, err := os.ReadFile(Path()) + if err == nil { + _ = json.Unmarshal(data, &s) + } + return s +} + +// Save writes the state, through a temp file and a rename so a run that is +// killed halfway never leaves half a file for the next to trip on. +func (s State) Save() error { + path := Path() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.Marshal(s) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".state-*.tmp") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +// ── versions ───────────────────────────────────────────────────────────── + +// DueForCheck is whether the latest version should be asked for again. +func (s State) DueForCheck(now time.Time) bool { + t, err := time.Parse(time.RFC3339, s.CheckedAt) + return err != nil || now.Sub(t) >= CheckEvery +} + +// Available is the newer release this copy should update to, or "". +// +// Nothing for a development build: one built from a working copy is ahead +// of or beside the last release, and telling somebody hacking on the CLI to +// install the release they are replacing is noise. +func (s State) Available() string { + if s.Latest == "" || strings.Contains(build.Version, "-dev") { + return "" + } + if Newer(s.Latest, build.Version) { + return s.Latest + } + return "" +} + +// Newer reports whether version a is later than b. Semantic versions, +// with a leading v ignored, and a pre-release (0.2.0-rc.1) older than its +// release (0.2.0). Anything that does not parse is never newer, so a +// malformed answer from the server cannot nag. +func Newer(a, b string) bool { + av, apre, ok1 := parse(a) + bv, bpre, ok2 := parse(b) + if !ok1 || !ok2 { + return false + } + for i := range av { + if av[i] != bv[i] { + return av[i] > bv[i] + } + } + // Same numbers: a release is newer than a pre-release of it. + return apre == "" && bpre != "" +} + +func parse(v string) (nums [3]int, pre string, ok bool) { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + core, pre, _ := strings.Cut(v, "-") + core, _, _ = strings.Cut(core, "+") + parts := strings.Split(core, ".") + if len(parts) != 3 { + return nums, "", false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return nums, "", false + } + nums[i] = n + } + return nums, pre, true +} + +// Command is what to run to update, worked out from how this copy was +// installed. The server cannot know that; only the binary's own path can +// tell. +func Command() string { + exe, _ := os.Executable() + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return commandFor(runtime.GOOS, exe, os.Getenv("GOPATH")) +} + +func commandFor(goos, exe, gopath string) string { + dir := filepath.Dir(exe) + switch { + // go install first: a copy built by it is replaced by it, on Windows too. + case isGoBin(dir, gopath): + return "go install github.com/nxssie/nan-cli/cmd/nan@latest" + case goos == "windows": + return "irm https://nan.builders/install.ps1 | iex" + case dir == "/usr/local/bin" || dir == ".": + return "curl -fsSL https://nan.builders/install | bash" + } + // The install script to the same place it went last time, or the new + // copy lands in /usr/local/bin and the old one keeps winning on PATH. + return "INSTALL_DIR=" + dir + " curl -fsSL https://nan.builders/install | bash" +} + +func isGoBin(dir, gopath string) bool { + if gopath != "" && dir == filepath.Join(gopath, "bin") { + return true + } + home, _ := os.UserHomeDir() + return home != "" && dir == filepath.Join(home, "go", "bin") +} + +// ── models ─────────────────────────────────────────────────────────────── + +// SeeModels compares the models available now with the ones known, records +// any that are new, and reports them. The first look records without +// reporting: everything is "new" to a machine that has never looked, and +// announcing the whole catalogue would bury the one that matters later. +func (s *State) SeeModels(now []string) []string { + if len(now) == 0 { + return s.NewModels + } + if len(s.KnownModels) == 0 { + s.KnownModels = sortedCopy(now) + return nil + } + known := map[string]bool{} + for _, m := range s.KnownModels { + known[m] = true + } + for _, m := range now { + if !known[m] && !contains(s.NewModels, m) { + s.NewModels = append(s.NewModels, m) + } + } + s.KnownModels = sortedCopy(now) + return s.NewModels +} + +// MarkSeen is the new models having been looked at: they stop being new. +func (s *State) MarkSeen() { + s.NewModels = nil +} + +func sortedCopy(in []string) []string { + out := append([]string(nil), in...) + sort.Strings(out) + return out +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..fd87bda --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,127 @@ +package update + +import ( + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/build" +) + +func TestNewerComparesSemanticVersions(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"0.2.0", "0.1.0", true}, + {"0.10.0", "0.9.9", true}, // numbers, not strings + {"v1.0.0", "0.9.0", true}, + {"0.2.0", "0.2.0-rc.1", true}, // a release beats its pre-release + {"0.2.0-rc.1", "0.2.0", false}, + {"0.2.0", "0.2.0", false}, + {"0.1.0", "0.2.0", false}, + {"0.2.0", "0.1.0-demo.abc123", true}, + {"garbage", "0.1.0", false}, // a malformed answer never nags + {"0.2", "0.1.0", false}, + } + for _, c := range cases { + if got := Newer(c.a, c.b); got != c.want { + t.Errorf("Newer(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +// A development build is never told to update: it is not behind a release, +// it is beside one. +func TestADevelopmentBuildIsNotNagged(t *testing.T) { + was := build.Version + t.Cleanup(func() { build.Version = was }) + + s := State{Latest: "9.9.9"} + build.Version = "0.1.0-dev.abc" + if s.Available() != "" { + t.Error("a dev build was offered an update") + } + build.Version = "0.1.0" + if s.Available() != "9.9.9" { + t.Error("a release that is behind was not offered one") + } +} + +func TestTheCheckIsDailyAtMost(t *testing.T) { + now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + if !(State{}).DueForCheck(now) { + t.Error("never checked, and not due") + } + recent := State{CheckedAt: now.Add(-2 * time.Hour).Format(time.RFC3339)} + if recent.DueForCheck(now) { + t.Error("checked two hours ago, and due again") + } + old := State{CheckedAt: now.Add(-25 * time.Hour).Format(time.RFC3339)} + if !old.DueForCheck(now) { + t.Error("checked yesterday, and not due") + } +} + +// The update command is the one that replaces THIS copy: the same install +// method, and the same place. +func TestTheCommandMatchesHowItWasInstalled(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cases := map[string]struct { + goos, exe, gopath, want string + }{ + "windows": {"windows", `C:\tools\helmcode.exe`, "", "irm https://nan.builders/install.ps1 | iex"}, + "go install": {"linux", filepath.Join(home, "go", "bin", "helmcode"), "", "go install github.com/nxssie/nan-cli/cmd/nan@latest"}, + "GOPATH": {"linux", "/opt/gopath/bin/helmcode", "/opt/gopath", "go install github.com/nxssie/nan-cli/cmd/nan@latest"}, + "script": {"darwin", "/usr/local/bin/helmcode", "", "curl -fsSL https://nan.builders/install | bash"}, + "script, dir": {"linux", filepath.Join(home, ".local", "bin", "helmcode"), "", "INSTALL_DIR=" + filepath.Join(home, ".local", "bin") + " curl -fsSL https://nan.builders/install | bash"}, + } + for name, c := range cases { + // Paths are the host's: Unix layouts cannot be reproduced on a + // Windows host, nor the other way round. + if (c.goos == "windows") != (runtime.GOOS == "windows") && name != "windows" { + continue + } + if got := commandFor(c.goos, c.exe, c.gopath); got != c.want { + t.Errorf("%s: %q, want %q", name, got, c.want) + } + } +} + +// The first look records the catalogue without announcing it; after that, +// a model that appears is new until the list is opened. +func TestNewModelsAreFoundAfterTheFirstLook(t *testing.T) { + var s State + if got := s.SeeModels([]string{"glm5.3", "deepseek-v4-flash"}); len(got) != 0 { + t.Fatalf("the first look announced %v", got) + } + if got := s.SeeModels([]string{"glm5.3", "deepseek-v4-flash"}); len(got) != 0 { + t.Fatalf("nothing changed, and %v is new", got) + } + got := s.SeeModels([]string{"glm5.3", "deepseek-v4-flash", "glm5.4-flash"}) + if len(got) != 1 || got[0] != "glm5.4-flash" { + t.Fatalf("the new model is %v", got) + } + // Still new on the next run, until it has been seen. + if got := s.SeeModels([]string{"glm5.3", "deepseek-v4-flash", "glm5.4-flash"}); len(got) != 1 { + t.Errorf("a new model was forgotten before it was seen: %v", got) + } + s.MarkSeen() + if got := s.SeeModels([]string{"glm5.3", "deepseek-v4-flash", "glm5.4-flash"}); len(got) != 0 { + t.Errorf("a seen model is still new: %v", got) + } +} + +func TestStateSurvivesARun(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + s := State{Latest: "0.2.0", KnownModels: []string{"a"}} + if err := s.Save(); err != nil { + t.Fatal(err) + } + if got := Load(); got.Latest != "0.2.0" || len(got.KnownModels) != 1 { + t.Errorf("loaded %+v", got) + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..61d3ecd --- /dev/null +++ b/justfile @@ -0,0 +1,56 @@ +# Every command this repo has, in one place, for people and for agents. +# `just` on its own lists them. + +default: + @just --list + +# Install the toolchain and the git hooks. Run once after cloning. +setup: + mise install + lefthook install + +# Build the binary into ./nan. +build: + CGO_ENABLED=0 go build -o nan ./cmd/nan + +# Run the dashboard from source. +dev: + go run ./cmd/nan + +# Run against a local stand-in for nan.builders instead of production: +# NAN_API_URL, NAN_APP_URL and NAN_INFERENCE_URL point every request there. +# just local +# just local runs ls +local api="http://127.0.0.1:8080" *args="": + NAN_API_URL={{api}} NAN_APP_URL={{api}} NAN_INFERENCE_URL={{api}}/v1 go run ./cmd/nan {{args}} + +# Regenerate THIRD-PARTY-NOTICES.txt from what is actually linked into the +# binary. Run it after adding, removing or bumping a dependency: one of them +# is Apache-2.0, which requires the notice to travel with a redistribution. +notices: + go run scripts/gen-notices.go + +test: + go test -race ./... + +# What CI runs. `fmt --diff` exits non-zero on a difference; `go fmt` would +# rewrite the files and exit 0 either way, which is a check that cannot fail. +lint: + golangci-lint run ./... + golangci-lint fmt --diff + +fmt: + golangci-lint fmt + +vuln: + go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... + +# Build every target the release publishes, to catch a break that only shows +# on a platform this machine is not. +cross: + #!/usr/bin/env bash + set -euo pipefail + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + echo "building ${target}" + CGO_ENABLED=0 GOOS="${target%/*}" GOARCH="${target#*/}" go build -o /dev/null ./cmd/nan + done diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..bfb98fa --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,27 @@ +# Bad commits die here rather than in CI. The same recipes run in both: +# this one is local and bypassable with --no-verify, and CI is the backstop. + +pre-commit: + parallel: true + commands: + gitleaks: + run: gitleaks protect --staged --redact + fmt: + glob: "*.go" + run: golangci-lint fmt --diff + lint: + glob: "*.go" + run: golangci-lint run ./... + +commit-msg: + commands: + conventional-and-english: + run: | + subject=$(head -1 "{1}") + case "$subject" in Merge*|Revert*|fixup!*|squash!*) exit 0 ;; esac + echo "$subject" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/ -]+\))?!?: .+' \ + || { echo "✖ Commit must be Conventional Commits: type(scope): subject"; exit 1; } + body=$(grep -vE '^(#|Co-authored-by:|Co-Authored-By:|Signed-off-by:)' "{1}") + if echo "$body" | grep -qiE '[áéíóúñ¿¡]|\b(añade|arregla|corrige|elimina|actualiza|cambia|mejora|quita|sube|prueba|versión|imagen|fichero|mensaje|además|también|porque|desde|según|aquí|para|con|los|las|del|una|que)\b'; then + echo "✖ Commit looks Spanish — commit messages must be in English."; exit 1 + fi diff --git a/main.go b/main.go deleted file mode 100644 index e69067c..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "github.com/nxssie/nan-cli/cmd" - -func main() { - cmd.Execute() -} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..d2da76f --- /dev/null +++ b/mise.toml @@ -0,0 +1,7 @@ +# Pinned so a build is the same on any machine and in CI. +[tools] +go = "1.27.2" +golangci-lint = "2.14.0" +lefthook = "latest" +gitleaks = "latest" +just = "latest" diff --git a/scripts/gen-notices.go b/scripts/gen-notices.go new file mode 100644 index 0000000..d3e3b80 --- /dev/null +++ b/scripts/gen-notices.go @@ -0,0 +1,207 @@ +//go:build ignore + +// Collects the licences of everything linked into the binary. +// +// go run scripts/gen-notices.go (or: just notices) +// +// Apache-2.0 section 4 requires that a redistribution carry the licence, the +// copyright notices, and any NOTICE file from the original. One of this +// binary's dependencies is Apache-2.0, so the obligation is real and it +// travels with the binary rather than living only in the repo. The BSD and +// MIT modules ask for their notice to be reproduced too; the file covers all +// of them rather than only the strictest. +// +// The module set is what is actually LINKED into ./cmd/nan, which is +// narrower than `go list -m all` — that reports the whole module graph, +// test-only and build-time dependencies included, and those never reach a +// user. Shipping notices for code that is not in the binary is not more +// careful, it is less accurate. +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// The binary whose linked dependencies are being collected. +const target = "./cmd/nan" + +// out is written at the repo root, beside LICENSE, so it is obvious that the +// two travel together. +const out = "THIRD-PARTY-NOTICES.txt" + +// licenceNames is what module authors actually call the file, most common +// first. The first hit in a module's root wins. +var licenceNames = []string{ + "LICENSE", "LICENCE", "LICENSE.md", "LICENSE.txt", + "COPYING", "COPYING.md", "LICENSE-MIT", +} + +type module struct{ path, version, dir string } + +func main() { + mods := linkedModules() + if len(mods) == 0 { + fail(fmt.Errorf("no modules found - has the build cache been populated? try `go build ./...` first")) + } + + var body strings.Builder + var toc strings.Builder + counts := map[string]int{} + + for _, m := range mods { + text, err := licenceOf(m) + if err != nil { + // A module with no licence file is not something to paper over: + // it is either a packaging mistake upstream or a dependency + // nobody has looked at. + fail(fmt.Errorf("no licence file in %s %s (%s)", m.path, m.version, m.dir)) + } + counts[classify(text)]++ + fmt.Fprintf(&toc, " %-44s %s\n", m.path, m.version) + rule := strings.Repeat("=", 78) + fmt.Fprintf(&body, "%s\n%s\n%s\n%s\n\n%s\n\n", rule, m.path, m.version, rule, text) + } + + var f strings.Builder + fmt.Fprintf(&f, `Third-party notices for nan +%s + +The distributed binary statically links the Go modules listed below, and +their licences are reproduced here in full. + +%s + +This is the set actually linked into %s, which is narrower than +`+"`go list -m all`"+`: that reports the whole module graph, test-only and +build-time dependencies included, and those never reach a user. + +Generated by scripts/gen-notices.go; run `+"`just notices`"+` after changing +dependencies. + +Modules: %d +%s + +%s +`, strings.Repeat("=", len("Third-party notices for nan")), apacheClause(counts), target, len(mods), summary(counts), toc.String()) + + if err := os.WriteFile(out, []byte(f.String()+body.String()), 0o644); err != nil { + fail(err) + } + fmt.Printf("wrote %s - %d modules (%s)\n", out, len(mods), summary(counts)) +} + +// linkedModules is every module contributing a package to the binary, minus +// this one and the standard library. +func linkedModules() []module { + pkgs := run("go", "list", "-deps", target) + + seen := map[string]module{} + for _, pkg := range strings.Fields(pkgs) { + if strings.HasPrefix(pkg, "github.com/nxssie/nan-cli") { + continue + } + line := strings.TrimSpace(run("go", "list", "-f", + "{{if .Module}}{{.Module.Path}}\t{{.Module.Version}}\t{{.Module.Dir}}{{end}}", pkg)) + if line == "" { + continue // standard library: no module + } + parts := strings.Split(line, "\t") + if len(parts) != 3 || parts[2] == "" { + continue + } + seen[parts[0]] = module{parts[0], parts[1], parts[2]} + } + + mods := make([]module, 0, len(seen)) + for _, m := range seen { + mods = append(mods, m) + } + sort.Slice(mods, func(i, j int) bool { + return strings.ToLower(mods[i].path) < strings.ToLower(mods[j].path) + }) + return mods +} + +func licenceOf(m module) (string, error) { + for _, name := range licenceNames { + if b, err := os.ReadFile(filepath.Join(m.dir, name)); err == nil { + return strings.TrimRight(string(b), "\n"), nil + } + } + return "", os.ErrNotExist +} + +// classify is for the summary line only — enough to say what is in the file, +// not an SPDX detector. +func classify(text string) string { + t := strings.ToLower(text) + switch { + case strings.Contains(t, "apache license") && strings.Contains(t, "2.0"): + return "Apache-2.0" + case strings.Contains(t, "permission is hereby granted, free of charge"): + return "MIT" + case strings.Contains(t, "redistributions of source code must retain"): + if strings.Contains(t, "neither the name") { + return "BSD-3-Clause" + } + return "BSD-2-Clause" + case strings.Contains(t, "permission to use, copy, modify, and/or distribute"): + return "ISC" + } + return "other" +} + +func summary(counts map[string]int) string { + keys := make([]string, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%d %s", counts[k], k)) + } + return strings.Join(parts, ", ") +} + +// apacheClause names the licence that makes this file an obligation rather +// than a courtesy, and says so only when one is actually linked. +func apacheClause(counts map[string]int) string { + switch n := counts["Apache-2.0"]; n { + case 0: + return "None of them is Apache-2.0 today, so this file is a courtesy\n" + + "rather than an obligation. The MIT and BSD notices still belong\n" + + "with the binary." + case 1: + return "One of them is Apache-2.0, whose section 4 requires this of any\n" + + "redistribution. The rest are reproduced alongside it rather than\n" + + "sorting the obligation per module." + default: + return fmt.Sprintf("%d of them are Apache-2.0, whose section 4 requires this of\n"+ + "any redistribution. The rest are reproduced alongside them rather\n"+ + "than sorting the obligation per module.", n) + } +} + +func run(name string, args ...string) string { + cmd := exec.Command(name, args...) + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + fail(fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, errBuf.String())) + } + return out.String() +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) +} From 7e4f36629e81d0863b024a9ce80c8cc169c17f6b Mon Sep 17 00:00:00 2001 From: crstian19 Date: Tue, 13 Oct 2026 15:20:49 +0200 Subject: [PATCH 2/3] build: add scripts/fresh.sh to try a build as a first-time member Runs the working tree against production with a throwaway HOME, so Setup never writes into the real tool configs. --- justfile | 8 +++++ scripts/fresh.sh | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100755 scripts/fresh.sh diff --git a/justfile b/justfile index 61d3ecd..d10deda 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,14 @@ dev: local api="http://127.0.0.1:8080" *args="": NAN_API_URL={{api}} NAN_APP_URL={{api}} NAN_INFERENCE_URL={{api}}/v1 go run ./cmd/nan {{args}} +# Run against production as a first-time member, with every file it writes +# kept in a throwaway HOME. See scripts/fresh.sh. +# just fresh +# just fresh --keep +# just fresh --show +fresh *args="": + scripts/fresh.sh {{args}} + # Regenerate THIRD-PARTY-NOTICES.txt from what is actually linked into the # binary. Run it after adding, removing or bumping a dependency: one of them # is Apache-2.0, which requires the notice to travel with a redistribution. diff --git a/scripts/fresh.sh b/scripts/fresh.sh new file mode 100755 index 0000000..f045b28 --- /dev/null +++ b/scripts/fresh.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Run the CLI against PRODUCTION as somebody installing it for the first +# time: no session, no tool configs. +# +# scripts/fresh.sh first run from scratch +# scripts/fresh.sh --keep reuse the last run's state (no re-login) +# scripts/fresh.sh --show print what Setup wrote, then exit +# scripts/fresh.sh pass a subcommand instead of the dashboard +# +# Why a throwaway HOME rather than just deleting ~/.config/nan: Setup writes +# into ~/.factory/settings.json, ~/.config/opencode/opencode.json, +# ~/.pi/agent/models.json, ~/.codex/config.toml and ~/.hermes - real files, +# holding real credentials for other services. Here it writes into a +# directory you can delete. +# +# Tool DETECTION still sees the real machine: it checks the PATH as well as +# the config directory, so installed tools are found and Setup offers them +# exactly as it would to a new member. Only the writing is redirected. +# +# This talks to the real nan.builders. Sign-in spends a real email, and runs, +# automations, approvals and secrets act on the real account. Only the tool +# configs are sandboxed. +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +home="${FRESH_HOME:-${TMPDIR:-/tmp}/nan-fresh}" + +keep=0 +show=0 +case "${1:-}" in + --keep) keep=1; shift ;; + --show) show=1; shift ;; +esac + +if [ "$show" = 1 ]; then + echo + echo " what Setup wrote under ${home}:" + echo + found=0 + for f in "$home/.factory/settings.json" \ + "$home/.config/opencode/opencode.json" \ + "$home/.pi/agent/models.json" \ + "$home/.codex/config.toml" \ + "$home"/.codex/nan-*.config.toml \ + "$home/.hermes/config.yaml" \ + "$home/.hermes/.env" \ + "$home/.config/nan/session.json"; do + [ -f "$f" ] || continue + found=1 + echo " ── ${f#"$home"/}" + # Every one of these holds a key; show the shape, never the key. + sed -E \ + -e 's/(("(token|apiKey|api_key|platformToken)"|apiKey)[[:space:]]*:[[:space:]]*"?)[^",]*/\1…/g' \ + -e 's/^([A-Z_]*KEY[[:space:]]*=[[:space:]]*).*/\1…/' \ + -e 's/((api_key|experimental_bearer_token)[[:space:]]*=[[:space:]]*"?)[^"]*/\1…/' \ + "$f" + echo + done + [ "$found" = 1 ] || echo " (nothing yet - run it without --show first)" + exit 0 +fi + +if [ "$keep" = 0 ]; then + rm -rf "$home" +fi +mkdir -p "$home/.local/bin" + +# Built from the working tree, so this exercises whatever was just changed, +# and installed into the throwaway HOME the way a member would have it. +echo " building from $(git -C "$repo" rev-parse --short HEAD 2>/dev/null || echo 'working tree')…" +CGO_ENABLED=0 go build -C "$repo" -o "$home/.local/bin/nan" ./cmd/nan + +echo +if [ "$keep" = 1 ]; then + echo " reusing ${home} (pass no --keep to start over)" +else + echo " fresh HOME: ${home}" +fi +echo " platform: PRODUCTION - sign-in spends a real email, writes are real" +echo " tool configs are sandboxed here; your own ~/.factory, ~/.codex and the rest are untouched" +echo + +# Production means these unset, and a shell that exported one of them for a +# local stand-in would otherwise carry it in here. +unset NAN_API_URL NAN_APP_URL NAN_INFERENCE_URL NAN_TOKEN +# And the ones that point a tool's files somewhere other than HOME: left set, +# they would aim Setup at the real files this sandbox exists to spare. +unset XDG_CONFIG_HOME XDG_DATA_HOME CODEX_HOME HERMES_HOME +HOME="$home" PATH="$home/.local/bin:$PATH" exec nan "$@" From 4b5ad72cc534dbc64a8255425bf94c4f0ee482c0 Mon Sep 17 00:00:00 2001 From: crstian19 Date: Tue, 13 Oct 2026 16:00:47 +0200 Subject: [PATCH 3/3] feat(pulse): areas as a row of tabs across the top nan has always shown its areas as tabs in a row. The side column is gone, the content takes the full width, and a narrow terminal drops the counts, then the numbers, so the row never wraps. --- internal/pulse/areas.go | 5 +-- internal/pulse/pulse.go | 1 - internal/pulse/view.go | 79 ++++++++++++++++++++++------------------- 3 files changed, 46 insertions(+), 39 deletions(-) diff --git a/internal/pulse/areas.go b/internal/pulse/areas.go index eeae291..14c4e9a 100644 --- a/internal/pulse/areas.go +++ b/internal/pulse/areas.go @@ -12,7 +12,8 @@ import ( // Every management TUI people keep open - lazygit, lazydocker, k9s - answers // "what is there, and where" the same way: the list of places is the // navigation, it is always visible, and each place has a number. So here: -// seven areas down the left, 1 to 7, and whatever is selected on the right. +// seven areas in a row of tabs across the top, 1 to 7, as nan has always +// had them, and whatever is selected underneath. type area int @@ -192,7 +193,7 @@ func areaOf(t target) area { return areaHome } -// count is the number shown beside an area in the navigation. +// count is the number shown beside an area in its tab. func (m model) count(a area) string { s := m.snap switch a { diff --git a/internal/pulse/pulse.go b/internal/pulse/pulse.go index 6f8334c..4457ff5 100644 --- a/internal/pulse/pulse.go +++ b/internal/pulse/pulse.go @@ -117,7 +117,6 @@ type ( const ( animFPS = 60 flashFor = 6 * time.Second - navWidth = 20 cardWidth = 88 ) diff --git a/internal/pulse/view.go b/internal/pulse/view.go index 000eabe..bd736ec 100644 --- a/internal/pulse/view.go +++ b/internal/pulse/view.go @@ -33,9 +33,9 @@ func (m model) View() tea.View { func pad() string { return strings.Repeat(" ", margin) } -// contentWidth is the right-hand side: the terminal minus the margins, the -// navigation and the rule after it. -func (m model) contentWidth() int { return max(m.w-2*margin-navWidth-3, 20) } +// contentWidth is the terminal minus the margins: the areas are a row of +// tabs over the content, not a column beside it. +func (m model) contentWidth() int { return max(m.w-2*margin, 20) } func (m model) render() string { if m.quitting || m.w == 0 { @@ -61,11 +61,11 @@ func (m model) render() string { case !m.loaded: body = "\n" + pad() + m.loader.View() default: - t := ui.Current() - rule := lipgloss.NewStyle().Foreground(t.Line).Render(strings.TrimRight(strings.Repeat("│\n", room), "\n")) - body = lipgloss.JoinHorizontal(lipgloss.Top, - pad(), fit(m.renderNav(room), room, navWidth), " ", rule, " ", - fit(m.renderContent(room), room, m.contentWidth())) + // The tabs, then the area under them; every area opens on a blank + // line, which is the gap between the two. + room := room - 1 + content := fit(m.renderContent(room), room, m.contentWidth()) + body = m.renderTabs() + "\n" + indent(content) } return header + "\n" + fit(body, room, m.w) + m.renderFlash() + footer } @@ -121,39 +121,46 @@ func (m model) renderHeader() string { return line + "\n" + pad() + lipgloss.NewStyle().Foreground(t.Line).Render(strings.Repeat("─", m.w-2*margin)) } -// navTips are pinned to the foot of the navigation: the three ways around, -// said where the eye already is. tab is the one nobody guesses. -var navTips = [][2]string{{"tab", "next area"}, {"1-7", "jump"}, {"/", "find"}} - -func (m model) renderNav(room int) string { - list := m.renderAreas() - if rows := room - len(navTips) - 1; rows > int(areaCount)+1 { - var tips strings.Builder - for _, tip := range navTips { - tips.WriteString(" " + ui.Value(ui.Pad(tip[0], 4)) + ui.Faint(tip[1]) + "\n") +// renderTabs is the areas in a row, the way nan has always shown them: the +// one you are on filled in, each with the number that jumps to it and a count +// where it has one. A terminal too narrow for all of that drops the counts +// first, then the numbers, so the row never wraps. +func (m model) renderTabs() string { + t := ui.Current() + room := m.w - 2*margin + for _, detail := range []struct{ num, count bool }{{true, true}, {true, false}, {false, false}} { + parts := make([]string, 0, areaCount) + for a := area(0); a < areaCount; a++ { + label := a.title() + if detail.num { + label = fmt.Sprintf("%d %s", a+1, label) + } + if c := m.count(a); detail.count && c != "" { + label += " " + c + } + style := lipgloss.NewStyle().Padding(0, 1) + if a == m.area { + style = style.Foreground(lipgloss.Color("#FFFFFF")).Background(t.Accent).Bold(true) + } else { + style = style.Foreground(t.TextDim) + } + parts = append(parts, style.Render(label)) + } + row := strings.Join(parts, " ") + if lipgloss.Width(row) <= room || !detail.num { + return pad() + ansi.Truncate(row, room, "…") } - list = fit(list, rows, navWidth) + "\n\n" + tips.String() } - return list + return "" } -func (m model) renderAreas() string { - t := ui.Current() - var b strings.Builder - b.WriteString("\n") - for a := area(0); a < areaCount; a++ { - num := fmt.Sprintf("%d ", a+1) - count := m.count(a) - width := navWidth - 2 - len(num) - len([]rune(count)) - if a == m.area { - b.WriteString(lipgloss.NewStyle().Foreground(t.Accent).Render("▌") + " " + - lipgloss.NewStyle().Foreground(t.AccentText).Bold(true).Render(num+ui.Pad(a.title(), width)) + - ui.Faint(count) + "\n") - } else { - b.WriteString(" " + ui.Faint(num) + lipgloss.NewStyle().Foreground(t.TextDim).Render(ui.Pad(a.title(), width)) + ui.Faint(count) + "\n") - } +// indent puts the margin in front of every line of a block. +func indent(block string) string { + lines := strings.Split(block, "\n") + for i := range lines { + lines[i] = pad() + lines[i] } - return b.String() + return strings.Join(lines, "\n") } // renderFlash is the result of the last action, or a blank line.