diff --git a/README.md b/README.md
index 41edad5..2037478 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,10 @@ modelslab auth login --browser
# Email/password login also gets both token and API key
modelslab auth login --email you@example.com --password "..."
+# 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`.
+
# Or set API key manually
modelslab config set api_key "your-api-key"
diff --git a/cmd/modelslab/main.go b/cmd/modelslab/main.go
index 6cb3c44..ba61e06 100644
--- a/cmd/modelslab/main.go
+++ b/cmd/modelslab/main.go
@@ -1,7 +1,6 @@
package main
import (
- "fmt"
"os"
"github.com/ModelsLab/modelslab-cli/internal/cmd"
@@ -17,7 +16,6 @@ var (
func main() {
cmd.SetVersion(version, commit, date)
if err := cmd.Execute(); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ os.Exit(cmd.ReportError(err))
}
}
diff --git a/internal/api/client.go b/internal/api/client.go
index 7ad71ae..d1e4bd0 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -8,26 +8,61 @@ import (
"math"
"net/http"
"os"
+ "regexp"
+ "sort"
"strconv"
"strings"
"time"
+ "unicode/utf8"
"github.com/google/uuid"
)
// Exit codes matching the design document
const (
- ExitSuccess = 0
- ExitGeneralError = 1
- ExitUsageError = 2
- ExitAuthError = 3
- ExitRateLimited = 4
- ExitNotFound = 5
- ExitPaymentError = 6
- ExitGenTimeout = 7
- ExitNetworkError = 10
+ ExitSuccess = 0
+ ExitGeneralError = 1
+ ExitUsageError = 2
+ ExitAuthError = 3
+ ExitRateLimited = 4
+ ExitNotFound = 5
+ ExitPaymentError = 6
+ ExitGenTimeout = 7
+ ExitNetworkError = 10
)
+// maxRawErrorBody caps how much of an unrecognised error body is echoed back.
+const maxRawErrorBody = 300
+
+// htmlTitle pulls the
out of a proxy's error page.
+var htmlTitle = regexp.MustCompile(`(?is)]*>(.*?)`)
+
+// maxRateLimitWait is the longest window the client will sleep through before it
+// gives the terminal back. Laravel's throttle routinely names 60s; blocking that
+// long without a word looks like a hang.
+const maxRateLimitWait = 30 * time.Second
+
+// rateLimitWait reads how long the server asked us to wait. Retry-After is the
+// standard header and the one Laravel's throttle middleware sends;
+// X-RateLimit-Reset is an absolute unix timestamp.
+func rateLimitWait(header http.Header) time.Duration {
+ if value := header.Get("Retry-After"); value != "" {
+ if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds > 0 {
+ return time.Duration(seconds) * time.Second
+ }
+ }
+
+ if value := header.Get("X-RateLimit-Reset"); value != "" {
+ if resetAt, err := strconv.ParseInt(value, 10, 64); err == nil {
+ if seconds := resetAt - time.Now().Unix(); seconds > 0 {
+ return time.Duration(seconds) * time.Second
+ }
+ }
+ }
+
+ return 0
+}
+
type Client struct {
BaseURL string
Token string // Bearer token for control plane
@@ -38,8 +73,34 @@ type Client struct {
type APIError struct {
StatusCode int
+ Code string
Message string
- ExitCode int
+ // Details carries the control plane's per-field validation errors, keyed by
+ // field name. Dropping it left the CLI guessing which field the server
+ // actually rejected.
+ Details map[string][]string
+ ExitCode int
+}
+
+// FieldErrors renders Details as "field: message" lines, sorted for stable output.
+func (e *APIError) FieldErrors() []string {
+ if len(e.Details) == 0 {
+ return nil
+ }
+
+ fields := make([]string, 0, len(e.Details))
+ for field := range e.Details {
+ fields = append(fields, field)
+ }
+ sort.Strings(fields)
+
+ lines := make([]string, 0, len(fields))
+ for _, field := range fields {
+ for _, message := range e.Details[field] {
+ lines = append(lines, field+": "+message)
+ }
+ }
+ return lines
}
func (e *APIError) Error() string {
@@ -171,20 +232,22 @@ func (c *Client) doRequest(method, path string, body interface{}, result interfa
// Handle rate limiting
if resp.StatusCode == 429 {
- if attempt < maxRetries {
- waitTime := math.Pow(2, float64(attempt))
- if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" {
- if resetTime, err := strconv.ParseInt(reset, 10, 64); err == nil {
- waitSecs := resetTime - time.Now().Unix()
- if waitSecs > 0 && waitSecs < 30 {
- waitTime = float64(waitSecs)
- }
- }
+ retryAfter := rateLimitWait(resp.Header)
+ if attempt < maxRetries && retryAfter <= maxRateLimitWait {
+ waitTime := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+ if retryAfter > 0 {
+ waitTime = retryAfter
}
- time.Sleep(time.Duration(waitTime) * time.Second)
+ time.Sleep(waitTime)
continue
}
- return &APIError{StatusCode: 429, Message: "Rate limited. Please try again later.", ExitCode: ExitRateLimited}
+ // Keep the server's own message and the window it named. The old
+ // blanket "try again later" sent people straight back into the wall.
+ apiErr := parseAPIError(429, respBody)
+ if retryAfter > 0 {
+ apiErr.Message = fmt.Sprintf("%s Retry in %s.", apiErr.Message, retryAfter)
+ }
+ return apiErr
}
// Handle server errors with retry
@@ -230,18 +293,106 @@ func parseAPIError(statusCode int, body []byte) *APIError {
exitCode = ExitRateLimited
}
- // Try to extract message from JSON response
+ code, message, details := extractAPIErrorFields(body)
+ if message == "" {
+ message = summarizeRawBody(body, statusCode)
+ }
+
+ return &APIError{StatusCode: statusCode, Code: code, Message: message, Details: details, ExitCode: exitCode}
+}
+
+// extractAPIErrorFields pulls the machine code and the human message out of an
+// error body. The control plane wraps every failure as
+//
+// {"data":null,"error":{"code":"invalid_credentials","message":"..."},"meta":{...}}
+//
+// so the message is NOT at the top level and "error" is an object, not a string.
+// Reading only the top level printed the whole JSON blob back at the user on
+// every failed login. The flatter shapes are still handled: some legacy
+// endpoints answer {"message":...} or {"error":"..."}.
+func extractAPIErrorFields(body []byte) (string, string, map[string][]string) {
var errResp map[string]interface{}
- message := string(body)
- if err := json.Unmarshal(body, &errResp); err == nil {
- if msg, ok := errResp["message"].(string); ok {
- message = msg
- } else if msg, ok := errResp["error"].(string); ok {
- message = msg
+ if err := json.Unmarshal(body, &errResp); err != nil {
+ return "", "", nil
+ }
+
+ if errObj, ok := errResp["error"].(map[string]interface{}); ok {
+ code, _ := errObj["code"].(string)
+ message, _ := errObj["message"].(string)
+ if message == "" {
+ message, _ = errResp["message"].(string)
}
+ return code, message, parseErrorDetails(errObj["details"])
+ }
+
+ if msg, ok := errResp["message"].(string); ok && msg != "" {
+ code, _ := errResp["code"].(string)
+ return code, msg, parseErrorDetails(errResp["errors"])
+ }
+
+ if msg, ok := errResp["error"].(string); ok && msg != "" {
+ code, _ := errResp["code"].(string)
+ return code, msg, parseErrorDetails(errResp["errors"])
+ }
+
+ return "", "", nil
+}
+
+// parseErrorDetails normalises Laravel's per-field error bag. It arrives as
+// {"field": ["message", ...]}, but an empty bag serialises as [] rather than {},
+// and some codes put a flat object there instead.
+func parseErrorDetails(raw interface{}) map[string][]string {
+ bag, ok := raw.(map[string]interface{})
+ if !ok || len(bag) == 0 {
+ return nil
}
- return &APIError{StatusCode: statusCode, Message: message, ExitCode: exitCode}
+ details := make(map[string][]string, len(bag))
+ for field, value := range bag {
+ switch typed := value.(type) {
+ case string:
+ details[field] = []string{typed}
+ case []interface{}:
+ for _, item := range typed {
+ if message, ok := item.(string); ok {
+ details[field] = append(details[field], message)
+ }
+ }
+ }
+ }
+
+ if len(details) == 0 {
+ return nil
+ }
+ return details
+}
+
+// summarizeRawBody is the last resort when the body carries no message we
+// recognise. An unparseable body is often an HTML error page from a proxy, and
+// dumping the whole thing into the terminal helps nobody.
+func summarizeRawBody(body []byte, statusCode int) string {
+ raw := strings.TrimSpace(string(body))
+ if raw == "" {
+ return fmt.Sprintf("Request failed with HTTP %d.", statusCode)
+ }
+
+ // A proxy error page is HTML. Its ("502 Bad Gateway") is the only
+ // part worth showing; the markup around it is noise in a terminal.
+ if match := htmlTitle.FindSubmatch(body); match != nil {
+ if title := strings.TrimSpace(string(match[1])); title != "" {
+ return fmt.Sprintf("%s (HTTP %d)", title, statusCode)
+ }
+ }
+
+ if len(raw) > maxRawErrorBody {
+ // Slice on a rune boundary; a body cut mid-rune renders as U+FFFD.
+ cut := maxRawErrorBody
+ for cut > 0 && !utf8.RuneStart(raw[cut]) {
+ cut--
+ }
+ return raw[:cut] + "…"
+ }
+ return raw
}
// GenerateIdempotencyKey creates a UUID for idempotent billing operations.
diff --git a/internal/api/client_test.go b/internal/api/client_test.go
index bbc2745..daac42f 100644
--- a/internal/api/client_test.go
+++ b/internal/api/client_test.go
@@ -4,7 +4,11 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strconv"
+ "strings"
"testing"
+ "time"
+ "unicode/utf8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -286,3 +290,121 @@ func TestDoControlPlane_ServerError_Retry(t *testing.T) {
assert.Equal(t, "ok", result["status"])
assert.GreaterOrEqual(t, attempts, 3)
}
+
+// The control plane wraps every failure as {"data":null,"error":{...},"meta":{...}}.
+// Reading only the top level used to print the whole JSON blob at the user.
+func TestParseAPIError_ControlPlaneEnvelope(t *testing.T) {
+ body := `{"data":null,"error":{"code":"invalid_credentials","message":"Email or password is incorrect.","details":[]},"meta":{"request_id":"abc"}}`
+
+ err := parseAPIError(401, []byte(body))
+
+ assert.Equal(t, "Email or password is incorrect.", err.Message)
+ assert.Equal(t, "invalid_credentials", err.Code)
+ assert.Equal(t, ExitAuthError, err.ExitCode)
+}
+
+func TestParseAPIError_ControlPlaneEnvelope_Unverified(t *testing.T) {
+ body := `{"data":null,"error":{"code":"email_not_verified","message":"Email is not verified.","details":{"verification_required":true}},"meta":{}}`
+
+ err := parseAPIError(403, []byte(body))
+
+ assert.Equal(t, "Email is not verified.", err.Message)
+ assert.Equal(t, "email_not_verified", err.Code)
+ assert.Equal(t, ExitAuthError, err.ExitCode)
+}
+
+func TestParseAPIError_TruncatesUnrecognisedBody(t *testing.T) {
+ body := strings.Repeat("proxy error", 100)
+
+ err := parseAPIError(502, []byte(body))
+
+ assert.LessOrEqual(t, len(err.Message), maxRawErrorBody+len("…"))
+ assert.Empty(t, err.Code)
+}
+
+func TestParseAPIError_EmptyBody(t *testing.T) {
+ err := parseAPIError(500, nil)
+
+ assert.Equal(t, "Request failed with HTTP 500.", err.Message)
+}
+
+func TestParseAPIError_KeepsValidationDetails(t *testing.T) {
+ body := `{"data":null,"error":{"code":"validation_error","message":"Invalid login payload.","details":{"token_expiry":["The selected token expiry is invalid."],"email":["The email field is required."]}},"meta":{}}`
+
+ err := parseAPIError(422, []byte(body))
+
+ assert.Equal(t, "validation_error", err.Code)
+ assert.Equal(t, []string{
+ "email: The email field is required.",
+ "token_expiry: The selected token expiry is invalid.",
+ }, err.FieldErrors())
+}
+
+// An empty details bag serialises as [] rather than {}.
+func TestParseAPIError_EmptyDetailsBag(t *testing.T) {
+ err := parseAPIError(401, []byte(`{"data":null,"error":{"code":"invalid_credentials","message":"Nope.","details":[]}}`))
+
+ assert.Nil(t, err.Details)
+ assert.Nil(t, err.FieldErrors())
+}
+
+func TestParseAPIError_TruncationLandsOnARuneBoundary(t *testing.T) {
+ err := parseAPIError(502, []byte(strings.Repeat("é", 400)))
+
+ assert.True(t, utf8.ValidString(err.Message), "truncated body must stay valid UTF-8")
+}
+
+// Laravel's throttle sends Retry-After; the old code read only
+// X-RateLimit-Reset and replaced the server's message with a blanket one.
+func TestRateLimitWait_PrefersRetryAfter(t *testing.T) {
+ header := http.Header{}
+ header.Set("Retry-After", "45")
+
+ assert.Equal(t, 45*time.Second, rateLimitWait(header))
+}
+
+func TestRateLimitWait_FallsBackToResetTimestamp(t *testing.T) {
+ header := http.Header{}
+ header.Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Unix()+12, 10))
+
+ wait := rateLimitWait(header)
+
+ assert.Greater(t, wait, 10*time.Second)
+ assert.LessOrEqual(t, wait, 12*time.Second)
+}
+
+func TestRateLimitWait_NoHeaders(t *testing.T) {
+ assert.Zero(t, rateLimitWait(http.Header{}))
+}
+
+// A long Retry-After must surface immediately instead of blocking the terminal,
+// and must keep the server's own wording and window.
+func TestDoControlPlane_LongRateLimitReturnsImmediately(t *testing.T) {
+ attempts := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ w.Header().Set("Retry-After", "60")
+ w.WriteHeader(429)
+ w.Write([]byte(`{"data":null,"error":{"code":"rate_limited","message":"Too Many Attempts."}}`))
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "token", "")
+ err := client.DoControlPlane("GET", "/api-keys", nil, nil)
+
+ require.Error(t, err)
+ apiErr := err.(*APIError)
+ assert.Equal(t, ExitRateLimited, apiErr.ExitCode)
+ assert.Contains(t, apiErr.Message, "Too Many Attempts.")
+ assert.Contains(t, apiErr.Message, "1m0s")
+ assert.Equal(t, 1, attempts, "a 60s window must not be slept through")
+}
+
+// A proxy error page is HTML; its is the only part worth a terminal line.
+func TestParseAPIError_SummarisesAProxyErrorPage(t *testing.T) {
+ body := `502 Bad Gateway502 Bad Gateway
nginx`
+
+ err := parseAPIError(502, []byte(body))
+
+ assert.Equal(t, "502 Bad Gateway (HTTP 502)", err.Message)
+}
diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go
index 9c2bca7..0bfb473 100644
--- a/internal/cmd/auth.go
+++ b/internal/cmd/auth.go
@@ -15,14 +15,12 @@ import (
"runtime"
"strconv"
"strings"
- "syscall"
"time"
"github.com/ModelsLab/modelslab-cli/internal/api"
"github.com/ModelsLab/modelslab-cli/internal/auth"
"github.com/ModelsLab/modelslab-cli/internal/output"
"github.com/spf13/cobra"
- "golang.org/x/term"
)
var authCmd = &cobra.Command{
@@ -61,17 +59,18 @@ var authLoginCmd = &cobra.Command{
deviceName, _ := cmd.Flags().GetString("device-name")
if email == "" {
- fmt.Print("Email: ")
- fmt.Scanln(&email)
+ value, err := promptLine("Email: ")
+ if err != nil {
+ return err
+ }
+ email = value
}
if password == "" {
- fmt.Print("Password: ")
- bytePw, err := term.ReadPassword(int(syscall.Stdin))
+ value, err := promptSecret("Password: ")
if err != nil {
- return fmt.Errorf("could not read password: %w", err)
+ return err
}
- password = string(bytePw)
- fmt.Println()
+ password = value
}
if expiry == "" {
@@ -94,7 +93,7 @@ var authLoginCmd = &cobra.Command{
if err != nil {
apiErr, ok := err.(*api.APIError)
if ok {
- output.PrintError(apiErr.Message, "Check your email and password.", "Run: modelslab auth forgot-password")
+ output.PrintError(apiErr.Message, loginFailureHints(apiErr, email)...)
os.Exit(apiErr.ExitCode)
}
return err
@@ -110,7 +109,9 @@ var authLoginCmd = &cobra.Command{
}
// Also store API key if returned
if k, ok := data["api_key"].(string); ok && k != "" {
- auth.StoreAPIKey(flagProfile, k)
+ if err := auth.StoreAPIKey(flagProfile, k); err != nil {
+ return fmt.Errorf("logged in, but could not store the API key: %w", err)
+ }
}
} else if t, ok := result["token"].(string); ok {
token = t
@@ -122,9 +123,17 @@ var authLoginCmd = &cobra.Command{
return fmt.Errorf("no token returned from login")
}
- // Store credentials
- auth.StoreToken(flagProfile, token)
- auth.StoreEmail(flagProfile, email)
+ // Checked, not fire-and-forget. When both the keychain and the file
+ // fallback fail — a read-only ~/.config, a denied keychain prompt — the
+ // CLI used to print "Logged in" and exit 0 while storing nothing, and
+ // every later command 401'd for no visible reason.
+ if err := auth.StoreToken(flagProfile, token); err != nil {
+ return fmt.Errorf("logged in, but could not store the access token: %w", err)
+ }
+ if err := auth.StoreEmail(flagProfile, email); err != nil {
+ return fmt.Errorf("logged in, but could not store the account email: %w", err)
+ }
+ apiClient = nil
outputResult(result, func() {
output.PrintSuccess(fmt.Sprintf("Logged in as %s (profile: %s)", email, flagProfile))
@@ -134,6 +143,50 @@ var authLoginCmd = &cobra.Command{
},
}
+// loginFailureHints turns a control-plane error code into next steps a user can
+// actually act on.
+//
+// The old blanket "check your email and password" was wrong for the two failures
+// people actually hit. An account created through Google or GitHub is stored with
+// a random password nobody ever sees, so email+password login can never succeed
+// for it and the only way in is `--browser`. And an unverified account fails with
+// credentials that are perfectly correct.
+func loginFailureHints(apiErr *api.APIError, email string) []string {
+ switch apiErr.Code {
+ case "email_not_verified":
+ hint := "Run: modelslab auth resend-verification"
+ if email != "" {
+ hint += " --email " + email
+ }
+ return []string{"This account exists but its email is not verified yet.", hint}
+ case "access_denied":
+ return []string{"Contact support@modelslab.com if you think this is a mistake."}
+ case "validation_error":
+ // Say which field the server rejected instead of guessing at --email.
+ if fields := apiErr.FieldErrors(); len(fields) > 0 {
+ return append([]string{"The server rejected these values:"}, fields...)
+ }
+ return []string{"The server rejected the login payload — check --email and --expiry."}
+ case "invalid_credentials":
+ return []string{
+ "Check your email and password.",
+ "Signed up with Google or GitHub? That account has no password — run: modelslab auth login --browser",
+ "Otherwise run: modelslab auth forgot-password",
+ }
+ case "":
+ // No code means we could not read the response at all — a proxy error
+ // page, a captive portal, an outage. Saying "check your password" here
+ // sends people to reset a password that was never the problem.
+ return []string{
+ fmt.Sprintf("The server did not return a recognisable error (HTTP %d).", apiErr.StatusCode),
+ "This is usually a network or service problem, not your credentials. Try again shortly.",
+ "Check https://modelslab.com/status, or pass --base-url if you are pointing at a non-default host.",
+ }
+ default:
+ return []string{"Run: modelslab auth login --browser", "Or run: modelslab auth forgot-password"}
+ }
+}
+
func runBrowserLogin(cmd *cobra.Command) error {
expiry, _ := cmd.Flags().GetString("expiry")
deviceName, _ := cmd.Flags().GetString("device-name")
@@ -199,13 +252,21 @@ func runBrowserLogin(cmd *cobra.Command) error {
return err
}
- if noOpen {
- fmt.Fprintf(os.Stderr, "Open this URL in Chrome to authorize ModelsLab CLI:\n%s\n\n", loginURL)
- } else {
- fmt.Fprintln(os.Stderr, "Opening Google Chrome for ModelsLab login...")
+ /*
+ * The URL is printed unconditionally, before any attempt to open it.
+ *
+ * openBrowser uses exec.Start(), which returns nil the moment the child is
+ * spawned — so `xdg-open` with no display, or a Chrome that dies on launch,
+ * both looked like success. The user saw "Waiting for browser
+ * authorization..." and then a timeout five minutes later, and was never
+ * once shown the URL they could have pasted somewhere that works.
+ */
+ fmt.Fprintf(os.Stderr, "Authorize ModelsLab CLI at:\n%s\n\n", loginURL)
+
+ if !noOpen {
+ fmt.Fprintln(os.Stderr, "Opening your browser...")
if err := openBrowser(loginURL); err != nil {
- fmt.Fprintf(os.Stderr, "Could not open Chrome automatically: %v\n", err)
- fmt.Fprintf(os.Stderr, "Open this URL manually:\n%s\n\n", loginURL)
+ fmt.Fprintf(os.Stderr, "Could not open a browser automatically (%v) — open the URL above yourself.\n", err)
}
}
fmt.Fprintln(os.Stderr, "Waiting for browser authorization...")
@@ -219,7 +280,13 @@ func runBrowserLogin(cmd *cobra.Command) error {
case err := <-serveErrCh:
return fmt.Errorf("OAuth callback server failed: %w", err)
case <-timer.C:
- return fmt.Errorf("browser login timed out after %s", timeout)
+ return fmt.Errorf(
+ "browser login timed out after %s.\n"+
+ " Open the URL above and finish the grant there. If the browser sent you to the model\n"+
+ " catalogue instead of a grant page, sign in at https://modelslab.com/login first and retry.\n"+
+ " Raise the wait with --timeout, or print the URL without opening a browser with --no-open",
+ timeout,
+ )
}
if callback.Error != "" {
@@ -402,21 +469,25 @@ var authSignupCmd = &cobra.Command{
name, _ := cmd.Flags().GetString("name")
if email == "" {
- fmt.Print("Email: ")
- fmt.Scanln(&email)
+ value, err := promptLine("Email: ")
+ if err != nil {
+ return err
+ }
+ email = value
}
if password == "" {
- fmt.Print("Password: ")
- bytePw, err := term.ReadPassword(int(syscall.Stdin))
+ value, err := promptSecret("Password: ")
if err != nil {
- return fmt.Errorf("could not read password: %w", err)
+ return err
}
- password = string(bytePw)
- fmt.Println()
+ password = value
}
if name == "" {
- fmt.Print("Name: ")
- fmt.Scanln(&name)
+ value, err := promptLine("Name: ")
+ if err != nil {
+ return err
+ }
+ name = value
}
client := getClient()
@@ -523,8 +594,11 @@ var authForgotPasswordCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
email, _ := cmd.Flags().GetString("email")
if email == "" {
- fmt.Print("Email: ")
- fmt.Scanln(&email)
+ value, err := promptLine("Email: ")
+ if err != nil {
+ return err
+ }
+ email = value
}
client := getClient()
@@ -578,8 +652,11 @@ var authResendVerificationCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
email, _ := cmd.Flags().GetString("email")
if email == "" {
- fmt.Print("Email: ")
- fmt.Scanln(&email)
+ value, err := promptLine("Email: ")
+ if err != nil {
+ return err
+ }
+ email = value
}
client := getClient()
diff --git a/internal/cmd/auth_hints_test.go b/internal/cmd/auth_hints_test.go
new file mode 100644
index 0000000..af60e49
--- /dev/null
+++ b/internal/cmd/auth_hints_test.go
@@ -0,0 +1,62 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/ModelsLab/modelslab-cli/internal/api"
+ "github.com/stretchr/testify/assert"
+)
+
+func hintsFor(code string, status int, details map[string][]string) []string {
+ return loginFailureHints(&api.APIError{Code: code, StatusCode: status, Details: details}, "maxx@example.com")
+}
+
+// An OAuth-only account has no password, so "check your email and password" is a
+// dead end — the hint has to name the path that can actually work.
+func TestLoginFailureHints_InvalidCredentialsPointsAtBrowserLogin(t *testing.T) {
+ hints := hintsFor("invalid_credentials", 401, nil)
+
+ assert.Contains(t, joined(hints), "auth login --browser")
+ assert.Contains(t, joined(hints), "forgot-password")
+}
+
+// A proxy 502 carries no error code. Telling that user their password is wrong
+// is the multi-day rabbit hole this whole ticket is about.
+func TestLoginFailureHints_UnrecognisedErrorIsNotBlamedOnCredentials(t *testing.T) {
+ hints := hintsFor("", 502, nil)
+
+ assert.NotContains(t, joined(hints), "Check your email and password")
+ assert.NotContains(t, joined(hints), "forgot-password")
+ assert.Contains(t, joined(hints), "502")
+ assert.Contains(t, joined(hints), "network or service problem")
+}
+
+func TestLoginFailureHints_UnverifiedEmailOffersResend(t *testing.T) {
+ hints := hintsFor("email_not_verified", 403, nil)
+
+ assert.Contains(t, joined(hints), "resend-verification --email maxx@example.com")
+}
+
+// The old hint blamed --email whatever the server actually rejected.
+func TestLoginFailureHints_ValidationErrorNamesTheRejectedField(t *testing.T) {
+ hints := hintsFor("validation_error", 422, map[string][]string{
+ "token_expiry": {"The selected token expiry is invalid."},
+ })
+
+ assert.Contains(t, joined(hints), "token_expiry: The selected token expiry is invalid.")
+ assert.NotContains(t, joined(hints), "Check the email address")
+}
+
+func TestLoginFailureHints_ValidationErrorWithoutDetailsStillGuides(t *testing.T) {
+ hints := hintsFor("validation_error", 422, nil)
+
+ assert.Contains(t, joined(hints), "--email")
+}
+
+func joined(hints []string) string {
+ out := ""
+ for _, hint := range hints {
+ out += hint + "\n"
+ }
+ return out
+}
diff --git a/internal/cmd/exit.go b/internal/cmd/exit.go
new file mode 100644
index 0000000..f2540be
--- /dev/null
+++ b/internal/cmd/exit.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+ "errors"
+ "os"
+
+ "github.com/ModelsLab/modelslab-cli/internal/api"
+ "github.com/ModelsLab/modelslab-cli/internal/auth"
+ "github.com/ModelsLab/modelslab-cli/internal/output"
+)
+
+// ReportError prints a failed command's error and returns the process exit code.
+//
+// Only `auth login` used to map an APIError to its documented exit code; every
+// other command exited 1, so a script could not tell an expired token (3) from a
+// bad flag (2) or a rate limit (4). And a 401 was reported as a bare
+// "Unauthenticated." with no hint that the CLI had simply never been logged in —
+// which is what it looks like when the keychain is locked and getClient()
+// silently sends no Authorization header at all.
+func ReportError(err error) int {
+ var apiErr *api.APIError
+ if !errors.As(err, &apiErr) {
+ output.PrintError(err.Error())
+ return api.ExitGeneralError
+ }
+
+ output.PrintError(apiErr.Message, commandFailureHints(apiErr)...)
+
+ return apiErr.ExitCode
+}
+
+func commandFailureHints(apiErr *api.APIError) []string {
+ hints := apiErr.FieldErrors()
+
+ if apiErr.ExitCode == api.ExitAuthError && !hasStoredCredentials() {
+ hints = append(hints,
+ "No credentials are stored for profile "+flagProfile+".",
+ "Run: modelslab auth login --browser",
+ "If you are logged in, your OS keychain may be locked — check: modelslab auth status",
+ )
+ }
+
+ return hints
+}
+
+func hasStoredCredentials() bool {
+ if os.Getenv("MODELSLAB_TOKEN") != "" || flagAPIKey != "" {
+ return true
+ }
+ if token, err := auth.GetToken(flagProfile); err == nil && token != "" {
+ return true
+ }
+ key, err := auth.GetAPIKey(flagProfile)
+
+ return err == nil && key != ""
+}
diff --git a/internal/cmd/exit_test.go b/internal/cmd/exit_test.go
new file mode 100644
index 0000000..5cca2e2
--- /dev/null
+++ b/internal/cmd/exit_test.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/ModelsLab/modelslab-cli/internal/api"
+ "github.com/stretchr/testify/assert"
+)
+
+// Only `auth login` used to map an APIError to its documented exit code; every
+// other command exited 1, so a script could not tell an expired token from a
+// bad flag.
+func TestReportError_UsesTheApiExitCode(t *testing.T) {
+ cases := map[int]error{
+ api.ExitAuthError: &api.APIError{StatusCode: 401, Message: "Unauthenticated.", ExitCode: api.ExitAuthError},
+ api.ExitRateLimited: &api.APIError{StatusCode: 429, Message: "Too Many Attempts.", ExitCode: api.ExitRateLimited},
+ api.ExitNotFound: &api.APIError{StatusCode: 404, Message: "Not found.", ExitCode: api.ExitNotFound},
+ api.ExitPaymentError: &api.APIError{StatusCode: 402, Message: "Insufficient balance.", ExitCode: api.ExitPaymentError},
+ api.ExitGeneralError: errors.New("something else"),
+ }
+
+ for want, err := range cases {
+ assert.Equal(t, want, ReportError(err))
+ }
+}
+
+func TestReportError_WrappedApiErrorStillMaps(t *testing.T) {
+ wrapped := errors.Join(errors.New("context"), &api.APIError{StatusCode: 401, ExitCode: api.ExitAuthError})
+
+ assert.Equal(t, api.ExitAuthError, ReportError(wrapped))
+}
diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go
index c8e30ab..971b609 100644
--- a/internal/cmd/mcp.go
+++ b/internal/cmd/mcp.go
@@ -20,7 +20,7 @@ var mcpServeCmd = &cobra.Command{
transport, _ := cmd.Flags().GetString("transport")
client := getClient()
- server := mcpserver.NewServer(client)
+ server := mcpserver.NewServer(client, flagProfile)
switch transport {
case "stdio":
@@ -39,7 +39,7 @@ var mcpToolsCmd = &cobra.Command{
Short: "List available MCP tools",
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
- server := mcpserver.NewServer(client)
+ server := mcpserver.NewServer(client, flagProfile)
tools := server.ListTools()
outputResult(tools, func() {
diff --git a/internal/cmd/prompt.go b/internal/cmd/prompt.go
new file mode 100644
index 0000000..320f573
--- /dev/null
+++ b/internal/cmd/prompt.go
@@ -0,0 +1,76 @@
+package cmd
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "syscall"
+
+ "golang.org/x/term"
+)
+
+// errNoInput is returned when a prompt gets nothing to work with.
+var errNoInput = errors.New("no input provided")
+
+// stdinReader is shared so a secret read from a pipe does not lose whatever the
+// next prompt was going to read. bufio would swallow it in its own buffer.
+var stdinReader = bufio.NewReader(os.Stdin)
+
+// promptLine reads one whole line.
+//
+// It replaces fmt.Scanln, which had two failure modes here: it stops at the
+// first space, so `auth signup` recorded "Ada" for "Ada Lovelace" and left the
+// rest to corrupt the next prompt; and on an empty line it returns an error the
+// caller ignored, so the command carried on with an empty email and the server
+// answered with a validation error about a flag the user never passed.
+func promptLine(label string) (string, error) {
+ fmt.Fprint(os.Stderr, label)
+
+ line, err := stdinReader.ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ return "", fmt.Errorf("could not read %s: %w", fieldName(label), err)
+ }
+
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return "", fmt.Errorf("%s: %w", fieldName(label), errNoInput)
+ }
+
+ return line, nil
+}
+
+// promptSecret reads a secret without echoing it.
+//
+// When stdin is not a terminal — a pipe, a heredoc, CI — term.ReadPassword fails
+// with "inappropriate ioctl for device", which used to abort the login entirely.
+// The only remaining non-interactive option was --password on the command line,
+// 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)) {
+ return promptLine(label)
+ }
+
+ fmt.Fprint(os.Stderr, label)
+ raw, err := term.ReadPassword(int(syscall.Stdin))
+ fmt.Fprintln(os.Stderr)
+ if err != nil {
+ return "", fmt.Errorf("could not read %s: %w", fieldName(label), err)
+ }
+
+ secret := strings.TrimRight(string(raw), "\r\n")
+ if secret == "" {
+ return "", fmt.Errorf("%s: %w", fieldName(label), errNoInput)
+ }
+
+ return secret, nil
+}
+
+// fieldName turns a prompt label ("Email: ") into something an error can read
+// naturally ("email").
+func fieldName(label string) string {
+ return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(label), ":")))
+}
diff --git a/internal/cmd/prompt_test.go b/internal/cmd/prompt_test.go
new file mode 100644
index 0000000..372fd67
--- /dev/null
+++ b/internal/cmd/prompt_test.go
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "bufio"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func withStdin(t *testing.T, input string) {
+ t.Helper()
+ original := stdinReader
+ stdinReader = bufio.NewReader(strings.NewReader(input))
+ t.Cleanup(func() { stdinReader = original })
+}
+
+// fmt.Scanln stopped at the first space, so `auth signup` recorded "Ada" for
+// "Ada Lovelace" and left "Lovelace" to corrupt the next prompt.
+func TestPromptLine_KeepsTheWholeLine(t *testing.T) {
+ withStdin(t, "Ada Lovelace\nnext-value\n")
+
+ name, err := promptLine("Name: ")
+ require.NoError(t, err)
+ assert.Equal(t, "Ada Lovelace", name)
+
+ next, err := promptLine("Next: ")
+ require.NoError(t, err)
+ assert.Equal(t, "next-value", next)
+}
+
+// An empty line used to leave the field empty and let the command run on, so the
+// server answered with a validation error about a flag the user never passed.
+func TestPromptLine_RejectsEmptyInput(t *testing.T) {
+ withStdin(t, "\n")
+
+ _, err := promptLine("Email: ")
+
+ require.ErrorIs(t, err, errNoInput)
+ assert.Contains(t, err.Error(), "email")
+}
+
+func TestPromptLine_RejectsClosedStdin(t *testing.T) {
+ withStdin(t, "")
+
+ _, err := promptLine("Email: ")
+
+ require.ErrorIs(t, err, errNoInput)
+}
+
+// term.ReadPassword fails with "inappropriate ioctl for device" when stdin is a
+// pipe, which aborted every non-interactive login.
+func TestPromptSecret_FallsBackWhenStdinIsNotATerminal(t *testing.T) {
+ withStdin(t, "hunter2\n")
+
+ secret, err := promptSecret("Password: ")
+
+ require.NoError(t, err)
+ assert.Equal(t, "hunter2", secret)
+}
+
+func TestFieldName(t *testing.T) {
+ assert.Equal(t, "email", fieldName("Email: "))
+ assert.Equal(t, "password", fieldName("Password: "))
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 03c2e9f..b804051 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"github.com/spf13/viper"
)
@@ -74,13 +75,50 @@ func Init() error {
projectViper.SetConfigFile(projectConfig)
projectViper.SetConfigType("toml")
if err := projectViper.ReadInConfig(); err == nil {
- viper.MergeConfigMap(projectViper.AllSettings())
+ viper.MergeConfigMap(stripUntrustedProjectKeys(projectViper.AllSettings()))
}
}
return nil
}
+/*
+ * untrustedProjectKeys are the settings a project-level config may NOT set.
+ *
+ * .modelslab/config.toml is read from the CURRENT WORKING DIRECTORY, so any
+ * repository you clone and cd into gets a say in it. Merged wholesale, a
+ * committed `base_url = "http://attacker/"` was enough to make the next
+ * `modelslab` command send the user's stored bearer token to that host —
+ * no prompt, no warning. `api_key` and `token` are just as bad in the other
+ * direction: a checked-out repo could silently swap in its own credentials and
+ * bill someone else's account.
+ *
+ * Everything harmless — default model, output format, output dir — still merges.
+ * Point the CLI at another host with --base-url or MODELSLAB_BASE_URL, both of
+ * which the user types themselves.
+ */
+var untrustedProjectKeys = []string{"api_key", "base_url", "token", "defaults.base_url"}
+
+func stripUntrustedProjectKeys(settings map[string]interface{}) map[string]interface{} {
+ for _, key := range untrustedProjectKeys {
+ parts := strings.Split(key, ".")
+ scope := settings
+ for _, part := range parts[:len(parts)-1] {
+ nested, ok := scope[part].(map[string]interface{})
+ if !ok {
+ scope = nil
+ break
+ }
+ scope = nested
+ }
+ if scope != nil {
+ delete(scope, parts[len(parts)-1])
+ }
+ }
+
+ return settings
+}
+
func ConfigDir() string {
return configDir
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 8b43433..0b12d65 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -61,3 +61,43 @@ func TestAllSettings(t *testing.T) {
settings := AllSettings()
assert.NotNil(t, settings)
}
+
+// A .modelslab/config.toml is read from the CURRENT WORKING DIRECTORY, so any
+// repository you clone and cd into gets a say in it. Merged wholesale, a
+// committed base_url was enough to redirect the user's stored bearer token to an
+// attacker-controlled host on the next command.
+func TestStripUntrustedProjectKeys(t *testing.T) {
+ settings := map[string]interface{}{
+ "base_url": "http://attacker.example",
+ "api_key": "ml-attacker-key",
+ "token": "attacker-token",
+ "defaults": map[string]interface{}{
+ "base_url": "http://attacker.example",
+ "output": "json",
+ },
+ "generation": map[string]interface{}{
+ "default_model": "sdxl",
+ "output_dir": "./out",
+ },
+ }
+
+ cleaned := stripUntrustedProjectKeys(settings)
+
+ assert.NotContains(t, cleaned, "base_url")
+ assert.NotContains(t, cleaned, "api_key")
+ assert.NotContains(t, cleaned, "token")
+
+ defaults := cleaned["defaults"].(map[string]interface{})
+ assert.NotContains(t, defaults, "base_url")
+ assert.Equal(t, "json", defaults["output"], "harmless preferences must still merge")
+
+ generation := cleaned["generation"].(map[string]interface{})
+ assert.Equal(t, "sdxl", generation["default_model"])
+ assert.Equal(t, "./out", generation["output_dir"])
+}
+
+func TestStripUntrustedProjectKeys_ToleratesMissingSections(t *testing.T) {
+ cleaned := stripUntrustedProjectKeys(map[string]interface{}{"generation": "not-a-map"})
+
+ assert.Equal(t, "not-a-map", cleaned["generation"])
+}
diff --git a/internal/mcp/server.go b/internal/mcp/server.go
index dbf8e2d..0eccdaf 100644
--- a/internal/mcp/server.go
+++ b/internal/mcp/server.go
@@ -7,6 +7,7 @@ import (
"net/http"
"github.com/ModelsLab/modelslab-cli/internal/api"
+ "github.com/ModelsLab/modelslab-cli/internal/auth"
mcpgo "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -18,15 +19,56 @@ type ToolInfo struct {
type Server struct {
client *api.Client
+ profile string
mcpServer *server.MCPServer
}
-func NewServer(client *api.Client) *Server {
- s := &Server{client: client}
+func NewServer(client *api.Client, profile string) *Server {
+ s := &Server{client: client, profile: profile}
s.init()
return s
}
+/*
+ * applyCredentials pushes an access token or API key from a tool response onto
+ * the live client, and persists it for the next process.
+ *
+ * Storage failures are deliberately not fatal: the in-memory client is already
+ * updated, so the session works either way, and an MCP tool call is the wrong
+ * place to abort over a locked keychain.
+ */
+func (s *Server) applyCredentials(result map[string]interface{}) {
+ data, ok := result["data"].(map[string]interface{})
+ if !ok {
+ return
+ }
+
+ token, _ := data["access_token"].(string)
+ if token == "" {
+ token, _ = data["token"].(string)
+ }
+ if token != "" {
+ s.client.Token = token
+ if s.profile != "" {
+ _ = auth.StoreToken(s.profile, token)
+ }
+ }
+
+ if key, _ := data["api_key"].(string); key != "" {
+ s.client.APIKey = key
+ if s.profile != "" {
+ _ = auth.StoreAPIKey(s.profile, key)
+ }
+ }
+
+ if key, _ := data["key"].(string); key != "" {
+ s.client.APIKey = key
+ if s.profile != "" {
+ _ = auth.StoreAPIKey(s.profile, key)
+ }
+ }
+}
+
func (s *Server) init() {
s.mcpServer = server.NewMCPServer(
"ModelsLab",
@@ -52,8 +94,20 @@ func (s *Server) registerControlPlaneTools() {
"required": []string{"email", "password"},
}, func(args map[string]interface{}) (interface{}, error) {
var result map[string]interface{}
- err := s.client.DoControlPlane("POST", "/auth/login", args, &result)
- return result, err
+ if err := s.client.DoControlPlane("POST", "/auth/login", args, &result); err != nil {
+ return nil, err
+ }
+ /*
+ * Apply the credentials, do not just hand them back.
+ *
+ * The client is built once in `mcp serve` and lives for the whole
+ * process. Returning the token without assigning it meant an agent that
+ * started the server unauthenticated, called auth-login, and got a token
+ * in the response then got 401 from every other tool for the life of the
+ * process — with a valid token sitting in its own transcript.
+ */
+ s.applyCredentials(result)
+ return result, nil
})
s.addTool("auth-signup", "Create a new ModelsLab account", map[string]interface{}{
@@ -106,8 +160,11 @@ func (s *Server) registerControlPlaneTools() {
},
}, func(args map[string]interface{}) (interface{}, error) {
var result map[string]interface{}
- err := s.client.DoControlPlane("POST", "/api-keys", args, &result)
- return result, err
+ if err := s.client.DoControlPlane("POST", "/api-keys", args, &result); err != nil {
+ return nil, err
+ }
+ s.applyCredentials(result)
+ return result, nil
})
// Models tools
diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go
new file mode 100644
index 0000000..cba7bda
--- /dev/null
+++ b/internal/mcp/server_test.go
@@ -0,0 +1,61 @@
+package mcp
+
+import (
+ "testing"
+
+ "github.com/ModelsLab/modelslab-cli/internal/api"
+ "github.com/stretchr/testify/assert"
+)
+
+// The client is built once in `mcp serve` and lives for the whole process.
+// auth-login used to return the token without ever assigning it, so an agent
+// that logged in through MCP got 401 from every other tool for the life of the
+// process — with a valid token sitting in its own transcript.
+//
+// profile is empty here so the test only touches the in-memory client and never
+// writes to the developer's real keychain.
+func TestApplyCredentials_UpdatesTheLiveClient(t *testing.T) {
+ s := &Server{client: api.NewClient("https://modelslab.com", "", "")}
+
+ s.applyCredentials(map[string]interface{}{
+ "data": map[string]interface{}{
+ "access_token": "tok-123",
+ "api_key": "ml-abc",
+ },
+ })
+
+ assert.Equal(t, "tok-123", s.client.Token)
+ assert.Equal(t, "ml-abc", s.client.APIKey)
+}
+
+func TestApplyCredentials_AcceptsTheLegacyTokenField(t *testing.T) {
+ s := &Server{client: api.NewClient("https://modelslab.com", "", "")}
+
+ s.applyCredentials(map[string]interface{}{
+ "data": map[string]interface{}{"token": "tok-legacy"},
+ })
+
+ assert.Equal(t, "tok-legacy", s.client.Token)
+}
+
+// api-keys-create returns the new key under "key".
+func TestApplyCredentials_PicksUpANewlyCreatedApiKey(t *testing.T) {
+ s := &Server{client: api.NewClient("https://modelslab.com", "tok", "ml-old")}
+
+ s.applyCredentials(map[string]interface{}{
+ "data": map[string]interface{}{"key": "ml-new"},
+ })
+
+ assert.Equal(t, "ml-new", s.client.APIKey)
+ assert.Equal(t, "tok", s.client.Token)
+}
+
+func TestApplyCredentials_LeavesTheClientAloneOnAnUnexpectedShape(t *testing.T) {
+ s := &Server{client: api.NewClient("https://modelslab.com", "tok", "ml-old")}
+
+ s.applyCredentials(map[string]interface{}{"data": "not-a-map"})
+ s.applyCredentials(map[string]interface{}{})
+
+ assert.Equal(t, "tok", s.client.Token)
+ assert.Equal(t, "ml-old", s.client.APIKey)
+}