396 lines
11 KiB
Go
396 lines
11 KiB
Go
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
|
|
}
|