diff --git a/.gitignore b/.gitignore index 1f801c2..f5448ec 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ vendor/ tmp/ temp/ *.log +nan.exe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16cb6af..cc9f6bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,10 +55,7 @@ All tool auto-configuration logic lives in `internal/tui/tui.go`. The relevant f 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 include all current NaN models: - - `qwen3.6` — Qwen 3.6 35B A3B - - `gemma4` — Gemma 4 26B A4B - - `deepseek-v4-flash` — DeepSeek V4 Flash 284B A13B +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. 3. **Implement `removeXxxConfig(cfgPath string) error`** — remove any NaN-related entries cleanly without touching the rest of the file. @@ -68,13 +65,13 @@ All tool auto-configuration logic lives in `internal/tui/tui.go`. The relevant f ## Adding a new NaN model -Models appear in three places in `tui.go`. Search for an existing model ID (e.g. `gemma4`) and add the new entry alongside it in each: +Add it to `All` in `internal/models/models.go` and every writer picks it up. -- `writeFactoryConfig` — `nanModels` slice -- `writeOpencodeConfig` — `nanModels` map -- `writePiConfig` — models array in the TypeScript template +The window and the output budget are the ones the setup guides publish on nan.builders (/docs/opencode, /docs/pi). Those are measured against the proxy, so copy them as they are rather than rounding: a window written short makes the tool compact a session that had room left, and one written long makes it fill the conversation until the model starts refusing requests. -The Codex config (`writeCodexConfig`) sets a default model but does not enumerate models, so no change is needed there. +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. ## Code style diff --git a/cmd/auth.go b/cmd/auth.go index 4c61041..7a5b7c6 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -2,19 +2,29 @@ package cmd import ( "bufio" + "bytes" + "encoding/json" "fmt" + "net/http" + "net/url" "os" - "os/exec" - "runtime" "strings" "github.com/spf13/cobra" "github.com/nxssie/nan-cli/internal/session" ) -const apiAuthURL = "https://cloud-api.nan.builders/api/auth/discord" +const ( + loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request" + loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify" + sessionCookie = "nan_session" +) -var tokenFlag string +var ( + tokenFlag string + emailFlag string + linkFlag string +) var authCmd = &cobra.Command{ Use: "auth", @@ -23,7 +33,7 @@ var authCmd = &cobra.Command{ var loginCmd = &cobra.Command{ Use: "login", - Short: "Log in with Discord (opens browser)", + Short: "Log in with a sign-in link sent to your email", RunE: runLogin, } @@ -37,30 +47,161 @@ func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(loginCmd) authCmd.AddCommand(logoutCmd) - loginCmd.Flags().StringVar(&tokenFlag, "token", "", "Save a nan_session token directly") + loginCmd.Flags().StringVar(&emailFlag, "email", "", "Email to send the sign-in link to") + loginCmd.Flags().StringVar(&linkFlag, "link", "", "Finish the login with the link from the email") + loginCmd.Flags().StringVar(&tokenFlag, "token", "", "Save a nan_session token directly, skipping the email") } +// The platform signs in by emailed link. It used to be Discord OAuth, and this +// 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 { if tokenFlag != "" { return saveToken(tokenFlag) } - fmt.Println("Opening browser to log in with Discord...") - openBrowser(apiAuthURL) + // `--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. + if linkFlag != "" { + token, err := tokenFromLink(strings.TrimSpace(linkFlag)) + if err != nil { + return err + } + sessionToken, err := exchangeToken(token) + if err != nil { + return err + } + return saveToken(sessionToken) + } + + in := bufio.NewScanner(os.Stdin) + + email := strings.TrimSpace(emailFlag) + if email == "" { + fmt.Print("Email: ") + if !in.Scan() { + return fmt.Errorf("no email provided") + } + email = strings.TrimSpace(in.Text()) + } + if !strings.Contains(email, "@") { + return fmt.Errorf("not an email address: %q", email) + } + + if err := requestSignInLink(email); err != nil { + return err + } + fmt.Println() - fmt.Println("After logging in:") - fmt.Println(" 1. Open DevTools (F12) → Application → Cookies → cloud-api.nan.builders") - fmt.Println(" 2. Copy the value of the 'nan_session' cookie") + fmt.Printf("A sign-in link is on its way to %s.\n", email) fmt.Println() - fmt.Print("Paste nan_session: ") + 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.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 + } + + token, err := tokenFromLink(strings.TrimSpace(in.Text())) + if err != nil { + return err + } + + sessionToken, err := exchangeToken(token) + if err != nil { + return err + } + return saveToken(sessionToken) +} - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - token := strings.TrimSpace(scanner.Text()) - if token == "" { - return fmt.Errorf("no token provided") +func requestSignInLink(email string) error { + body, err := json.Marshal(map[string]string{"email": email}) + if err != nil { + return err + } + resp, err := http.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) } - return saveToken(token) + // 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) + } + token := u.Query().Get("token") + if token == "" { + return "", fmt.Errorf("that link carries no token: %s", pasted) + } + return token, nil + } + if strings.ContainsAny(pasted, " \t") { + return "", fmt.Errorf("that is neither a link nor a token") + } + return pasted, nil +} + +// 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{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + form := url.Values{"token": {token}} + 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 "", fmt.Errorf("the link did not work: %s", strings.ReplaceAll(reason, "_", " ")) + } + } + return "", fmt.Errorf("no session came back (HTTP %d)", resp.StatusCode) } func runLogout(cmd *cobra.Command, args []string) error { @@ -72,24 +213,14 @@ func runLogout(cmd *cobra.Command, args []string) error { } func saveToken(token string) error { - if err := session.Save(&session.Session{Token: token}); err != nil { + current, err := session.Load() + if err != nil { + current = &session.Session{} + } + current.Token = token + if err := session.Save(current); err != nil { return fmt.Errorf("could not save session: %w", err) } fmt.Println("Logged in successfully.") return nil } - -func openBrowser(url string) { - var bin string - var binArgs []string - switch runtime.GOOS { - case "darwin": - bin = "open" - case "windows": - bin = "rundll32" - binArgs = []string{"url.dll,FileProtocolHandler"} - default: - bin = "xdg-open" - } - exec.Command(bin, append(binArgs, url)...).Start() -} diff --git a/cmd/root.go b/cmd/root.go index 62eb9c9..74470da 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,12 @@ 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, }, @@ -21,18 +27,17 @@ var rootCmd = &cobra.Command{ } func init() { - const ( - violet = "\033[38;2;167;139;250m" - dim = "\033[38;2;113;113;122m" - bold = "\033[1m" - reset = "\033[0m" - ) - rootCmd.SetVersionTemplate( - "\n " + bold + violet + "nan" + reset + - " " + dim + "v{{.Version}}" + reset + "\n" + - " " + dim + "nan.builders cloud CLI" + reset + "\n" + - " " + dim + "by @Nxssie" + reset + "\n\n", - ) + // 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 + }) + + // The same wordmark the About tab draws, so `--version` and the panel are + // recognisably the same program. This one is framed and stacked, which a + // command that prints once and exits can afford and a tab cannot. + rootCmd.SetVersionTemplate("\n" + tui.WelcomeStacked(" ") + "\n") } func Execute() { diff --git a/internal/api/client.go b/internal/api/client.go index 9535566..2181aeb 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -66,6 +66,53 @@ func (c *Client) GetMetricsUsage() (map[string]any, error) { return result, json.Unmarshal(body, &result) } +// 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" + +// 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) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := (&http.Client{}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("the API key in Setup is not valid") + } + 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(body, &parsed); err != nil { + return nil, err + } + ids := make([]string, 0, len(parsed.Data)) + for _, m := range parsed.Data { + ids = append(ids, m.ID) + } + return ids, nil +} + func (c *Client) GetAgentsModels() (any, error) { body, err := c.get("/agents/models") if err != nil { diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..4ed05f0 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,167 @@ +// Package models is the CLI's one list of what the cluster serves. +// +// It existed three times before, written by hand inside each tool writer in +// internal/tui, and all three had drifted to the same stale set: qwen3.6, +// gemma4 and deepseek-v4-flash. The cluster serves seven chat models, so a +// member who ran `nan` got a tool configured for three of them and no way to +// discover the rest. The windows were worse than stale: Pi was configured with +// 128000 tokens on models served at 1,048,576, so it compacted the +// conversation at an eighth of the real window. +// +// The numbers here are the ones nan.builders publishes on the setup guides +// (/docs/opencode, /docs/pi), measured against the proxy on 2026-09-11. When +// 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 +// 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 + +const ( + Chat Kind = "chat" + Embedding Kind = "embedding" + Rerank Kind = "rerank" + TTS Kind = "text to speech" + STT Kind = "speech to text" + Image Kind = "image" +) + +type Model struct { + ID string + Kind Kind + // As the setup guides name it, so a picker in one tool reads like the + // picker in the next. + Name string + // The window the proxy serves, in tokens. + Context int + // The answer budget a client should plan a turn against. Not a server cap: + // vLLM bounds a completion by the window minus the prompt. + Output int + // Input modalities. Output is text on every model here. + Inputs []string + // Whether it reasons before answering. + Reasoning bool + // Callable only with a key on the premium tier. Left in the configs it + // writes, with the tier in its display name, because leaving it out is how + // a member on premium ends up not knowing they have it. + Premium bool +} + +const ( + InputText = "text" + InputImage = "image" + InputAudio = "audio" +) + +var All = []Model{ + { + ID: "deepseek-v4-flash", + Kind: Chat, + Name: "DeepSeek V4 Flash", + Context: 1_048_575, + Output: 32_768, + Inputs: []string{InputText, InputImage}, + Reasoning: true, + }, + { + ID: "glm5.3-flash", + Kind: Chat, + Name: "GLM 5.3 Flash", + Context: 1_048_576, + Output: 32_768, + Inputs: []string{InputText, InputImage}, + Reasoning: true, + }, + { + ID: "qwen3.8-flash", + Kind: Chat, + Name: "Qwen 3.8 Flash", + Context: 262_144, + Output: 32_768, + Inputs: []string{InputText, InputImage}, + Reasoning: true, + }, + { + ID: "mimo-v2.5", + Kind: Chat, + Name: "Xiaomi MiMo V2.5", + Context: 1_048_576, + Output: 32_768, + Inputs: []string{InputText, InputImage, InputAudio}, + Reasoning: true, + }, + { + ID: "gemma4", + Kind: Chat, + Name: "Gemma 4", + Context: 262_144, + Output: 65_536, + Inputs: []string{InputText, InputImage}, + Reasoning: true, + }, + { + ID: "qwen3.6", + Kind: Chat, + Name: "Qwen 3.6", + Context: 262_144, + Output: 65_536, + Inputs: []string{InputText, InputImage}, + Reasoning: true, + }, + { + ID: "glm5.3", + Kind: Chat, + Name: "GLM 5.3 (premium)", + Context: 1_048_576, + Output: 32_768, + Inputs: []string{InputText}, + Reasoning: true, + Premium: true, + }, + + // Not chat, and not in any tool config: they are here so the Models tab can + // say what an id is for. A window is meaningless on most of them. + {ID: "qwen3-embedding", Kind: Embedding, Name: "Qwen 3 Embedding", Inputs: []string{InputText}}, + {ID: "rerank", Kind: Rerank, Name: "Qwen 3 Reranker", Inputs: []string{InputText}}, + {ID: "kokoro", Kind: TTS, Name: "Kokoro", Inputs: []string{InputText}}, + {ID: "whisper", Kind: STT, Name: "Whisper large-v3", Inputs: []string{InputAudio}}, + {ID: "flux-2-klein", Kind: Image, Name: "FLUX.2 Klein", Inputs: []string{InputText, InputImage}}, +} + +// ChatModels is what a coding tool gets configured with. The rest of the +// catalogue has no business in an agent's model picker. +func ChatModels() []Model { + out := make([]Model, 0, len(All)) + for _, m := range All { + if m.Kind == Chat { + out = append(out, m) + } + } + return out +} + +// Default is what a tool is left pointing at when it has no preference of its +// own: the model the quickstart on nan.builders starts everyone with. +const Default = "deepseek-v4-flash" + +// Coding is for tools whose whole job is an editor or an agent in a repo. +const Coding = "glm5.3-flash" + +func (m Model) Accepts(input string) bool { + for _, i := range m.Inputs { + if i == input { + return true + } + } + return false +} + +func Get(id string) (Model, bool) { + for _, m := range All { + if m.ID == id { + return m, true + } + } + return Model{}, false +} diff --git a/internal/tui/banner.go b/internal/tui/banner.go new file mode 100644 index 0000000..6c307cc --- /dev/null +++ b/internal/tui/banner.go @@ -0,0 +1,161 @@ +package tui + +import ( + "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 +) + +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 wordmarkWidth + sideGap + widest +}() + +// Banner draws the wordmark with the text beside it. Six rows, no frame: it is +// what goes inside a tab that has other things to say. +func Banner(indent string) string { + side := bannerSide() + 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() +} + +// 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))) + b.WriteString("\n" + edge("└─", "─┘") + "\n") + return b.String() +} + +// WelcomeStacked is the other arrangement: a small label above the wordmark and +// the name under it, the way the Copilot CLI lays its welcome out, instead of +// everything sitting to the right. +func WelcomeStacked(indent string) string { + corner := lipgloss.NewStyle().Foreground(lipgloss.Color(brandVioletDeep)) + name, dim, text := styles() + + const pad = 3 + inner := wordmarkWidth + 2 + body := indent + strings.Repeat(" ", pad) + edge := func(left, right string) string { + return indent + corner.Render(left) + strings.Repeat(" ", inner) + corner.Render(right) + } + + // Right-aligned under the art, like the reference's "Command-line + // interface" under the logo. + under := name.Render("nan.builders") + dim.Render(" · cloud CLI v"+Version) + gap := wordmarkWidth - lipgloss.Width(under) + if gap < 0 { + gap = 0 + } + + var b strings.Builder + b.WriteString(edge("┌─", "─┐") + "\n\n") + b.WriteString(body + dim.Render("welcome to") + "\n") + for _, row := range wordmark { + b.WriteString(body + paint(row) + "\n") + } + b.WriteString(body + strings.Repeat(" ", gap) + under + "\n\n") + b.WriteString(body + text.Render("created by ") + name.Render("@Nxssie") + "\n") + b.WriteString(body + text.Render("maintained by ") + name.Render("Helmcode Team") + "\n") + b.WriteString("\n" + edge("└─", "─┘") + "\n") + return b.String() +} diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go new file mode 100644 index 0000000..3023a22 --- /dev/null +++ b/internal/tui/config_test.go @@ -0,0 +1,536 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + catalog "github.com/nxssie/nan-cli/internal/models" +) + +// 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 { + t.Helper() + return filepath.Join(t.TempDir(), name) +} + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("%s is not valid JSON: %v", path, err) + } + return out +} + +func TestOpencodeConfigPublishesEveryModelWithItsWindow(t *testing.T) { + path := tempConfig(t, "opencode.json") + if err := writeOpencodeConfig(path, testKey); err != nil { + t.Fatal(err) + } + + cfg := readJSON(t, path) + nan := cfg["provider"].(map[string]any)["nan"].(map[string]any) + + if npm := nan["npm"]; npm != "@ai-sdk/openai-compatible" { + t.Errorf("npm = %v, want the generic openai-compatible adapter", npm) + } + + written := nan["models"].(map[string]any) + if len(written) != len(catalog.ChatModels()) { + t.Errorf("wrote %d models, the cluster serves %d", len(written), len(catalog.ChatModels())) + } + + for _, m := range catalog.ChatModels() { + entry, ok := written[m.ID].(map[string]any) + if !ok { + t.Errorf("%s: not written", m.ID) + continue + } + // `contextWindow` is not in opencode's schema. An unknown key is + // ignored in silence, which is how this went unnoticed for months. + if _, bad := entry["contextWindow"]; bad { + t.Errorf("%s: contextWindow is not a field opencode reads", m.ID) + } + limit, ok := entry["limit"].(map[string]any) + if !ok { + t.Errorf("%s: no limit, so opencode will guess the window", m.ID) + continue + } + if got := int(limit["context"].(float64)); got != m.Context { + t.Errorf("%s: context %d, served at %d", m.ID, got, m.Context) + } + if got := int(limit["output"].(float64)); got != m.Output { + t.Errorf("%s: output %d, want %d", m.ID, got, m.Output) + } + } +} + +func TestOpencodeConfigRepairsAnEntryWrittenByAnOlderVersion(t *testing.T) { + path := tempConfig(t, "opencode.json") + // Exactly what versions up to v0.1.1 left behind: the model is there, the + // window is not, and the member has renamed it. + old := `{ + "provider": { + "nan": { + "npm": "@ai-sdk/openai-compatible", + "options": { "baseURL": "https://api.nan.builders/v1", "apiKey": "sk-old" }, + "models": { "gemma4": { "name": "my own name for it" } } + } + } + }` + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + if err := writeOpencodeConfig(path, testKey); err != nil { + t.Fatal(err) + } + + cfg := readJSON(t, path) + models := cfg["provider"].(map[string]any)["nan"].(map[string]any)["models"].(map[string]any) + gemma := models["gemma4"].(map[string]any) + + if gemma["name"] != "my own name for it" { + t.Errorf("name = %v, the member's own value was overwritten", gemma["name"]) + } + limit, ok := gemma["limit"].(map[string]any) + if !ok { + t.Fatal("gemma4 still has no limit, so an upgrade fixes nothing for anyone already configured") + } + if got, want := int(limit["context"].(float64)), 262_144; got != want { + t.Errorf("context %d, want %d", got, want) + } + if _, ok := models["glm5.3-flash"]; !ok { + t.Error("glm5.3-flash was not added to an existing config") + } +} + +func TestOpencodeConfigKeepsWhatItDoesNotOwn(t *testing.T) { + path := tempConfig(t, "opencode.json") + if err := os.WriteFile(path, []byte(`{"theme":"tokyonight","provider":{}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := writeOpencodeConfig(path, testKey); err != nil { + t.Fatal(err) + } + if theme := readJSON(t, path)["theme"]; theme != "tokyonight" { + t.Errorf("theme = %v, an unrelated setting was lost", theme) + } +} + +func piModels(t *testing.T, path string) map[string]map[string]any { + t.Helper() + cfg := readJSON(t, path) + providers, ok := cfg["providers"].(map[string]any) + if !ok { + t.Fatal("models.json has no providers object, which is the only thing Pi reads") + } + nan, ok := providers["nan"].(map[string]any) + if !ok { + t.Fatal("no nan provider") + } + out := map[string]map[string]any{} + for _, raw := range nan["models"].([]any) { + m := raw.(map[string]any) + out[m["id"].(string)] = m + } + return out +} + +func TestPiConfigIsAModelsJsonWithTheRealWindows(t *testing.T) { + path := tempConfig(t, "models.json") + if err := writePiConfig(path, testKey); err != nil { + t.Fatal(err) + } + + written := piModels(t, path) + for _, m := range catalog.ChatModels() { + entry, ok := written[m.ID] + if !ok { + t.Errorf("%s: missing from the Pi provider", m.ID) + continue + } + // The old template wrote 128000 and 8192 for every model, whatever it + // was: an eighth of the room on the 1M models. + if got := int(entry["contextWindow"].(float64)); got != m.Context { + t.Errorf("%s: contextWindow %d, served at %d", m.ID, got, m.Context) + } + if got := int(entry["maxTokens"].(float64)); got != m.Output { + t.Errorf("%s: maxTokens %d, want %d", m.ID, got, m.Output) + } + } +} + +func TestPiConfigWritesOnlyTheModalitiesPiAccepts(t *testing.T) { + path := tempConfig(t, "models.json") + if err := writePiConfig(path, testKey); err != nil { + t.Fatal(err) + } + // Pi's schema is `("text" | "image")[]`. A third value fails validation and + // Pi then refuses the whole file, every other provider in it included, so + // mimo-v2.5 goes in without its audio. + for id, entry := range piModels(t, path) { + for _, raw := range entry["input"].([]any) { + if in := raw.(string); in != "text" && in != "image" { + t.Errorf("%s: input %q is not in Pi's schema", id, in) + } + } + } +} + +func TestPiConfigLeavesOtherProvidersAlone(t *testing.T) { + path := tempConfig(t, "models.json") + existing := `{"providers":{"openai":{"baseUrl":"https://api.openai.com/v1","models":[]}}}` + if err := os.WriteFile(path, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + if err := writePiConfig(path, testKey); err != nil { + t.Fatal(err) + } + providers := readJSON(t, path)["providers"].(map[string]any) + if _, ok := providers["openai"]; !ok { + t.Fatal("another provider was dropped from a shared file") + } + + // And removing ours has to leave theirs standing, which deleting the file + // would not. + if err := removePiConfig(path); err != nil { + t.Fatal(err) + } + providers = readJSON(t, path)["providers"].(map[string]any) + if _, ok := providers["openai"]; !ok { + t.Error("removing the NaN provider took another one with it") + } + if _, ok := providers["nan"]; ok { + t.Error("the NaN provider is still there after removing it") + } +} + +func TestPiConfigIsRecognisedAsConfigured(t *testing.T) { + path := tempConfig(t, "models.json") + if isNaNConfigured("Pi", path) { + t.Error("an absent file reads as configured") + } + if err := writePiConfig(path, testKey); err != nil { + t.Fatal(err) + } + if !isNaNConfigured("Pi", path) { + t.Error("the Setup tab will not see the config it just wrote") + } +} + +func TestCodexConfigSpeaksTheStreamingEndpoint(t *testing.T) { + path := tempConfig(t, "config.toml") + if err := writeCodexConfig(path, testKey); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(data) + + // /responses answers in one terminal event on this cluster, so with + // wire_api = "responses" the whole reply lands at once at the end. + if !strings.Contains(content, `wire_api = "chat"`) { + t.Error(`wire_api is not "chat", so Codex will not stream`) + } + model, _ := catalog.Get(catalog.Coding) + if !strings.Contains(content, `model = "`+model.ID+`"`) { + t.Errorf("the default model is not %s", model.ID) + } + if strings.Contains(content, "model_context_window = 131072") { + t.Error("still declaring a window no model on the cluster has") + } +} + +func TestCodexConfigDoesNotTouchAnExistingChoice(t *testing.T) { + path := tempConfig(t, "config.toml") + existing := "model = \"gpt-5\"\nmodel_provider = \"openai\"\n" + if err := os.WriteFile(path, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + if err := writeCodexConfig(path, testKey); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), `model = "gpt-5"`) { + t.Error("the member's own model choice was replaced") + } + if !strings.Contains(string(data), "[model_providers.nan]") { + t.Error("the NaN provider was not appended") + } +} + +func TestFactoryConfigMarksWhatCannotSeeImages(t *testing.T) { + path := tempConfig(t, "settings.json") + if err := writeFactoryConfig(path, testKey); err != nil { + t.Fatal(err) + } + cfg := readJSON(t, path) + custom := cfg["customModels"].([]any) + if len(custom) != len(catalog.ChatModels()) { + t.Errorf("wrote %d models, the cluster serves %d", len(custom), len(catalog.ChatModels())) + } + for _, raw := range custom { + entry := raw.(map[string]any) + id := entry["model"].(string) + m, ok := catalog.Get(id) + if !ok { + t.Errorf("%s: not a model the cluster serves", id) + continue + } + if got, want := entry["noImageSupport"].(bool), !m.Accepts(catalog.InputImage); got != want { + t.Errorf("%s: noImageSupport = %v, want %v", id, got, want) + } + } + def, ok := cfg["sessionDefaultSettings"].(map[string]any) + if !ok { + t.Fatal("no default model was set") + } + // Factory points its default at the custom entry's own id, not at the + // model id, so the check has to go back through the list. + var defaultModel string + for _, raw := range custom { + entry := raw.(map[string]any) + if entry["id"] == def["model"] { + defaultModel = entry["model"].(string) + } + } + if defaultModel != catalog.Default { + t.Errorf("default is %q, want %q, the model the quickstart recommends", + defaultModel, catalog.Default) + } +} + +func TestEveryModelIsWrittenTheSameEverywhere(t *testing.T) { + // The three writers used to keep their own list and all three disagreed. + dir := t.TempDir() + opencodePath := filepath.Join(dir, "opencode.json") + factoryPath := filepath.Join(dir, "settings.json") + piPath := filepath.Join(dir, "models.json") + for _, err := range []error{ + writeOpencodeConfig(opencodePath, testKey), + writeFactoryConfig(factoryPath, testKey), + writePiConfig(piPath, testKey), + } { + if err != nil { + t.Fatal(err) + } + } + + opencode := readJSON(t, opencodePath)["provider"].(map[string]any)["nan"].(map[string]any)["models"].(map[string]any) + factory := map[string]bool{} + for _, raw := range readJSON(t, factoryPath)["customModels"].([]any) { + factory[raw.(map[string]any)["model"].(string)] = true + } + pi, err := os.ReadFile(piPath) + if err != nil { + t.Fatal(err) + } + + for _, m := range catalog.ChatModels() { + if _, ok := opencode[m.ID]; !ok { + t.Errorf("%s: missing from opencode", m.ID) + } + if !factory[m.ID] { + t.Errorf("%s: missing from Factory", m.ID) + } + if !strings.Contains(string(pi), m.ID) { + t.Errorf("%s: missing from Pi", m.ID) + } + } +} + +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. +// Here HOME points somewhere disposable. +func TestConfigureToolsWritesEveryEnabledTool(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // os.UserHomeDir reads this one on Windows + + // A tool counts as installed if its binary is on PATH *or* its config path + // exists, so an empty file each is enough to make all four visible. + paths := 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"), + } + for _, p := range paths { + 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) + } + } + + if msg := configureTools(testKey, nil); !strings.Contains(msg, "4 added") { + t.Fatalf("configureTools said %q, want the four tools written", msg) + } + + for name, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + if !strings.Contains(string(data), "api.nan.builders") { + t.Errorf("%s: written without the NaN base URL", name) + } + if !strings.Contains(string(data), testKey) { + t.Errorf("%s: written without the API key", name) + } + if !isNaNConfigured(name, p) { + t.Errorf("%s: the Setup tab will not show it as configured", name) + } + } + + // And unticking a tool takes only that one out. + if msg := configureTools(testKey, map[string]bool{"Pi": false}); !strings.Contains(msg, "1 removed") { + t.Errorf("configureTools said %q, want Pi removed", msg) + } + if isNaNConfigured("Pi", paths["Pi"]) { + t.Error("Pi is still configured after being unticked") + } + if !isNaNConfigured("OpenCode", paths["OpenCode"]) { + t.Error("unticking Pi took OpenCode with it") + } +} + +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(" ") + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != len(wordmark) { + t.Fatalf("the banner is %d rows, the wordmark is %d", len(lines), 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(80, 24)), "█") { + t.Error("the banner is missing at a width that fits it") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index cac0324..d7f07e3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2,6 +2,7 @@ package tui import ( "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -14,6 +15,7 @@ import ( 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" ) @@ -315,6 +317,7 @@ func (m *model) maybeLoad() tea.Cmd { func (m model) fetchTab(id tabID) tea.Cmd { client := m.client + apiKey := m.sess.APIKey return func() tea.Msg { switch id { case tabProfile: @@ -330,6 +333,17 @@ func (m model) fetchTab(id tabID) tea.Cmd { } 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} @@ -584,38 +598,75 @@ var modeColor = map[string]lipgloss.TerminalColor{ "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"), } -func renderModels(data any, usageData any, l layout) string { - // Extract models list (unwrap {"models": [...]}) - 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" +// 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 +} - // Build model list - models := make([]modelInfo, 0, len(raw)) - for _, item := range raw { - obj, ok := item.(map[string]any) - if !ok { - continue +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 } - mi := modelInfo{ - name: fmt.Sprintf("%v", obj["name"]), - mode: fmt.Sprintf("%v", obj["mode"]), + if raw == nil { + return l.indent + lipgloss.NewStyle().Foreground(cGray).Render("No models found.") + "\n" } - if mi.mode == "" { - mi.mode = "" + 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) } - 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 @@ -645,7 +696,7 @@ func renderModels(data any, usageData any, l layout) string { b.WriteString(divider + "\n") } - color := modeColor[mi.mode] + color := badgeColor(mi.mode) label, ok := modeLabel[mi.mode] if !ok { label = mi.mode @@ -750,8 +801,10 @@ func calcCost(pt periodTokens, p providerPricing) float64 { } 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 fmt.Sprintf("$%,.2f", v) + return fmtCostAligned(v) } return fmt.Sprintf("$%.2f", v) } @@ -902,8 +955,10 @@ func renderCosts(usage map[string]any, l layout) string { Border(lipgloss.RoundedBorder()). BorderForeground(cBlueDim). Padding(0, 1). - Width(len(notePlain)) - b.WriteString(l.indent + noteStyle.Render(note) + "\n") + // Not len(): the em dash is one column and three bytes, so counting + // bytes drew the box two columns wider than its own text. + Width(lipgloss.Width(notePlain)) + b.WriteString(indentBlock(noteStyle.Render(note), l.indent) + "\n") return b.String() } @@ -983,10 +1038,18 @@ func sortedKeys(m map[string]any) []string { } func humanKey(s string) string { + runes := []rune(s) var out []rune - for i, r := range s { - if i > 0 && r >= 'A' && r <= 'Z' { - out = append(out, ' ') + 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) } @@ -1026,7 +1089,7 @@ func detectTools() []toolInfo { { name: "Pi", binary: "pi", - configPath: filepath.Join(home, ".pi", "agent", "extensions", "nan.ts"), + configPath: filepath.Join(home, ".pi", "agent", "models.json"), installPath: filepath.Join(home, ".pi"), }, { @@ -1080,7 +1143,14 @@ func isNaNConfigured(toolName, cfgPath string) bool { base, _ := opts["baseURL"].(string) return strings.Contains(base, "api.nan.builders") case "Pi": - return strings.Contains(string(data), "api.nan.builders") + 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") } @@ -1201,23 +1271,19 @@ func writeFactoryConfig(cfgPath, apiKey string) error { } // Append only missing models - nanModels := []struct{ id, display string }{ - {"qwen3.6", "Qwen 3.6 35B A3B (NaN)"}, - {"gemma4", "Gemma 4 26B A4B (NaN)"}, - {"deepseek-v4-flash", "DeepSeek V4 Flash 284B A13B (NaN)"}, - } added := false - for _, nm := range nanModels { - if !existingIDs[nm.id] { + 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(nm.display, idx), + "model": nm.ID, + "id": factoryCustomID(display, idx), "index": idx, "baseUrl": "https://api.nan.builders/v1", "apiKey": apiKey, - "displayName": nm.display, - "noImageSupport": false, + "displayName": display, + "noImageSupport": !nm.Accepts(catalog.InputImage), "provider": "openai", }) added = true @@ -1228,10 +1294,11 @@ func writeFactoryConfig(cfgPath, apiKey string) error { } cfg["customModels"] = models - // Set qwen3.6 as default model if sessionDefaultSettings not present + // 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 == "qwen3.6" { + if id, _ := m["model"].(string); id == catalog.Default { cfg["sessionDefaultSettings"] = map[string]any{ "model": m["id"], "reasoningEffort": "none", @@ -1266,10 +1333,24 @@ func writeOpencodeConfig(cfgPath, apiKey string) error { providers = map[string]any{} } - nanModels := map[string]any{ - "qwen3.6": map[string]any{"name": "Qwen 3.6 35B A3B"}, - "gemma4": map[string]any{"name": "Gemma 4 26B A4B"}, - "deepseek-v4-flash": map[string]any{"name": "DeepSeek V4 Flash 284B A13B"}, + // `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 { @@ -1282,9 +1363,21 @@ func writeOpencodeConfig(cfgPath, apiKey string) error { } changed := false for id, m := range nanModels { - if _, found := existing[id]; !found { + 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 { @@ -1326,64 +1419,104 @@ func writeOpencodeConfig(cfgPath, apiKey string) error { return os.WriteFile(cfgPath, data, 0o600) } +// 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. + var cfg map[string]any if data, err := os.ReadFile(cfgPath); err == nil { - if strings.Contains(string(data), "api.nan.builders") { - return nil // already configured - } - } - const tmpl = `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -export default function (pi: ExtensionAPI) { - pi.registerProvider("nan", { - name: "NaN", - baseUrl: "https://api.nan.builders/v1", - apiKey: %q, - api: "openai-completions", - models: [ - { - id: "qwen3.6", - name: "Qwen 3.6 35B A3B", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 8192, - }, - { - id: "gemma4", - name: "Gemma 4 26B A4B", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 8192, - }, - { - id: "deepseek-v4-flash", - name: "DeepSeek V4 Flash 284B A13B", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 8192, - }, - ], - }); -} -` + _ = json.Unmarshal(data, &cfg) + } + if cfg == nil { + cfg = map[string]any{} + } + + 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 + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil { return err } - return os.WriteFile(cfgPath, []byte(fmt.Sprintf(tmpl, apiKey)), 0o600) + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(cfgPath, data, 0o600) +} + +// 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 { - err := os.Remove(cfgPath) + data, err := os.ReadFile(cfgPath) if os.IsNotExist(err) { return nil } - return err + 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 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 os.WriteFile(cfgPath, out, 0o600) } func writeCodexConfig(cfgPath, apiKey string) error { @@ -1392,18 +1525,23 @@ func writeCodexConfig(cfgPath, apiKey string) error { return nil } + // wire_api = "chat", not "responses": the cluster's /responses endpoint + // emits a single terminal event, so with "responses" the whole answer + // appears at once at the end instead of streaming. + codexModel, _ := catalog.Get(catalog.Coding) + // If no existing config, write a complete starter config. if len(data) == 0 { - content := fmt.Sprintf(`model = "gemma4" + content := fmt.Sprintf(`model = %q model_provider = "nan" -model_context_window = 131072 +model_context_window = %d [model_providers.nan] name = "NaN" base_url = "https://api.nan.builders/v1" experimental_bearer_token = %q -wire_api = "responses" -`, apiKey) +wire_api = "chat" +`, codexModel.ID, codexModel.Context, apiKey) if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil { return err } @@ -1413,14 +1551,14 @@ wire_api = "responses" // Existing config: only append the provider section; preserve user's model/provider choices. // model_context_window suppresses the "metadata not found" warning for nan models. section := fmt.Sprintf(` -model_context_window = 131072 +model_context_window = %d [model_providers.nan] name = "NaN" base_url = "https://api.nan.builders/v1" experimental_bearer_token = %q -wire_api = "responses" -`, apiKey) +wire_api = "chat" +`, codexModel.Context, apiKey) content := strings.TrimRight(string(data), "\n") + "\n" + section if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil { return err @@ -1647,9 +1785,16 @@ func renderAbout(l layout) string { accentStyle := lipgloss.NewStyle().Foreground(cCyan) sectionStyle := lipgloss.NewStyle().Foreground(cGray).Bold(true) - b.WriteString(l.indent + logoStyle.Render("nan") + - " " + dimStyle.Render("v"+Version) + "\n") - b.WriteString(l.indent + dimStyle.Render("nan.builders cloud CLI") + "\n\n") + // 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 >= BannerWidth+4 + if banner { + b.WriteString(Banner(l.indent) + "\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:") + @@ -1657,9 +1802,13 @@ func renderAbout(l layout) string { b.WriteString(l.indent + labelStyle.Render("Cloud:") + linkStyle.Render("https://cloud.nan.builders") + "\n\n") - b.WriteString(l.indent + sectionStyle.Render("Maintainer") + "\n\n") - b.WriteString(l.indent + labelStyle.Render("Author:") + - accentStyle.Render("@Nxssie") + "\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:") + @@ -1708,9 +1857,17 @@ func renderHelp() string { // ── 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 { - return err + if !errors.Is(err, session.ErrNotLoggedIn) { + return err + } + sess = &session.Session{} } client := api.New(sess.Token) m := newModel(client, sess) @@ -1718,3 +1875,17 @@ func Run() error { _, 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") +} +