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
65 changes: 23 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ This installs both hooks automatically:
- **`SessionStart`** — runs `scripts/setup.sh` which auto-installs the `uncompact` binary via `go install` if not already present.
- **`Stop`** — runs `scripts/uncompact-hook.sh` which reinjects context after every compaction event.

> **Note:** After plugin installation, authenticate once with `uncompact auth login` to connect your Supermodel API key. That's it — no manual binary install or hook setup required.
> **Note:** After plugin installation, run `uncompact auth login` once to authenticate. This also ensures hooks are installed — no separate `uncompact install` step needed.

### CI / GitHub Actions

Expand All @@ -65,69 +65,48 @@ env:

## Quick Start

### 1. Install

**Via npm (recommended):**

```bash
# Install CLI and automatically configure Claude Code hooks
npm install -g uncompact
```

*Note: npm might hide the configuration output. You can verify the installation with `uncompact verify-install`.*

**Or run/install without global installation:**

```bash
# This will show full interactive output
npx uncompact install
```

### 🗑 Uninstall

To remove the Claude Code hooks only:
### Via npm (recommended)

```bash
uncompact uninstall
npm install -g uncompact --foreground-scripts
```

To **completely remove** Uncompact configuration and cached data:

```bash
uncompact uninstall --total
```
That's it. The installer downloads the binary, opens your browser for GitHub authentication, and installs the Claude Code hooks — all in one step.

*Note: After running the above, you can remove the CLI itself with `npm uninstall -g uncompact`.*
> `--foreground-scripts` is required so the interactive auth prompt is visible in your terminal.

**Via Go:**
### Via Go or manual binary

```bash
go install github.com/supermodeltools/uncompact@latest
# then:
uncompact auth login
```

**Or download a binary** from [Releases](https://github.com/supermodeltools/Uncompact/releases).
`auth login` opens your browser for GitHub OAuth, saves your API key, and installs the Claude Code hooks automatically.

### 2. Authenticate
**Or download a binary** from [Releases](https://github.com/supermodeltools/Uncompact/releases) and run `uncompact auth login`.

### Verify

```bash
uncompact auth login
uncompact verify-install
uncompact run --debug
```

This opens [dashboard.supermodeltools.com/api-keys/](https://dashboard.supermodeltools.com/api-keys/) where you can subscribe and generate an API key.
### 🗑 Uninstall

### 3. Install Claude Code Hooks
To remove the Claude Code hooks only:

```bash
uncompact install
uncompact uninstall
```

This auto-detects your Claude Code `settings.json`, shows a diff, and merges the hooks non-destructively.

### 4. Verify
To **completely remove** Uncompact configuration and cached data:

```bash
uncompact verify-install
uncompact run --debug
uncompact uninstall --total
npm uninstall -g uncompact
```

## CLI Reference
Expand Down Expand Up @@ -164,6 +143,8 @@ uncompact cache clear --project # Clear only the current project's cache
| Variable | Description |
|----------|-------------|
| `SUPERMODEL_API_KEY` | Supermodel API key (overrides config file) |
| `UNCOMPACT_API_URL` | Override the API base URL (e.g. for staging) |
| `UNCOMPACT_DASHBOARD_URL` | Override the dashboard base URL (e.g. for staging) |

### Config File

Expand Down Expand Up @@ -244,7 +225,7 @@ uncompact/
|---------|--------|
| Default TTL | 15 minutes |
| Stale cache | Served with `⚠️ STALE` warning; fresh fetch attempted |
| API unavailable | Serve most recent cache entry silently |
| API unreachable | Fail fast on connection error; serve stale cache if available |
| No cache + API down | Silent exit 0 (never blocks Claude Code) |
| Storage growth | Auto-prune entries older than 30 days |
| Force refresh | `--force-refresh` flag |
Expand Down
80 changes: 76 additions & 4 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/supermodeltools/uncompact/internal/api"
"github.com/supermodeltools/uncompact/internal/cache"
"github.com/supermodeltools/uncompact/internal/config"
"github.com/supermodeltools/uncompact/internal/hooks"
"golang.org/x/term"
)

Expand Down Expand Up @@ -115,6 +117,8 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
// authLoginBrowser starts a localhost callback server, opens the dashboard,
// and waits for the key to arrive via redirect.
func authLoginBrowser(cfg *config.Config) (string, error) {
logFn := makeLogger()

state, err := generateState()
if err != nil {
return "", fmt.Errorf("generating state: %w", err)
Expand All @@ -125,6 +129,7 @@ func authLoginBrowser(cfg *config.Config) (string, error) {
return "", fmt.Errorf("starting callback server: %w", err)
}
port := listener.Addr().(*net.TCPAddr).Port
logFn("[debug] auth: callback server listening on 127.0.0.1:%d", port)

type callbackResult struct {
key string
Expand All @@ -134,7 +139,18 @@ func authLoginBrowser(cfg *config.Config) (string, error) {

mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != state {
// Log the callback path and which query params are present, but never
// their values — key and state are secrets.
q := r.URL.Query()
params := make([]string, 0, len(q))
for k := range q {
params = append(params, k)
}
logFn("[debug] auth: callback received: %s (params: %v)", r.URL.Path, params)

gotState := r.URL.Query().Get("state")
if gotState != state {
logFn("[debug] auth: state mismatch (CSRF check failed)")
http.Error(w, "Invalid state parameter", http.StatusForbidden)
resultCh <- callbackResult{err: fmt.Errorf("state mismatch (possible CSRF)")}
return
Expand All @@ -146,11 +162,13 @@ func authLoginBrowser(cfg *config.Config) (string, error) {
if errMsg == "" {
errMsg = "no key received"
}
logFn("[debug] auth: callback error — %s", errMsg)
http.Error(w, errMsg, http.StatusBadRequest)
resultCh <- callbackResult{err: fmt.Errorf("dashboard returned error: %s", errMsg)}
return
}

logFn("[debug] auth: key received (%d chars)", len(key))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, successHTML)
Expand All @@ -170,7 +188,8 @@ func authLoginBrowser(cfg *config.Config) (string, error) {
_ = server.Shutdown(ctx)
}()

dashURL := fmt.Sprintf("%s?port=%d&state=%s", config.DashboardCLIAuthURL, port, state)
dashURL := fmt.Sprintf("%s?port=%d&state=%s", config.EffectiveCLIAuthURL(), port, state)
logFn("[debug] auth: opening dashboard (port=%d, state=<redacted>)", port)
fmt.Println("Opening your browser to sign in...")
fmt.Printf(" %s\n\n", dashURL)
fmt.Println("Waiting for authentication (this will timeout in 2 minutes)...")
Expand Down Expand Up @@ -267,7 +286,8 @@ func authLoginManual(cfg *config.Config) error {
return saveAndCacheKey(cfg, key)
}

// saveAndCacheKey encrypts and saves the key, then updates the auth cache.
// saveAndCacheKey encrypts and saves the key, updates the auth cache, then
// automatically installs the Claude Code hooks so auth login is a one-step setup.
func saveAndCacheKey(cfg *config.Config, key string) error {
if os.Getenv(config.EnvAPIKey) != "" {
fmt.Println()
Expand All @@ -294,11 +314,63 @@ func saveAndCacheKey(cfg *config.Config, key string) error {
} else {
fmt.Println("\nAPI key saved.")
}

// Auto-install hooks so auth login is a complete one-step setup.
fmt.Println()
fmt.Println("Next: run 'uncompact install' to add hooks to Claude Code.")
autoInstallHooks()
return nil
}

// autoInstallHooks installs the Claude Code hooks as part of the auth login
// flow. If already installed it confirms silently. If not, it shows the diff,
// prompts for confirmation, and applies — matching the behaviour of
// `uncompact install` but without requiring a separate command.
func autoInstallHooks() {
settingsPath, err := hooks.FindSettingsFile()
if err != nil {
fmt.Println("Could not find Claude Code settings.json — run 'uncompact install' manually.")
return
}

result, err := hooks.Install(settingsPath, true) // dry-run to get diff
if err != nil {
fmt.Printf("Could not check hook status: %v\n", err)
fmt.Println("Run 'uncompact install' manually.")
return
}

if result.AlreadySet {
fmt.Println("✓ Claude Code hooks already installed.")
return
}

fmt.Println("The following changes will be made to Claude Code settings.json:")
fmt.Println()
fmt.Println(result.Diff)
fmt.Print("Install hooks now? [y/N]: ")

scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
fmt.Println("Skipped. Run 'uncompact install' to add hooks later.")
return
}
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
if answer != "y" && answer != "yes" {
fmt.Println("Skipped. Run 'uncompact install' to add hooks later.")
return
}

if _, err := hooks.Install(settingsPath, false); err != nil {
fmt.Printf("Hook install failed: %v\n", err)
fmt.Println("Run 'uncompact install' manually.")
return
}

fmt.Println()
fmt.Println("✓ Claude Code hooks installed.")
fmt.Println(" Uncompact will now reinject context automatically after compaction.")
}

func authStatusHandler(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(apiKey)
if err != nil {
Expand Down
23 changes: 19 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
)

const (
EnvAPIKey = "SUPERMODEL_API_KEY"
EnvMode = "UNCOMPACT_MODE"
APIBaseURL = "https://api.supermodeltools.com"
EnvAPIKey = "SUPERMODEL_API_KEY"
EnvMode = "UNCOMPACT_MODE"
EnvDashboardURL = "UNCOMPACT_DASHBOARD_URL" // override base dashboard URL for staging
EnvAPIURL = "UNCOMPACT_API_URL" // override API base URL for staging
APIBaseURL = "https://api.supermodeltools.com"
DashboardURL = "https://dashboard.supermodeltools.com"
DashboardKeyURL = "https://dashboard.supermodeltools.com/api-keys/"
DashboardCLIAuthURL = "https://dashboard.supermodeltools.com/cli-auth/"
Expand All @@ -41,6 +43,16 @@ type Config struct {
Source string `json:"-"`
}

// EffectiveCLIAuthURL returns the dashboard CLI auth URL, allowing the base
// domain to be overridden via UNCOMPACT_DASHBOARD_URL for staging or local
// development. Example: UNCOMPACT_DASHBOARD_URL=https://staging.dashboard.supermodeltools.com
func EffectiveCLIAuthURL() string {
if override := os.Getenv(EnvDashboardURL); override != "" {
return strings.TrimRight(override, "/") + "/cli-auth/"
}
return DashboardCLIAuthURL
}

// ConfigDir returns the XDG-compatible config directory.
func ConfigDir() (string, error) {
var base string
Expand Down Expand Up @@ -194,7 +206,10 @@ func Load(flagAPIKey string) (*Config, error) {
}

// Apply defaults for any fields not set by file/env/flag.
if cfg.BaseURL == "" {
// UNCOMPACT_API_URL overrides the API base URL (useful for staging).
if envAPIURL := os.Getenv(EnvAPIURL); envAPIURL != "" {
cfg.BaseURL = strings.TrimRight(envAPIURL, "/")
} else if cfg.BaseURL == "" {
cfg.BaseURL = APIBaseURL
}
if cfg.MaxTokens <= 0 {
Expand Down
35 changes: 20 additions & 15 deletions npm/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,27 +187,32 @@ async function main() {
log(`[uncompact] Installed to ${destPath}\n\n`);
}

// Automatically install Claude Code hooks
log("[uncompact] Configuring Claude Code hooks...\n");
// Check if already authenticated so we can decide what to run.
let isAuthenticated = false;
try {
execFileSync(destPath, ["install", "--yes"], { stdio: "inherit" });
const out = execFileSync(destPath, ["auth", "status"], { stdio: "pipe" }).toString();
isAuthenticated = out.includes("authenticated");
} catch (err) {
log("[uncompact] Note: Automatic hook configuration skipped or failed. Run manually if needed:\n");
log(" uncompact install\n");
// binary failed or not runnable — fall through to install
}

// Show status to verify setup
try {
console.log();
execFileSync(destPath, ["status"], { stdio: "inherit" });
} catch (err) {
if (isAuthenticated) {
// Existing user (upgrade): just make sure hooks are current.
log("[uncompact] Already authenticated. Ensuring Claude Code hooks are installed...\n");
try {
execFileSync(destPath, [], { stdio: "inherit" });
} catch (e) {}
execFileSync(destPath, ["install", "--yes"], { stdio: "inherit" });
} catch (err) {
log("[uncompact] Note: hook configuration skipped. Run 'uncompact install' manually if needed.\n");
}
} else {
// Fresh install: auth login handles both authentication and hook installation.
log("[uncompact] Starting setup...\n\n");
try {
execFileSync(destPath, ["auth", "login"], { stdio: "inherit" });
} catch (err) {
log("[uncompact] Setup skipped or failed. Run 'uncompact auth login' to complete setup.\n");
}
}

log("\n");
log("[uncompact] To authenticate: run 'uncompact auth login'\n");
}

main().catch((err) => {
Expand Down