Skip to content
This repository was archived by the owner on Apr 2, 2026. It is now read-only.

Commit 317cc93

Browse files
greynewellclaude
andcommitted
feat: frictionless CLI login via localhost callback
Replace the copy-paste login flow with a zero-friction browser callback: 1. CLI starts a temporary HTTP server on 127.0.0.1 (random port) 2. Opens dashboard.supermodeltools.com/cli-auth/?port=PORT&state=STATE 3. Dashboard auto-creates an API key and redirects to localhost callback 4. CLI captures the key, validates it, encrypts, and saves Falls back to manual paste flow if browser login fails or --no-browser is passed. Postinstall no longer attempts interactive login to avoid hanging processes. Co-Authored-By: Grey Newell <greyshipscode@gmail.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8fefdb4 commit 317cc93

4 files changed

Lines changed: 175 additions & 46 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
node_modules/
1313
npm/bin/
1414
package-lock.json
15+
uncompact-0.0.0.tgz
16+
17+
# Wrangler
18+
.wrangler/
1519

1620
# IDE
1721
.idea/

cmd/auth.go

Lines changed: 161 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package cmd
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"encoding/hex"
57
"fmt"
8+
"net"
9+
"net/http"
610
"os"
711
"strings"
812
"time"
@@ -15,6 +19,8 @@ import (
1519
"golang.org/x/term"
1620
)
1721

22+
var noBrowser bool
23+
1824
var authCmd = &cobra.Command{
1925
Use: "auth",
2026
Short: "Manage Supermodel API authentication",
@@ -48,16 +54,161 @@ var authOpenCmd = &cobra.Command{
4854
}
4955

5056
func init() {
57+
authLoginCmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Skip browser-based login and paste API key manually")
5158
authCmd.AddCommand(authLoginCmd, authStatusCmd, authLogoutCmd, authOpenCmd)
5259
rootCmd.AddCommand(authCmd)
5360
}
5461

62+
const callbackTimeout = 2 * time.Minute
63+
64+
const successHTML = `<!DOCTYPE html>
65+
<html>
66+
<head><title>Uncompact</title>
67+
<style>
68+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
69+
align-items: center; min-height: 100vh; margin: 0; background: #0f172a; color: #e2e8f0; }
70+
.card { text-align: center; padding: 2rem; }
71+
h1 { color: #5eead4; margin-bottom: 0.5rem; }
72+
p { color: #94a3b8; }
73+
</style>
74+
</head>
75+
<body>
76+
<div class="card">
77+
<h1>Authenticated</h1>
78+
<p>You can close this tab and return to your terminal.</p>
79+
</div>
80+
</body>
81+
</html>`
82+
83+
func generateState() (string, error) {
84+
b := make([]byte, 16)
85+
if _, err := rand.Read(b); err != nil {
86+
return "", err
87+
}
88+
return hex.EncodeToString(b), nil
89+
}
90+
5591
func authLoginHandler(cmd *cobra.Command, args []string) error {
5692
cfg, err := config.Load(apiKey)
5793
if err != nil {
5894
return err
5995
}
6096

97+
isTTY := term.IsTerminal(int(os.Stdin.Fd()))
98+
99+
if noBrowser || !isTTY {
100+
return authLoginManual(cfg)
101+
}
102+
103+
key, err := authLoginBrowser(cfg)
104+
if err != nil {
105+
fmt.Fprintf(os.Stderr, "[uncompact] Browser login failed: %v\n", err)
106+
fmt.Println()
107+
fmt.Println("Falling back to manual login...")
108+
fmt.Println()
109+
return authLoginManual(cfg)
110+
}
111+
112+
return saveAndCacheKey(cfg, key)
113+
}
114+
115+
// authLoginBrowser starts a localhost callback server, opens the dashboard,
116+
// and waits for the key to arrive via redirect.
117+
func authLoginBrowser(cfg *config.Config) (string, error) {
118+
state, err := generateState()
119+
if err != nil {
120+
return "", fmt.Errorf("generating state: %w", err)
121+
}
122+
123+
listener, err := net.Listen("tcp", "127.0.0.1:0")
124+
if err != nil {
125+
return "", fmt.Errorf("starting callback server: %w", err)
126+
}
127+
port := listener.Addr().(*net.TCPAddr).Port
128+
129+
type callbackResult struct {
130+
key string
131+
err error
132+
}
133+
resultCh := make(chan callbackResult, 1)
134+
135+
mux := http.NewServeMux()
136+
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
137+
if r.URL.Query().Get("state") != state {
138+
http.Error(w, "Invalid state parameter", http.StatusForbidden)
139+
resultCh <- callbackResult{err: fmt.Errorf("state mismatch (possible CSRF)")}
140+
return
141+
}
142+
143+
key := r.URL.Query().Get("key")
144+
if key == "" {
145+
errMsg := r.URL.Query().Get("error")
146+
if errMsg == "" {
147+
errMsg = "no key received"
148+
}
149+
http.Error(w, errMsg, http.StatusBadRequest)
150+
resultCh <- callbackResult{err: fmt.Errorf("dashboard returned error: %s", errMsg)}
151+
return
152+
}
153+
154+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
155+
w.WriteHeader(http.StatusOK)
156+
fmt.Fprint(w, successHTML)
157+
158+
resultCh <- callbackResult{key: key}
159+
})
160+
161+
server := &http.Server{Handler: mux}
162+
go func() {
163+
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
164+
resultCh <- callbackResult{err: fmt.Errorf("callback server error: %w", err)}
165+
}
166+
}()
167+
defer func() {
168+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
169+
defer cancel()
170+
_ = server.Shutdown(ctx)
171+
}()
172+
173+
dashURL := fmt.Sprintf("%s?port=%d&state=%s", config.DashboardCLIAuthURL, port, state)
174+
fmt.Println("Opening your browser to sign in...")
175+
fmt.Printf(" %s\n\n", dashURL)
176+
fmt.Println("Waiting for authentication (this will timeout in 2 minutes)...")
177+
178+
if err := browser.OpenURL(dashURL); err != nil {
179+
return "", fmt.Errorf("opening browser: %w", err)
180+
}
181+
182+
select {
183+
case result := <-resultCh:
184+
if result.err != nil {
185+
return "", result.err
186+
}
187+
fmt.Println()
188+
fmt.Print("Validating key... ")
189+
190+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
191+
defer cancel()
192+
193+
testClient := api.New(cfg.BaseURL, result.key, false, nil)
194+
identity, err := testClient.ValidateKey(ctx)
195+
if err != nil {
196+
fmt.Println("✗")
197+
return "", fmt.Errorf("key validation failed: %w", err)
198+
}
199+
fmt.Println("✓")
200+
if identity != "" {
201+
fmt.Printf("Authenticated as: %s\n", identity)
202+
}
203+
return result.key, nil
204+
205+
case <-time.After(callbackTimeout):
206+
return "", fmt.Errorf("timed out waiting for browser callback")
207+
}
208+
}
209+
210+
// authLoginManual is the original paste-based login flow.
211+
func authLoginManual(cfg *config.Config) error {
61212
fmt.Println("Uncompact uses the Supermodel Public API.")
62213
fmt.Println()
63214
fmt.Println("1. Opening your browser to the Supermodel dashboard...")
@@ -79,7 +230,6 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
79230
}
80231
key = strings.TrimSpace(string(b))
81232
} else {
82-
// Non-interactive fallback (e.g. piped input in CI)
83233
var raw string
84234
if _, err := fmt.Fscanln(os.Stdin, &raw); err != nil {
85235
return fmt.Errorf("reading API key: %w", err)
@@ -90,7 +240,6 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
90240
return fmt.Errorf("API key cannot be empty")
91241
}
92242

93-
// Validate the key
94243
fmt.Print("Validating key... ")
95244
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
96245
defer cancel()
@@ -101,7 +250,7 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
101250
fmt.Println("✗")
102251
if strings.Contains(err.Error(), "402") {
103252
fmt.Println()
104-
fmt.Println("⚠️ SUBSCRIPTION REQUIRED")
253+
fmt.Println("SUBSCRIPTION REQUIRED")
105254
fmt.Println(" The API key is valid, but your account requires an active subscription.")
106255
fmt.Printf(" Please visit: %s\n", config.DashboardURL)
107256
fmt.Println()
@@ -115,26 +264,28 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
115264
fmt.Printf("Authenticated as: %s\n", identity)
116265
}
117266

118-
// Warn if environment variable is masking
267+
return saveAndCacheKey(cfg, key)
268+
}
269+
270+
// saveAndCacheKey encrypts and saves the key, then updates the auth cache.
271+
func saveAndCacheKey(cfg *config.Config, key string) error {
119272
if os.Getenv(config.EnvAPIKey) != "" {
120273
fmt.Println()
121-
fmt.Printf("⚠️ WARNING: The environment variable %s is currently set.\n", config.EnvAPIKey)
122-
fmt.Println(" It will continue to override the API key you just saved to the config file.")
123-
fmt.Println(" To use the new key, you must unset the environment variable or update it.")
274+
fmt.Printf("NOTE: The environment variable %s is currently set.\n", config.EnvAPIKey)
275+
fmt.Println(" It will continue to override the API key saved to the config file.")
276+
fmt.Println(" To use the new key, unset the environment variable or update it.")
124277
}
125278

126-
// Save
127279
cfg.APIKey = key
128280
if err := config.Save(cfg); err != nil {
129281
return fmt.Errorf("saving config: %w", err)
130282
}
131283

132-
// Cache the auth status
133284
dbPath, err := config.DBPath()
134285
if err == nil {
135286
if store, err := cache.Open(dbPath); err == nil {
136287
defer store.Close()
137-
_ = store.SetAuthStatus(cfg.APIKeyHash(), identity)
288+
_ = store.SetAuthStatus(cfg.APIKeyHash(), "ok")
138289
}
139290
}
140291

internal/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ const (
1515
EnvAPIKey = "SUPERMODEL_API_KEY"
1616
EnvMode = "UNCOMPACT_MODE"
1717
APIBaseURL = "https://api.supermodeltools.com"
18-
DashboardURL = "https://dashboard.supermodeltools.com"
19-
DashboardKeyURL = "https://dashboard.supermodeltools.com/api-keys/"
18+
DashboardURL = "https://dashboard.supermodeltools.com"
19+
DashboardKeyURL = "https://dashboard.supermodeltools.com/api-keys/"
20+
DashboardCLIAuthURL = "https://dashboard.supermodeltools.com/cli-auth/"
2021

2122
ModeLocal = "local"
2223
ModeAPI = "api"

npm/install.js

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const https = require("https");
44
const fs = require("fs");
55
const path = require("path");
6-
const { execSync, execFileSync } = require("child_process");
6+
const { execFileSync } = require("child_process");
77
const os = require("os");
88

99
const REPO_OWNER = "supermodeltools";
@@ -189,52 +189,25 @@ async function main() {
189189

190190
// Automatically install Claude Code hooks
191191
log("[uncompact] Configuring Claude Code hooks...\n");
192-
let installSuccessful = false;
193192
try {
194-
// The 'install' command now automatically shows the help menu upon completion
195193
execFileSync(destPath, ["install", "--yes"], { stdio: "inherit" });
196-
installSuccessful = true;
197194
} catch (err) {
198195
log("[uncompact] Note: Automatic hook configuration skipped or failed. Run manually if needed:\n");
199196
log(" uncompact install\n");
200197
}
201198

202-
// Always show status to verify API key detection, regardless of install success
199+
// Show status to verify setup
203200
try {
204201
console.log();
205202
execFileSync(destPath, ["status"], { stdio: "inherit" });
206-
207-
// If not authenticated, take them to the next step automatically
208-
// Only attempt interactive login if we are in a terminal (TTY)
209-
if (process.stdin.isTTY) {
210-
const checkAuthCmd = `"${destPath}" auth status`;
211-
try {
212-
const authStatus = execSync(checkAuthCmd).toString();
213-
if (authStatus.includes("Status: not authenticated") || authStatus.includes("✗")) {
214-
log("\n[uncompact] Authentication required. Starting login flow...\n");
215-
try {
216-
// Use 'auth login' which opens browser AND prompts for key
217-
execFileSync(destPath, ["auth", "login"], { stdio: "inherit" });
218-
log("\n[uncompact] Login successful.\n");
219-
} catch (err) {
220-
// Command failed (e.g. invalid key, 402, or user cancelled)
221-
// We don't print the error message here because 'inherit' already showed it
222-
log("\n[uncompact] Login incomplete or failed. You can run it manually later: uncompact auth login\n");
223-
}
224-
}
225-
} catch (e) {}
226-
} else {
227-
log("\n[uncompact] Authentication required. Run 'uncompact auth login' to connect your account.\n");
228-
}
229203
} catch (err) {
230-
// If status fails, show help as a fallback if we haven't shown anything yet
231-
if (!installSuccessful) {
232-
try {
233-
execFileSync(destPath, [], { stdio: "inherit" });
234-
} catch (e) {}
235-
}
204+
try {
205+
execFileSync(destPath, [], { stdio: "inherit" });
206+
} catch (e) {}
236207
}
208+
237209
log("\n");
210+
log("[uncompact] To authenticate: run 'uncompact auth login'\n");
238211
}
239212

240213
main().catch((err) => {

0 commit comments

Comments
 (0)