first commit
This commit is contained in:
395
cli/internal/app/app.go
Normal file
395
cli/internal/app/app.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/maximhq/bifrost/cli/internal/apis"
|
||||
"github.com/maximhq/bifrost/cli/internal/config"
|
||||
"github.com/maximhq/bifrost/cli/internal/harness"
|
||||
"github.com/maximhq/bifrost/cli/internal/installer"
|
||||
"github.com/maximhq/bifrost/cli/internal/mcp"
|
||||
"github.com/maximhq/bifrost/cli/internal/runtime"
|
||||
"github.com/maximhq/bifrost/cli/internal/secrets"
|
||||
"github.com/maximhq/bifrost/cli/internal/ui/logo"
|
||||
"github.com/maximhq/bifrost/cli/internal/ui/tui"
|
||||
"github.com/maximhq/bifrost/cli/internal/update"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Options holds the CLI flags and build metadata passed to the application.
|
||||
type Options struct {
|
||||
Version string
|
||||
Commit string
|
||||
NoResume bool
|
||||
Config string
|
||||
Worktree string
|
||||
}
|
||||
|
||||
// App is the main Bifrost CLI application. It manages configuration, state,
|
||||
// and the interactive TUI loop for selecting and launching harnesses.
|
||||
type App struct {
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
errOut io.Writer
|
||||
opts Options
|
||||
apiClient *apis.Client
|
||||
state *config.State
|
||||
cfgFile *config.FileConfig
|
||||
|
||||
statePath string
|
||||
configPath string
|
||||
configSource string
|
||||
bootHeader string
|
||||
}
|
||||
|
||||
// New creates a new App instance with the given I/O streams and options.
|
||||
func New(in io.Reader, out, errOut io.Writer, opts Options) *App {
|
||||
return &App{
|
||||
in: in,
|
||||
out: out,
|
||||
errOut: errOut,
|
||||
opts: opts,
|
||||
apiClient: apis.NewClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the interactive TUI loop. It loads config and state, then presents
|
||||
// the chooser, launches harnesses in a tabbed multiplexer, and loops back when
|
||||
// all tabs are closed.
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
if err := a.loadStateAndConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateCh := update.CheckInBackground(a.opts.Version, a.statePath)
|
||||
|
||||
activeProfile := a.getOrCreateProfile()
|
||||
if activeProfile == nil {
|
||||
return errors.New("failed to initialize profile")
|
||||
}
|
||||
|
||||
vk, err := secrets.GetVirtualKey(activeProfile.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.errOut, "warning: %v\n", err)
|
||||
}
|
||||
if vk == "" && a.cfgFile != nil && strings.TrimSpace(a.cfgFile.VirtualKey) != "" {
|
||||
if err := secrets.SetVirtualKey(activeProfile.ID, strings.TrimSpace(a.cfgFile.VirtualKey)); err == nil {
|
||||
vk = strings.TrimSpace(a.cfgFile.VirtualKey)
|
||||
a.cfgFile.VirtualKey = ""
|
||||
if a.configPath != "" {
|
||||
if err := config.SaveConfig(a.configPath, a.cfgFile); err != nil {
|
||||
fmt.Fprintf(a.errOut, "warning: save config after key migration: %v\n", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(a.errOut, "warning: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
selection := a.state.Selections[activeProfile.ID]
|
||||
if a.opts.NoResume {
|
||||
selection = config.Selection{}
|
||||
}
|
||||
|
||||
// Seed defaults from config if state has no selection
|
||||
if a.cfgFile != nil {
|
||||
if selection.Harness == "" {
|
||||
selection.Harness = strings.TrimSpace(a.cfgFile.DefaultHarness)
|
||||
}
|
||||
if selection.Model == "" {
|
||||
selection.Model = strings.TrimSpace(a.cfgFile.DefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
worktree := strings.TrimSpace(a.opts.Worktree)
|
||||
var updateVersion string
|
||||
|
||||
// chooseAndPrepare runs the chooser TUI, handles installation flows,
|
||||
// persists state, and returns a launch spec. Loops internally until
|
||||
// the user picks a valid harness or quits.
|
||||
chooseAndPrepare := func(_ context.Context, notify func(runtime.TabNoticeLevel, string), tabBarLine func() string, stdinReader io.Reader, msg string, isAfterSession bool, seed *runtime.LaunchSpec) (*runtime.LaunchSpec, error) {
|
||||
seedApplied := false
|
||||
for {
|
||||
harnesses := a.harnessOptions()
|
||||
baseURL := activeProfile.BaseURL
|
||||
currentVK := vk
|
||||
currentSelection := selection
|
||||
currentWorktree := worktree
|
||||
if seed != nil && !seedApplied {
|
||||
baseURL = seed.BaseURL
|
||||
currentVK = seed.VirtualKey
|
||||
currentSelection.Harness = seed.Harness.ID
|
||||
currentSelection.Model = seed.Model
|
||||
currentWorktree = seed.Worktree
|
||||
seedApplied = true
|
||||
}
|
||||
|
||||
choice, err := tui.RunChooser(tui.ChooserConfig{
|
||||
Version: a.opts.Version,
|
||||
Commit: a.opts.Commit,
|
||||
ConfigSrc: a.configSource,
|
||||
Message: msg,
|
||||
UpdateVersion: updateVersion,
|
||||
BaseURL: baseURL,
|
||||
VirtualKey: currentVK,
|
||||
Harness: currentSelection.Harness,
|
||||
Model: currentSelection.Model,
|
||||
Worktree: currentWorktree,
|
||||
AfterSession: isAfterSession,
|
||||
ReservedRows: 1, // bottom tab bar
|
||||
Harnesses: harnesses,
|
||||
TabBarLine: tabBarLine,
|
||||
FetchModels: a.apiClient.ListModels,
|
||||
Input: stdinReader,
|
||||
Notify: func(message string, isError bool) {
|
||||
level := runtime.TabNoticeInfo
|
||||
if isError {
|
||||
level = runtime.TabNoticeError
|
||||
}
|
||||
if notify != nil {
|
||||
notify(level, message)
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if choice.BackToTabs {
|
||||
return nil, runtime.ErrBackToTabs
|
||||
}
|
||||
if choice.UpdateRequested {
|
||||
return nil, runtime.ErrUpdateRequested
|
||||
}
|
||||
if choice.Quit {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
activeProfile.BaseURL = strings.TrimSpace(choice.BaseURL)
|
||||
selection.Harness = strings.TrimSpace(choice.Harness)
|
||||
selection.Model = strings.TrimSpace(choice.Model)
|
||||
vk = strings.TrimSpace(choice.VirtualKey)
|
||||
worktree = strings.TrimSpace(choice.Worktree)
|
||||
|
||||
h, ok := harness.Get(selection.Harness)
|
||||
if !ok {
|
||||
msg = "invalid harness selected"
|
||||
isAfterSession = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle install request
|
||||
if choice.InstallHarness {
|
||||
cmd, args := installer.InstallCommand(h)
|
||||
shouldInstall, err := tui.RunConfirmInstall(a.bootHeader, h.Label, cmd+" "+strings.Join(args, " "))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !shouldInstall {
|
||||
msg = h.Label + " installation skipped"
|
||||
continue
|
||||
}
|
||||
if err := installer.EnsureNPM(); err != nil {
|
||||
msg = err.Error()
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(a.out, "\nInstalling %s...\n", h.Label)
|
||||
if err := installer.RunInstall(ctx, a.out, a.errOut, h); err != nil {
|
||||
msg = err.Error()
|
||||
continue
|
||||
}
|
||||
if !installer.IsInstalled(h) {
|
||||
msg = h.Label + " installed but binary still not in PATH"
|
||||
continue
|
||||
}
|
||||
msg = h.Label + " installed successfully"
|
||||
continue
|
||||
}
|
||||
|
||||
// Save virtual key
|
||||
if err := secrets.SetVirtualKey(activeProfile.ID, vk); err != nil {
|
||||
fmt.Fprintf(a.errOut, "warning: %v\n", err)
|
||||
}
|
||||
|
||||
// Persist state
|
||||
a.state.LastProfileID = activeProfile.ID
|
||||
a.state.Selections[activeProfile.ID] = selection
|
||||
if err := config.SaveState(a.statePath, a.state); err != nil {
|
||||
fmt.Fprintf(a.errOut, "warning: %v\n", err)
|
||||
}
|
||||
|
||||
// Persist config
|
||||
if a.cfgFile == nil {
|
||||
a.cfgFile = &config.FileConfig{}
|
||||
}
|
||||
a.cfgFile.BaseURL = activeProfile.BaseURL
|
||||
a.cfgFile.DefaultHarness = selection.Harness
|
||||
a.cfgFile.DefaultModel = selection.Model
|
||||
if a.configPath != "" {
|
||||
if err := config.SaveConfig(a.configPath, a.cfgFile); err != nil {
|
||||
fmt.Fprintf(a.errOut, "warning: save config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
mcp.AttachBestEffort(ctx, a.out, a.errOut, h, activeProfile.BaseURL, vk)
|
||||
|
||||
return &runtime.LaunchSpec{
|
||||
Harness: h,
|
||||
BaseURL: activeProfile.BaseURL,
|
||||
VirtualKey: vk,
|
||||
Model: selection.Model,
|
||||
Worktree: worktree,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Main loop — each iteration enters tabbed mode (Home → chooser → tabs).
|
||||
// When all tabs close, we loop back.
|
||||
message := ""
|
||||
afterSession := false
|
||||
|
||||
// Wait for update check to complete (up to 4s — the HTTP request has a 3s timeout).
|
||||
select {
|
||||
case result := <-updateCh:
|
||||
if result != nil && result.UpdateAvailable {
|
||||
updateVersion = result.LatestVersion
|
||||
a.state.LastVersionCheck = result.CheckedAt
|
||||
a.state.LastKnownVersion = result.LatestVersion
|
||||
_ = config.SaveState(a.statePath, a.state) // best-effort
|
||||
}
|
||||
updateCh = nil
|
||||
case <-time.After(4 * time.Second):
|
||||
}
|
||||
|
||||
// Enter tabbed mode — draws chrome, opens chooser, runs tabs.
|
||||
err = runtime.RunTabbed(ctx, a.out, a.errOut, a.opts.Version, updateVersion, func(tabCtx context.Context, notify func(runtime.TabNoticeLevel, string), tabBarLine func() string, stdinReader io.Reader, seed *runtime.LaunchSpec) (*runtime.LaunchSpec, error) {
|
||||
return chooseAndPrepare(tabCtx, notify, tabBarLine, stdinReader, message, afterSession, seed)
|
||||
})
|
||||
|
||||
if errors.Is(err, runtime.ErrUpdateRequested) {
|
||||
if err := update.RunSelfUpdate(a.opts.Version); err != nil {
|
||||
return fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
// Re-exec with the updated binary.
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.out, "Updated successfully. Please restart bifrost.\n")
|
||||
return nil
|
||||
}
|
||||
return reexecSelf(execPath, os.Args, os.Environ())
|
||||
}
|
||||
|
||||
if errors.Is(err, runtime.ErrQuit) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// loadStateAndConfig loads configuration from saved state from the last run
|
||||
func (a *App) loadStateAndConfig() error {
|
||||
statePath, err := config.DefaultStatePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.statePath = statePath
|
||||
|
||||
s, err := config.LoadState(statePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.state = s
|
||||
|
||||
cfgPath := strings.TrimSpace(a.opts.Config)
|
||||
if cfgPath == "" {
|
||||
p, err := config.DefaultConfigPath()
|
||||
if err == nil {
|
||||
cfgPath = p
|
||||
}
|
||||
}
|
||||
|
||||
if cfgPath != "" {
|
||||
cfg, source, err := config.LoadFile(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.cfgFile = cfg
|
||||
a.configPath = cfgPath
|
||||
if source != "" {
|
||||
a.configSource = source
|
||||
}
|
||||
}
|
||||
if a.configPath == "" {
|
||||
if p, err := config.DefaultConfigPath(); err == nil {
|
||||
a.configPath = p
|
||||
}
|
||||
}
|
||||
if a.configSource == "" {
|
||||
a.configSource = "none"
|
||||
}
|
||||
|
||||
width := 120
|
||||
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
|
||||
width = w
|
||||
}
|
||||
noColor := strings.TrimSpace(os.Getenv("NO_COLOR")) != ""
|
||||
a.bootHeader = logo.BootHeader(width, a.opts.Version, a.opts.Commit, a.configSource, noColor)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOrCreateProfile fetches or creates a new Bifrost CLI profile
|
||||
func (a *App) getOrCreateProfile() *config.Profile {
|
||||
if !a.opts.NoResume && strings.TrimSpace(a.state.LastProfileID) != "" {
|
||||
if p := a.state.ProfileByID(a.state.LastProfileID); p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if len(a.state.Profiles) > 0 && !a.opts.NoResume {
|
||||
return &a.state.Profiles[0]
|
||||
}
|
||||
|
||||
p := config.Profile{ID: "default", Name: "Default"}
|
||||
if a.cfgFile != nil {
|
||||
p.BaseURL = strings.TrimSpace(a.cfgFile.BaseURL)
|
||||
}
|
||||
|
||||
if existing := a.state.ProfileByID("default"); existing != nil {
|
||||
if strings.TrimSpace(existing.BaseURL) == "" {
|
||||
existing.BaseURL = p.BaseURL
|
||||
}
|
||||
return existing
|
||||
}
|
||||
a.state.Profiles = append(a.state.Profiles, p)
|
||||
return &a.state.Profiles[len(a.state.Profiles)-1]
|
||||
}
|
||||
|
||||
// harnessOptions responds with available harness options with states like installed/not installed etc.
|
||||
// Version detection runs concurrently across all harnesses to avoid serial subprocess waits.
|
||||
func (a *App) harnessOptions() []tui.HarnessOption {
|
||||
ids := harness.IDs()
|
||||
out := make([]tui.HarnessOption, len(ids))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, id := range ids {
|
||||
h, _ := harness.Get(id)
|
||||
out[i] = tui.HarnessOption{
|
||||
ID: h.ID,
|
||||
Label: h.Label,
|
||||
SupportsWorktree: h.SupportsWorktree,
|
||||
SupportsModelOverride: h.RunArgsForMod != nil || h.ModelEnv != "" || h.PreLaunch != nil,
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(idx int, h harness.Harness) {
|
||||
defer wg.Done()
|
||||
out[idx].Installed = installer.IsInstalled(h)
|
||||
out[idx].Version = harness.DetectVersion(h)
|
||||
}(i, h)
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user