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