Skip to content
Open
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
1 change: 1 addition & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
6 changes: 4 additions & 2 deletions internal/colors/colors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ 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"},
{"supabase"},
{"spotify"},
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/plugins/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func NewRegistry() *Registry {
r.registerWithCache(&UsageBarsPlugin{})
r.registerWithCache(&UsageTextPlugin{})
r.registerWithCache(&UsagePlugin{})
r.registerWithCache(&SupabasePlugin{})

return r
}
Expand Down
205 changes: 205 additions & 0 deletions internal/plugins/supabase.go
Original file line number Diff line number Diff line change
@@ -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["supabase_green"]
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
}
Comment on lines +144 to +158

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of checkLocalStatus has two areas for improvement:

  1. Robustness: Checking out.Len() > 0 is not a reliable way to determine if the service is running, especially when requesting JSON output. The supabase CLI might return null or an empty JSON object ({}) when stopped, both of which have a length greater than 0 and would lead to an incorrect status.
  2. Testability: The function mixes command execution with output parsing, making it difficult to unit test.

I recommend refactoring this to separate the parsing logic into its own function, similar to the pattern you've used for countPendingMigrations and parseMigrationOutput. This would allow you to add unit tests for the parsing logic to cover various outputs from the supabase CLI (e.g., empty string, null, valid JSON) and make the status detection more resilient.


// 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
}
Loading
Loading