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
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,18 @@ modelslab update --check
modelslab update
```

The CLI also checks for updates periodically during normal human-readable commands and prints a short notice when a newer release is available. It stays silent for `--output json`, `--jq`, shell completions, and MCP server mode. Disable the startup check for one command with `--no-update-check`, or persistently with:
If a package manager installed the CLI, update it with that package manager. `modelslab update` detects this and prints the right command instead of replacing a file the package manager owns:

| Installed with | Update with |
|---|---|
| pip | `pip install --upgrade modelslab-cli` |
| pipx | `pipx upgrade modelslab-cli` |
| uv | `uv tool upgrade modelslab-cli` |
| npm | `npm install -g modelslab-cli@latest` |
| Homebrew | `brew upgrade modelslab/tap/modelslab` |
| Scoop | `scoop update modelslab` |

The CLI also checks for updates once a day during normal human-readable commands and prints a short notice, with the right update command, when a newer release is available. It stays silent for `--output json`, `--jq`, shell completions, MCP server mode, in CI (`CI` is set), and when stderr is not a terminal. Disable the startup check for one command with `--no-update-check`, or persistently with:

```bash
modelslab config set updates.auto_check false
Expand All @@ -93,8 +104,8 @@ modelslab config set updates.auto_check false
# New user? Sign up first
modelslab auth signup --name "Your Name" --email you@example.com --password "..." --confirm-password "..."

# Login to your account in the browser
modelslab auth login --browser
# Login to your account in the browser (the default in a terminal)
modelslab auth login

# Or login with email/password
modelslab auth login --email you@example.com --password "..."
Expand Down Expand Up @@ -157,7 +168,7 @@ modelslab auth signup --name "Your Name" --email you@example.com --password "...
modelslab auth verify-email --token <verification-token>

# 3. Login in the browser (auto-stores bearer token + API key in OS keychain)
modelslab auth login --browser
modelslab auth login

# Or use email/password
modelslab auth login --email you@example.com --password "..."
Expand All @@ -177,15 +188,21 @@ modelslab auth tokens create --name "ci-token"
### Existing Users

```bash
# Browser login opens Chrome, asks you to grant CLI access, and stores both credentials
modelslab auth login --browser
# Browser login opens Chrome, asks you to grant CLI access, and stores both credentials.
# It is the default in an interactive terminal. Over SSH, with no display, or with
# piped stdin, the CLI asks for email and password instead.
modelslab auth login

# Email/password login also gets both token and API key
modelslab auth login --email you@example.com --password "..."

# Force one mode or the other
modelslab auth login --browser
modelslab auth login --browser=false

# NOTE: accounts created with "Continue with Google" or "Continue with GitHub"
# have no password, so --email/--password can never work for them. Use
# --browser, or set a password first with `modelslab auth forgot-password`.
# have no password, so --email/--password can never work for them. Use the
# browser login, or set a password first with `modelslab auth forgot-password`.

# Or set API key manually
modelslab config set api_key "your-api-key"
Expand Down
81 changes: 78 additions & 3 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,34 @@ type browserLoginCallback struct {
var authLoginCmd = &cobra.Command{
Use: "login",
Short: "Login to ModelsLab",
Long: `Login to ModelsLab.

In an interactive terminal this opens your browser, asks you to grant the CLI
access, and stores both the access token and the API key. This works for every
account, including accounts created with "Continue with Google" or GitHub.

The CLI uses email and password instead when you pass --email or --password,
when you pass --browser=false, when stdin is not a terminal, or when no local
browser is available (an SSH session, or Linux with no display).`,
RunE: func(cmd *cobra.Command, args []string) error {
email, _ := cmd.Flags().GetString("email")
password, _ := cmd.Flags().GetString("password")

useBrowser, _ := cmd.Flags().GetBool("browser")
if !cmd.Flags().Changed("browser") {
interactive := stdinIsTerminal()
useBrowser = defaultToBrowserLogin(email, password, interactive, runtime.GOOS, os.Getenv)

if !useBrowser && interactive && email == "" && password == "" {
fmt.Fprintln(os.Stderr, "No local browser found (SSH session or no display), so the CLI uses email and password.")
fmt.Fprintln(os.Stderr, "Google or GitHub account? It has no password. Set one first with: modelslab auth forgot-password")
fmt.Fprintln(os.Stderr)
}
}
if useBrowser {
return runBrowserLogin(cmd)
}

email, _ := cmd.Flags().GetString("email")
password, _ := cmd.Flags().GetString("password")
expiry, _ := cmd.Flags().GetString("expiry")
deviceName, _ := cmd.Flags().GetString("device-name")

Expand All @@ -67,6 +87,9 @@ var authLoginCmd = &cobra.Command{
}
if password == "" {
value, err := promptSecret("Password: ")
if errors.Is(err, errNoInput) {
return errNoPasswordEntered
}
if err != nil {
return err
}
Expand Down Expand Up @@ -143,6 +166,57 @@ var authLoginCmd = &cobra.Command{
},
}

// errNoPasswordEntered replaces the bare "password: no input provided".
//
// An empty answer at the password prompt is almost never a typo. It is someone
// whose account came from "Continue with Google" or GitHub, looking at a prompt
// for a password that does not exist.
var errNoPasswordEntered = errors.New("no password entered.\n" +
" Signed up with Google or GitHub? That account has no password — run: modelslab auth login --browser\n" +
" Or set a password first: modelslab auth forgot-password")

// defaultToBrowserLogin decides how `auth login` signs in when --browser is not
// given.
//
// Email and password used to be the default, and it is a dead end for every
// account created with "Continue with Google" or GitHub: the account has no
// password, so the prompt had nothing the user could type and the login died with
// "password: no input provided". The npm and PyPI READMEs send new users straight
// to a bare `modelslab auth login`, so that was the first thing they saw.
//
// Email and password is still the choice when the caller supplied either one,
// when stdin is not a terminal (a script piping credentials must not start
// waiting on a browser), and when there is no browser here to finish the grant.
func defaultToBrowserLogin(email, password string, interactive bool, goos string, getenv func(string) string) bool {
if email != "" || password != "" || !interactive {
return false
}

return hasLocalBrowser(goos, getenv)
}

// hasLocalBrowser reports whether a browser on this machine can finish the OAuth
// grant.
//
// "On this machine" matters more than "a browser exists": the callback server
// listens on 127.0.0.1, so a browser on the far side of an SSH session opens the
// grant page and then cannot deliver the result, and the login hangs until it
// times out.
func hasLocalBrowser(goos string, getenv func(string) string) bool {
for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} {
if getenv(key) != "" {
return false
}
}

switch goos {
case "darwin", "windows":
return true
default:
return getenv("DISPLAY") != "" || getenv("WAYLAND_DISPLAY") != ""
}
}

// loginFailureHints turns a control-plane error code into next steps a user can
// actually act on.
//
Expand Down Expand Up @@ -270,6 +344,7 @@ func runBrowserLogin(cmd *cobra.Command) error {
}
}
fmt.Fprintln(os.Stderr, "Waiting for browser authorization...")
fmt.Fprintln(os.Stderr, "(To use email and password instead, press Ctrl+C and run: modelslab auth login --email you@example.com)")

timer := time.NewTimer(timeout)
defer timer.Stop()
Expand Down Expand Up @@ -794,7 +869,7 @@ func init() {
// auth login
authLoginCmd.Flags().String("email", "", "Account email")
authLoginCmd.Flags().String("password", "", "Account password")
authLoginCmd.Flags().Bool("browser", false, "Log in with browser OAuth instead of email and password")
authLoginCmd.Flags().Bool("browser", false, "Log in through the browser (the default in an interactive terminal); --browser=false forces email and password")
authLoginCmd.Flags().Int("callback-port", 0, "Local callback port for browser OAuth (0 chooses a free port)")
authLoginCmd.Flags().String("expiry", "1_month", "Token expiry: 1_week, 1_month, 3_months, 6_months, 1_year, never")
authLoginCmd.Flags().String("device-name", "", "Device name for token")
Expand Down
45 changes: 45 additions & 0 deletions internal/cmd/auth_login_mode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cmd

import (
"testing"

"github.com/stretchr/testify/assert"
)

func env(vars map[string]string) func(string) string {
return func(key string) string { return vars[key] }
}

// A Google or GitHub sign-up has no password. A bare `auth login` used to prompt
// for one anyway and die with "password: no input provided".
func TestDefaultToBrowserLogin_BareLoginInATerminalUsesTheBrowser(t *testing.T) {
assert.True(t, defaultToBrowserLogin("", "", true, "windows", env(nil)))
assert.True(t, defaultToBrowserLogin("", "", true, "darwin", env(nil)))
assert.True(t, defaultToBrowserLogin("", "", true, "linux", env(map[string]string{"DISPLAY": ":0"})))
assert.True(t, defaultToBrowserLogin("", "", true, "linux", env(map[string]string{"WAYLAND_DISPLAY": "wayland-0"})))
}

func TestDefaultToBrowserLogin_CredentialFlagsKeepEmailAndPassword(t *testing.T) {
assert.False(t, defaultToBrowserLogin("ada@example.com", "", true, "darwin", env(nil)))
assert.False(t, defaultToBrowserLogin("", "hunter2", true, "darwin", env(nil)))
}

// A script piping credentials must never start waiting on a browser.
func TestDefaultToBrowserLogin_NonInteractiveStdinKeepsEmailAndPassword(t *testing.T) {
assert.False(t, defaultToBrowserLogin("", "", false, "darwin", env(nil)))
}

// The callback listens on 127.0.0.1, which a browser across an SSH session cannot
// reach — the login would hang until it timed out.
func TestDefaultToBrowserLogin_NoLocalBrowserKeepsEmailAndPassword(t *testing.T) {
for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} {
assert.False(t, defaultToBrowserLogin("", "", true, "darwin", env(map[string]string{key: "x"})), key)
}

assert.False(t, defaultToBrowserLogin("", "", true, "linux", env(nil)))
}

func TestErrNoPasswordEntered_PointsAtBrowserLogin(t *testing.T) {
assert.Contains(t, errNoPasswordEntered.Error(), "modelslab auth login --browser")
assert.Contains(t, errNoPasswordEntered.Error(), "forgot-password")
}
8 changes: 7 additions & 1 deletion internal/cmd/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func promptLine(label string) (string, error) {
// which lands in shell history, ps output and CI logs. Fall back to reading the
// line instead; there is no echo to suppress when nobody is typing.
func promptSecret(label string) (string, error) {
if !term.IsTerminal(int(syscall.Stdin)) {
if !stdinIsTerminal() {
return promptLine(label)
}

Expand All @@ -69,6 +69,12 @@ func promptSecret(label string) (string, error) {
return secret, nil
}

// stdinIsTerminal reports whether a person could be typing at stdin, as opposed to
// a pipe, a heredoc or CI.
func stdinIsTerminal() bool {
return term.IsTerminal(int(syscall.Stdin))
}

// fieldName turns a prompt label ("Email: ") into something an error can read
// naturally ("email").
func fieldName(label string) string {
Expand Down
13 changes: 12 additions & 1 deletion internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"

Expand All @@ -14,6 +15,7 @@ import (
"github.com/ModelsLab/modelslab-cli/internal/output"
"github.com/ModelsLab/modelslab-cli/internal/updater"
"github.com/spf13/cobra"
"golang.org/x/term"
)

var (
Expand Down Expand Up @@ -80,6 +82,9 @@ Designed for both humans and AI agents.`,
flagNoColor = true
}

if runtime.GOOS == "windows" {
updater.RemoveStaleBackups()
}
maybeNotifyUpdate(cmd)

return nil
Expand Down Expand Up @@ -181,7 +186,8 @@ func maybeNotifyUpdate(cmd *cobra.Command) {
return
}

fmt.Fprintf(os.Stderr, "Update available: ModelsLab CLI %s (current %s). Run `modelslab update`.\n\n", formatVersion(info.LatestVersion), formatVersion(info.CurrentVersion))
fmt.Fprintf(os.Stderr, "Update available: ModelsLab CLI %s (current %s). Run: %s\n\n",
formatVersion(info.LatestVersion), formatVersion(info.CurrentVersion), updater.CurrentInstallMethod().UpdateCommand)
}

func shouldSkipUpdateNotification(cmd *cobra.Command) bool {
Expand All @@ -194,6 +200,11 @@ func shouldSkipUpdateNotification(cmd *cobra.Command) bool {
if flagOutput == string(output.FormatJSON) || flagJQ != "" {
return true
}
// Nobody reads a notice in CI, and a script that captures stderr should not
// find one in its logs.
if os.Getenv("CI") != "" || !term.IsTerminal(int(os.Stderr.Fd())) {
return true
}
if !updater.IsComparableVersion(cliVersion) {
return true
}
Expand Down
21 changes: 18 additions & 3 deletions internal/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,17 @@ var updateCmd = &cobra.Command{
Long: `Check GitHub Releases for a newer ModelsLab CLI version and install it.

The updater downloads the release archive for your platform, verifies it against
the release checksums.txt file, and replaces the current executable.`,
the release checksums.txt file, and replaces the current executable.

If pip, pipx, uv, npm, Homebrew or Scoop installed the CLI, that package manager
owns the file. The updater does not replace it and shows the command to run.`,
RunE: func(cmd *cobra.Command, args []string) error {
if updateTimeout <= 0 {
updateTimeout = 2 * time.Minute
}

method := updater.CurrentInstallMethod()

ctx, cancel := context.WithTimeout(cmd.Context(), updateTimeout)
defer cancel()

Expand All @@ -48,11 +53,21 @@ the release checksums.txt file, and replaces the current executable.`,
SkipChecksum: updateSkipChecksum,
}

if updateCheckOnly {
if updateCheckOnly || method.Managed() {
info, err := updater.Check(ctx, options.CheckOptions)
if err != nil {
return err
}
info.InstallMethod = method.Name
info.UpdateCommand = method.UpdateCommand

if !updateCheckOnly && (info.UpdateAvailable || updateForce) {
return fmt.Errorf(
"%s installed this copy of the ModelsLab CLI, so %s must update it.\n Run: %s",
method.Name, method.Name, method.UpdateCommand,
)
}

outputResult(info, func() {
printUpdateCheck(info)
})
Expand Down Expand Up @@ -83,7 +98,7 @@ func printUpdateCheck(info *updater.Info) {

if info.UpdateAvailable {
fmt.Printf("Update available: %s -> %s\n", formatVersion(info.CurrentVersion), formatVersion(info.LatestVersion))
fmt.Println("Run `modelslab update` to install it.")
fmt.Printf("Run: %s\n", info.UpdateCommand)
return
}

Expand Down
Loading
Loading