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

Commit 228c483

Browse files
greynewellclaude
andcommitted
feat: automate API key detection and enhance login flow
- Add 'auth_cache' table to SQLite to store validated API key results (24h TTL) - Implement global auth hook that validates keys from env vars/config on any command - Log "Authenticated as <identity>" on any first action using cached results - Enhance 'uncompact auth login' to automatically open browser to dashboard - Add github.com/pkg/browser dependency for reliable browser integration Co-Authored-By: Grey Newell <greyshipscode@gmail.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1c585c8 commit 228c483

File tree

6 files changed

+168
-7
lines changed

6 files changed

+168
-7
lines changed

cmd/auth.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"strings"
88
"time"
99

10+
"github.com/pkg/browser"
1011
"github.com/spf13/cobra"
1112
"github.com/supermodeltools/uncompact/internal/api"
13+
"github.com/supermodeltools/uncompact/internal/cache"
1214
"github.com/supermodeltools/uncompact/internal/config"
1315
"golang.org/x/term"
1416
)
@@ -49,10 +51,14 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
4951

5052
fmt.Println("Uncompact uses the Supermodel Public API.")
5153
fmt.Println()
52-
fmt.Println("1. Visit the dashboard to get your API key:")
54+
fmt.Println("1. Opening your browser to the Supermodel dashboard...")
5355
fmt.Println(" " + config.DashboardURL)
5456
fmt.Println()
55-
fmt.Print("2. Paste your API key here: ")
57+
58+
_ = browser.OpenURL(config.DashboardURL)
59+
60+
fmt.Println("2. Sign in, create an API key, and paste it below.")
61+
fmt.Print(" API Key: ")
5662

5763
var key string
5864
if term.IsTerminal(int(os.Stdin.Fd())) {
@@ -97,6 +103,15 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
97103
return fmt.Errorf("saving config: %w", err)
98104
}
99105

106+
// Cache the auth status
107+
dbPath, err := config.DBPath()
108+
if err == nil {
109+
if store, err := cache.Open(dbPath); err == nil {
110+
defer store.Close()
111+
_ = store.SetAuthStatus(cfg.APIKeyHash(), identity)
112+
}
113+
}
114+
100115
if cfgFile, err := config.ConfigFile(); err == nil {
101116
fmt.Printf("\nAPI key saved to: %s\n", cfgFile)
102117
} else {
@@ -131,7 +146,27 @@ func authStatusHandler(cmd *cobra.Command, args []string) error {
131146
)
132147
}
133148

134-
// Try to validate
149+
// Try to get from cache first
150+
dbPath, _ := config.DBPath()
151+
var store *cache.Store
152+
if dbPath != "" {
153+
store, _ = cache.Open(dbPath)
154+
}
155+
if store != nil {
156+
defer store.Close()
157+
if auth, _ := store.GetAuthStatus(cfg.APIKeyHash()); auth != nil {
158+
// Only use cache if it's less than 24h old
159+
if time.Since(auth.LastValidatedAt) < 24*time.Hour {
160+
fmt.Printf("API check: ✓ (cached %s ago)\n", humanDuration(time.Since(auth.LastValidatedAt)))
161+
if auth.Identity != "" {
162+
fmt.Printf("Identity: %s\n", auth.Identity)
163+
}
164+
return nil
165+
}
166+
}
167+
}
168+
169+
// Not in cache or stale, validate via API
135170
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
136171
defer cancel()
137172

@@ -144,6 +179,10 @@ func authStatusHandler(cmd *cobra.Command, args []string) error {
144179
if identity != "" {
145180
fmt.Printf("Identity: %s\n", identity)
146181
}
182+
// Update cache
183+
if store != nil {
184+
_ = store.SetAuthStatus(cfg.APIKeyHash(), identity)
185+
}
147186
}
148187
return nil
149188
}

cmd/root.go

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package cmd
22

33
import (
4+
"context"
45
"fmt"
56
"os"
7+
"time"
68

79
"github.com/spf13/cobra"
810

11+
"github.com/supermodeltools/uncompact/internal/api"
12+
"github.com/supermodeltools/uncompact/internal/cache"
913
"github.com/supermodeltools/uncompact/internal/config"
1014
)
1115

@@ -30,7 +34,7 @@ Modes:
3034
analysis (file structure, git history, CLAUDE.md). This is the default
3135
when no API key is configured.
3236
33-
api Uses the Supermodel Public API for AI-powered summarization, smarter
37+
api Uses the Supermodel Public API for AI-powered summarization, smarter
3438
context prioritization, and session state analysis. Requires an API key.
3539
3640
Get started (local mode — no API key needed):
@@ -39,8 +43,67 @@ Get started (local mode — no API key needed):
3943
Get started (API mode — full AI-powered features):
4044
uncompact auth login # Authenticate via dashboard.supermodeltools.com
4145
uncompact install # Add hooks to Claude Code settings.json`,
42-
SilenceErrors: true,
43-
SilenceUsage: true,
46+
SilenceErrors: true,
47+
SilenceUsage: true,
48+
PersistentPreRunE: checkAuth,
49+
}
50+
51+
func checkAuth(cmd *cobra.Command, args []string) error {
52+
// Skip auth check for auth commands, help, and completion
53+
for c := cmd; c != nil; c = c.Parent() {
54+
if c.Name() == "auth" || c.Name() == "help" || c.Name() == "completion" {
55+
return nil
56+
}
57+
}
58+
59+
cfg, err := config.Load(apiKey)
60+
if err != nil {
61+
return err
62+
}
63+
64+
if !cfg.IsAuthenticated() {
65+
return nil
66+
}
67+
68+
keyHash := cfg.APIKeyHash()
69+
dbPath, _ := config.DBPath()
70+
var store *cache.Store
71+
if dbPath != "" {
72+
store, _ = cache.Open(dbPath)
73+
}
74+
75+
if store != nil {
76+
defer store.Close()
77+
if auth, _ := store.GetAuthStatus(keyHash); auth != nil {
78+
// Cache is valid for 24h
79+
if time.Since(auth.LastValidatedAt) < 24*time.Hour {
80+
if auth.Identity != "" {
81+
fmt.Fprintf(os.Stderr, "[uncompact] Authenticated as %s\n", auth.Identity)
82+
}
83+
return nil
84+
}
85+
}
86+
}
87+
88+
// Stale or missing cache, validate via API
89+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
90+
defer cancel()
91+
92+
client := api.New(cfg.BaseURL, cfg.APIKey, false, nil)
93+
identity, err := client.ValidateKey(ctx)
94+
if err != nil {
95+
fmt.Fprintf(os.Stderr, "[uncompact] ⚠️ API key validation failed: %v\n", err)
96+
return nil // Don't block command execution on auth failure
97+
}
98+
99+
if identity != "" {
100+
fmt.Fprintf(os.Stderr, "[uncompact] Authenticated as %s\n", identity)
101+
if store != nil {
102+
_ = store.SetAuthStatus(keyHash, identity)
103+
}
104+
}
105+
106+
return nil
44107
}
45108

46109
// Execute runs the root command.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.0
44

55
require (
66
github.com/google/uuid v1.6.0
7+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
78
github.com/spf13/cobra v1.8.1
89
golang.org/x/sys v0.41.0
910
golang.org/x/term v0.40.0

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
1313
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
1414
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
1515
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
16+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
17+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
1618
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1719
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1820
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -24,6 +26,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
2426
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
2527
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
2628
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
29+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
2730
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
2831
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
2932
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

internal/cache/store.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
const (
1515
defaultTTL = 15 * time.Minute
1616
defaultMaxAge = 30 * 24 * time.Hour // 30 days
17-
schemaVersion = 1
17+
schemaVersion = 2
1818
)
1919

2020
// Store is the SQLite-backed cache for Uncompact.
@@ -23,6 +23,13 @@ type Store struct {
2323
ttl time.Duration
2424
}
2525

26+
// AuthStatus is a cached authentication result.
27+
type AuthStatus struct {
28+
APIKeyHash string
29+
Identity string
30+
LastValidatedAt time.Time
31+
}
32+
2633
// InjectionLog is a record of a context bomb injection.
2734
type InjectionLog struct {
2835
ID int64
@@ -84,6 +91,12 @@ func (s *Store) migrate() error {
8491
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
8592
);
8693
94+
CREATE TABLE IF NOT EXISTS auth_cache (
95+
api_key_hash TEXT PRIMARY KEY,
96+
identity TEXT NOT NULL,
97+
last_validated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
98+
);
99+
87100
CREATE INDEX IF NOT EXISTS idx_graph_cache_hash ON graph_cache(project_hash);
88101
CREATE INDEX IF NOT EXISTS idx_graph_cache_expires ON graph_cache(expires_at);
89102
CREATE INDEX IF NOT EXISTS idx_injection_log_project ON injection_log(project_hash);
@@ -329,3 +342,34 @@ func (s *Store) LastInjection(projectHash string) (*InjectionLog, error) {
329342
}
330343
return &l, nil
331344
}
345+
346+
// GetAuthStatus retrieves the cached auth status for a given key hash.
347+
func (s *Store) GetAuthStatus(apiKeyHash string) (*AuthStatus, error) {
348+
row := s.db.QueryRow(`
349+
SELECT api_key_hash, identity, last_validated_at
350+
FROM auth_cache
351+
WHERE api_key_hash = ?`,
352+
apiKeyHash,
353+
)
354+
355+
var auth AuthStatus
356+
if err := row.Scan(&auth.APIKeyHash, &auth.Identity, &auth.LastValidatedAt); err == sql.ErrNoRows {
357+
return nil, nil
358+
} else if err != nil {
359+
return nil, err
360+
}
361+
return &auth, nil
362+
}
363+
364+
// SetAuthStatus caches the auth status for a given key hash.
365+
func (s *Store) SetAuthStatus(apiKeyHash, identity string) error {
366+
_, err := s.db.Exec(`
367+
INSERT INTO auth_cache (api_key_hash, identity, last_validated_at)
368+
VALUES (?, ?, CURRENT_TIMESTAMP)
369+
ON CONFLICT(api_key_hash) DO UPDATE SET
370+
identity = excluded.identity,
371+
last_validated_at = excluded.last_validated_at`,
372+
apiKeyHash, identity,
373+
)
374+
return err
375+
}

internal/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package config
22

33
import (
4+
"crypto/sha256"
5+
"encoding/hex"
46
"encoding/json"
57
"fmt"
68
"os"
@@ -203,6 +205,15 @@ func (c *Config) IsAuthenticated() bool {
203205
return c.APIKey != ""
204206
}
205207

208+
// APIKeyHash returns a SHA-256 hash of the API key for secure caching.
209+
func (c *Config) APIKeyHash() string {
210+
if c.APIKey == "" {
211+
return ""
212+
}
213+
hash := sha256.Sum256([]byte(c.APIKey))
214+
return hex.EncodeToString(hash[:])
215+
}
216+
206217
// ValidateMode reports whether s is a recognised operation mode.
207218
// An empty string is valid (triggers auto-detection in EffectiveMode).
208219
func ValidateMode(s string) error {

0 commit comments

Comments
 (0)