From 2f7894fd8358d74a442519df8bb5598daab6907c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 20:45:12 +0000 Subject: [PATCH 1/4] Add Supabase plugin for status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows local dev stack status (⚡ green=running, gray=stopped) and pending migration count (↑N) when idle. Auto-detects Supabase projects via supabase/config.toml. Configurable show_migrations and show_when_stopped options. https://claude.ai/code/session_01GC6HCBL4rVMGeffPXFQWin --- internal/cache/cache.go | 1 + internal/plugins/interface.go | 1 + internal/plugins/supabase.go | 205 +++++++++++++++++++ internal/plugins/supabase_test.go | 330 ++++++++++++++++++++++++++++++ 4 files changed, 537 insertions(+) create mode 100644 internal/plugins/supabase.go create mode 100644 internal/plugins/supabase_test.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go index b8bd0e6..b0d9d0b 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -118,4 +118,5 @@ const ( ConfigTTL = 10 * time.Second WorktreeTTL = 5 * time.Minute // Worktree status rarely changes SpotifyTTL = 3 * time.Second // Fast enough for track changes + SupabaseTTL = 3 * time.Second // Local stack status can change ) diff --git a/internal/plugins/interface.go b/internal/plugins/interface.go index d7b4bff..f721565 100644 --- a/internal/plugins/interface.go +++ b/internal/plugins/interface.go @@ -37,6 +37,7 @@ func NewRegistry() *Registry { r.registerWithCache(&UsageBarsPlugin{}) r.registerWithCache(&UsageTextPlugin{}) r.registerWithCache(&UsagePlugin{}) + r.registerWithCache(&SupabasePlugin{}) return r } diff --git a/internal/plugins/supabase.go b/internal/plugins/supabase.go new file mode 100644 index 0000000..804892d --- /dev/null +++ b/internal/plugins/supabase.go @@ -0,0 +1,205 @@ +package plugins + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/himattm/prism/internal/cache" + "github.com/himattm/prism/internal/plugin" +) + +// SupabasePlugin displays Supabase local dev stack status and pending migrations +type SupabasePlugin struct { + cache *cache.Cache +} + +// supabaseConfig holds plugin configuration +type supabaseConfig struct { + showMigrations bool + showWhenStopped bool +} + +func (p *SupabasePlugin) Name() string { + return "supabase" +} + +func (p *SupabasePlugin) SetCache(c *cache.Cache) { + p.cache = c +} + +// OnHook invalidates Supabase cache when Claude becomes idle +func (p *SupabasePlugin) OnHook(ctx context.Context, hookType HookType, hookCtx HookContext) (string, error) { + if hookType == HookIdle && p.cache != nil { + p.cache.DeleteByPrefix("supabase:") + } + return "", nil +} + +func (p *SupabasePlugin) Execute(ctx context.Context, input plugin.Input) (string, error) { + projectDir := input.Prism.ProjectDir + if projectDir == "" { + return "", nil + } + + // Check cache for full output + cacheKey := "supabase:output:" + projectDir + if p.cache != nil { + if cached, ok := p.cache.Get(cacheKey); ok { + return cached, nil + } + } + + // Detect Supabase project + if !p.isSupabaseProject(projectDir) { + return "", nil + } + + // Check CLI availability + supabasePath, err := exec.LookPath("supabase") + if err != nil { + return "", nil + } + + cfg := parseSupabaseConfig(input.Config) + + // Check local stack status + running := p.checkLocalStatus(ctx, supabasePath, projectDir) + + if !running && !cfg.showWhenStopped { + return "", nil + } + + // Build output + green := input.Colors["emerald"] + gray := input.Colors["gray"] + reset := input.Colors["reset"] + + var result strings.Builder + if running { + result.WriteString(green) + } else { + result.WriteString(gray) + } + result.WriteString("⚡") + + // Fetch migrations (idle-only for fresh data, use cache otherwise) + if running && cfg.showMigrations { + migrationKey := "supabase:migrations:" + projectDir + if input.Prism.IsIdle { + pending := countPendingMigrations(ctx, supabasePath, projectDir) + migrationFragment := "" + if pending > 0 { + migrationFragment = fmt.Sprintf(" ↑%d", pending) + } + if p.cache != nil { + p.cache.Set(migrationKey, migrationFragment, cache.ConfigTTL) + } + result.WriteString(migrationFragment) + } else if p.cache != nil { + if cached, ok := p.cache.Get(migrationKey); ok { + result.WriteString(cached) + } + } + } + + result.WriteString(reset) + output := result.String() + + if p.cache != nil { + p.cache.Set(cacheKey, output, cache.SupabaseTTL) + } + + return output, nil +} + +// isSupabaseProject checks for supabase/config.toml in the project directory +func (p *SupabasePlugin) isSupabaseProject(projectDir string) bool { + detectKey := "supabase:detect:" + projectDir + if p.cache != nil { + if cached, ok := p.cache.Get(detectKey); ok { + return cached == "true" + } + } + + configPath := filepath.Join(projectDir, "supabase", "config.toml") + _, err := os.Stat(configPath) + exists := err == nil + + if p.cache != nil { + val := "false" + if exists { + val = "true" + } + p.cache.Set(detectKey, val, cache.ConfigTTL) + } + + return exists +} + +// checkLocalStatus runs `supabase status --output json` to determine if the local stack is running +func (p *SupabasePlugin) checkLocalStatus(ctx context.Context, supabasePath, projectDir string) bool { + cmd := exec.CommandContext(ctx, supabasePath, "status", "--output", "json") + cmd.Dir = projectDir + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &bytes.Buffer{} + + if err := cmd.Run(); err != nil { + return false + } + + // If the command succeeds and produces output, the stack is running + return out.Len() > 0 +} + +// countPendingMigrations runs `supabase migration list` and counts unapplied migrations +func countPendingMigrations(ctx context.Context, supabasePath, projectDir string) int { + cmd := exec.CommandContext(ctx, supabasePath, "migration", "list") + cmd.Dir = projectDir + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &bytes.Buffer{} + + if err := cmd.Run(); err != nil { + return 0 + } + + return parseMigrationOutput(out.String()) +} + +// parseMigrationOutput counts lines containing "Not Applied" in migration list output +func parseMigrationOutput(output string) int { + count := 0 + for _, line := range strings.Split(output, "\n") { + if strings.Contains(strings.ToLower(line), "not applied") { + count++ + } + } + return count +} + +func parseSupabaseConfig(config map[string]any) supabaseConfig { + cfg := supabaseConfig{ + showMigrations: true, + showWhenStopped: false, + } + + supabaseCfg, ok := config["supabase"].(map[string]any) + if !ok { + return cfg + } + + if v, ok := supabaseCfg["show_migrations"].(bool); ok { + cfg.showMigrations = v + } + if v, ok := supabaseCfg["show_when_stopped"].(bool); ok { + cfg.showWhenStopped = v + } + + return cfg +} diff --git a/internal/plugins/supabase_test.go b/internal/plugins/supabase_test.go new file mode 100644 index 0000000..037c2eb --- /dev/null +++ b/internal/plugins/supabase_test.go @@ -0,0 +1,330 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/himattm/prism/internal/cache" + "github.com/himattm/prism/internal/plugin" +) + +func TestSupabasePlugin_Name(t *testing.T) { + p := &SupabasePlugin{} + if p.Name() != "supabase" { + t.Errorf("expected name 'supabase', got '%s'", p.Name()) + } +} + +func TestSupabasePlugin_SetCache(t *testing.T) { + p := &SupabasePlugin{} + c := cache.New() + p.SetCache(c) + + if p.cache != c { + t.Error("cache was not set correctly") + } +} + +func TestParseSupabaseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]any + expected supabaseConfig + }{ + { + name: "empty config uses defaults", + input: map[string]any{}, + expected: supabaseConfig{ + showMigrations: true, + showWhenStopped: false, + }, + }, + { + name: "show_migrations false", + input: map[string]any{ + "supabase": map[string]any{ + "show_migrations": false, + }, + }, + expected: supabaseConfig{ + showMigrations: false, + showWhenStopped: false, + }, + }, + { + name: "show_when_stopped true", + input: map[string]any{ + "supabase": map[string]any{ + "show_when_stopped": true, + }, + }, + expected: supabaseConfig{ + showMigrations: true, + showWhenStopped: true, + }, + }, + { + name: "all options", + input: map[string]any{ + "supabase": map[string]any{ + "show_migrations": false, + "show_when_stopped": true, + }, + }, + expected: supabaseConfig{ + showMigrations: false, + showWhenStopped: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseSupabaseConfig(tt.input) + if result.showMigrations != tt.expected.showMigrations { + t.Errorf("showMigrations: expected %v, got %v", tt.expected.showMigrations, result.showMigrations) + } + if result.showWhenStopped != tt.expected.showWhenStopped { + t.Errorf("showWhenStopped: expected %v, got %v", tt.expected.showWhenStopped, result.showWhenStopped) + } + }) + } +} + +func TestParseMigrationOutput(t *testing.T) { + tests := []struct { + name string + output string + expected int + }{ + { + name: "no output", + output: "", + expected: 0, + }, + { + name: "all applied", + output: ` LOCAL │ REMOTE │ TIME + ─────────┼────────┼────────────── + 20240101 │ ✓ │ Applied + 20240102 │ ✓ │ Applied`, + expected: 0, + }, + { + name: "one pending", + output: ` LOCAL │ REMOTE │ TIME + ─────────┼────────┼────────────── + 20240101 │ ✓ │ Applied + 20240102 │ │ Not Applied`, + expected: 1, + }, + { + name: "multiple pending", + output: ` LOCAL │ REMOTE │ TIME + ─────────┼────────┼────────────── + 20240101 │ ✓ │ Applied + 20240102 │ │ Not Applied + 20240103 │ │ Not Applied + 20240104 │ │ Not Applied`, + expected: 3, + }, + { + name: "all pending", + output: ` LOCAL │ REMOTE │ TIME + ─────────┼────────┼────────────── + 20240101 │ │ Not Applied + 20240102 │ │ Not Applied`, + expected: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseMigrationOutput(tt.output) + if result != tt.expected { + t.Errorf("expected %d pending migrations, got %d", tt.expected, result) + } + }) + } +} + +func TestSupabasePlugin_NoConfigToml(t *testing.T) { + p := &SupabasePlugin{} + c := cache.New() + p.SetCache(c) + + // Use a temp dir with no supabase/config.toml + tmpDir := t.TempDir() + + ctx := context.Background() + input := plugin.Input{ + Prism: plugin.PrismContext{ + ProjectDir: tmpDir, + }, + Config: map[string]any{}, + Colors: map[string]string{ + "emerald": "[emerald]", + "gray": "[gray]", + "reset": "[reset]", + }, + } + + result, err := p.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != "" { + t.Errorf("expected empty string for non-supabase project, got '%s'", result) + } +} + +func TestSupabasePlugin_IsSupabaseProject(t *testing.T) { + p := &SupabasePlugin{} + c := cache.New() + p.SetCache(c) + + // Create a temp dir with supabase/config.toml + tmpDir := t.TempDir() + supabaseDir := filepath.Join(tmpDir, "supabase") + if err := os.MkdirAll(supabaseDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(supabaseDir, "config.toml"), []byte("[project]\nid = \"test\""), 0o644); err != nil { + t.Fatal(err) + } + + if !p.isSupabaseProject(tmpDir) { + t.Error("expected isSupabaseProject to return true") + } + + // Should be cached now + if cached, ok := c.Get("supabase:detect:" + tmpDir); !ok || cached != "true" { + t.Error("expected detection result to be cached") + } + + // Non-supabase dir + emptyDir := t.TempDir() + if p.isSupabaseProject(emptyDir) { + t.Error("expected isSupabaseProject to return false for empty dir") + } +} + +func TestSupabasePlugin_OnHook_InvalidatesCache(t *testing.T) { + p := &SupabasePlugin{} + c := cache.New() + p.SetCache(c) + + // Add items to cache + c.Set("supabase:output:/test", "test value", time.Minute) + c.Set("supabase:detect:/test", "true", time.Minute) + c.Set("supabase:migrations:/test", " ↑2", time.Minute) + + // Verify they're there + if _, ok := c.Get("supabase:output:/test"); !ok { + t.Fatal("cache item not found before hook") + } + + // Fire idle hook + _, err := p.OnHook(context.Background(), HookIdle, HookContext{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // All supabase cache should be invalidated + if _, ok := c.Get("supabase:output:/test"); ok { + t.Error("output cache should be invalidated after idle hook") + } + if _, ok := c.Get("supabase:detect:/test"); ok { + t.Error("detect cache should be invalidated after idle hook") + } + if _, ok := c.Get("supabase:migrations:/test"); ok { + t.Error("migrations cache should be invalidated after idle hook") + } +} + +func TestSupabasePlugin_OnHook_OtherHooksIgnored(t *testing.T) { + p := &SupabasePlugin{} + c := cache.New() + p.SetCache(c) + + c.Set("supabase:output:/test", "test value", time.Minute) + + // Fire busy hook (not idle) + _, err := p.OnHook(context.Background(), HookBusy, HookContext{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // Cache should NOT be invalidated + if _, ok := c.Get("supabase:output:/test"); !ok { + t.Error("cache item should not be invalidated by busy hook") + } +} + +func TestSupabasePlugin_OnHook_NilCache(t *testing.T) { + p := &SupabasePlugin{} // No cache set + + // Should not panic + _, err := p.OnHook(context.Background(), HookIdle, HookContext{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestSupabasePlugin_EmptyProjectDir(t *testing.T) { + p := &SupabasePlugin{} + c := cache.New() + p.SetCache(c) + + ctx := context.Background() + input := plugin.Input{ + Prism: plugin.PrismContext{ + ProjectDir: "", + }, + Config: map[string]any{}, + Colors: map[string]string{}, + } + + result, err := p.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != "" { + t.Errorf("expected empty string for empty project dir, got '%s'", result) + } +} + +func TestSupabasePlugin_Execute_Caching(t *testing.T) { + p := &SupabasePlugin{} + c := cache.New() + p.SetCache(c) + + // Pre-populate cache + expectedOutput := "[emerald]⚡[reset]" + c.Set("supabase:output:/test/dir", expectedOutput, time.Minute) + + ctx := context.Background() + input := plugin.Input{ + Prism: plugin.PrismContext{ + ProjectDir: "/test/dir", + }, + Config: map[string]any{}, + Colors: map[string]string{ + "emerald": "[emerald]", + "gray": "[gray]", + "reset": "[reset]", + }, + } + + result, err := p.Execute(ctx, input) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if result != expectedOutput { + t.Errorf("expected cached output '%s', got '%s'", expectedOutput, result) + } +} From d37583cc27d588467e94fdf4417ad00e379e38c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 20:53:20 +0000 Subject: [PATCH 2/4] Use Supabase brand green and show on second line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SupabaseGreen color (#3ECF8E) using 24-bit true color - Use supabase_green instead of emerald for the ⚡ icon - Add supabase to default second line alongside spotify https://claude.ai/code/session_01GC6HCBL4rVMGeffPXFQWin --- internal/colors/colors.go | 6 ++++-- internal/config/config.go | 2 +- internal/plugins/supabase.go | 2 +- internal/plugins/supabase_test.go | 6 +++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/colors/colors.go b/internal/colors/colors.go index d838a54..0a98546 100644 --- a/internal/colors/colors.go +++ b/internal/colors/colors.go @@ -63,7 +63,8 @@ const ( SpringGreen = "\033[38;5;48m" Mint = "\033[38;5;121m" LightGreen = "\033[38;5;119m" - PaleGreen = "\033[38;5;157m" + PaleGreen = "\033[38;5;157m" + SupabaseGreen = "\033[38;2;62;207;142m" // Supabase brand #3ECF8E // Teals & Cyans (256-color) Teal = "\033[38;5;30m" @@ -172,7 +173,8 @@ func ColorMap() map[string]string { "spring_green": SpringGreen, "mint": Mint, "light_green": LightGreen, - "pale_green": PaleGreen, + "pale_green": PaleGreen, + "supabase_green": SupabaseGreen, // Teals & Cyans "teal": Teal, diff --git a/internal/config/config.go b/internal/config/config.go index 51bdd16..4c6ebc4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,7 +30,7 @@ func (c Config) GetAutocompactBuffer() float64 { func DefaultSectionLines() [][]string { return [][]string{ {"dir", "model", "context", "usage", "git"}, - {"spotify"}, + {"spotify", "supabase"}, } } diff --git a/internal/plugins/supabase.go b/internal/plugins/supabase.go index 804892d..5e473c0 100644 --- a/internal/plugins/supabase.go +++ b/internal/plugins/supabase.go @@ -75,7 +75,7 @@ func (p *SupabasePlugin) Execute(ctx context.Context, input plugin.Input) (strin } // Build output - green := input.Colors["emerald"] + green := input.Colors["supabase_green"] gray := input.Colors["gray"] reset := input.Colors["reset"] diff --git a/internal/plugins/supabase_test.go b/internal/plugins/supabase_test.go index 037c2eb..daaf388 100644 --- a/internal/plugins/supabase_test.go +++ b/internal/plugins/supabase_test.go @@ -166,7 +166,7 @@ func TestSupabasePlugin_NoConfigToml(t *testing.T) { }, Config: map[string]any{}, Colors: map[string]string{ - "emerald": "[emerald]", + "supabase_green": "[supabase_green]", "gray": "[gray]", "reset": "[reset]", }, @@ -303,7 +303,7 @@ func TestSupabasePlugin_Execute_Caching(t *testing.T) { p.SetCache(c) // Pre-populate cache - expectedOutput := "[emerald]⚡[reset]" + expectedOutput := "[supabase_green]⚡[reset]" c.Set("supabase:output:/test/dir", expectedOutput, time.Minute) ctx := context.Background() @@ -313,7 +313,7 @@ func TestSupabasePlugin_Execute_Caching(t *testing.T) { }, Config: map[string]any{}, Colors: map[string]string{ - "emerald": "[emerald]", + "supabase_green": "[supabase_green]", "gray": "[gray]", "reset": "[reset]", }, From ad9f9baba53124bb188fbc5b1215b58176046626 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 23:29:18 +0000 Subject: [PATCH 3/4] Move supabase to its own second line, spotify to third https://claude.ai/code/session_01GC6HCBL4rVMGeffPXFQWin --- internal/config/config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 4c6ebc4..fe620f3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,7 +30,8 @@ func (c Config) GetAutocompactBuffer() float64 { func DefaultSectionLines() [][]string { return [][]string{ {"dir", "model", "context", "usage", "git"}, - {"spotify", "supabase"}, + {"supabase"}, + {"spotify"}, } } From 42eb13720fe0fb7b4157839bcab5dc43242db0cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 23:31:22 +0000 Subject: [PATCH 4/4] Document the rationale behind default section line groupings Line 1 is the agent harness (dir, model, context, usage, git), line 2 is project tooling (supabase, vercel, android, etc), line 3 is auxiliary ambient info (spotify, etc). https://claude.ai/code/session_01GC6HCBL4rVMGeffPXFQWin --- internal/config/config.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fe620f3..1da07d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,8 +25,13 @@ func (c Config) GetAutocompactBuffer() float64 { return *c.AutocompactBuffer } -// DefaultSectionLines returns the default multi-line section layout -// This is the single source of truth for default sections +// DefaultSectionLines returns the default multi-line section layout. +// This is the single source of truth for default sections. +// +// Lines are organized by concern: +// - Line 1: Agent harness — dir, model, context window, token usage, and git branch +// - Line 2: Project tooling — supabase, vercel, android, and other dev stack plugins +// - Line 3: Auxiliary — ambient info like now-playing music func DefaultSectionLines() [][]string { return [][]string{ {"dir", "model", "context", "usage", "git"},