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
45 changes: 42 additions & 3 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"strings"
"time"

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

fmt.Println("Uncompact uses the Supermodel Public API.")
fmt.Println()
fmt.Println("1. Visit the dashboard to get your API key:")
fmt.Println("1. Opening your browser to the Supermodel dashboard...")
fmt.Println(" " + config.DashboardURL)
fmt.Println()
fmt.Print("2. Paste your API key here: ")

_ = browser.OpenURL(config.DashboardURL)

fmt.Println("2. Sign in, create an API key, and paste it below.")
fmt.Print(" API Key: ")

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

// Cache the auth status
dbPath, err := config.DBPath()
if err == nil {
if store, err := cache.Open(dbPath); err == nil {
defer store.Close()
_ = store.SetAuthStatus(cfg.APIKeyHash(), identity)
}
}

if cfgFile, err := config.ConfigFile(); err == nil {
fmt.Printf("\nAPI key saved to: %s\n", cfgFile)
} else {
Expand Down Expand Up @@ -131,7 +146,27 @@ func authStatusHandler(cmd *cobra.Command, args []string) error {
)
}

// Try to validate
// Try to get from cache first
dbPath, _ := config.DBPath()
var store *cache.Store
if dbPath != "" {
store, _ = cache.Open(dbPath)
}
if store != nil {
defer store.Close()
if auth, _ := store.GetAuthStatus(cfg.APIKeyHash()); auth != nil {
// Only use cache if it's less than 24h old
if time.Since(auth.LastValidatedAt) < 24*time.Hour {
fmt.Printf("API check: ✓ (cached %s ago)\n", humanDuration(time.Since(auth.LastValidatedAt)))
if auth.Identity != "" {
fmt.Printf("Identity: %s\n", auth.Identity)
}
return nil
}
}
}

// Not in cache or stale, validate via API
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

Expand All @@ -144,6 +179,10 @@ func authStatusHandler(cmd *cobra.Command, args []string) error {
if identity != "" {
fmt.Printf("Identity: %s\n", identity)
}
// Update cache
if store != nil {
_ = store.SetAuthStatus(cfg.APIKeyHash(), identity)
}
}
return nil
}
Expand Down
69 changes: 66 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package cmd

import (
"context"
"fmt"
"os"
"time"

"github.com/spf13/cobra"

"github.com/supermodeltools/uncompact/internal/api"
"github.com/supermodeltools/uncompact/internal/cache"
"github.com/supermodeltools/uncompact/internal/config"
)

Expand All @@ -30,7 +34,7 @@ Modes:
analysis (file structure, git history, CLAUDE.md). This is the default
when no API key is configured.

api Uses the Supermodel Public API for AI-powered summarization, smarter
api Uses the Supermodel Public API for AI-powered summarization, smarter
context prioritization, and session state analysis. Requires an API key.

Get started (local mode — no API key needed):
Expand All @@ -39,8 +43,67 @@ Get started (local mode — no API key needed):
Get started (API mode — full AI-powered features):
uncompact auth login # Authenticate via dashboard.supermodeltools.com
uncompact install # Add hooks to Claude Code settings.json`,
SilenceErrors: true,
SilenceUsage: true,
SilenceErrors: true,
SilenceUsage: true,
PersistentPreRunE: checkAuth,
}

func checkAuth(cmd *cobra.Command, args []string) error {
// Skip auth check for auth commands, help, and completion
for c := cmd; c != nil; c = c.Parent() {
if c.Name() == "auth" || c.Name() == "help" || c.Name() == "completion" {
return nil
}
}

cfg, err := config.Load(apiKey)
if err != nil {
return err
}

if !cfg.IsAuthenticated() {
return nil
}

keyHash := cfg.APIKeyHash()
dbPath, _ := config.DBPath()
var store *cache.Store
if dbPath != "" {
store, _ = cache.Open(dbPath)
}

if store != nil {
defer store.Close()
if auth, _ := store.GetAuthStatus(keyHash); auth != nil {
// Cache is valid for 24h
if time.Since(auth.LastValidatedAt) < 24*time.Hour {
if auth.Identity != "" {
fmt.Fprintf(os.Stderr, "[uncompact] Authenticated as %s\n", auth.Identity)
}
return nil
}
}
}

// Stale or missing cache, validate via API
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

client := api.New(cfg.BaseURL, cfg.APIKey, false, nil)
identity, err := client.ValidateKey(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "[uncompact] ⚠️ API key validation failed: %v\n", err)
return nil // Don't block command execution on auth failure
}

if identity != "" {
fmt.Fprintf(os.Stderr, "[uncompact] Authenticated as %s\n", identity)
if store != nil {
_ = store.SetAuthStatus(keyHash, identity)
}
}

return nil
}

// Execute runs the root command.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24.0

require (
github.com/google/uuid v1.6.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.8.1
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
Expand All @@ -24,6 +26,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
Expand Down
46 changes: 45 additions & 1 deletion internal/cache/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
const (
defaultTTL = 15 * time.Minute
defaultMaxAge = 30 * 24 * time.Hour // 30 days
schemaVersion = 1
schemaVersion = 2
)

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

// AuthStatus is a cached authentication result.
type AuthStatus struct {
APIKeyHash string
Identity string
LastValidatedAt time.Time
}

// InjectionLog is a record of a context bomb injection.
type InjectionLog struct {
ID int64
Expand Down Expand Up @@ -84,6 +91,12 @@ func (s *Store) migrate() error {
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS auth_cache (
api_key_hash TEXT PRIMARY KEY,
identity TEXT NOT NULL,
last_validated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_graph_cache_hash ON graph_cache(project_hash);
CREATE INDEX IF NOT EXISTS idx_graph_cache_expires ON graph_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_injection_log_project ON injection_log(project_hash);
Expand Down Expand Up @@ -329,3 +342,34 @@ func (s *Store) LastInjection(projectHash string) (*InjectionLog, error) {
}
return &l, nil
}

// GetAuthStatus retrieves the cached auth status for a given key hash.
func (s *Store) GetAuthStatus(apiKeyHash string) (*AuthStatus, error) {
row := s.db.QueryRow(`
SELECT api_key_hash, identity, last_validated_at
FROM auth_cache
WHERE api_key_hash = ?`,
apiKeyHash,
)

var auth AuthStatus
if err := row.Scan(&auth.APIKeyHash, &auth.Identity, &auth.LastValidatedAt); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
return &auth, nil
}

// SetAuthStatus caches the auth status for a given key hash.
func (s *Store) SetAuthStatus(apiKeyHash, identity string) error {
_, err := s.db.Exec(`
INSERT INTO auth_cache (api_key_hash, identity, last_validated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(api_key_hash) DO UPDATE SET
identity = excluded.identity,
last_validated_at = excluded.last_validated_at`,
apiKeyHash, identity,
)
return err
}
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package config

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -203,6 +205,15 @@ func (c *Config) IsAuthenticated() bool {
return c.APIKey != ""
}

// APIKeyHash returns a SHA-256 hash of the API key for secure caching.
func (c *Config) APIKeyHash() string {
if c.APIKey == "" {
return ""
}
hash := sha256.Sum256([]byte(c.APIKey))
return hex.EncodeToString(hash[:])
}

// ValidateMode reports whether s is a recognised operation mode.
// An empty string is valid (triggers auto-detection in EffectiveMode).
func ValidateMode(s string) error {
Expand Down