first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

0
cli/changelog.md Normal file
View File

52
cli/go.mod Normal file
View File

@@ -0,0 +1,52 @@
module github.com/maximhq/bifrost/cli
go 1.26.2
require (
github.com/bytedance/sonic v1.15.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/maximhq/vt10x v0.0.0-20260312213827-20648b37d999
github.com/zalando/go-keyring v0.2.6
golang.org/x/term v0.41.0
)
require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

105
cli/go.sum Normal file
View File

@@ -0,0 +1,105 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/maximhq/vt10x v0.0.0-20260312213827-20648b37d999 h1:K5tlU7N6HpZVUoQnae2hrw6fedbirVjiyyg/JNp5n8M=
github.com/maximhq/vt10x v0.0.0-20260312213827-20648b37d999/go.mod h1:wYVKs3nG8h6lhuE2GYgSEDszozAG0EJLjdyyIJMK1lo=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

107
cli/internal/apis/models.go Normal file
View File

@@ -0,0 +1,107 @@
package apis
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/bytedance/sonic"
)
// Model represents a single model entry returned by the /v1/models API.
type Model struct {
ID string `json:"id"`
}
type listModelsResp struct {
Data []Model `json:"data"`
}
type Client struct {
http *http.Client
}
// NewClient creates a Bifrost API client with a default HTTP timeout.
func NewClient() *Client {
return &Client{
http: &http.Client{Timeout: 20 * time.Second},
}
}
// NormalizeBaseURL trims whitespace and trailing slashes from a base URL.
func NormalizeBaseURL(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimSuffix(raw, "/")
return raw
}
// BuildEndpoint joins a base URL with a path suffix, returning the full endpoint URL.
func BuildEndpoint(baseURL, suffix string) (string, error) {
baseURL = NormalizeBaseURL(baseURL)
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid base url: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("invalid base url %q", baseURL)
}
u.Path = strings.TrimSuffix(u.Path, "/") + suffix
return u.String(), nil
}
// ListModels fetches available model IDs from the Bifrost /v1/models endpoint,
// returning them sorted alphabetically.
func (c *Client) ListModels(ctx context.Context, baseURL, virtualKey string) ([]string, error) {
endpoint, err := BuildEndpoint(baseURL, "/v1/models")
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if strings.TrimSpace(virtualKey) != "" {
req.Header.Set("x-bf-vk", strings.TrimSpace(virtualKey))
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("request /v1/models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("/v1/models status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
const maxModelsResponseBytes = 1 << 20 // 1 MiB
b, err := io.ReadAll(io.LimitReader(resp.Body, maxModelsResponseBytes))
if err != nil {
return nil, fmt.Errorf("read model response: %w", err)
}
var parsed listModelsResp
if err := sonic.Unmarshal(b, &parsed); err != nil {
return nil, fmt.Errorf("parse model response: %w", err)
}
set := map[string]struct{}{}
for _, m := range parsed.Data {
id := strings.TrimSpace(m.ID)
if id == "" {
continue
}
set[id] = struct{}{}
}
models := make([]string, 0, len(set))
for m := range set {
models = append(models, m)
}
sort.Strings(models)
return models, nil
}

395
cli/internal/app/app.go Normal file
View 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
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package app
import "syscall"
// reexecSelf replaces the current process with the updated binary.
// On Unix, this uses execve(2) via syscall.Exec.
func reexecSelf(execPath string, args []string, env []string) error {
return syscall.Exec(execPath, args, env)
}

View File

@@ -0,0 +1,12 @@
//go:build windows
package app
import "fmt"
// reexecSelf on Windows cannot replace the running process (syscall.Exec is a
// stub that returns EWINDOWS). Instead, inform the user to restart manually.
func reexecSelf(_ string, _ []string, _ []string) error {
fmt.Println("Updated successfully. Please restart bifrost.")
return nil
}

View File

@@ -0,0 +1,192 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/bytedance/sonic"
)
const (
defaultDirName = ".bifrost"
defaultFileName = "config.json"
stateFileName = "state.json"
)
// FileConfig represents the on-disk bifrost configuration file (~/.bifrost/config.json).
type FileConfig struct {
BaseURL string `json:"base_url"`
VirtualKey string `json:"virtual_key"`
DefaultHarness string `json:"default_harness"`
DefaultModel string `json:"default_model"`
AutoInstallHarness *bool `json:"auto_install_harness"`
AutoAttachMCP *bool `json:"auto_attach_mcp"`
}
// Profile represents a named Bifrost connection profile with a base URL.
type Profile struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
}
// Selection stores the user's last chosen harness and model for a profile.
type Selection struct {
Harness string `json:"harness"`
Model string `json:"model"`
}
// State holds the persistent runtime state including profiles and per-profile selections.
type State struct {
Profiles []Profile `json:"profiles"`
LastProfileID string `json:"last_profile_id"`
Selections map[string]Selection `json:"selections"`
LastVersionCheck int64 `json:"last_version_check,omitempty"`
LastKnownVersion string `json:"last_known_version,omitempty"`
}
// DefaultConfigPath returns the default path to the bifrost config file (~/.bifrost/config.json).
func DefaultConfigPath() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}
return filepath.Join(h, defaultDirName, defaultFileName), nil
}
// DefaultStatePath returns the default path to the bifrost state file (~/.bifrost/state.json).
func DefaultStatePath() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}
return filepath.Join(h, defaultDirName, stateFileName), nil
}
// LoadFile reads and parses a config file from disk.
// Returns nil with no error if the file does not exist.
func LoadFile(path string) (*FileConfig, string, error) {
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, "", nil
}
return nil, "", fmt.Errorf("read config file: %w", err)
}
var c FileConfig
if err := sonic.Unmarshal(b, &c); err != nil {
return nil, "", fmt.Errorf("parse config file: %w", err)
}
abs := path
if a, err := filepath.Abs(path); err == nil {
abs = a
}
return &c, abs, nil
}
// LoadState reads and parses the state file from disk.
// Returns a fresh State if the file does not exist.
func LoadState(path string) (*State, error) {
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &State{Selections: map[string]Selection{}}, nil
}
return nil, fmt.Errorf("read state: %w", err)
}
var s State
if err := sonic.Unmarshal(b, &s); err != nil {
return nil, fmt.Errorf("parse state: %w", err)
}
if s.Selections == nil {
s.Selections = map[string]Selection{}
}
return &s, nil
}
// WriteAtomic writes data to a temp file in the same directory and atomically
// replaces the target path via rename, preventing partial/corrupt files on crash.
func WriteAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmpPath := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmpPath)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpPath)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Chmod(tmpPath, perm); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// SaveState writes the state to disk, creating parent directories as needed.
func SaveState(path string, s *State) error {
d := filepath.Dir(path)
if err := os.MkdirAll(d, 0o755); err != nil {
return fmt.Errorf("create state dir: %w", err)
}
b, err := sonic.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
if err := WriteAtomic(path, b, 0o600); err != nil {
return fmt.Errorf("write state: %w", err)
}
return nil
}
// SaveConfig writes the config to disk at the given path, creating parent
// directories as needed. File permissions are set to 0o600.
func SaveConfig(path string, c *FileConfig) error {
d := filepath.Dir(path)
if err := os.MkdirAll(d, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
persisted := *c
persisted.VirtualKey = ""
b, err := sonic.MarshalIndent(&persisted, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := WriteAtomic(path, b, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
// ProfileByID returns a pointer to the profile with the given ID, or nil if not found.
func (s *State) ProfileByID(id string) *Profile {
for i := range s.Profiles {
if s.Profiles[i].ID == id {
return &s.Profiles[i]
}
}
return nil
}

View File

@@ -0,0 +1,474 @@
package harness
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bytedance/sonic"
)
// Harness defines a coding assistant CLI that Bifrost can launch and manage.
type Harness struct {
ID string
Label string
Binary string
InstallPkg string
VersionArgs []string
BasePath string
BaseURLEnv string
APIKeyEnv string
AuthTokenEnv string
ModelEnv string
SupportsMCP bool
SupportsWorktree bool
RunArgsForMod func(model string) []string
WorktreeArgs func(name string) []string
// PreLaunch is called before launching the harness binary. It can write
// config files and return extra environment variables to inject. The
// returned cleanup function is deferred after the process exits.
PreLaunch func(baseURL, apiKey, model string) (extraEnv []string, cleanup func(), err error)
// WriteNativeConfig persists the bifrost connection settings into the
// harness CLI's own config file so the same configuration is available
// when users launch the CLI directly outside bifrost.
WriteNativeConfig func(baseURL, apiKey, model string) error
// NativeConfigPath is the human-readable path to the file that
// WriteNativeConfig modifies (e.g. "~/.claude/settings.json").
// Used to inform the user in the confirmation prompt.
NativeConfigPath string
}
var all = map[string]Harness{
"claude": {
ID: "claude",
Label: "Claude Code",
Binary: "claude",
InstallPkg: "@anthropic-ai/claude-code",
VersionArgs: []string{"--version"},
BasePath: "/anthropic",
BaseURLEnv: "ANTHROPIC_BASE_URL",
APIKeyEnv: "ANTHROPIC_API_KEY",
AuthTokenEnv: "ANTHROPIC_AUTH_TOKEN",
SupportsMCP: true,
SupportsWorktree: true,
RunArgsForMod: func(model string) []string {
if strings.TrimSpace(model) == "" {
return nil
}
return []string{"--model", model}
},
WorktreeArgs: func(name string) []string {
name = strings.TrimSpace(name)
if name == "" {
return []string{"--worktree"}
}
return []string{"--worktree", name}
},
PreLaunch: claudePreLaunch,
WriteNativeConfig: claudeWriteNativeConfig,
NativeConfigPath: "~/.claude/settings.json",
},
"codex": {
ID: "codex",
Label: "Codex CLI",
Binary: "codex",
InstallPkg: "@openai/codex",
VersionArgs: []string{
"--version",
},
BasePath: "/openai",
BaseURLEnv: "OPENAI_BASE_URL",
APIKeyEnv: "OPENAI_API_KEY",
ModelEnv: "OPENAI_MODEL",
RunArgsForMod: func(model string) []string {
if strings.TrimSpace(model) == "" {
return nil
}
return []string{"--model", model}
},
},
"gemini": {
ID: "gemini",
Label: "Gemini CLI",
Binary: "gemini",
InstallPkg: "@google/gemini-cli",
VersionArgs: []string{
"--version",
},
BasePath: "/genai",
BaseURLEnv: "GOOGLE_GEMINI_BASE_URL",
APIKeyEnv: "GEMINI_API_KEY",
ModelEnv: "GEMINI_MODEL",
RunArgsForMod: func(model string) []string {
if strings.TrimSpace(model) == "" {
return nil
}
return []string{"--model", model}
},
},
"opencode": {
ID: "opencode",
Label: "Opencode",
Binary: "opencode",
InstallPkg: "opencode-ai",
VersionArgs: []string{
"--version",
},
BasePath: "/openai",
BaseURLEnv: "OPENAI_BASE_URL",
APIKeyEnv: "OPENAI_API_KEY",
RunArgsForMod: func(model string) []string {
if strings.TrimSpace(model) == "" {
return nil
}
return []string{"--model", opencodeModelRef(model)}
},
PreLaunch: opencodePreLaunch,
},
}
// Get returns the harness with the given ID and whether it exists.
func Get(id string) (Harness, bool) {
h, ok := all[id]
return h, ok
}
// IDs returns the sorted list of all registered harness IDs.
func IDs() []string {
ids := make([]string, 0, len(all))
for id := range all {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// Labels returns display labels for all harnesses in the format "Label (id)".
func Labels() []string {
ids := IDs()
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, fmt.Sprintf("%s (%s)", all[id].Label, id))
}
return out
}
// ParseChoice extracts the harness ID from a label string like "Label (id)".
func ParseChoice(raw string) string {
raw = strings.TrimSpace(raw)
if i := strings.LastIndex(raw, "("); i >= 0 && strings.HasSuffix(raw, ")") {
return strings.TrimSuffix(raw[i+1:], ")")
}
return raw
}
// DetectVersion runs the harness binary with its version flag and returns the version string.
func DetectVersion(h Harness) string {
if _, err := exec.LookPath(h.Binary); err != nil {
return "not-installed"
}
args := h.VersionArgs
if len(args) == 0 {
args = []string{"--version"}
}
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel()
out, err := exec.CommandContext(ctx, h.Binary, args...).CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "timeout"
}
return "unknown"
}
s := strings.TrimSpace(string(out))
if s == "" {
return "unknown"
}
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
return s
}
// opencodePreLaunch writes temporary OpenCode config files when Bifrost needs
// to override runtime model/provider settings and/or supply an adaptive TUI
// theme. The returned cleanup removes any generated temp files after exit.
func opencodePreLaunch(baseURL, apiKey, model string) ([]string, func(), error) {
var env []string
var cleanupFns []func()
tuiEnv, tuiCleanup, err := opencodeTUIPreLaunch()
if err != nil {
return nil, nil, err
}
env = append(env, tuiEnv...)
if tuiCleanup != nil {
cleanupFns = append(cleanupFns, tuiCleanup)
}
model = strings.TrimSpace(model)
if model == "" {
return env, combineCleanup(cleanupFns), nil
}
modelRef := opencodeModelRef(model)
runtimeCfg := fmt.Sprintf(`{
"$schema": "https://opencode.ai/config.json",
"model": %q,
"provider": {
"bifrost": {
"npm": "@ai-sdk/openai-compatible",
"name": "Bifrost",
"options": {
"baseURL": %q,
"apiKey": %q
},
"models": {
%q: {
"name": %q
}
}
}
}
}`, modelRef, strings.TrimSpace(baseURL), strings.TrimSpace(apiKey), model, model)
f, err := os.CreateTemp("", "bifrost-opencode-*.json")
if err != nil {
return nil, nil, fmt.Errorf("create opencode config: %w", err)
}
if _, err := f.WriteString(runtimeCfg); err != nil {
f.Close()
os.Remove(f.Name())
return nil, nil, fmt.Errorf("write opencode config: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return nil, nil, fmt.Errorf("close opencode config: %w", err)
}
env = append(env, "OPENCODE_CONFIG="+f.Name())
cleanupFns = append(cleanupFns, func() { os.Remove(f.Name()) })
return env, combineCleanup(cleanupFns), nil
}
// opencodeModelRef returns the Opencode model reference.
func opencodeModelRef(model string) string {
return "bifrost/" + strings.TrimSpace(model)
}
// opencodeTUIPreLaunch loads the Opencode TUI config from the user's home directory.
func opencodeTUIPreLaunch() ([]string, func(), error) {
path, err := opencodeTUIConfigPath()
if err != nil {
return nil, nil, fmt.Errorf("resolve opencode tui config: %w", err)
}
cfg, hasTheme, err := loadOpencodeTUIConfig(path)
if err != nil {
return nil, nil, err
}
if hasTheme {
return nil, nil, nil
}
if cfg == nil {
cfg = map[string]any{}
}
cfg["theme"] = "system"
b, err := sonic.MarshalIndent(cfg, "", " ")
if err != nil {
return nil, nil, fmt.Errorf("marshal opencode tui config: %w", err)
}
f, err := os.CreateTemp("", "bifrost-opencode-tui-*.json")
if err != nil {
return nil, nil, fmt.Errorf("create opencode tui config: %w", err)
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(f.Name())
return nil, nil, fmt.Errorf("write opencode tui config: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return nil, nil, fmt.Errorf("close opencode tui config: %w", err)
}
return []string{"OPENCODE_TUI_CONFIG=" + f.Name()}, func() { os.Remove(f.Name()) }, nil
}
// opencodeTUIConfigPath returns the path to the Opencode TUI config.
func opencodeTUIConfigPath() (string, error) {
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return filepath.Join(xdg, "opencode", "tui.json"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "opencode", "tui.json"), nil
}
// loadOpencodeTUIConfig loads the Opencode TUI config from the given path.
func loadOpencodeTUIConfig(path string) (map[string]any, bool, error) {
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
return nil, false, fmt.Errorf("read opencode tui config: %w", err)
}
normalized := normalizeJSONC(b)
if len(bytes.TrimSpace(normalized)) == 0 {
return map[string]any{}, false, nil
}
var cfg map[string]any
if err := sonic.Unmarshal(normalized, &cfg); err != nil {
return nil, false, fmt.Errorf("parse opencode tui config: %w", err)
}
theme, ok := cfg["theme"]
return cfg, ok && strings.TrimSpace(fmt.Sprint(theme)) != "", nil
}
// combineCleanup combines multiple cleanup functions into a single function.
func combineCleanup(cleanups []func()) func() {
return func() {
for i := len(cleanups) - 1; i >= 0; i-- {
if cleanups[i] != nil {
cleanups[i]()
}
}
}
}
// normalizeJSONC removes trailing commas and comments from JSONC data.
func normalizeJSONC(data []byte) []byte {
return stripTrailingCommas(stripJSONComments(data))
}
// stripJSONComments removes comments from JSONC data.
func stripJSONComments(data []byte) []byte {
out := make([]byte, 0, len(data))
inString := false
escape := false
inLineComment := false
inBlockComment := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inLineComment {
if ch == '\n' {
inLineComment = false
out = append(out, ch)
}
continue
}
if inBlockComment {
if ch == '*' && i+1 < len(data) && data[i+1] == '/' {
inBlockComment = false
i++
}
continue
}
if inString {
out = append(out, ch)
if escape {
escape = false
continue
}
if ch == '\\' {
escape = true
} else if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out = append(out, ch)
continue
}
if ch == '/' && i+1 < len(data) {
switch data[i+1] {
case '/':
inLineComment = true
i++
continue
case '*':
inBlockComment = true
i++
continue
}
}
out = append(out, ch)
}
return out
}
func stripTrailingCommas(data []byte) []byte {
out := make([]byte, 0, len(data))
inString := false
escape := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inString {
out = append(out, ch)
if escape {
escape = false
continue
}
if ch == '\\' {
escape = true
} else if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out = append(out, ch)
continue
}
if ch == ',' {
j := i + 1
for j < len(data) {
switch data[j] {
case ' ', '\t', '\r', '\n':
j++
continue
case '}', ']':
ch = 0
}
break
}
if ch == 0 {
continue
}
}
out = append(out, ch)
}
return out
}

View File

@@ -0,0 +1,246 @@
package harness
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/bytedance/sonic"
)
func TestClaudePreLaunchPinsSelectedModelAcrossClaudeTiers(t *testing.T) {
t.Parallel()
env, cleanup, err := claudePreLaunch("https://example.com/anthropic", "test-key", "openai/gpt-5")
if err != nil {
t.Fatalf("claudePreLaunch() error = %v", err)
}
defer cleanup()
for _, want := range []string{
"CLAUDE_CODE_SIMPLE=1",
"ANTHROPIC_DEFAULT_SONNET_MODEL=openai/gpt-5",
"ANTHROPIC_DEFAULT_OPUS_MODEL=openai/gpt-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-5",
} {
parts := strings.SplitN(want, "=", 2)
if got := envValue(env, parts[0]); got != parts[1] {
t.Fatalf("env[%q] = %q, want %q", parts[0], got, parts[1])
}
}
if got := envValue(env, "ANTHROPIC_MODEL"); got != "" {
t.Fatalf("did not expect ANTHROPIC_MODEL in env, got %#v", env)
}
}
func TestClaudeWriteNativeConfigPinsTierDefaults(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
settingsDir := filepath.Join(home, ".claude")
if err := os.MkdirAll(settingsDir, 0o755); err != nil {
t.Fatalf("mkdir settings dir: %v", err)
}
settingsPath := filepath.Join(settingsDir, "settings.json")
initial := `{"env":{"EXISTING":"keep","ANTHROPIC_MODEL":"stale-model"}}`
if err := os.WriteFile(settingsPath, []byte(initial), 0o600); err != nil {
t.Fatalf("write initial settings: %v", err)
}
if err := claudeWriteNativeConfig("https://example.com/anthropic", "test-key", "openai/gpt-5"); err != nil {
t.Fatalf("claudeWriteNativeConfig() error = %v", err)
}
b, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatalf("read settings: %v", err)
}
var settings map[string]any
if err := sonic.Unmarshal(b, &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
envRaw, ok := settings["env"]
if !ok {
t.Fatalf("expected env map in settings, got %#v", settings)
}
envMap, ok := envRaw.(map[string]any)
if !ok {
t.Fatalf("env map type = %T, want map[string]any", envRaw)
}
for key, want := range map[string]string{
"EXISTING": "keep",
"ANTHROPIC_BASE_URL": "https://example.com/anthropic",
"ANTHROPIC_API_KEY": "test-key",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "openai/gpt-5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "openai/gpt-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "openai/gpt-5",
} {
if got, _ := envMap[key].(string); got != want {
t.Fatalf("env[%q] = %q, want %q", key, got, want)
}
}
if _, ok := envMap["ANTHROPIC_MODEL"]; ok {
t.Fatalf("did not expect legacy ANTHROPIC_MODEL in settings env: %#v", envMap)
}
}
func TestOpencodeModelRef(t *testing.T) {
t.Parallel()
if got := opencodeModelRef("gpt-4.1"); got != "bifrost/gpt-4.1" {
t.Fatalf("opencodeModelRef() = %q, want %q", got, "bifrost/gpt-4.1")
}
}
func TestOpencodePreLaunchWritesCustomProviderConfig(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
env, cleanup, err := opencodePreLaunch("https://example.com/openai", "test-key", "gpt-4.1")
if err != nil {
t.Fatalf("opencodePreLaunch() error = %v", err)
}
defer cleanup()
if len(env) != 2 {
t.Fatalf("unexpected env returned: %#v", env)
}
configPath := envValue(env, "OPENCODE_CONFIG")
if configPath == "" {
t.Fatalf("expected OPENCODE_CONFIG in env, got %#v", env)
}
b, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read generated config: %v", err)
}
cfg := string(b)
for _, want := range []string{
`"model": "bifrost/gpt-4.1"`,
`"bifrost": {`,
`"npm": "@ai-sdk/openai-compatible"`,
`"baseURL": "https://example.com/openai"`,
`"apiKey": "test-key"`,
`"gpt-4.1": {`,
} {
if !strings.Contains(cfg, want) {
t.Fatalf("expected generated config to contain %q, got %s", want, cfg)
}
}
tuiPath := envValue(env, "OPENCODE_TUI_CONFIG")
if tuiPath == "" {
t.Fatalf("expected OPENCODE_TUI_CONFIG in env, got %#v", env)
}
tuiCfg, err := os.ReadFile(tuiPath)
if err != nil {
t.Fatalf("read generated tui config: %v", err)
}
if !strings.Contains(string(tuiCfg), `"theme": "system"`) {
t.Fatalf("expected generated tui config to set system theme, got %s", string(tuiCfg))
}
}
func TestOpencodePreLaunchPreservesExistingTheme(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
tuiPath := filepath.Join(xdg, "opencode", "tui.json")
if err := os.MkdirAll(filepath.Dir(tuiPath), 0o755); err != nil {
t.Fatalf("mkdir tui dir: %v", err)
}
if err := os.WriteFile(tuiPath, []byte("{\n \"theme\": \"light\"\n}\n"), 0o600); err != nil {
t.Fatalf("write tui config: %v", err)
}
env, cleanup, err := opencodePreLaunch("https://example.com/openai", "test-key", "gpt-4.1")
if err != nil {
t.Fatalf("opencodePreLaunch() error = %v", err)
}
defer cleanup()
if got := envValue(env, "OPENCODE_TUI_CONFIG"); got != "" {
t.Fatalf("did not expect OPENCODE_TUI_CONFIG override when user theme exists, got %#v", env)
}
if got := envValue(env, "OPENCODE_CONFIG"); got == "" {
t.Fatalf("expected OPENCODE_CONFIG to remain present, got %#v", env)
}
}
func TestOpencodePreLaunchAddsSystemThemeWithoutModel(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
env, cleanup, err := opencodePreLaunch("https://example.com/openai", "test-key", "")
if err != nil {
t.Fatalf("opencodePreLaunch() error = %v", err)
}
tuiPath := envValue(env, "OPENCODE_TUI_CONFIG")
if tuiPath == "" {
t.Fatalf("expected OPENCODE_TUI_CONFIG in env, got %#v", env)
}
if got := envValue(env, "OPENCODE_CONFIG"); got != "" {
t.Fatalf("did not expect OPENCODE_CONFIG without a model, got %#v", env)
}
if _, err := os.Stat(tuiPath); err != nil {
t.Fatalf("expected generated tui config to exist: %v", err)
}
cleanup()
if _, err := os.Stat(tuiPath); !os.IsNotExist(err) {
t.Fatalf("expected generated tui config to be removed after cleanup, stat err=%v", err)
}
}
func TestLoadOpencodeTUIConfigSupportsJSONC(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "tui.json")
content := "{\n // keep my choice\n \"theme\": \"light\",\n \"foo\": true,\n}\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write tui config: %v", err)
}
cfg, hasTheme, err := loadOpencodeTUIConfig(path)
if err != nil {
t.Fatalf("loadOpencodeTUIConfig() error = %v", err)
}
if !hasTheme {
t.Fatal("expected theme to be detected")
}
if cfg["theme"] != "light" {
t.Fatalf("cfg[theme] = %#v, want %q", cfg["theme"], "light")
}
}
func TestOpencodeTUIConfigPathPrefersXDG(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
got, err := opencodeTUIConfigPath()
if err != nil {
t.Fatalf("opencodeTUIConfigPath() error = %v", err)
}
want := filepath.Join(xdg, "opencode", "tui.json")
if got != want {
t.Fatalf("opencodeTUIConfigPath() = %q, want %q", got, want)
}
}
func envValue(env []string, key string) string {
prefix := key + "="
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
return strings.TrimPrefix(entry, prefix)
}
}
return ""
}

View File

@@ -0,0 +1,98 @@
package harness
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/bytedance/sonic"
"github.com/maximhq/bifrost/cli/internal/config"
)
// claudePreLaunch forces Claude Code into its simpler terminal mode when
// launched inside Bifrost's tab multiplexer. This avoids Claude-specific
// full-screen terminal behavior that doesn't restore reliably across tab swaps.
func claudePreLaunch(baseURL, apiKey, model string) ([]string, func(), error) {
env := []string{"CLAUDE_CODE_SIMPLE=1"}
if model = strings.TrimSpace(model); model != "" {
env = append(env, claudeTierModelEnv(model)...)
}
return env, func() {}, nil
}
// claudeWriteNativeConfig writes the bifrost endpoint, API key, and model
// into Claude Code's settings file (~/.claude/settings.json) so the same
// configuration is available when users launch Claude Code directly.
//
// It merges into the existing file, preserving any user-defined settings.
func claudeWriteNativeConfig(baseURL, apiKey, model string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home dir: %w", err)
}
dir := filepath.Join(home, ".claude")
settingsPath := filepath.Join(dir, "settings.json")
// Read existing settings or start fresh
settings := make(map[string]any)
if b, err := os.ReadFile(settingsPath); err == nil {
if err := sonic.Unmarshal(b, &settings); err != nil {
return fmt.Errorf("parse existing claude settings: %w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read claude settings: %w", err)
}
// Get or create the env map
envRaw, ok := settings["env"]
var envMap map[string]any
if ok {
envMap, ok = envRaw.(map[string]any)
if !ok {
envMap = make(map[string]any)
}
} else {
envMap = make(map[string]any)
}
envMap["ANTHROPIC_BASE_URL"] = baseURL
envMap["ANTHROPIC_API_KEY"] = apiKey
if model = strings.TrimSpace(model); model != "" {
for key, value := range claudeTierModelEnvMap(model) {
envMap[key] = value
}
delete(envMap, "ANTHROPIC_MODEL")
}
settings["env"] = envMap
b, err := sonic.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("marshal claude settings: %w", err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create claude config dir: %w", err)
}
return config.WriteAtomic(settingsPath, b, 0o600)
}
func claudeTierModelEnv(model string) []string {
envMap := claudeTierModelEnvMap(model)
return []string{
"ANTHROPIC_DEFAULT_SONNET_MODEL=" + envMap["ANTHROPIC_DEFAULT_SONNET_MODEL"],
"ANTHROPIC_DEFAULT_OPUS_MODEL=" + envMap["ANTHROPIC_DEFAULT_OPUS_MODEL"],
"ANTHROPIC_DEFAULT_HAIKU_MODEL=" + envMap["ANTHROPIC_DEFAULT_HAIKU_MODEL"],
}
}
func claudeTierModelEnvMap(model string) map[string]string {
return map[string]string{
"ANTHROPIC_DEFAULT_SONNET_MODEL": model,
"ANTHROPIC_DEFAULT_OPUS_MODEL": model,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": model,
}
}

View File

@@ -0,0 +1,44 @@
package installer
import (
"context"
"fmt"
"io"
"os/exec"
"github.com/maximhq/bifrost/cli/internal/harness"
)
// IsInstalled reports whether the harness binary exists in the system PATH.
func IsInstalled(h harness.Harness) bool {
_, err := exec.LookPath(h.Binary)
return err == nil
}
// InstallCommand returns the command and arguments needed to install the harness globally via npm.
func InstallCommand(h harness.Harness) (string, []string) {
return "npm", []string{"install", "-g", h.InstallPkg}
}
// EnsureNPM checks that npm is available in the system PATH.
func EnsureNPM() error {
_, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("npm not found in path: %w", err)
}
return nil
}
// RunInstall executes the npm install command for the given harness,
// streaming output to the provided writers.
func RunInstall(ctx context.Context, stdout, stderr io.Writer, h harness.Harness) error {
cmdName, args := InstallCommand(h)
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Stdin = nil
if err := cmd.Run(); err != nil {
return fmt.Errorf("install %s: %w", h.Label, err)
}
return nil
}

71
cli/internal/mcp/mcp.go Normal file
View File

@@ -0,0 +1,71 @@
package mcp
import (
"context"
"fmt"
"io"
"os/exec"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/maximhq/bifrost/cli/internal/apis"
"github.com/maximhq/bifrost/cli/internal/harness"
)
// AttachBestEffort attempts to register the Bifrost MCP server with the harness.
// For Claude Code it auto-attaches via the CLI; for other harnesses it prints manual instructions.
func AttachBestEffort(ctx context.Context, stdout, stderr io.Writer, h harness.Harness, baseURL, vk string) {
mcpURL, err := apis.BuildEndpoint(baseURL, "/mcp")
if err != nil {
fmt.Fprintf(stderr, "warning: invalid MCP URL: %v\n", err)
return
}
if !h.SupportsMCP {
fmt.Fprintf(stdout, "MCP: %s has no native auto-attach yet. Use server URL: %s\n", h.Label, mcpURL)
return
}
if h.ID != "claude" {
fmt.Fprintf(stdout, "MCP: manual setup for %s with URL %s\n", h.Label, mcpURL)
if strings.TrimSpace(vk) != "" {
fmt.Fprintf(stdout, "MCP: include header \"Authorization: Bearer <your-virtual-key>\" when connecting\n")
}
return
}
if strings.TrimSpace(vk) == "" {
tCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(tCtx, "claude", "mcp", "add", "--transport", "http", "bifrost", mcpURL)
if err := cmd.Run(); err != nil {
fmt.Fprintf(stderr, "warning: auto MCP add failed. Run: claude mcp add --transport http bifrost %s\n", mcpURL)
return
}
fmt.Fprintln(stdout, "MCP: attached Claude MCP server 'bifrost'.")
return
}
payloadBytes, err := sonic.Marshal(map[string]any{
"type": "http",
"url": mcpURL,
"headers": map[string]string{
"Authorization": "Bearer " + strings.TrimSpace(vk),
},
})
if err != nil {
fmt.Fprintln(stderr, "warning: build MCP payload:", err)
return
}
payload := string(payloadBytes)
tCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(tCtx, "claude", "mcp", "add-json", "bifrost", payload)
if err := cmd.Run(); err != nil {
fmt.Fprintf(stderr, "warning: auto MCP add failed. Run: claude mcp add-json bifrost '<payload>' (with your Authorization header)\n")
return
}
fmt.Fprintln(stdout, "MCP: attached Claude MCP server 'bifrost' with virtual key.")
}

View File

@@ -0,0 +1,13 @@
package runtime
import "github.com/maximhq/vt10x"
// TabNoticeLevel controls how transient tab bar notices are styled.
type TabNoticeLevel int
const (
TabNoticeInfo TabNoticeLevel = iota
TabNoticeError
)
const hostTrackedVTModeMask = vt10x.ModeMouseMask | vt10x.ModeMouseSgr | vt10x.ModeFocus

View File

@@ -0,0 +1,94 @@
//go:build !windows
package runtime
import (
"context"
"io"
"os"
"os/signal"
"syscall"
"github.com/creack/pty"
"golang.org/x/term"
"os/exec"
)
// runWithPTY starts cmd attached to a new pseudo-terminal and relays I/O
// between the outer terminal and the PTY master. This lets TUI apps render
// correctly while bifrost retains control of the process.
func runWithPTY(ctx context.Context, stdout io.Writer, cmd *exec.Cmd) error {
// Get the initial terminal size from the real terminal
sz, err := pty.GetsizeFull(os.Stdin)
if err != nil {
// Fallback to a reasonable default if stdin isn't a terminal
sz = &pty.Winsize{Rows: 24, Cols: 80}
}
// Start the command with a PTY attached, sized to match the outer terminal
ptmx, err := pty.StartWithSize(cmd, sz)
if err != nil {
return err
}
defer ptmx.Close()
// Handle SIGWINCH — propagate terminal resizes to the PTY
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
done := make(chan struct{})
defer func() {
signal.Stop(sigCh)
close(done)
}()
go func() {
for {
select {
case <-sigCh:
if newSz, err := pty.GetsizeFull(os.Stdin); err == nil {
_ = pty.Setsize(ptmx, newSz)
}
case <-done:
return
}
}
}()
// Put the outer terminal into raw mode so keystrokes (Ctrl-C, etc.)
// are forwarded as bytes to the PTY rather than handled by the OS.
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
// If we can't go raw (e.g., piped input), continue without it
oldState = nil
}
if oldState != nil {
defer func() {
_ = term.Restore(int(os.Stdin.Fd()), oldState)
_, _ = io.WriteString(stdout, hostCursorResetSequence())
}()
}
// Relay stdout: PTY master → caller's stdout
// This goroutine exits when the child process dies and the PTY master
// returns EOF.
outDone := make(chan struct{})
go func() {
defer close(outDone)
_, _ = io.Copy(stdout, ptmx)
}()
// Relay stdin: outer terminal → PTY master
// This goroutine will block on os.Stdin.Read after the child exits;
// that's expected and harmless — it unblocks on the next keystroke.
go func() {
_, _ = io.Copy(ptmx, os.Stdin)
}()
// Wait for the command to finish
err = cmd.Wait()
// Drain any remaining PTY output
<-outDone
return err
}

View File

@@ -0,0 +1,20 @@
//go:build windows
package runtime
import (
"context"
"io"
"os"
"os/exec"
)
// runWithPTY is a no-op on Windows — falls back to direct stdin/stdout piping.
// Windows does not support POSIX pseudo-terminals; full support would require
// ConPTY which is not yet portable in Go.
func runWithPTY(_ context.Context, stdout io.Writer, cmd *exec.Cmd) error {
cmd.Stdout = stdout
cmd.Stderr = stdout
cmd.Stdin = os.Stdin
return cmd.Run()
}

View File

@@ -0,0 +1,240 @@
package runtime
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
"github.com/maximhq/vt10x"
)
type replayFixture struct {
Cols int `json:"cols"`
Rows int `json:"rows"`
Chunks []string `json:"chunks"`
Snapshots []replaySnapshot `json:"snapshots"`
}
type replaySnapshot struct {
AfterChunk int `json:"after_chunk"`
Screen []string `json:"screen"`
}
func TestOpencodeScrollReplayFixture(t *testing.T) {
t.Parallel()
fixture := loadReplayFixture(t, "testdata/opencode_scroll_replay.json")
term := vt10x.New(vt10x.WithSize(fixture.Cols, fixture.Rows))
var normalizer vtStreamNormalizer
checkpoints := make(map[int][]string, len(fixture.Snapshots))
for _, snapshot := range fixture.Snapshots {
checkpoints[snapshot.AfterChunk] = snapshot.Screen
}
for i, chunk := range fixture.Chunks {
data := normalizer.Normalize([]byte(chunk))
if len(data) > 0 {
if _, err := term.Write(data); err != nil {
t.Fatalf("write chunk %d: %v", i+1, err)
}
}
if want, ok := checkpoints[i+1]; ok {
if got := snapshotScreen(term, fixture.Cols, fixture.Rows); !equalLines(got, want) {
t.Fatalf("snapshot after chunk %d mismatch\nwant:\n%s\n\ngot:\n%s", i+1, strings.Join(want, "\n"), strings.Join(got, "\n"))
}
}
}
}
func TestVTStreamNormalizerHandlesSplitTrueColorSGR(t *testing.T) {
t.Parallel()
term := vt10x.New(vt10x.WithSize(8, 1))
var normalizer vtStreamNormalizer
if data := normalizer.Normalize([]byte("\x1b[38:2::100")); len(data) != 0 {
t.Fatalf("expected incomplete chunk to be buffered, got %q", string(data))
}
data := normalizer.Normalize([]byte(":150:200mHi"))
if _, err := term.Write(data); err != nil {
t.Fatalf("write normalized chunk: %v", err)
}
term.Lock()
defer term.Unlock()
cell := term.Cell(0, 0)
if cell.Char != 'H' {
t.Fatalf("expected first cell to contain H, got %q", cell.Char)
}
if want := vt10x.Color(100<<16 | 150<<8 | 200); cell.FG != want {
t.Fatalf("expected truecolor fg %v, got %v", want, cell.FG)
}
}
func TestVTAlternateScreenRestore(t *testing.T) {
t.Parallel()
term := vt10x.New(vt10x.WithSize(10, 3))
var normalizer vtStreamNormalizer
writeReplayChunk(t, term, &normalizer, "main")
writeReplayChunk(t, term, &normalizer, "\x1b[?1049h\x1b[2J\x1b[Halt")
writeReplayChunk(t, term, &normalizer, "\x1b[?1049l")
got := snapshotScreen(term, 10, 3)
if got[0] != "main " {
t.Fatalf("expected main screen to be restored, got %q", got[0])
}
}
func TestVTWritesCursorPositionRepliesToConfiguredWriter(t *testing.T) {
t.Parallel()
var out bytes.Buffer
term := vt10x.New(
vt10x.WithWriter(&out),
vt10x.WithSize(10, 3),
)
if _, err := term.Write([]byte("\x1b[6n")); err != nil {
t.Fatalf("write cpr request: %v", err)
}
if got := out.String(); got != "\x1b[1;1R" {
t.Fatalf("expected CPR reply %q, got %q", "\x1b[1;1R", got)
}
}
func TestExtractCursorVisible(t *testing.T) {
t.Parallel()
if got := extractCursorVisible([]byte("hello")); got != -1 {
t.Fatalf("extractCursorVisible(no toggle) = %d, want -1", got)
}
if got := extractCursorVisible([]byte("\x1b[?25h")); got != 1 {
t.Fatalf("extractCursorVisible(show) = %d, want 1", got)
}
if got := extractCursorVisible([]byte("\x1b[?25l")); got != 0 {
t.Fatalf("extractCursorVisible(hide) = %d, want 0", got)
}
if got := extractCursorVisible([]byte("\x1b[?25h...\x1b[?25l")); got != 0 {
t.Fatalf("extractCursorVisible(last wins) = %d, want 0", got)
}
}
func TestNormalizerDropsKittyKeyboardProtocol(t *testing.T) {
t.Parallel()
term := vt10x.New(vt10x.WithSize(20, 5))
var normalizer vtStreamNormalizer
// Write text, move cursor to row 3, then send Kitty keyboard sequences.
// Without the fix, \x1b[>1u would be misinterpreted as DECRC (cursor
// restore) and snap the cursor back to (0,0).
writeReplayChunk(t, term, &normalizer, "hello") // cursor at (5, 0)
writeReplayChunk(t, term, &normalizer, "\x1b[4;1H") // move to row 4, col 1
writeReplayChunk(t, term, &normalizer, "world") // cursor at (5, 3)
writeReplayChunk(t, term, &normalizer, "\x1b[>1u") // push kitty keyboard mode
writeReplayChunk(t, term, &normalizer, "\x1b[?u") // query kitty keyboard mode
writeReplayChunk(t, term, &normalizer, "\x1b[<u") // pop kitty keyboard mode
writeReplayChunk(t, term, &normalizer, "\x1b[=1;2u") // kitty with flags
term.Lock()
cursor := term.Cursor()
term.Unlock()
// Cursor should still be at (5, 3) — kitty sequences must not move it.
if cursor.X != 5 || cursor.Y != 3 {
t.Fatalf("expected cursor at (5, 3), got (%d, %d) — kitty sequences corrupted cursor state", cursor.X, cursor.Y)
}
// Verify the text was written correctly.
got := snapshotScreen(term, 20, 5)
if !strings.HasPrefix(got[0], "hello") {
t.Fatalf("expected 'hello' on row 0, got %q", got[0])
}
if !strings.HasPrefix(got[3], "world") {
t.Fatalf("expected 'world' on row 3, got %q", got[3])
}
}
func TestNormalizerPassesThroughNormalCSI(t *testing.T) {
t.Parallel()
var normalizer vtStreamNormalizer
// Regular CSI sequences (\x1b[?1049h, \x1b[2J, \x1b[H) must pass through.
data := normalizer.Normalize([]byte("\x1b[?1049h\x1b[2J\x1b[H"))
if string(data) != "\x1b[?1049h\x1b[2J\x1b[H" {
t.Fatalf("expected normal CSI to pass through, got %q", string(data))
}
// Kitty keyboard protocol must be stripped.
data = normalizer.Normalize([]byte("A\x1b[>1uB"))
if string(data) != "AB" {
t.Fatalf("expected kitty sequence to be stripped, got %q", string(data))
}
}
func loadReplayFixture(t *testing.T, path string) replayFixture {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read replay fixture: %v", err)
}
var fixture replayFixture
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("unmarshal replay fixture: %v", err)
}
return fixture
}
func writeReplayChunk(t *testing.T, term vt10x.Terminal, normalizer *vtStreamNormalizer, chunk string) {
t.Helper()
data := normalizer.Normalize([]byte(chunk))
if len(data) == 0 {
return
}
if _, err := term.Write(data); err != nil {
t.Fatalf("write replay chunk: %v", err)
}
}
func snapshotScreen(term vt10x.Terminal, cols, rows int) []string {
term.Lock()
defer term.Unlock()
lines := make([]string, rows)
for y := 0; y < rows; y++ {
var line []rune
for x := 0; x < cols; x++ {
ch := term.Cell(x, y).Char
if ch == 0 {
ch = ' '
}
line = append(line, ch)
}
lines[y] = string(line)
}
return lines
}
func equalLines(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,163 @@
package runtime
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/maximhq/bifrost/cli/internal/apis"
"github.com/maximhq/bifrost/cli/internal/harness"
)
// LaunchSpec holds the parameters needed to launch a harness subprocess.
type LaunchSpec struct {
Harness harness.Harness
BaseURL string
VirtualKey string
Model string
Worktree string // empty = no worktree, non-empty = worktree name (or " " for unnamed)
}
// BuildEnv constructs the environment variables for the harness process,
// including the provider endpoint, API key, and model overrides.
func BuildEnv(spec LaunchSpec) ([]string, error) {
endpoint, err := apis.BuildEndpoint(spec.BaseURL, spec.Harness.BasePath)
if err != nil {
return nil, err
}
env := os.Environ()
env = append(env, spec.Harness.BaseURLEnv+"="+endpoint)
vk := strings.TrimSpace(spec.VirtualKey)
if vk != "" {
env = append(env, spec.Harness.APIKeyEnv+"="+vk)
if spec.Harness.AuthTokenEnv != "" {
env = append(env, spec.Harness.AuthTokenEnv+"="+vk)
}
}
model := strings.TrimSpace(spec.Model)
if model != "" {
env = append(env, "BIFROST_MODEL="+model)
if spec.Harness.ModelEnv != "" {
env = append(env, spec.Harness.ModelEnv+"="+model)
}
}
// Mark session as running inside bifrost
env = append(env, "BIFROST_SESSION=1")
env = append(env, "BIFROST_BASE_URL="+spec.BaseURL)
return env, nil
}
// PreparedCmd holds a command ready to execute along with any cleanup
// function that should be called after the process exits.
type PreparedCmd struct {
Cmd *exec.Cmd
Cleanup func()
}
// PrepareCommand builds the exec.Cmd for a harness launch, including
// environment variables, pre-launch hooks, and CLI arguments.
func PrepareCommand(ctx context.Context, spec LaunchSpec) (*PreparedCmd, error) {
env, err := BuildEnv(spec)
if err != nil {
return nil, err
}
var cleanup func()
if spec.Harness.PreLaunch != nil {
endpoint, err := apis.BuildEndpoint(spec.BaseURL, spec.Harness.BasePath)
if err != nil {
return nil, fmt.Errorf("build endpoint for pre-launch: %w", err)
}
vk := strings.TrimSpace(spec.VirtualKey)
if vk == "" {
vk = "dummy-key"
}
extraEnv, c, err := spec.Harness.PreLaunch(endpoint, vk, spec.Model)
if err != nil {
return nil, fmt.Errorf("pre-launch %s: %w", spec.Harness.Label, err)
}
cleanup = c
env = append(env, extraEnv...)
}
args := []string{}
if spec.Harness.RunArgsForMod != nil {
args = append(args, spec.Harness.RunArgsForMod(spec.Model)...)
}
if spec.Worktree != "" && spec.Harness.WorktreeArgs != nil {
args = append(args, spec.Harness.WorktreeArgs(spec.Worktree)...)
}
cmd := exec.CommandContext(ctx, spec.Harness.Binary, args...)
cmd.Env = env
return &PreparedCmd{Cmd: cmd, Cleanup: cleanup}, nil
}
// RunInteractive launches the harness as an interactive subprocess with full
// TTY access. It prints a bifrost banner before launch and a summary after exit.
func RunInteractive(ctx context.Context, stdout, stderr io.Writer, spec LaunchSpec) error {
p, err := PrepareCommand(ctx, spec)
if err != nil {
return err
}
if p.Cleanup != nil {
defer p.Cleanup()
}
fmt.Fprint(stdout, renderBanner(spec))
if err := runWithPTY(ctx, stdout, p.Cmd); err != nil {
fmt.Fprintf(stdout, "\n\033[36mbifrost>\033[0m session ended with error: %v\n", err)
return fmt.Errorf("run harness: %w", err)
}
fmt.Fprintf(stdout, "\n\033[36mbifrost>\033[0m session ended\n")
return nil
}
// renderBanner builds the pre-launch info box showing harness, model,
// endpoint, and the equivalent command.
func renderBanner(spec LaunchSpec) string {
endpoint, err := apis.BuildEndpoint(spec.BaseURL, spec.Harness.BasePath)
if err != nil {
endpoint = spec.BaseURL + " (invalid)"
}
vkStatus := "no"
if strings.TrimSpace(spec.VirtualKey) != "" {
vkStatus = "yes"
}
cmdLine := spec.Harness.Binary
if spec.Harness.RunArgsForMod != nil {
if a := spec.Harness.RunArgsForMod(spec.Model); len(a) > 0 {
cmdLine += " " + strings.Join(a, " ")
}
}
if spec.Worktree != "" && spec.Harness.WorktreeArgs != nil {
if a := spec.Harness.WorktreeArgs(spec.Worktree); len(a) > 0 {
cmdLine += " " + strings.Join(a, " ")
}
}
cyan := "\033[36m"
dim := "\033[2m"
bold := "\033[1m"
reset := "\033[0m"
var b strings.Builder
b.WriteString("\n")
b.WriteString(dim + "───────────────────────────────────────────────────" + reset + "\n")
b.WriteString(cyan + "bifrost>" + reset + " " + bold + spec.Harness.Label + reset + " " + dim + spec.Model + reset + "\n")
b.WriteString(dim + " endpoint : " + reset + endpoint + "\n")
b.WriteString(dim + " vk : " + reset + vkStatus + "\n")
b.WriteString(dim + " command : " + reset + cmdLine + "\n")
b.WriteString(dim + "───────────────────────────────────────────────────" + reset + "\n")
b.WriteString("\n")
return b.String()
}

View File

@@ -0,0 +1,71 @@
package runtime
import (
"bytes"
"strconv"
)
// rewriteSGRParams converts colon-separated SGR sub-parameters to semicolon-
// separated equivalents. Each semicolon-delimited group is processed:
//
// - 4:x -> 4 (underline style -> basic underline)
// - 38:2:cs:r:g:b -> 38;2;r;g;b (fg true-color, drop colorspace)
// - 48:2:cs:r:g:b -> 48;2;r;g;b (bg true-color, drop colorspace)
// - 38:5:n -> 38;5;n (fg 256-color)
// - 48:5:n -> 48;5;n (bg 256-color)
// - 58:... -> (dropped - underline color, unsupported by vt10x)
// - other:x -> other (keep first sub-param only)
func rewriteSGRParams(params []byte) []byte {
parts := bytes.Split(params, []byte{';'})
var out [][]byte
for _, part := range parts {
if !bytes.ContainsRune(part, ':') {
out = append(out, part)
continue
}
subs := bytes.Split(part, []byte{':'})
if len(subs) == 0 {
continue
}
code, err := strconv.Atoi(string(subs[0]))
if err != nil {
out = append(out, subs[0])
continue
}
switch code {
case 4: // underline style -> basic underline
out = append(out, []byte("4"))
case 38, 48: // fg/bg color
if len(subs) >= 2 {
switch string(subs[1]) {
case "2": // true-color: code:2[:cs]:r:g:b
// Find r,g,b - skip optional colorspace id.
if len(subs) >= 6 {
// code:2:cs:r:g:b
out = append(out, subs[0], []byte("2"), subs[3], subs[4], subs[5])
} else if len(subs) >= 5 {
// code:2:r:g:b (no colorspace)
out = append(out, subs[0], []byte("2"), subs[2], subs[3], subs[4])
} else {
out = append(out, subs[0])
}
case "5": // 256-color: code:5:n
if len(subs) >= 3 {
out = append(out, subs[0], []byte("5"), subs[2])
} else {
out = append(out, subs[0])
}
default:
out = append(out, subs[0])
}
} else {
out = append(out, subs[0])
}
case 58: // underline color - not supported by vt10x, drop entirely
continue
default:
out = append(out, subs[0])
}
}
return bytes.Join(out, []byte{';'})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
//go:build windows
package runtime
import (
"context"
"errors"
"io"
)
// ErrQuit is returned by RunTabbed when the user quits the chooser
// without creating any tabs.
var ErrQuit = errors.New("user quit")
// ErrBackToTabs is returned by the newTabFn when the user presses Ctrl+B
// to dismiss the chooser and return to tab command mode.
var ErrBackToTabs = errors.New("back to tabs")
// ErrUpdateRequested is returned by RunTabbed when the user presses U in
// tab command mode to trigger a self-update and re-exec.
var ErrUpdateRequested = errors.New("update requested")
// NewTabFunc is called when the user requests a new tab or reopens the
// chooser for the active tab.
// stdinReader provides keyboard input; when nil the callback should read os.Stdin.
// tabBarLine returns the current tab bar content for embedding in the chooser view.
type NewTabFunc func(ctx context.Context, notify func(level TabNoticeLevel, message string), tabBarLine func() string, stdinReader io.Reader, seed *LaunchSpec) (*LaunchSpec, error)
// RunTabbed is not supported on Windows — falls back to single-session mode.
func RunTabbed(ctx context.Context, stdout, stderr io.Writer, version string, updateVersion string, newTabFn NewTabFunc) error {
spec, err := newTabFn(ctx, func(TabNoticeLevel, string) {}, nil, nil, nil)
if err != nil {
return err
}
if spec == nil {
return ErrQuit
}
return RunInteractive(ctx, stdout, stderr, *spec)
}

View File

@@ -0,0 +1,40 @@
{
"cols": 18,
"rows": 6,
"chunks": [
"\u001b[?1049h\u001b[2J\u001b[H",
"HEADER ",
"\u001b[6;1HFOOTER ",
"\u001b[2;",
"5r\u001b[?6h\u001b[2;1H",
"item 1\r\nitem 2\r\n",
"item 3\r\nitem 4",
"\r\nitem 5",
"\r\nitem 6",
"\u001b[?6l\u001b[r"
],
"snapshots": [
{
"after_chunk": 3,
"screen": [
"HEADER ",
" ",
" ",
" ",
" ",
"FOOTER "
]
},
{
"after_chunk": 9,
"screen": [
"HEADER ",
"item 3 ",
"item 4 ",
"item 5 ",
"item 6 ",
"FOOTER "
]
}
]
}

View File

@@ -0,0 +1,161 @@
package runtime
import "bytes"
// vtStreamNormalizer preserves incomplete CSI sequences across PTY reads before
// applying the SGR compatibility rewrite that vt10x still needs.
type vtStreamNormalizer struct {
pendingCSI []byte
}
func (n *vtStreamNormalizer) Normalize(data []byte) []byte {
if len(n.pendingCSI) > 0 {
combined := make([]byte, 0, len(n.pendingCSI)+len(data))
combined = append(combined, n.pendingCSI...)
combined = append(combined, data...)
data = combined
n.pendingCSI = nil
}
if len(data) == 0 {
return nil
}
result := make([]byte, 0, len(data)+32)
for i := 0; i < len(data); {
if data[i] == 0x1b {
if i+1 >= len(data) {
n.pendingCSI = append(n.pendingCSI[:0], data[i:]...)
break
}
if data[i+1] == '[' {
start := i
j := i + 2
for j < len(data) && data[j] < 0x40 {
j++
}
if j >= len(data) {
n.pendingCSI = append(n.pendingCSI[:0], data[start:]...)
break
}
// Drop CSI sequences that vt10x would misinterpret.
// Sequences with intermediate bytes (>, <, =) are private-use
// extensions (e.g. Kitty keyboard \x1b[>1u) that vt10x's CSI
// parser misroutes. Also drop ?-prefixed sequences ending in
// 'u' (\x1b[?u — Kitty keyboard query) which vt10x wrongly
// dispatches as DECRC (cursor restore), corrupting cursor state.
if shouldDropCSI(data[i+2:j], data[j]) {
// silently drop — vt10x would misinterpret
} else if data[j] == 'm' && bytes.ContainsRune(data[i+2:j], ':') {
result = append(result, 0x1b, '[')
result = append(result, rewriteSGRParams(data[i+2:j])...)
result = append(result, 'm')
} else {
result = append(result, data[start:j+1]...)
}
i = j + 1
continue
}
}
result = append(result, data[i])
i++
}
return result
}
// extractCursorShape scans data for the last DECSCUSR sequence (\x1b[N SP q)
// and returns the cursor shape value (0-6). Returns -1 if none found.
// DECSCUSR: 0=default, 1=blinking block, 2=steady block, 3=blinking underline,
// 4=steady underline, 5=blinking bar, 6=steady bar.
func extractCursorShape(data []byte) int32 {
shape := int32(-1)
for i := 0; i < len(data); i++ {
if data[i] != 0x1b || i+1 >= len(data) || data[i+1] != '[' {
continue
}
j := i + 2
// Collect parameter + intermediate bytes (< 0x40)
for j < len(data) && data[j] < 0x40 {
j++
}
if j >= len(data) {
break
}
params := data[i+2 : j]
final := data[j]
// DECSCUSR: CSI Ps SP q — params end with space (0x20), final is 'q'
if final == 'q' && len(params) >= 2 && params[len(params)-1] == ' ' {
// Parse the digit(s) before the space
numPart := params[:len(params)-1]
if len(numPart) == 1 && numPart[0] >= '0' && numPart[0] <= '6' {
shape = int32(numPart[0] - '0')
}
}
i = j
}
return shape
}
// extractCursorVisible scans data for the last cursor visibility toggle
// (\x1b[?25h or \x1b[?25l). Returns 1 for show, 0 for hide, -1 if none found.
func extractCursorVisible(data []byte) int32 {
vis := int32(-1)
for i := 0; i+5 < len(data); i++ {
if data[i] != 0x1b || data[i+1] != '[' || data[i+2] != '?' ||
data[i+3] != '2' || data[i+4] != '5' {
continue
}
switch data[i+5] {
case 'h':
vis = 1
i += 5
case 'l':
vis = 0
i += 5
}
}
return vis
}
// lastCursorShowIndex returns the byte index of the last \x1b[?25h in data,
// or -1 if not found. Used to split vt10x writes so we can capture cursor
// position at the exact moment the child shows the cursor.
func lastCursorShowIndex(data []byte) int {
result := -1
for i := 0; i+5 < len(data); i++ {
if data[i] == 0x1b && data[i+1] == '[' && data[i+2] == '?' &&
data[i+3] == '2' && data[i+4] == '5' && data[i+5] == 'h' {
result = i
i += 5
}
}
return result
}
// shouldDropCSI decides whether a CSI sequence should be stripped before it
// reaches vt10x. Two categories are filtered:
//
// 1. Sequences with '>', '<', or '=' as the first parameter byte. These are
// private-use extensions (Kitty keyboard protocol, DA2 responses, etc.)
// that vt10x's CSI parser conflates with standard sequences.
//
// 2. '?'-prefixed sequences whose final byte is 'u'. The Kitty keyboard
// query (\x1b[?u) would otherwise be dispatched as DECRC (cursor restore)
// because vt10x's 'u' handler does not check the private flag.
//
// Regular '?'-prefixed sequences (\x1b[?1049h, \x1b[?25l, etc.) are NOT
// filtered — vt10x handles those correctly via its priv flag.
func shouldDropCSI(params []byte, finalByte byte) bool {
if len(params) == 0 {
return false
}
switch params[0] {
case '>', '<', '=':
return true
case '?':
return finalByte == 'u'
}
return false
}

View File

@@ -0,0 +1,49 @@
package secrets
import (
"errors"
"fmt"
"strings"
"github.com/zalando/go-keyring"
)
const service = "bifrost-cli"
func keyForProfile(profileID string) string {
return "profile:" + profileID + ":virtual-key"
}
// SetVirtualKey stores a virtual key in the system keyring for the given profile.
// If value is empty, the existing key is deleted.
func SetVirtualKey(profileID, value string) error {
if strings.TrimSpace(value) == "" {
return DeleteVirtualKey(profileID)
}
if err := keyring.Set(service, keyForProfile(profileID), value); err != nil {
return fmt.Errorf("store virtual key: %w", err)
}
return nil
}
// GetVirtualKey retrieves the virtual key for the given profile from the system keyring.
// Returns an empty string if no key is stored.
func GetVirtualKey(profileID string) (string, error) {
v, err := keyring.Get(service, keyForProfile(profileID))
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return "", nil
}
return "", fmt.Errorf("read virtual key: %w", err)
}
return strings.TrimSpace(v), nil
}
// DeleteVirtualKey removes the virtual key for the given profile from the system keyring.
func DeleteVirtualKey(profileID string) error {
err := keyring.Delete(service, keyForProfile(profileID))
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
return fmt.Errorf("delete virtual key: %w", err)
}
return nil
}

View File

@@ -0,0 +1,54 @@
package logo
import (
"fmt"
"strings"
)
const compact = "BIFROST CLI"
// Render returns the ASCII logo for the given terminal width.
func Render(width int) string {
if width < 61 {
return compact
}
return strings.Join([]string{
"╔═══════════════════════════════════════════════════════════╗",
"║ ║",
"║ ██████╗ ██╗███████╗██████╗ ██████╗ ███████╗████████╗ ║",
"║ ██╔══██╗██║██╔════╝██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝ ║",
"║ ██████╔╝██║█████╗ ██████╔╝██║ ██║███████╗ ██║ ║",
"║ ██╔══██╗██║██╔══╝ ██╔══██╗██║ ██║╚════██║ ██║ ║",
"║ ██████╔╝██║██║ ██║ ██║╚██████╔╝███████║ ██║ ║",
"║ ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ║",
"║ ║",
"║═══════════════════════════════════════════════════════════║",
"║ CLI ║",
"║═══════════════════════════════════════════════════════════║",
"║ https://github.com/maximhq/bifrost ║",
"╚═══════════════════════════════════════════════════════════╝",
}, "\n")
}
// BootHeader builds the full boot header with the ASCII logo and version info.
func BootHeader(width int, version, commit, source string, noColor bool) string {
if width < 61 {
meta := fmt.Sprintf("%s (%s)", version, commit)
return fmt.Sprintf("\n\n%s\n%s", Render(width), meta)
}
meta := fmt.Sprintf("%s (%s) config=%s", version, commit, source)
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(Render(width))
b.WriteString("\n")
if noColor {
b.WriteString(meta)
} else {
b.WriteString("\033[2;36m" + meta + "\033[0m")
}
b.WriteString("\n")
return b.String()
}

View File

@@ -0,0 +1,30 @@
package logo
import (
"strings"
"testing"
)
func TestRenderLarge(t *testing.T) {
got := Render(120)
if !strings.Contains(got, "██████╗") {
t.Fatalf("expected large logo, got %q", got)
}
}
func TestRenderCompact(t *testing.T) {
got := Render(20)
if got != "BIFROST CLI" {
t.Fatalf("expected compact logo, got %q", got)
}
}
func TestBootHeaderStartsWithLogo(t *testing.T) {
header := BootHeader(120, "v1", "abc", "none", true)
if !strings.Contains(header, Render(120)) {
t.Fatalf("expected boot header to contain logo")
}
if !strings.Contains(header, "config=none") {
t.Fatalf("expected boot header to contain config source")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
package tui
import (
"os"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestPrefersPlainChooserLayoutAppleTerminal(t *testing.T) {
old := os.Getenv("TERM_PROGRAM")
t.Cleanup(func() {
if old == "" {
os.Unsetenv("TERM_PROGRAM")
return
}
os.Setenv("TERM_PROGRAM", old)
})
os.Setenv("TERM_PROGRAM", "Apple_Terminal")
if !prefersPlainChooserLayout() {
t.Fatal("expected Apple Terminal to use the plain chooser layout")
}
os.Setenv("TERM_PROGRAM", "iTerm.app")
if prefersPlainChooserLayout() {
t.Fatal("did not expect iTerm to use the plain chooser layout")
}
}
func TestRenderPlainChooserView(t *testing.T) {
out := renderPlainChooserView("Ready", "base url\nmodel", "enter launch")
for _, want := range []string{"BIFROST CLI", "Ready", "base url", "model", "enter launch"} {
if !strings.Contains(out, want) {
t.Fatalf("expected output to contain %q, got %q", want, out)
}
}
}
func TestChooserViewShowsUpdatePrompt(t *testing.T) {
m := newChooserModel(ChooserConfig{
Version: "v1.0.0",
Commit: "abc123",
ConfigSrc: "test",
UpdateVersion: "v1.2.3",
})
view := m.View()
for _, want := range []string{"Update available:", "bifrost v1.2.3", "press y to update now"} {
if !strings.Contains(view, want) {
t.Fatalf("expected chooser view to contain %q, got %q", want, view)
}
}
}
func TestChooserUpdateShortcutRequestsUpdate(t *testing.T) {
m := newChooserModel(ChooserConfig{
UpdateVersion: "v1.2.3",
})
// Move to a non-text-entry phase so 'y' isn't consumed by the input field.
m.phase = phaseSummary
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
got := next.(chooserModel)
if !got.updateRequested {
t.Fatal("expected y to request update when update is available")
}
}

View File

@@ -0,0 +1,134 @@
package tui
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// confirmModel holds information about current model selection
type confirmModel struct {
header string
prompt string
command string
idx int
quit bool
yes bool
clearFirst bool
}
// runConfirm runs a confirm dialog with the given model and returns the user's
// choice.
func runConfirm(m confirmModel) (bool, error) {
p := tea.NewProgram(
m,
tea.WithInput(os.Stdin),
tea.WithOutput(os.Stdout),
tea.WithInputTTY(),
)
out, err := p.Run()
if err != nil {
return false, err
}
fm, ok := out.(confirmModel)
if !ok {
return false, fmt.Errorf("unexpected model type from tui")
}
if fm.quit {
return false, nil
}
return fm.yes, nil
}
// RunConfirmInstall displays a yes/no confirmation dialog asking the user
// whether to install a missing harness. Returns true if the user confirms.
func RunConfirmInstall(header, harnessLabel, command string) (bool, error) {
return runConfirm(confirmModel{
header: header,
prompt: fmt.Sprintf("%s is not installed. Install now?", harnessLabel),
command: command,
})
}
// RunConfirmSettings displays a yes/no confirmation dialog asking the user
// whether to update the harness's native settings file. Returns true if the
// user confirms.
func RunConfirmSettings(harnessLabel, settingsPath string) (bool, error) {
return runConfirm(confirmModel{
prompt: fmt.Sprintf("Update %s settings? (%s)", harnessLabel, settingsPath),
clearFirst: true,
})
}
// Init implements tea.Model.
func (m confirmModel) Init() tea.Cmd {
if m.clearFirst {
return tea.ClearScreen
}
return nil
}
// Update implements tea.Model. Handles y/n, arrow keys, and enter for confirmation.
func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
s := msg.String()
switch s {
case "ctrl+c", "q", "esc":
m.quit = true
return m, tea.Quit
case "left", "h", "right", "l", "tab":
if m.idx == 0 {
m.idx = 1
} else {
m.idx = 0
}
return m, nil
case "y":
m.yes = true
return m, tea.Quit
case "n":
m.yes = false
return m, tea.Quit
case "enter":
m.yes = m.idx == 0
return m, tea.Quit
}
}
return m, nil
}
// View implements tea.Model. Renders the confirmation prompt with Yes/No buttons.
func (m confirmModel) View() string {
selected := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42"))
normal := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
yes := normal.Render("[ Yes ]")
no := normal.Render("[ No ]")
if m.idx == 0 {
yes = selected.Render("[ Yes ]")
} else {
no = selected.Render("[ No ]")
}
var b strings.Builder
if m.header != "" {
b.WriteString(m.header)
b.WriteString("\n")
}
b.WriteString(m.prompt)
b.WriteString("\n")
if m.command != "" {
b.WriteString("command: ")
b.WriteString(m.command)
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(yes + " " + no)
b.WriteString("\n")
b.WriteString("enter: confirm, y/n quick choice, q: cancel")
return b.String()
}

View File

@@ -0,0 +1,163 @@
package update
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/maximhq/bifrost/cli/internal/config"
)
const (
baseURL = "https://downloads.getmaxim.ai"
versionURL = baseURL + "/bifrost-cli/latest/version.txt"
checkInterval = 24 * time.Hour
requestTimeout = 3 * time.Second
)
// CheckResult holds the outcome of a version check.
type CheckResult struct {
CurrentVersion string
LatestVersion string
UpdateAvailable bool
CheckedAt int64
}
// CheckInBackground starts a goroutine that checks for a newer CLI version
// and returns the result on the returned channel. The channel receives nil
// if no check was performed or if any error occurred (silent fail).
func CheckInBackground(currentVersion, statePath string) <-chan *CheckResult {
ch := make(chan *CheckResult, 1)
go func() {
defer func() {
if r := recover(); r != nil {
ch <- nil
}
}()
result := doCheck(currentVersion, statePath)
ch <- result
}()
return ch
}
func doCheck(currentVersion, statePath string) *CheckResult {
if currentVersion == "dev" || strings.TrimSpace(currentVersion) == "" {
return nil
}
if envSet("BIFROST_NO_UPDATE_CHECK") {
return nil
}
// Try cached version from state
state, err := config.LoadState(statePath)
if err == nil && state.LastKnownVersion != "" {
if time.Since(time.Unix(state.LastVersionCheck, 0)) < checkInterval {
return compareVersions(currentVersion, state.LastKnownVersion)
}
}
// Fetch latest version
latest, err := fetchLatestVersion()
if err != nil {
return nil
}
return compareVersions(currentVersion, latest)
}
func fetchLatestVersion() (string, error) {
client := &http.Client{Timeout: requestTimeout}
resp, err := client.Get(versionURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("version check: status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
if err != nil {
return "", err
}
version := strings.TrimSpace(string(body))
if version == "" {
return "", fmt.Errorf("version check: empty response")
}
return version, nil
}
func compareVersions(current, latest string) *CheckResult {
result := &CheckResult{
CurrentVersion: current,
LatestVersion: latest,
UpdateAvailable: isNewer(latest, current),
CheckedAt: time.Now().Unix(),
}
return result
}
type parsedVersion struct {
nums [3]int
prerelease string // empty for stable releases
}
// isNewer returns true if a is newer than b. Both should be "vX.Y.Z" or
// "vX.Y.Z-prerelease" format. Per SemVer, a stable release is newer than a
// prerelease with the same major.minor.patch.
func isNewer(a, b string) bool {
av := parseVersion(a)
bv := parseVersion(b)
if av == nil || bv == nil {
return false
}
for i := 0; i < 3; i++ {
if av.nums[i] > bv.nums[i] {
return true
}
if av.nums[i] < bv.nums[i] {
return false
}
}
// Same major.minor.patch: stable > prerelease
if av.prerelease == "" && bv.prerelease != "" {
return true
}
return false
}
func parseVersion(v string) *parsedVersion {
v = strings.TrimPrefix(v, "v")
var pre string
if idx := strings.Index(v, "-"); idx != -1 {
pre = v[idx+1:]
v = v[:idx]
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
return nil
}
var nums [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil
}
nums[i] = n
}
return &parsedVersion{nums: nums, prerelease: pre}
}
func envSet(key string) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return v == "1" || v == "true"
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package update
import "os"
// atomicReplace replaces oldPath with newPath using os.Rename.
// On Unix systems, this is atomic if both paths are on the same filesystem.
func atomicReplace(oldPath, newPath string) error {
return os.Rename(newPath, oldPath)
}

View File

@@ -0,0 +1,29 @@
//go:build windows
package update
import (
"fmt"
"os"
"path/filepath"
)
// atomicReplace replaces oldPath with newPath. On Windows, we cannot rename
// over a running executable directly, so we move the old binary aside first.
func atomicReplace(oldPath, newPath string) error {
backupPath := filepath.Join(filepath.Dir(oldPath), ".bifrost-old.exe")
os.Remove(backupPath) // clean up any previous backup
if err := os.Rename(oldPath, backupPath); err != nil {
return err
}
if err := os.Rename(newPath, oldPath); err != nil {
// Rollback — report if rollback also fails
if rbErr := os.Rename(backupPath, oldPath); rbErr != nil {
return fmt.Errorf("rename failed: %w; rollback also failed: %v (backup at %s)", err, rbErr, backupPath)
}
return err
}
os.Remove(backupPath)
return nil
}

View File

@@ -0,0 +1,330 @@
package update
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// RunSelfUpdate downloads and replaces the current binary with the latest version.
func RunSelfUpdate(currentVersion string) error {
fmt.Println("Checking latest bifrost version...")
latest, err := fetchLatestVersion()
if err != nil {
return fmt.Errorf("check latest version: %w", err)
}
if !isNewer(latest, currentVersion) {
fmt.Printf("Already up to date (%s).\n", currentVersion)
return nil
}
fmt.Printf("Updating bifrost from %s to %s...\n", currentVersion, latest)
binaryName := "bifrost"
if runtime.GOOS == "windows" {
binaryName = "bifrost.exe"
}
downloadURL := fmt.Sprintf("%s/bifrost-cli/%s/%s/%s/%s", baseURL, latest, runtime.GOOS, runtime.GOARCH, binaryName)
checksumURL := downloadURL + ".sha256"
fmt.Printf("Resolved update artifact: %s/%s\n", runtime.GOOS, runtime.GOARCH)
targetPath, err := resolveManagedBinaryTarget(binaryName)
if err != nil {
return fmt.Errorf("resolve managed binary path: %w", err)
}
fmt.Printf("Managed binary target: %s\n", targetPath)
// Download to the OS temp directory first so partial downloads never touch
// the managed install location.
tmpFile, err := os.CreateTemp("", ".bifrost-update-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
fmt.Printf("Temporary download path: %s\n", tmpPath)
// Download binary (use a generous timeout for large binaries)
downloadClient := &http.Client{Timeout: 5 * time.Minute}
fmt.Printf("Downloading update from %s\n", downloadURL)
resp, err := downloadClient.Get(downloadURL)
if err != nil {
tmpFile.Close()
return fmt.Errorf("download binary: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
tmpFile.Close()
return fmt.Errorf("download binary: status %d", resp.StatusCode)
}
hasher := sha256.New()
progress := newProgressLogger(resp.ContentLength)
defer progress.Finish()
writer := io.MultiWriter(tmpFile, hasher, progress)
if _, err := io.Copy(writer, resp.Body); err != nil {
tmpFile.Close()
return fmt.Errorf("write binary: %w", err)
}
progress.Finish()
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
return fmt.Errorf("sync binary: %w", err)
}
tmpFile.Close()
actualHash := hex.EncodeToString(hasher.Sum(nil))
// Verify checksum (mandatory — refuse to install unverified binaries)
fmt.Printf("Fetching checksum from %s\n", checksumURL)
expectedHash, err := fetchChecksum(checksumURL)
if err != nil {
return fmt.Errorf("checksum verification failed: %w", err)
}
if actualHash != expectedHash {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
}
fmt.Println("Checksum verified.")
// Preserve permissions from old binary
fmt.Println("Preserving executable permissions...")
info, err := os.Stat(targetPath)
if err != nil {
return fmt.Errorf("stat target binary: %w", err)
}
stagePath, err := stageUpdateBinary(tmpPath, targetPath, info.Mode())
if err != nil {
return fmt.Errorf("stage binary: %w", err)
}
defer os.Remove(stagePath)
if err := os.Chmod(stagePath, info.Mode()); err != nil {
return fmt.Errorf("set permissions: %w", err)
}
// Atomic replace: rename new over old
fmt.Printf("Replacing binary at %s\n", targetPath)
if err := atomicReplace(targetPath, stagePath); err != nil {
return fmt.Errorf("replace binary: %w", err)
}
fmt.Printf("Updated bifrost from %s to %s. Please restart.\n", currentVersion, latest)
return nil
}
type progressLogger struct {
total int64
written int64
lastReport time.Time
finished bool
mu sync.Mutex
}
func newProgressLogger(total int64) *progressLogger {
return &progressLogger{
total: total,
lastReport: time.Now(),
}
}
func (p *progressLogger) Write(data []byte) (int, error) {
n := len(data)
p.mu.Lock()
defer p.mu.Unlock()
p.written += int64(n)
now := time.Now()
if now.Sub(p.lastReport) >= time.Second {
fmt.Println(p.statusLine())
p.lastReport = now
}
return n, nil
}
func (p *progressLogger) Finish() {
p.mu.Lock()
defer p.mu.Unlock()
if p.finished {
return
}
p.finished = true
fmt.Println(p.statusLine())
}
func (p *progressLogger) statusLine() string {
if p.total > 0 {
pct := float64(p.written) * 100 / float64(p.total)
return fmt.Sprintf("Download progress: %.1f%% (%s/%s)", pct, formatBytes(p.written), formatBytes(p.total))
}
return fmt.Sprintf("Download progress: %s", formatBytes(p.written))
}
func formatBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for value := n / unit; value >= unit; value /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
func resolveManagedBinaryTarget(binaryName string) (string, error) {
execPath, err := os.Executable()
if err == nil {
if resolved, resolveErr := filepath.EvalSymlinks(execPath); resolveErr == nil {
execPath = resolved
}
if isWrapperManagedBinaryPath(execPath, binaryName) {
return execPath, nil
}
// If the running binary has the expected name, update it in place
// even if it's not under a wrapper-managed path.
if filepath.Base(execPath) == binaryName {
return execPath, nil
}
}
return bifrostCLIManagedBinaryPath(binaryName)
}
func bifrostCLIManagedBinaryPath(binaryName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".bifrost", "bin", binaryName), nil
}
func isWrapperManagedBinaryPath(path, binaryName string) bool {
if path == "" {
return false
}
cleanPath := filepath.Clean(path)
homePath, err := bifrostCLIManagedBinaryPath(binaryName)
if err == nil && cleanPath == filepath.Clean(homePath) {
return true
}
cacheRoot, err := wrapperCacheRoot()
if err != nil {
return false
}
cachePrefix := filepath.Clean(filepath.Join(cacheRoot, "bifrost")) + string(os.PathSeparator)
if !strings.HasPrefix(cleanPath, cachePrefix) {
return false
}
base := filepath.Base(cleanPath)
if base == binaryName {
return true
}
return strings.HasPrefix(base, binaryName+"-")
}
func wrapperCacheRoot() (string, error) {
switch runtime.GOOS {
case "linux":
if xdg := strings.TrimSpace(os.Getenv("XDG_CACHE_HOME")); xdg != "" {
return xdg, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".cache"), nil
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "Caches"), nil
case "windows":
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
return localAppData, nil
}
userProfile := strings.TrimSpace(os.Getenv("USERPROFILE"))
if userProfile == "" {
return "", fmt.Errorf("userprofile is not set")
}
return filepath.Join(userProfile, "AppData", "Local"), nil
default:
return "", fmt.Errorf("unsupported platform %s", runtime.GOOS)
}
}
func stageUpdateBinary(downloadPath, targetPath string, mode os.FileMode) (string, error) {
stageFile, err := os.CreateTemp(filepath.Dir(targetPath), ".bifrost-stage-*")
if err != nil {
return "", err
}
stagePath := stageFile.Name()
src, err := os.Open(downloadPath)
if err != nil {
stageFile.Close()
os.Remove(stagePath)
return "", err
}
defer src.Close()
if _, err := io.Copy(stageFile, src); err != nil {
stageFile.Close()
os.Remove(stagePath)
return "", err
}
if err := stageFile.Sync(); err != nil {
stageFile.Close()
os.Remove(stagePath)
return "", err
}
if err := stageFile.Close(); err != nil {
os.Remove(stagePath)
return "", err
}
if err := os.Chmod(stagePath, mode); err != nil {
os.Remove(stagePath)
return "", err
}
return stagePath, nil
}
func fetchChecksum(url string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
if err != nil {
return "", err
}
hash := strings.TrimSpace(strings.Split(string(body), " ")[0])
if hash == "" {
return "", fmt.Errorf("empty checksum")
}
return hash, nil
}

View File

@@ -0,0 +1,111 @@
package update
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func setTestHome(t *testing.T, home string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
return
}
t.Setenv("HOME", home)
}
func TestResolveManagedBinaryTargetFallsBackToCLIInstallLocation(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
binaryName := "bifrost"
if runtime.GOOS == "windows" {
binaryName = "bifrost.exe"
}
got, err := bifrostCLIManagedBinaryPath(binaryName)
if err != nil {
t.Fatalf("bifrostCLIManagedBinaryPath() error = %v", err)
}
want := filepath.Join(home, ".bifrost", "bin", binaryName)
if got != want {
t.Fatalf("bifrostCLIManagedBinaryPath() = %q, want %q", got, want)
}
}
func TestIsWrapperManagedBinaryPathMatchesCLIInstallLocation(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
binaryName := "bifrost"
if runtime.GOOS == "windows" {
binaryName = "bifrost.exe"
}
path := filepath.Join(home, ".bifrost", "bin", binaryName)
if !isWrapperManagedBinaryPath(path, binaryName) {
t.Fatalf("expected %q to be recognized as wrapper-managed", path)
}
}
func TestIsWrapperManagedBinaryPathMatchesCacheInstallLocation(t *testing.T) {
if runtime.GOOS == "windows" {
localAppData := t.TempDir()
t.Setenv("LOCALAPPDATA", localAppData)
path := filepath.Join(localAppData, "bifrost", "v1.2.3", "bin", "bifrost.exe-0")
if !isWrapperManagedBinaryPath(path, "bifrost.exe") {
t.Fatalf("expected %q to be recognized as wrapper-managed cache path", path)
}
return
}
home := t.TempDir()
setTestHome(t, home)
if runtime.GOOS == "linux" {
xdg := filepath.Join(home, ".custom-cache")
t.Setenv("XDG_CACHE_HOME", xdg)
path := filepath.Join(xdg, "bifrost", "v1.2.3", "bin", "bifrost-0")
if !isWrapperManagedBinaryPath(path, "bifrost") {
t.Fatalf("expected %q to be recognized as wrapper-managed cache path", path)
}
return
}
path := filepath.Join(home, "Library", "Caches", "bifrost", "v1.2.3", "bin", "bifrost-0")
if !isWrapperManagedBinaryPath(path, "bifrost") {
t.Fatalf("expected %q to be recognized as wrapper-managed cache path", path)
}
}
func TestStageUpdateBinaryCopiesIntoTargetDirectory(t *testing.T) {
t.Parallel()
root := t.TempDir()
targetDir := filepath.Join(root, ".bifrost", "bin")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("mkdir target dir: %v", err)
}
downloadPath := filepath.Join(t.TempDir(), "downloaded-bifrost")
if err := os.WriteFile(downloadPath, []byte("new-binary"), 0o600); err != nil {
t.Fatalf("write download: %v", err)
}
targetPath := filepath.Join(targetDir, "bifrost")
stagePath, err := stageUpdateBinary(downloadPath, targetPath, 0o755)
if err != nil {
t.Fatalf("stageUpdateBinary() error = %v", err)
}
if filepath.Dir(stagePath) != targetDir {
t.Fatalf("stage path dir = %q, want %q", filepath.Dir(stagePath), targetDir)
}
if got, err := os.ReadFile(stagePath); err != nil {
t.Fatalf("read staged file: %v", err)
} else if string(got) != "new-binary" {
t.Fatalf("staged file contents = %q, want %q", string(got), "new-binary")
}
}

59
cli/main.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/maximhq/bifrost/cli/internal/app"
"github.com/maximhq/bifrost/cli/internal/update"
)
var (
version = "dev"
commit = "none"
)
// main is the CLI entry-point
func main() {
var cfgPath string
var noResume bool
var worktree string
flag.StringVar(&cfgPath, "config", "", "Path to config.json")
flag.BoolVar(&noResume, "no-resume", false, "Skip resume flow and open setup")
flag.StringVar(&worktree, "worktree", "", "Create a git worktree for the session (harness must support it)")
flag.Parse()
if args := flag.Args(); len(args) > 0 {
switch args[0] {
case "update":
if err := update.RunSelfUpdate(version); err != nil {
fmt.Fprintf(os.Stderr, "bifrost: %v\n", err)
os.Exit(1)
}
return
case "version":
fmt.Printf("bifrost %s (%s)\n", version, commit)
return
default:
fmt.Fprintf(os.Stderr, "bifrost: unknown command %q\n", args[0])
fmt.Fprintf(os.Stderr, "Available commands: update, version\n")
os.Exit(1)
}
}
a := app.New(os.Stdin, os.Stdout, os.Stderr, app.Options{
Version: version,
Commit: commit,
NoResume: noResume,
Config: cfgPath,
Worktree: worktree,
})
if err := a.Run(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "bifrost: %v\n", err)
os.Exit(1)
}
}

1
cli/version Normal file
View File

@@ -0,0 +1 @@
0.10.3