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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Thanks for your interest in contributing. This document covers how to get the pr

## Prerequisites

- **Go 1.26.8+** — the version in `go.mod`, which is where the standard
- **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).

Expand Down Expand Up @@ -35,9 +35,11 @@ INSTALL_DIR=~/.local/bin curl -fsSL https://nan.builders/install | bash

```
main.go Entry point — delegates to cmd.Execute()
cmd/ Cobra subcommands (auth, me, metrics)
cmd/ Cobra subcommands (auth, me, metrics, run, runs)
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
```
Expand Down
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,80 @@ 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.

## Agent runs

`nan run` starts an agent (Pi or Hermes) in one of your
[workspaces](https://cloud.nan.builders) and streams what it does. The run
happens in the workspace, not on your machine: it keeps going if the command
stops, and its log stays on nan.builders.

```bash
nan run "review PR 42 and write the findings to artifacts/review.md"
nan run --ws develop --cwd /home/nan/projects/api -f task.md
git diff | nan run -
id=$(nan run --detach "upgrade the dependencies")
```

| Flag | Default | |
|---|---|---|
| `--ws NAME` | your only workspace | Workspace to run in. Required when you have more than one. |
| `--agent pi\|hermes` | `pi` | Agent to run. |
| `--model M` | the agent's own | Model for the agent. |
| `--cwd PATH` | `/home/nan` | Directory in the workspace. |
| `--worktree` / `--no-worktree` | auto | Run in a separate git worktree (auto: when `--cwd` is in a git repo). |
| `--timeout D` | `30m` | Stop the run after this long, `1m` to `2h`. |
| `--detach` | | Queue the run, print its id and return. |
| `--json` | | Events as JSON lines, then the finished run object. With `--detach`, the run object. |
| `--idempotency-key K` | | Retrying with the same key and request returns the run already created. |
| `-f FILE` | | Read the prompt from a file (`-` for stdin). |

While it streams, **Ctrl-C** detaches and leaves the run going, and prints how
to pick it up again. A second Ctrl-C within 2 seconds cancels the run.

`--detach` exits 75 on purpose (the run is still going), so under `set -e`
allow for it:

```bash
id=$(nan run --detach "upgrade the dependencies") || [ $? -eq 75 ]
```

A script that may retry should pass `--idempotency-key`: a retry after a lost
connection then gets the run it already started instead of a second one.

Everything the agent prints is shown with escape sequences and control
characters removed: the output comes from your workspace, and it is not
allowed to drive your terminal.

```bash
nan runs ls [--ws NAME] [--state S] [--limit 20] [--json] # newest first
nan runs show <id> [--json] # state, result, prompt
nan runs logs <id> [-f] [--after N] [--json] # -f follows until it ends
nan runs cancel <id> [--json]
```

### Exit codes

`nan run` and `nan runs logs -f` exit with the run's outcome, so a script can
branch on it without parsing text:

| Code | Meaning |
|---|---|
| 0 | succeeded |
| 1 | failed |
| 2 | timed out |
| 3 | cancelled (after a double Ctrl-C: cancel requested — `nan runs show` has the final state) |
| 4 | the workspace is not set up for it: agent not installed, no inference key, bad configuration |
| 64 | usage error (also: more than one workspace and no `--ws`) |
| 65 | not signed in, session expired, or not allowed |
| 69 | nan.builders unavailable, or the stream was lost after retries (the run goes on; the id is printed) |
| 75 | `--detach`, or Ctrl-C: the run was accepted and is still going |

The runs commands use your `nan auth login` session, or the API key from the
Setup tab when there is no session.

## Build from source

Requires Go 1.26.8+ ([mise](https://mise.jdx.dev/) recommended) — the version
Requires Go 1.26.9+ ([mise](https://mise.jdx.dev/) recommended) — the version
in `go.mod`, which is where the standard library carries the current security
fixes:

Expand Down
128 changes: 128 additions & 0 deletions cmd/exit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package cmd

import (
"errors"
"fmt"
"strings"

"github.com/nxssie/nan-cli/internal/api"
"github.com/nxssie/nan-cli/internal/runs"
"github.com/nxssie/nan-cli/internal/session"
)

// Exit codes of `nan run` and `nan runs`. A script that starts a run needs to
// know how it ended without parsing text, so these are a contract: the first
// five are the run's own outcome, the rest follow sysexits.h.
const (
exitSucceeded = 0
exitFailed = 1
exitTimedOut = 2
exitCancelled = 3
exitConfig = 4
exitUsage = 64
exitAuth = 65
exitUnavailable = 69
exitDetached = 75
)

// ExitError ends the process with Code, printing Err first when there is one.
type ExitError struct {
Code int
Err error
}

func (e *ExitError) Error() string {
if e.Err == nil {
return fmt.Sprintf("exit status %d", e.Code)
}
return e.Err.Error()
}

func (e *ExitError) Unwrap() error { return e.Err }

func exitf(code int, format string, args ...any) *ExitError {
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
}

func usageErrorf(format string, args ...any) *ExitError {
return exitf(exitUsage, format, args...)
}

// authError is a refused or missing credential, with the one command that
// fixes it.
func authError(usingKey bool) *ExitError {
if usingKey {
return exitf(exitAuth, "nan.builders refused your API key — run: nan auth login")
}
return exitf(exitAuth, "%v — run: nan auth login", api.ErrSessionExpired)
}

// apiExit maps a failed request onto an exit code and a message that says
// what to do next. The platform's own message is guest-adjacent text too, so
// it is sanitised like everything else.
func apiExit(err error, usingKey bool) *ExitError {
var exit *ExitError
if errors.As(err, &exit) {
return exit
}
if errors.Is(err, api.ErrSessionExpired) {
return authError(usingKey)
}
if errors.Is(err, session.ErrNotLoggedIn) {
return &ExitError{Code: exitAuth, Err: err}
}
var apiErr *api.APIError
if !errors.As(err, &apiErr) {
return &ExitError{Code: exitUnavailable, Err: err}
}
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")
case "workspace_key_not_allowed":
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)
case "rate_limited":
return exitf(exitUnavailable, "too many runs started in the last hour — try again later")
}
switch {
case apiErr.Status == 400:
if apiErr.Param != "" {
return usageErrorf("%s: %s", runs.SanitizeLine(apiErr.Param), msg)
}
return usageErrorf("%s", msg)
case apiErr.Status == 401:
return exitf(exitAuth, "%v", session.ErrNotLoggedIn)
case apiErr.Status == 403:
return exitf(exitAuth, "%s", msg)
case apiErr.Status == 404:
return usageErrorf("%s", msg)
}
return exitf(exitUnavailable, "nan.builders could not do that right now: %s", msg)
}

// runExit is the exit for a run that ended - or the detach code for one
// that has not.
func runExit(run *api.Run) error {
code, ok := runs.ExitCode(run)
if !ok {
return &ExitError{Code: exitDetached}
}
if code == exitSucceeded {
return nil
}
return &ExitError{Code: code}
}

func joinNames(names []string) string {
clean := make([]string, len(names))
for i, n := range names {
clean[i] = " " + runs.SanitizeLine(n)
}
return strings.Join(clean, "\n")
}
Loading
Loading