1// Package cli provides CLI commands using Bubble Tea TUI.
2package cli
3
4import (
5 "context"
6 "database/sql"
7 "fmt"
8 "os"
9 "path/filepath"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/bootstrap"
14 "github.com/bnema/dumber/internal/cli/styles"
15 "github.com/bnema/dumber/internal/domain/build"
16 "github.com/bnema/dumber/internal/domain/entity"
17 "github.com/bnema/dumber/internal/domain/repository"
18 "github.com/bnema/dumber/internal/infrastructure/config"
19 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
20 "github.com/bnema/dumber/internal/infrastructure/favicon"
21 "github.com/bnema/dumber/internal/infrastructure/filesystem"
22 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
23 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
24 "github.com/bnema/dumber/internal/logging"
25)
26
27// App holds CLI dependencies.
28type App struct {
29 Config *config.Config
30 Theme *styles.Theme
31 BuildInfo build.Info
32 db *sql.DB
33 History repository.HistoryRepository
34
35 // Use cases
36 SearchHistoryUC *usecase.SearchHistoryUseCase
37 SessionUC *usecase.ManageSessionUseCase
38 ListSessionsUC *usecase.ListSessionsUseCase
39 RestoreUC *usecase.RestoreSessionUseCase
40 DeleteSessionUC *usecase.DeleteSessionUseCase
41
42 // Services
43 FaviconService *favicon.Service
44 SessionSpawner port.SessionSpawner
45 LocalPaths port.LocalPathResolver
46 NavigationURLNormalizer *usecase.NavigationURLNormalizer
47
48 // Context with logger
49 ctx context.Context
50 logCleanup func()
51}
52
53// NewApp creates a new CLI application with all dependencies.
54func NewApp() (*App, error) {
55 const dataDirPerm = 0o755
56 var err error
57 // Load config
58 cfg := loadConfig()
59 profile, err := bootstrap.ResolveRuntimeProfile(cfg)
60 if err != nil {
61 return nil, fmt.Errorf("resolve runtime profile: %w", err)
62 }
63
64 // Create theme through the shared resolver. CLI keeps the historical dark default
65 // unless config explicitly requests a light scheme.
66 theme := styles.NewThemeFromResolved(resolveCLITheme(cfg))
67
68 // Start with a quiet logger. If a browser session is active, we'll attach to it.
69 logLevel := cfg.Logging.Level
70 if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" {
71 logLevel = envLevel
72 }
73
74 logger, logCleanup, _ := logging.NewWithFile(
75 logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"},
76 logging.FileConfig{Enabled: false, WriteToStderr: false},
77 )
78 ctx := logging.WithContext(context.Background(), logger)
79
80 // Determine database path - use share directory per XDG spec (user data)
81 home, _ := os.UserHomeDir()
82 dbFile := cfg.Database.Path
83 if dbFile == "" {
84 dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
85 }
86 dbPath := filepath.Dir(dbFile)
87
88 // Ensure data directory exists
89 if err = os.MkdirAll(dbPath, dataDirPerm); err != nil {
90 return nil, fmt.Errorf("create data dir: %w", err)
91 }
92
93 // Open database connection
94 db, err := sqlite.NewConnection(ctx, dbFile)
95 if err != nil {
96 return nil, fmt.Errorf("open database: %w", err)
97 }
98
99 // Attach CLI logs to the active browser session (if any).
100 // This avoids creating extra tiny session log files for each CLI invocation.
101 sessionRepo := sqlite.NewSessionRepository(db)
102 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
103 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
104 if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil {
105 attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{
106 Level: logLevel,
107 Format: cfg.Logging.Format,
108 TimeFormat: "15:04:05",
109 LogDir: cfg.Logging.LogDir,
110 WriteToStderr: false,
111 EnableFileLog: cfg.Logging.EnableFileLog,
112 })
113 logger = attachedLogger
114 logCleanup = attachedCleanup
115 ctx = logging.WithContext(context.Background(), logger)
116 logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session")
117 }
118
119 logger.Debug().Str("db_path", dbFile).Msg("database connected")
120
121 // Create repositories
122 historyRepo := sqlite.NewHistoryRepository(db)
123 sessionStateRepo := sqlite.NewSessionStateRepository(db)
124
125 // Create use cases
126 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
127 listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo)
128 restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
129 deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo)
130
131 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
132 faviconCacheDir, _ := config.GetFaviconCacheDir()
133 faviconService := favicon.NewService(faviconCacheDir)
134 localPaths := filesystem.New()
135
136 return &App{
137 Config: cfg,
138 Theme: theme,
139 db: db,
140 History: historyRepo,
141 SearchHistoryUC: searchHistoryUC,
142 SessionUC: sessionUC,
143 ListSessionsUC: listSessionsUC,
144 RestoreUC: restoreUC,
145 DeleteSessionUC: deleteSessionUC,
146 FaviconService: faviconService,
147 SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
148 LocalPaths: localPaths,
149 NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
150 ctx: ctx,
151 logCleanup: logCleanup,
152 }, nil
153}
154
155// Close releases all resources.
156func (a *App) Close() error {
157 if a.logCleanup != nil {
158 a.logCleanup()
159 }
160 if a.FaviconService != nil {
161 a.FaviconService.Close()
162 }
163 if a.db != nil {
164 return a.db.Close()
165 }
166 return nil
167}
168
169func resolveCLITheme(cfg *config.Config) entity.ResolvedTheme {
170 colorScheme := ""
171 if cfg != nil {
172 colorScheme = cfg.Appearance.ColorScheme
173 }
174 preference := usecase.ColorSchemePreferenceFromConfig(colorScheme, port.ColorSchemePreference{
175 PrefersDark: true,
176 Source: "cli",
177 })
178
179 var appearance *entity.AppearanceConfig
180 var styling *entity.WorkspaceStylingConfig
181 var uiScale float64
182 if cfg != nil {
183 appearance = &cfg.Appearance
184 styling = &cfg.Workspace.Styling
185 uiScale = cfg.DefaultUIScale
186 }
187
188 externalThemeSource := noctalia.NewFileSource(false, "")
189 if cfg != nil {
190 externalThemeSource.Configure(cfg.Appearance.ExternalTheme)
191 }
192 uc := usecase.NewResolveThemeUseCase(externalThemeSource)
193 out, err := uc.Execute(context.Background(), usecase.ResolveThemeInputFromConfig(
194 appearance,
195 uiScale,
196 styling,
197 preference,
198 ))
199 if err != nil {
200 return entity.ResolvedTheme{ActivePalette: entity.DefaultDarkPalette()}
201 }
202 return out.Theme
203}
204
205// Ctx returns the application context with logger.
206func (a *App) Ctx() context.Context {
207 return a.ctx
208}
209
210// loadConfig loads configuration from standard locations.
211func loadConfig() *config.Config {
212 mgr, err := config.NewManager()
213 if err != nil {
214 // Return default config if manager fails
215 return config.DefaultConfig()
216 }
217
218 if err := mgr.Load(); err != nil {
219 // Return default config if loading fails
220 return config.DefaultConfig()
221 }
222
223 return mgr.Get()
224}