package main import ( "flag" "fmt" "os" "path/filepath" "strings" tea "github.com/charmbracelet/bubbletea" ) func main() { if handled, err := handleCompletionCommand(os.Args[1:], os.Stdout); handled { if err != nil { exitf("%v", err) } return } defaults := defaultConfig() defaultConfigPath, err := configPath() if err != nil { exitf("configuration: %v", err) } flag.Usage = func() { writeCLIHelp(flag.CommandLine.Output(), defaults, defaultConfigPath) } if len(os.Args) == 2 && os.Args[1] == "help" { flag.Usage() return } var ( configFile = flag.String("config", defaultConfigPath, "TOML configuration file") repo = flag.String("repo", "", "limit pull requests to an owner/repository") poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "base interval between GitHub refreshes") showAll = flag.Bool("all", defaults.ShowAll, "show every open PR in --repo instead of only assigned PRs") limit = flag.Int("limit", defaults.Limit, "maximum number of open pull requests to load") endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL API endpoint") theme = flag.String("theme", defaults.Theme, "built-in or custom color theme") foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved review threads folded") listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread-list width as a percentage of the terminal") dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "open the dashboard by hotkey or as an intermediate screen") compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate repeated submitted-review entries") pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll file paths that do not fit") pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "interval between file-path scroll steps") cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable the local read cache and offline fallback") cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "oldest cache entry accepted for offline fallback") cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read-cache directory") editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode") ) flag.Parse() if flag.NArg() != 0 { exitf("unexpected argument %q; run 'diple --help' for usage", flag.Arg(0)) } visited := map[string]bool{} flag.Visit(func(item *flag.Flag) { visited[item.Name] = true }) config, err := loadConfig( *configFile, visited["config"] || os.Getenv("DIPLE_CONFIG") != "" || os.Getenv("GH_THREADS_CONFIG") != "", ) if err != nil { exitf("configuration: %v", err) } if visited["repo"] { config.Repository = *repo } else if envRepo := os.Getenv("GH_REPO"); envRepo != "" { config.Repository = envRepo } if visited["poll"] { config.RefreshInterval.Duration = *poll } if visited["all"] { config.ShowAll = *showAll } if visited["limit"] { config.Limit = *limit } if visited["endpoint"] { config.Endpoint = *endpoint } if visited["theme"] { config.Theme = *theme } if visited["fold-resolved"] { config.Display.FoldResolved = *foldResolved } if visited["thread-list-width"] { config.Display.ThreadListWidthPercent = *listWidth } if visited["dashboard-mode"] { config.Display.DashboardMode = *dashboardMode } if visited["compact-reviews"] { config.Display.CompactReviews = *compactReviews } if visited["path-scroll"] { config.Paths.Scroll = *pathScroll } if visited["path-scroll-interval"] { config.Paths.ScrollInterval.Duration = *pathScrollRate } if visited["cache"] { config.Cache.Enabled = *cacheEnabled } if visited["cache-max-age"] { config.Cache.MaxAge.Duration = *cacheMaxAge } if visited["cache-dir"] { config.Cache.Directory = *cacheDir } if visited["editor-mode"] { config.Editing.Mode = *editorMode } if err := validateConfig(config); err != nil { exitf("configuration: %v", err) } var owner, name string if config.Repository != "" { parts := strings.Split(config.Repository, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { exitf("--repo must be owner/name") } owner, name = parts[0], parts[1] } if err := applyTheme(config.Theme, config.CustomTheme); err != nil { exitf("configuration: %v", err) } token, err := resolveToken(config.Endpoint) if err != nil { exitf("authenticate: %v", err) } client := NewGitHubClient(config.Endpoint, token) var service GitHubService = client if config.Cache.Enabled { cacheDir := config.Cache.Directory if cacheDir == "" { cacheDir, err = defaultCacheDir() if err != nil { exitf("configuration: %v", err) } } service = NewCachedGitHubService( client, cacheDir, config.Cache.MaxAge.Duration, config.Cache.MaxEntries, ) } statePath := filepath.Join(filepath.Dir(*configFile), "state.json") draftPath := filepath.Join(filepath.Dir(*configFile), "drafts.json") var aiController *AIController var aiStore *AIStore if config.AI.Enabled { aiDir := config.AI.StoreDirectory if aiDir == "" { aiDir = filepath.Join(filepath.Dir(*configFile), "ai") } aiStore = NewAIStore(aiDir) diffService, ok := service.(AIDiffService) if !ok { exitf("configuration: GitHub service cannot provide authenticated PR diffs") } workingDirectory, cwdErr := os.Getwd() if cwdErr != nil { exitf("configuration: determine working directory: %v", cwdErr) } aiController = &AIController{ config: config.AI, provider: NewCodexCLIProvider(config.AI, workingDirectory), diffs: diffService, store: aiStore, } } app := NewAppWithSettings( service, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration, AppSettings{ FoldResolved: config.Display.FoldResolved, ThreadListWidthPercent: config.Display.ThreadListWidthPercent, DashboardMode: config.Display.DashboardMode, CompactReviews: config.Display.CompactReviews, ViewerLabel: config.Display.ViewerLabel, ReadState: loadReadState(statePath), Drafts: loadDraftStore(draftPath), PathScroll: config.Paths.Scroll, PathScrollInterval: config.Paths.ScrollInterval.Duration, ThreadStatusOrder: config.Threads.StatusOrder, ThreadWithinStatus: config.Threads.WithinStatus, EditorMode: config.Editing.Mode, KeyBindings: config.KeyBindings, AI: aiController, AIStore: aiStore, }, ) cursorOutput := newTerminalCursorOutput(os.Stdout) app.cursorOutput = cursorOutput if _, err := tea.NewProgram( app, tea.WithAltScreen(), tea.WithOutput(cursorOutput), ).Run(); err != nil { exitf("run TUI: %v", err) } } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } func exitf(format string, args ...any) { fmt.Fprintf(os.Stderr, "diple: "+format+"\n", args...) os.Exit(1) } // Keep interface drift visible at compile time. var _ GitHubService = (*GitHubClient)(nil) var _ GitHubMergeService = (*GitHubClient)(nil) var _ GitHubMergeService = (*CachedGitHubService)(nil) var _ GitHubWriteService = (*GitHubClient)(nil) var _ GitHubWriteService = (*CachedGitHubService)(nil) var _ GitHubPullRequestWriteService = (*GitHubClient)(nil) var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil)