From 538699e54f50af3473a29982df30ace8f3c7f8f7 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 11:05:18 +0100 Subject: [PATCH 01/16] feat(vars): add interactive prompting for required variables Add support for interactive variable prompting using Bubble Tea. Variables can be marked as `interactive: true` in the requires section, and users will be prompted to enter values for missing variables when running in a TTY. Features: - New `interactive` field on required variables - Bubble Tea-based text input and select prompts - --no-tty flag to disable interactive prompts - Automatic skip when variable is already set --- executor.go | 14 ++ go.mod | 19 +++ go.sum | 42 ++++- internal/flags/flags.go | 3 + internal/prompt/prompt.go | 223 +++++++++++++++++++++++++ requires.go | 56 +++++++ task.go | 14 ++ taskfile/ast/requires.go | 16 +- testdata/interactive_vars/Taskfile.yml | 35 ++++ 9 files changed, 412 insertions(+), 10 deletions(-) create mode 100644 internal/prompt/prompt.go create mode 100644 testdata/interactive_vars/Taskfile.yml diff --git a/executor.go b/executor.go index 3ca5fbcf66..09bc3dc2f2 100644 --- a/executor.go +++ b/executor.go @@ -43,6 +43,7 @@ type ( DisableFuzzy bool AssumeYes bool AssumeTerm bool // Used for testing + NoTTY bool Dry bool Summary bool Parallel bool @@ -353,6 +354,19 @@ func (o *assumeTermOption) ApplyToExecutor(e *Executor) { e.AssumeTerm = o.assumeTerm } +// WithNoTTY tells the [Executor] to disable interactive prompts for variables. +func WithNoTTY(noTTY bool) ExecutorOption { + return &noTTYOption{noTTY} +} + +type noTTYOption struct { + noTTY bool +} + +func (o *noTTYOption) ApplyToExecutor(e *Executor) { + e.NoTTY = o.noTTY +} + // WithDry tells the [Executor] to output the commands that would be run without // actually running them. func WithDry(dry bool) ExecutorOption { diff --git a/go.mod b/go.mod index 6aa7cd9b3f..db6bcc4cf6 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/alecthomas/chroma/v2 v2.20.0 github.com/chainguard-dev/git-urls v1.0.2 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/davecgh/go-spew v1.1.1 github.com/dominikbraun/graph v0.23.0 github.com/elliotchance/orderedmap/v3 v3.1.0 @@ -36,11 +39,18 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -48,20 +58,29 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/pgzip v1.2.6 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // 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.16 // 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/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/u-root/u-root v0.15.1-0.20251014130006-62f7144b33da // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 219ab458d0..3cfde69684 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,26 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +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/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ= github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -42,6 +60,8 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -50,8 +70,6 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= @@ -91,13 +109,25 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +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/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= @@ -109,10 +139,11 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/puzpuzpuz/xsync/v4 v4.1.0 h1:x9eHRl4QhZFIPJ17yl4KKW9xLyVWbb3/Yq4SXpjF71U= -github.com/puzpuzpuz/xsync/v4 v4.1.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0= github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= @@ -141,6 +172,8 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +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/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -162,6 +195,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/flags/flags.go b/internal/flags/flags.go index eb5930dc10..b5d4271622 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -79,6 +79,7 @@ var ( ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration + NoTTY bool ) func init() { @@ -128,6 +129,7 @@ func init() { pflag.BoolVarP(&Silent, "silent", "s", false, "Disables echoing.") pflag.BoolVar(&DisableFuzzy, "disable-fuzzy", getConfig(config, func() *bool { return config.DisableFuzzy }, false), "Disables fuzzy matching for task names.") pflag.BoolVarP(&AssumeYes, "yes", "y", false, "Assume \"yes\" as answer to all prompts.") + pflag.BoolVar(&NoTTY, "no-tty", false, "Disable interactive prompts for variables.") pflag.BoolVarP(&Parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.") pflag.BoolVarP(&Dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.") pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.") @@ -252,6 +254,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithSilent(Silent), task.WithDisableFuzzy(DisableFuzzy), task.WithAssumeYes(AssumeYes), + task.WithNoTTY(NoTTY), task.WithDry(Dry || Status), task.WithSummary(Summary), task.WithParallel(Parallel), diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000000..058c98c5d2 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,223 @@ +package prompt + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/fatih/color" + + "github.com/go-task/task/v3/errors" +) + +var ( + ErrCancelled = errors.New("prompt cancelled") +) + +var ( + cyan = color.New(color.FgCyan).SprintFunc() + green = color.New(color.FgGreen).SprintFunc() + gray = color.New(color.FgHiBlack).SprintFunc() +) + +// Prompter handles interactive variable prompting +type Prompter struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New creates a new Prompter with default stdin/stdout/stderr +func New() *Prompter { + return &Prompter{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } +} + +// Text prompts the user for a text value +func (p *Prompter) Text(varName string) (string, error) { + m := newTextModel(varName) + + prog := tea.NewProgram(m, + tea.WithInput(p.Stdin), + tea.WithOutput(p.Stderr), + ) + + result, err := prog.Run() + if err != nil { + return "", err + } + + model := result.(textModel) + if model.cancelled { + return "", ErrCancelled + } + + return model.value, nil +} + +// Select prompts the user to select from a list of options +func (p *Prompter) Select(varName string, options []string) (string, error) { + if len(options) == 0 { + return "", errors.New("no options provided") + } + + m := newSelectModel(varName, options) + + prog := tea.NewProgram(m, + tea.WithInput(p.Stdin), + tea.WithOutput(p.Stderr), + ) + + result, err := prog.Run() + if err != nil { + return "", err + } + + model := result.(selectModel) + if model.cancelled { + return "", ErrCancelled + } + + return model.options[model.cursor], nil +} + +// textModel is the Bubble Tea model for text input +type textModel struct { + varName string + textInput textinput.Model + value string + cancelled bool + done bool +} + +func newTextModel(varName string) textModel { + ti := textinput.New() + ti.Placeholder = "" + ti.Focus() + ti.CharLimit = 256 + ti.Width = 40 + + return textModel{ + varName: varName, + textInput: ti, + } +} + +func (m textModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m textModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + m.done = true + return m, tea.Quit + case tea.KeyEnter: + m.value = m.textInput.Value() + m.done = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m textModel) View() string { + if m.done { + return "" + } + + prompt := cyan(fmt.Sprintf("? Enter value for %s: ", m.varName)) + return prompt + m.textInput.View() + "\n" +} + +// selectModel is the Bubble Tea model for selection +type selectModel struct { + varName string + options []string + cursor int + cancelled bool + done bool +} + +func newSelectModel(varName string, options []string) selectModel { + return selectModel{ + varName: varName, + options: options, + cursor: 0, + } +} + +func (m selectModel) Init() tea.Cmd { + return nil +} + +func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + m.done = true + return m, tea.Quit + case tea.KeyUp, tea.KeyShiftTab: + if m.cursor > 0 { + m.cursor-- + } + case tea.KeyDown, tea.KeyTab: + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case tea.KeyEnter: + m.done = true + return m, tea.Quit + } + + // Also handle j/k for vim users + switch msg.String() { + case "k": + if m.cursor > 0 { + m.cursor-- + } + case "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + } + } + + return m, nil +} + +func (m selectModel) View() string { + if m.done { + return "" + } + + var b strings.Builder + + b.WriteString(cyan(fmt.Sprintf("? Select value for %s:\n", m.varName))) + + for i, opt := range m.options { + if i == m.cursor { + b.WriteString(green(fmt.Sprintf("> %s\n", opt))) + } else { + b.WriteString(fmt.Sprintf(" %s\n", opt)) + } + } + + b.WriteString(gray(" (↑/↓ to move, enter to select, esc to cancel)")) + + return b.String() +} diff --git a/requires.go b/requires.go index 119b073ba2..b0001b5249 100644 --- a/requires.go +++ b/requires.go @@ -4,9 +4,65 @@ import ( "slices" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/prompt" + "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/taskfile/ast" ) +// promptForInteractiveVars prompts the user for any missing interactive variables +// and injects them into the call's Vars. It returns true if any variables were +// prompted for (meaning the task needs to be recompiled). +func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, error) { + if t.Requires == nil || len(t.Requires.Vars) == 0 { + return false, nil + } + + // Don't prompt if NoTTY is set or we're not in a terminal + if e.NoTTY || (!e.AssumeTerm && !term.IsTerminal()) { + return false, nil + } + + prompter := prompt.New() + var prompted bool + + for _, requiredVar := range t.Requires.Vars { + // Skip non-interactive vars + if !requiredVar.Interactive { + continue + } + + // Skip if already set + if _, ok := t.Vars.Get(requiredVar.Name); ok { + continue + } + + var value string + var err error + + if len(requiredVar.Enum) > 0 { + value, err = prompter.Select(requiredVar.Name, requiredVar.Enum) + } else { + value, err = prompter.Text(requiredVar.Name) + } + + if err != nil { + if errors.Is(err, prompt.ErrCancelled) { + return false, &errors.TaskCancelledByUserError{TaskName: call.Task} + } + return false, err + } + + // Inject into call.Vars + if call.Vars == nil { + call.Vars = ast.NewVars() + } + call.Vars.Set(requiredVar.Name, ast.Var{Value: value}) + prompted = true + } + + return prompted, nil +} + func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { if t.Requires == nil || len(t.Requires.Vars) == 0 { return nil diff --git a/task.go b/task.go index 489ef7e5dd..43a3c9ae82 100644 --- a/task.go +++ b/task.go @@ -129,6 +129,20 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { return nil } + // Prompt for any interactive variables that are missing + prompted, err := e.promptForInteractiveVars(t, call) + if err != nil { + return err + } + + // Recompile if we prompted for variables + if prompted { + t, err = e.FastCompiledTask(call) + if err != nil { + return err + } + } + if err := e.areTaskRequiredVarsSet(t); err != nil { return err } diff --git a/taskfile/ast/requires.go b/taskfile/ast/requires.go index 5a76e13fcd..6461bfa974 100644 --- a/taskfile/ast/requires.go +++ b/taskfile/ast/requires.go @@ -23,8 +23,9 @@ func (r *Requires) DeepCopy() *Requires { } type VarsWithValidation struct { - Name string - Enum []string + Name string + Enum []string + Interactive bool } func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { @@ -32,8 +33,9 @@ func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { return nil } return &VarsWithValidation{ - Name: v.Name, - Enum: v.Enum, + Name: v.Name, + Enum: v.Enum, + Interactive: v.Interactive, } } @@ -52,14 +54,16 @@ func (v *VarsWithValidation) UnmarshalYAML(node *yaml.Node) error { case yaml.MappingNode: var vv struct { - Name string - Enum []string + Name string + Enum []string + Interactive bool } if err := node.Decode(&vv); err != nil { return errors.NewTaskfileDecodeError(err, node) } v.Name = vv.Name v.Enum = vv.Enum + v.Interactive = vv.Interactive return nil } diff --git a/testdata/interactive_vars/Taskfile.yml b/testdata/interactive_vars/Taskfile.yml new file mode 100644 index 0000000000..049f27b4f7 --- /dev/null +++ b/testdata/interactive_vars/Taskfile.yml @@ -0,0 +1,35 @@ +version: "3" + +tasks: + simple: + requires: + vars: + - name: MY_VAR + interactive: true + cmds: + - echo "{{.MY_VAR}}" + + with-enum: + requires: + vars: + - name: ENV + interactive: true + enum: [dev, staging, prod] + cmds: + - echo "{{.ENV}}" + + non-interactive: + requires: + vars: + - NON_INTERACTIVE_VAR + cmds: + - echo "{{.NON_INTERACTIVE_VAR}}" + + mixed: + requires: + vars: + - name: INTERACTIVE_VAR + interactive: true + - NON_INTERACTIVE_VAR + cmds: + - echo "{{.INTERACTIVE_VAR}} {{.NON_INTERACTIVE_VAR}}" From 08117c4604ac3f4d9ff5629adf1b0c32c27a5765 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 11:08:11 +0100 Subject: [PATCH 02/16] refactor(prompt): use lipgloss instead of fatih/color for styling --- internal/prompt/prompt.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 058c98c5d2..a3c1d4a1af 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/fatih/color" + "github.com/charmbracelet/lipgloss" "github.com/go-task/task/v3/errors" ) @@ -18,9 +18,9 @@ var ( ) var ( - cyan = color.New(color.FgCyan).SprintFunc() - green = color.New(color.FgGreen).SprintFunc() - gray = color.New(color.FgHiBlack).SprintFunc() + promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray ) // Prompter handles interactive variable prompting @@ -138,7 +138,7 @@ func (m textModel) View() string { return "" } - prompt := cyan(fmt.Sprintf("? Enter value for %s: ", m.varName)) + prompt := promptStyle.Render(fmt.Sprintf("? Enter value for %s: ", m.varName)) return prompt + m.textInput.View() + "\n" } @@ -207,17 +207,19 @@ func (m selectModel) View() string { var b strings.Builder - b.WriteString(cyan(fmt.Sprintf("? Select value for %s:\n", m.varName))) + b.WriteString(promptStyle.Render(fmt.Sprintf("? Select value for %s:", m.varName))) + b.WriteString("\n") for i, opt := range m.options { if i == m.cursor { - b.WriteString(green(fmt.Sprintf("> %s\n", opt))) + b.WriteString(selectedStyle.Render("> " + opt)) } else { - b.WriteString(fmt.Sprintf(" %s\n", opt)) + b.WriteString(" " + opt) } + b.WriteString("\n") } - b.WriteString(gray(" (↑/↓ to move, enter to select, esc to cancel)")) + b.WriteString(dimStyle.Render(" (↑/↓ to move, enter to select, esc to cancel)")) return b.String() } From 0944050616ea65fe531404d217ac739a198c95d0 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 11:21:39 +0100 Subject: [PATCH 03/16] style(prompt): improve select cursor with bold green selection --- internal/prompt/prompt.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index a3c1d4a1af..c3c25aab8a 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -18,9 +18,10 @@ var ( ) var ( - promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) // cyan bold + cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) // cyan bold + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) // green bold + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray ) // Prompter handles interactive variable prompting @@ -212,7 +213,8 @@ func (m selectModel) View() string { for i, opt := range m.options { if i == m.cursor { - b.WriteString(selectedStyle.Render("> " + opt)) + b.WriteString(cursorStyle.Render("❯ ")) + b.WriteString(selectedStyle.Render(opt)) } else { b.WriteString(" " + opt) } From 2f01add607d1de61623d87401edd499673f987d4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 11:47:04 +0100 Subject: [PATCH 04/16] docs(interactive-vars): add documentation for interactive variable prompts --- completion/fish/task.fish | 1 + completion/ps/task.ps1 | 1 + completion/zsh/_task | 1 + website/src/docs/guide.md | 49 ++++++++++++++++++++++++++++ website/src/docs/reference/cli.md | 10 ++++++ website/src/docs/reference/schema.md | 18 +++++++++- website/src/public/schema.json | 9 +++-- 7 files changed, 86 insertions(+), 3 deletions(-) diff --git a/completion/fish/task.fish b/completion/fish/task.fish index b2b9b045cb..431c410f6f 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -86,6 +86,7 @@ complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions' complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON' complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON' +complete -c $GO_TASK_PROGNAME -l no-tty -d 'disable interactive prompts' complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed" complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output' complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output' diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 941c1d48c4..fb6fe8ce4c 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -40,6 +40,7 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock { [CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'), [CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'), [CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'), + [CompletionResult]::new('--no-tty', '--no-tty', [CompletionResultType]::ParameterName, 'disable interactive prompts'), [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'), [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'), [CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'), diff --git a/completion/zsh/_task b/completion/zsh/_task index 85a5dd1e67..c0202c551b 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -71,6 +71,7 @@ _task() { '(-j --json)'{-j,--json}'[format task list as JSON]' '(--nested)--nested[nest namespaces when listing as JSON]' '(--no-status)--no-status[ignore status when listing as JSON]' + '(--no-tty)--no-tty[disable interactive prompts]' '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index ef59375cb0..aa67c554e5 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1127,6 +1127,55 @@ This is supported only for string variables. ::: +### Prompting for missing variables interactively + +If you want Task to prompt users for missing variables instead of failing, you +can mark a variable as `interactive: true`. When a variable is missing and has +this flag, Task will display an interactive prompt to collect the value. + +For variables with an `enum`, a selection menu is shown. For variables without +an enum, a text input is displayed. + +```yaml +version: '3' + +tasks: + deploy: + requires: + vars: + - name: ENVIRONMENT + interactive: true + enum: [dev, staging, prod] + - name: VERSION + interactive: true + cmds: + - echo "Deploying {{.VERSION}} to {{.ENVIRONMENT}}" +``` + +```shell +$ task deploy +? Select value for ENVIRONMENT: +❯ dev + staging + prod +``` + +If the variable is already set (via CLI, environment, or Taskfile), no prompt +is shown: + +```shell +$ task deploy ENVIRONMENT=prod VERSION=1.0.0 +Deploying 1.0.0 to prod +``` + +::: warning + +Interactive prompts require a TTY. In non-interactive environments like CI +pipelines, use `--no-tty` to disable prompts (missing variables will cause an +error as usual), or provide all required variables explicitly. + +::: + ## Variables Task allows you to set variables using the `vars` keyword. The following diff --git a/website/src/docs/reference/cli.md b/website/src/docs/reference/cli.md index c434e5b854..260a8b2a1c 100644 --- a/website/src/docs/reference/cli.md +++ b/website/src/docs/reference/cli.md @@ -301,6 +301,16 @@ Automatically answer "yes" to all prompts. task deploy --yes ``` +#### `--no-tty` + +Disable interactive prompts for missing variables. When a variable is marked +with `interactive: true` in the Taskfile and is not provided, Task will error +instead of prompting for input. Useful in CI environments. + +```bash +task deploy --no-tty +``` + ## Exit Codes Task uses specific exit codes to indicate different types of errors: diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index f28b299857..9e526fe785 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -632,7 +632,7 @@ tasks: #### `requires` - **Type**: `Requires` -- **Description**: Required variables with optional enums +- **Description**: Required variables with optional enums and interactive prompting ```yaml tasks: @@ -655,8 +655,24 @@ tasks: cmds: - echo "Deploying to {{.ENVIRONMENT}} with log level {{.LOG_LEVEL}}" - ./deploy.sh + + # Interactive prompts for missing variables + interactive-deploy: + requires: + vars: + - name: ENVIRONMENT + interactive: true + enum: [development, staging, production] + - name: API_KEY + interactive: true + cmds: + - ./deploy.sh ``` +When `interactive: true` is set, Task will prompt the user for the variable value +if it is not already defined. For variables with `enum`, a selection menu is shown. +Use `--no-tty` to disable interactive prompts (useful for CI environments). + #### `watch` - **Type**: `bool` diff --git a/website/src/public/schema.json b/website/src/public/schema.json index a7faa3a973..ab0c004caf 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -598,9 +598,14 @@ "type": "object", "properties": { "name": { "type": "string" }, - "enum": { "type": "array", "items": { "type": "string" } } + "enum": { "type": "array", "items": { "type": "string" } }, + "interactive": { + "type": "boolean", + "description": "If true, prompt the user for this variable if it is not set", + "default": false + } }, - "required": ["name", "enum"], + "required": ["name"], "additionalProperties": false } ] From 419442bba68cfe966f497bc2b97c2be71acef784 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 12:01:36 +0100 Subject: [PATCH 05/16] refactor(interactive): move interactive setting to taskrc --- executor.go | 14 +++++++++++ internal/flags/flags.go | 3 +++ requires.go | 12 +++++----- taskfile/ast/requires.go | 16 +++++-------- taskrc/ast/taskrc.go | 2 ++ testdata/interactive_vars/Taskfile.yml | 20 ++++------------ website/src/docs/guide.md | 33 ++++++++++++++++---------- website/src/docs/reference/cli.md | 6 ++++- website/src/docs/reference/config.md | 14 +++++++++++ website/src/docs/reference/schema.md | 19 +++------------ website/src/public/schema-taskrc.json | 5 ++++ website/src/public/schema.json | 7 +----- 12 files changed, 85 insertions(+), 66 deletions(-) diff --git a/executor.go b/executor.go index 09bc3dc2f2..0e909b8772 100644 --- a/executor.go +++ b/executor.go @@ -44,6 +44,7 @@ type ( AssumeYes bool AssumeTerm bool // Used for testing NoTTY bool + Interactive bool Dry bool Summary bool Parallel bool @@ -367,6 +368,19 @@ func (o *noTTYOption) ApplyToExecutor(e *Executor) { e.NoTTY = o.noTTY } +// WithInteractive tells the [Executor] to prompt for missing required variables. +func WithInteractive(interactive bool) ExecutorOption { + return &interactiveOption{interactive} +} + +type interactiveOption struct { + interactive bool +} + +func (o *interactiveOption) ApplyToExecutor(e *Executor) { + e.Interactive = o.interactive +} + // WithDry tells the [Executor] to output the commands that would be run without // actually running them. func WithDry(dry bool) ExecutorOption { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index b5d4271622..62c899f122 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -80,6 +80,7 @@ var ( Timeout time.Duration CacheExpiryDuration time.Duration NoTTY bool + Interactive bool ) func init() { @@ -145,6 +146,7 @@ func init() { pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.") pflag.BoolVarP(&Failfast, "failfast", "F", getConfig(config, func() *bool { return &config.Failfast }, false), "When running tasks in parallel, stop all tasks if one fails.") pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.") + Interactive = getConfig(config, func() *bool { return config.Interactive }, false) pflag.BoolVar(&Experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.") // Gentle force experiment will override the force flag and add a new force-all flag @@ -255,6 +257,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithDisableFuzzy(DisableFuzzy), task.WithAssumeYes(AssumeYes), task.WithNoTTY(NoTTY), + task.WithInteractive(Interactive), task.WithDry(Dry || Status), task.WithSummary(Summary), task.WithParallel(Parallel), diff --git a/requires.go b/requires.go index b0001b5249..d934d3289d 100644 --- a/requires.go +++ b/requires.go @@ -9,7 +9,7 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -// promptForInteractiveVars prompts the user for any missing interactive variables +// promptForInteractiveVars prompts the user for any missing required variables // and injects them into the call's Vars. It returns true if any variables were // prompted for (meaning the task needs to be recompiled). func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, error) { @@ -17,6 +17,11 @@ func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, erro return false, nil } + // Don't prompt if interactive mode is disabled + if !e.Interactive { + return false, nil + } + // Don't prompt if NoTTY is set or we're not in a terminal if e.NoTTY || (!e.AssumeTerm && !term.IsTerminal()) { return false, nil @@ -26,11 +31,6 @@ func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, erro var prompted bool for _, requiredVar := range t.Requires.Vars { - // Skip non-interactive vars - if !requiredVar.Interactive { - continue - } - // Skip if already set if _, ok := t.Vars.Get(requiredVar.Name); ok { continue diff --git a/taskfile/ast/requires.go b/taskfile/ast/requires.go index 6461bfa974..5a76e13fcd 100644 --- a/taskfile/ast/requires.go +++ b/taskfile/ast/requires.go @@ -23,9 +23,8 @@ func (r *Requires) DeepCopy() *Requires { } type VarsWithValidation struct { - Name string - Enum []string - Interactive bool + Name string + Enum []string } func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { @@ -33,9 +32,8 @@ func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { return nil } return &VarsWithValidation{ - Name: v.Name, - Enum: v.Enum, - Interactive: v.Interactive, + Name: v.Name, + Enum: v.Enum, } } @@ -54,16 +52,14 @@ func (v *VarsWithValidation) UnmarshalYAML(node *yaml.Node) error { case yaml.MappingNode: var vv struct { - Name string - Enum []string - Interactive bool + Name string + Enum []string } if err := node.Decode(&vv); err != nil { return errors.NewTaskfileDecodeError(err, node) } v.Name = vv.Name v.Enum = vv.Enum - v.Interactive = vv.Interactive return nil } diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 8952315d62..4d65b1efd4 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -14,6 +14,7 @@ type TaskRC struct { Verbose *bool `yaml:"verbose"` DisableFuzzy *bool `yaml:"disable-fuzzy"` Concurrency *int `yaml:"concurrency"` + Interactive *bool `yaml:"interactive"` Remote Remote `yaml:"remote"` Failfast bool `yaml:"failfast"` Experiments map[string]int `yaml:"experiments"` @@ -56,5 +57,6 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Verbose = cmp.Or(other.Verbose, t.Verbose) t.DisableFuzzy = cmp.Or(other.DisableFuzzy, t.DisableFuzzy) t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency) + t.Interactive = cmp.Or(other.Interactive, t.Interactive) t.Failfast = cmp.Or(other.Failfast, t.Failfast) } diff --git a/testdata/interactive_vars/Taskfile.yml b/testdata/interactive_vars/Taskfile.yml index 049f27b4f7..2c1e4a85b5 100644 --- a/testdata/interactive_vars/Taskfile.yml +++ b/testdata/interactive_vars/Taskfile.yml @@ -4,8 +4,7 @@ tasks: simple: requires: vars: - - name: MY_VAR - interactive: true + - MY_VAR cmds: - echo "{{.MY_VAR}}" @@ -13,23 +12,14 @@ tasks: requires: vars: - name: ENV - interactive: true enum: [dev, staging, prod] cmds: - echo "{{.ENV}}" - non-interactive: + multiple: requires: vars: - - NON_INTERACTIVE_VAR + - VAR1 + - VAR2 cmds: - - echo "{{.NON_INTERACTIVE_VAR}}" - - mixed: - requires: - vars: - - name: INTERACTIVE_VAR - interactive: true - - NON_INTERACTIVE_VAR - cmds: - - echo "{{.INTERACTIVE_VAR}} {{.NON_INTERACTIVE_VAR}}" + - echo "{{.VAR1}} {{.VAR2}}" diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index aa67c554e5..0adec5b8c2 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1129,14 +1129,20 @@ This is supported only for string variables. ### Prompting for missing variables interactively -If you want Task to prompt users for missing variables instead of failing, you -can mark a variable as `interactive: true`. When a variable is missing and has -this flag, Task will display an interactive prompt to collect the value. +If you want Task to prompt users for missing required variables instead of +failing, you can enable interactive mode in your `.taskrc.yml`: -For variables with an `enum`, a selection menu is shown. For variables without -an enum, a text input is displayed. +```yaml +# ~/.taskrc.yml +interactive: true +``` + +When enabled, Task will display an interactive prompt for any missing required +variable. For variables with an `enum`, a selection menu is shown. For variables +without an enum, a text input is displayed. ```yaml +# Taskfile.yml version: '3' tasks: @@ -1144,10 +1150,8 @@ tasks: requires: vars: - name: ENVIRONMENT - interactive: true enum: [dev, staging, prod] - - name: VERSION - interactive: true + - VERSION cmds: - echo "Deploying {{.VERSION}} to {{.ENVIRONMENT}}" ``` @@ -1158,6 +1162,8 @@ $ task deploy ❯ dev staging prod +? Enter value for VERSION: 1.0.0 +Deploying 1.0.0 to prod ``` If the variable is already set (via CLI, environment, or Taskfile), no prompt @@ -1168,11 +1174,14 @@ $ task deploy ENVIRONMENT=prod VERSION=1.0.0 Deploying 1.0.0 to prod ``` -::: warning +::: info + +Interactive prompts require a TTY (terminal). Task automatically detects +non-interactive environments like GitHub Actions, GitLab CI, and other CI +pipelines where stdin/stdout are not connected to a terminal. In these cases, +prompts are skipped and missing variables will cause an error as usual. -Interactive prompts require a TTY. In non-interactive environments like CI -pipelines, use `--no-tty` to disable prompts (missing variables will cause an -error as usual), or provide all required variables explicitly. +You can also explicitly disable prompts with `--no-tty` if needed. ::: diff --git a/website/src/docs/reference/cli.md b/website/src/docs/reference/cli.md index 260a8b2a1c..c59315ce35 100644 --- a/website/src/docs/reference/cli.md +++ b/website/src/docs/reference/cli.md @@ -305,7 +305,11 @@ task deploy --yes Disable interactive prompts for missing variables. When a variable is marked with `interactive: true` in the Taskfile and is not provided, Task will error -instead of prompting for input. Useful in CI environments. +instead of prompting for input. + +Note: Task automatically detects non-TTY environments (like CI pipelines) and +disables prompts. This flag is useful when you want to explicitly disable +prompts even when a TTY is available. ```bash task deploy --no-tty diff --git a/website/src/docs/reference/config.md b/website/src/docs/reference/config.md index a0129e8f7a..0e30576800 100644 --- a/website/src/docs/reference/config.md +++ b/website/src/docs/reference/config.md @@ -124,6 +124,20 @@ concurrency: 4 failfast: true ``` +### `interactive` + +- **Type**: `boolean` +- **Default**: `false` +- **Description**: Prompt for missing required variables instead of failing. + When enabled, Task will display an interactive prompt for any missing required + variable. Requires a TTY. Task automatically detects non-TTY environments + (CI pipelines, etc.) and skips prompts. +- **CLI override**: [`--no-tty`](./cli.md#--no-tty) to disable prompts + +```yaml +interactive: true +``` + ## Example Configuration Here's a complete example of a `.taskrc.yml` file with all available options: diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index 9e526fe785..d0227d5da3 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -632,7 +632,7 @@ tasks: #### `requires` - **Type**: `Requires` -- **Description**: Required variables with optional enums and interactive prompting +- **Description**: Required variables with optional enum validation ```yaml tasks: @@ -655,23 +655,10 @@ tasks: cmds: - echo "Deploying to {{.ENVIRONMENT}} with log level {{.LOG_LEVEL}}" - ./deploy.sh - - # Interactive prompts for missing variables - interactive-deploy: - requires: - vars: - - name: ENVIRONMENT - interactive: true - enum: [development, staging, production] - - name: API_KEY - interactive: true - cmds: - - ./deploy.sh ``` -When `interactive: true` is set, Task will prompt the user for the variable value -if it is not already defined. For variables with `enum`, a selection menu is shown. -Use `--no-tty` to disable interactive prompts (useful for CI environments). +See [Prompting for missing variables interactively](/docs/guide#prompting-for-missing-variables-interactively) +for information on enabling interactive prompts for missing required variables. #### `watch` diff --git a/website/src/public/schema-taskrc.json b/website/src/public/schema-taskrc.json index 34c6ed5336..d69b54be11 100644 --- a/website/src/public/schema-taskrc.json +++ b/website/src/public/schema-taskrc.json @@ -70,6 +70,11 @@ "description": "When running tasks in parallel, stop all tasks if one fails.", "type": "boolean", "default": false + }, + "interactive": { + "description": "Prompt for missing required variables instead of failing. Requires a TTY.", + "type": "boolean", + "default": false } }, "additionalProperties": false diff --git a/website/src/public/schema.json b/website/src/public/schema.json index ab0c004caf..e3dfa94589 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -598,12 +598,7 @@ "type": "object", "properties": { "name": { "type": "string" }, - "enum": { "type": "array", "items": { "type": "string" } }, - "interactive": { - "type": "boolean", - "description": "If true, prompt the user for this variable if it is not set", - "default": false - } + "enum": { "type": "array", "items": { "type": "string" } } }, "required": ["name"], "additionalProperties": false From 00f7788c3589f71b363edb5e8880d9ccf26d058f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 12:28:10 +0100 Subject: [PATCH 06/16] wip: add synchronized output to prevent prompt interleaving - Add SyncWriter to synchronize stdout/stderr writes with prompts - Add promptMutex to serialize interactive prompts - Keep rawStdout/rawStderr for BubbleTea to avoid deadlock - Update test Taskfile with nested deps scenario --- executor.go | 9 ++-- internal/output/sync_writer.go | 28 ++++++++++++ requires.go | 11 ++++- setup.go | 11 +++++ testdata/interactive_vars/.taskrc.yml | 1 + testdata/interactive_vars/Taskfile.yml | 63 +++++++++++++++++++++----- 6 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 internal/output/sync_writer.go create mode 100644 testdata/interactive_vars/.taskrc.yml diff --git a/executor.go b/executor.go index 0e909b8772..85516890f9 100644 --- a/executor.go +++ b/executor.go @@ -54,9 +54,11 @@ type ( Failfast bool // I/O - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + rawStdout io.Writer // unwrapped stdout for prompts + rawStderr io.Writer // unwrapped stderr for prompts // Internal Taskfile *ast.Taskfile @@ -71,6 +73,7 @@ type ( fuzzyModel *fuzzy.Model fuzzyModelOnce sync.Once + promptMutex sync.Mutex concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex diff --git a/internal/output/sync_writer.go b/internal/output/sync_writer.go new file mode 100644 index 0000000000..8cddd030cd --- /dev/null +++ b/internal/output/sync_writer.go @@ -0,0 +1,28 @@ +package output + +import ( + "io" + "sync" +) + +// SyncWriter wraps an io.Writer with a mutex to synchronize writes. +// This is used to prevent output from interleaving with interactive prompts. +type SyncWriter struct { + w io.Writer + mu *sync.Mutex +} + +// NewSyncWriter creates a new SyncWriter that uses the provided mutex. +func NewSyncWriter(w io.Writer, mu *sync.Mutex) *SyncWriter { + return &SyncWriter{ + w: w, + mu: mu, + } +} + +// Write implements io.Writer with synchronized access. +func (sw *SyncWriter) Write(p []byte) (n int, err error) { + sw.mu.Lock() + defer sw.mu.Unlock() + return sw.w.Write(p) +} diff --git a/requires.go b/requires.go index d934d3289d..9d4c108994 100644 --- a/requires.go +++ b/requires.go @@ -27,7 +27,16 @@ func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, erro return false, nil } - prompter := prompt.New() + // Lock to prevent multiple parallel prompts from interleaving + e.promptMutex.Lock() + defer e.promptMutex.Unlock() + + // Use raw stderr for prompts to avoid deadlock with SyncWriter + prompter := &prompt.Prompter{ + Stdin: e.Stdin, + Stdout: e.rawStdout, + Stderr: e.rawStderr, + } var prompted bool for _, requiredVar := range t.Requires.Vars { diff --git a/setup.go b/setup.go index 2fc3a6bf1e..32a59c230a 100644 --- a/setup.go +++ b/setup.go @@ -179,6 +179,17 @@ func (e *Executor) setupStdFiles() { if e.Stderr == nil { e.Stderr = os.Stderr } + + // Keep raw references for interactive prompts + e.rawStdout = e.Stdout + e.rawStderr = e.Stderr + + // Wrap with synchronized writers when interactive mode is enabled + // to prevent output from interleaving with prompts + if e.Interactive { + e.Stdout = output.NewSyncWriter(e.Stdout, &e.promptMutex) + e.Stderr = output.NewSyncWriter(e.Stderr, &e.promptMutex) + } } func (e *Executor) setupLogger() { diff --git a/testdata/interactive_vars/.taskrc.yml b/testdata/interactive_vars/.taskrc.yml new file mode 100644 index 0000000000..9f4af05635 --- /dev/null +++ b/testdata/interactive_vars/.taskrc.yml @@ -0,0 +1 @@ +interactive: true diff --git a/testdata/interactive_vars/Taskfile.yml b/testdata/interactive_vars/Taskfile.yml index 2c1e4a85b5..d26dfdc472 100644 --- a/testdata/interactive_vars/Taskfile.yml +++ b/testdata/interactive_vars/Taskfile.yml @@ -1,25 +1,66 @@ version: "3" tasks: - simple: + main: + desc: Main task with nested deps + deps: + - dep-a + - dep-b + cmds: + - echo "Main task done!" + + dep-a: + desc: Dependency A + deps: + - leaf-a1 + - leaf-a2 + cmds: + - echo "Dep A done" + + dep-b: + desc: Dependency B + deps: + - leaf-b1 + - leaf-b2 + cmds: + - echo "Dep B done" + + leaf-a1: + desc: Leaf A1 with enum + requires: + vars: + - name: VAR_A1 + enum: + - alpha + - beta + - gamma + cmds: + - echo "Leaf A1 {{.VAR_A1}}" + + leaf-a2: + desc: Leaf A2 with text requires: vars: - - MY_VAR + - VAR_A2 cmds: - - echo "{{.MY_VAR}}" + - echo "Leaf A2 {{.VAR_A2}}" - with-enum: + leaf-b1: + desc: Leaf B1 with enum requires: vars: - - name: ENV - enum: [dev, staging, prod] + - name: VAR_B1 + enum: + - one + - two + - three cmds: - - echo "{{.ENV}}" + - echo "Leaf B1 {{.VAR_B1}}" - multiple: + leaf-b2: + desc: Leaf B2 with text requires: vars: - - VAR1 - - VAR2 + - VAR_B2 cmds: - - echo "{{.VAR1}} {{.VAR2}}" + - echo "Leaf B2 {{.VAR_B2}}" From 647052aaab05906d209c7d077d4f48ace007c379 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 13:05:39 +0100 Subject: [PATCH 07/16] wip: collect all required vars upfront before execution - Add collectAllRequiredVars to traverse dep tree and find missing vars - Add promptForAllVars to prompt for all vars at once - Store prompted vars on Executor and inject into all RunTask calls - Remove redundant GetTask call in collectAllRequiredVars - Make SyncWriter conditional on TTY presence --- executor.go | 1 + requires.go | 108 +++++++++++++++++++++++++++++++++++++++------------- setup.go | 5 ++- task.go | 39 ++++++++++++------- 4 files changed, 110 insertions(+), 43 deletions(-) diff --git a/executor.go b/executor.go index 85516890f9..e7fbeff991 100644 --- a/executor.go +++ b/executor.go @@ -74,6 +74,7 @@ type ( fuzzyModelOnce sync.Once promptMutex sync.Mutex + promptedVars *ast.Vars // vars collected via interactive prompts concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex diff --git a/requires.go b/requires.go index 9d4c108994..2aeec4300c 100644 --- a/requires.go +++ b/requires.go @@ -9,25 +9,88 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -// promptForInteractiveVars prompts the user for any missing required variables -// and injects them into the call's Vars. It returns true if any variables were -// prompted for (meaning the task needs to be recompiled). -func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, error) { - if t.Requires == nil || len(t.Requires.Vars) == 0 { - return false, nil +// collectAllRequiredVars traverses the dependency tree of all calls and collects +// all required variables that are missing. Returns a deduplicated list. +func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidation, error) { + visited := make(map[string]bool) + varsMap := make(map[string]*ast.VarsWithValidation) + + var collect func(call *Call) error + collect = func(call *Call) error { + // Avoid infinite loops + if visited[call.Task] { + return nil + } + visited[call.Task] = true + + // Compile to resolve variables (also fetches the task) + compiledTask, err := e.FastCompiledTask(call) + if err != nil { + return err + } + + // Collect required vars from this task + if compiledTask.Requires != nil { + for _, v := range compiledTask.Requires.Vars { + // Check if var is already set + if _, ok := compiledTask.Vars.Get(v.Name); !ok { + // Add to map if not already there + if _, exists := varsMap[v.Name]; !exists { + varsMap[v.Name] = v + } + } + } + } + + // Recurse into deps + for _, dep := range compiledTask.Deps { + depCall := &Call{ + Task: dep.Task, + Vars: dep.Vars, + Silent: dep.Silent, + } + if err := collect(depCall); err != nil { + return err + } + } + + return nil + } + + // Collect from all initial calls + for _, call := range calls { + if err := collect(call); err != nil { + return nil, err + } + } + + // Convert map to slice + result := make([]*ast.VarsWithValidation, 0, len(varsMap)) + for _, v := range varsMap { + result = append(result, v) + } + + return result, nil +} + +// promptForAllVars prompts for all the given variables at once and returns +// a Vars object with all the values. +func (e *Executor) promptForAllVars(vars []*ast.VarsWithValidation) (*ast.Vars, error) { + if len(vars) == 0 { + return nil, nil } // Don't prompt if interactive mode is disabled if !e.Interactive { - return false, nil + return nil, nil } // Don't prompt if NoTTY is set or we're not in a terminal if e.NoTTY || (!e.AssumeTerm && !term.IsTerminal()) { - return false, nil + return nil, nil } - // Lock to prevent multiple parallel prompts from interleaving + // Lock to prevent any output during prompting e.promptMutex.Lock() defer e.promptMutex.Unlock() @@ -37,39 +100,30 @@ func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, erro Stdout: e.rawStdout, Stderr: e.rawStderr, } - var prompted bool - for _, requiredVar := range t.Requires.Vars { - // Skip if already set - if _, ok := t.Vars.Get(requiredVar.Name); ok { - continue - } + result := ast.NewVars() + for _, v := range vars { var value string var err error - if len(requiredVar.Enum) > 0 { - value, err = prompter.Select(requiredVar.Name, requiredVar.Enum) + if len(v.Enum) > 0 { + value, err = prompter.Select(v.Name, v.Enum) } else { - value, err = prompter.Text(requiredVar.Name) + value, err = prompter.Text(v.Name) } if err != nil { if errors.Is(err, prompt.ErrCancelled) { - return false, &errors.TaskCancelledByUserError{TaskName: call.Task} + return nil, &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} } - return false, err + return nil, err } - // Inject into call.Vars - if call.Vars == nil { - call.Vars = ast.NewVars() - } - call.Vars.Set(requiredVar.Name, ast.Var{Value: value}) - prompted = true + result.Set(v.Name, ast.Var{Value: value}) } - return prompted, nil + return result, nil } func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { diff --git a/setup.go b/setup.go index 32a59c230a..ba92f77030 100644 --- a/setup.go +++ b/setup.go @@ -19,6 +19,7 @@ import ( "github.com/go-task/task/v3/internal/fsext" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" + "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/internal/version" "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" @@ -184,9 +185,9 @@ func (e *Executor) setupStdFiles() { e.rawStdout = e.Stdout e.rawStderr = e.Stderr - // Wrap with synchronized writers when interactive mode is enabled + // Wrap with synchronized writers when interactive mode is enabled AND we have a TTY // to prevent output from interleaving with prompts - if e.Interactive { + if e.Interactive && !e.NoTTY && (e.AssumeTerm || term.IsTerminal()) { e.Stdout = output.NewSyncWriter(e.Stdout, &e.promptMutex) e.Stderr = output.NewSyncWriter(e.Stderr, &e.promptMutex) } diff --git a/task.go b/task.go index 43a3c9ae82..cd5d3b502c 100644 --- a/task.go +++ b/task.go @@ -73,6 +73,18 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { return nil } + // Collect all required vars upfront and prompt for them all at once + requiredVars, err := e.collectAllRequiredVars(calls) + if err != nil { + return err + } + + // Prompt for all missing vars and store on executor + e.promptedVars, err = e.promptForAllVars(requiredVars) + if err != nil { + return err + } + regularCalls, watchCalls, err := e.splitRegularAndWatchCalls(calls...) if err != nil { return err @@ -120,6 +132,19 @@ func (e *Executor) splitRegularAndWatchCalls(calls ...*Call) (regularCalls []*Ca // RunTask runs a task by its name func (e *Executor) RunTask(ctx context.Context, call *Call) error { + // Inject prompted vars into call if available + if e.promptedVars != nil { + if call.Vars == nil { + call.Vars = ast.NewVars() + } + for name, v := range e.promptedVars.All() { + // Only inject if not already set in call + if _, ok := call.Vars.Get(name); !ok { + call.Vars.Set(name, v) + } + } + } + t, err := e.FastCompiledTask(call) if err != nil { return err @@ -129,20 +154,6 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { return nil } - // Prompt for any interactive variables that are missing - prompted, err := e.promptForInteractiveVars(t, call) - if err != nil { - return err - } - - // Recompile if we prompted for variables - if prompted { - t, err = e.FastCompiledTask(call) - if err != nil { - return err - } - } - if err := e.areTaskRequiredVarsSet(t); err != nil { return err } From bd166ff271bcb66da582118a15b2d1c20cf0da4a Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 13 Dec 2025 13:16:37 +0100 Subject: [PATCH 08/16] fix(interactive): collect vars before checking visited to handle duplicate task calls --- requires.go | 17 +++--- testdata/interactive_vars/Taskfile.yml | 71 ++++++-------------------- 2 files changed, 25 insertions(+), 63 deletions(-) diff --git a/requires.go b/requires.go index 2aeec4300c..a31b0adc53 100644 --- a/requires.go +++ b/requires.go @@ -17,19 +17,13 @@ func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidat var collect func(call *Call) error collect = func(call *Call) error { - // Avoid infinite loops - if visited[call.Task] { - return nil - } - visited[call.Task] = true - - // Compile to resolve variables (also fetches the task) + // Always compile to resolve variables (also fetches the task) compiledTask, err := e.FastCompiledTask(call) if err != nil { return err } - // Collect required vars from this task + // Always collect required vars from this task if compiledTask.Requires != nil { for _, v := range compiledTask.Requires.Vars { // Check if var is already set @@ -42,6 +36,13 @@ func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidat } } + // Only skip recursion if already visited (to avoid infinite loops) + // We already collected the vars above, so we're good + if visited[call.Task] { + return nil + } + visited[call.Task] = true + // Recurse into deps for _, dep := range compiledTask.Deps { depCall := &Call{ diff --git a/testdata/interactive_vars/Taskfile.yml b/testdata/interactive_vars/Taskfile.yml index d26dfdc472..a269a4e20b 100644 --- a/testdata/interactive_vars/Taskfile.yml +++ b/testdata/interactive_vars/Taskfile.yml @@ -1,66 +1,27 @@ version: "3" +# Test case for the visited bug: +# - build is called twice with different vars +# - First call provides TARGET, second doesn't +# - BUG: we should prompt for TARGET but we don't + tasks: main: - desc: Main task with nested deps - deps: - - dep-a - - dep-b - cmds: - - echo "Main task done!" - - dep-a: - desc: Dependency A - deps: - - leaf-a1 - - leaf-a2 - cmds: - - echo "Dep A done" - - dep-b: - desc: Dependency B + desc: Main task that calls build twice with different vars deps: - - leaf-b1 - - leaf-b2 - cmds: - - echo "Dep B done" - - leaf-a1: - desc: Leaf A1 with enum - requires: - vars: - - name: VAR_A1 - enum: - - alpha - - beta - - gamma - cmds: - - echo "Leaf A1 {{.VAR_A1}}" - - leaf-a2: - desc: Leaf A2 with text - requires: - vars: - - VAR_A2 + - task: build + - task: build cmds: - - echo "Leaf A2 {{.VAR_A2}}" + - echo "Main done" - leaf-b1: - desc: Leaf B1 with enum + build: + desc: Build task requiring TARGET requires: vars: - - name: VAR_B1 + - name: TARGET enum: - - one - - two - - three - cmds: - - echo "Leaf B1 {{.VAR_B1}}" - - leaf-b2: - desc: Leaf B2 with text - requires: - vars: - - VAR_B2 + - linux + - windows + - darwin cmds: - - echo "Leaf B2 {{.VAR_B2}}" + - echo "Building for target = {{.TARGET}} (mode={{.MODE}})" From feb97c061c78aabe25253aaedaf004394795acee Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 16:14:52 +0100 Subject: [PATCH 09/16] refactor(interactive): remove sync writer now that prompts are upfront --- executor.go | 9 +++------ internal/output/sync_writer.go | 28 ---------------------------- internal/prompt/prompt.go | 10 ---------- requires.go | 9 ++------- setup.go | 12 ------------ 5 files changed, 5 insertions(+), 63 deletions(-) delete mode 100644 internal/output/sync_writer.go diff --git a/executor.go b/executor.go index e7fbeff991..ddb841bedc 100644 --- a/executor.go +++ b/executor.go @@ -54,11 +54,9 @@ type ( Failfast bool // I/O - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - rawStdout io.Writer // unwrapped stdout for prompts - rawStderr io.Writer // unwrapped stderr for prompts + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer // Internal Taskfile *ast.Taskfile @@ -73,7 +71,6 @@ type ( fuzzyModel *fuzzy.Model fuzzyModelOnce sync.Once - promptMutex sync.Mutex promptedVars *ast.Vars // vars collected via interactive prompts concurrencySemaphore chan struct{} taskCallCount map[string]*int32 diff --git a/internal/output/sync_writer.go b/internal/output/sync_writer.go deleted file mode 100644 index 8cddd030cd..0000000000 --- a/internal/output/sync_writer.go +++ /dev/null @@ -1,28 +0,0 @@ -package output - -import ( - "io" - "sync" -) - -// SyncWriter wraps an io.Writer with a mutex to synchronize writes. -// This is used to prevent output from interleaving with interactive prompts. -type SyncWriter struct { - w io.Writer - mu *sync.Mutex -} - -// NewSyncWriter creates a new SyncWriter that uses the provided mutex. -func NewSyncWriter(w io.Writer, mu *sync.Mutex) *SyncWriter { - return &SyncWriter{ - w: w, - mu: mu, - } -} - -// Write implements io.Writer with synchronized access. -func (sw *SyncWriter) Write(p []byte) (n int, err error) { - sw.mu.Lock() - defer sw.mu.Unlock() - return sw.w.Write(p) -} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index c3c25aab8a..5ced69b1fa 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -3,7 +3,6 @@ package prompt import ( "fmt" "io" - "os" "strings" "github.com/charmbracelet/bubbles/textinput" @@ -31,15 +30,6 @@ type Prompter struct { Stderr io.Writer } -// New creates a new Prompter with default stdin/stdout/stderr -func New() *Prompter { - return &Prompter{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - } -} - // Text prompts the user for a text value func (p *Prompter) Text(varName string) (string, error) { m := newTextModel(varName) diff --git a/requires.go b/requires.go index a31b0adc53..2b30dcae35 100644 --- a/requires.go +++ b/requires.go @@ -91,15 +91,10 @@ func (e *Executor) promptForAllVars(vars []*ast.VarsWithValidation) (*ast.Vars, return nil, nil } - // Lock to prevent any output during prompting - e.promptMutex.Lock() - defer e.promptMutex.Unlock() - - // Use raw stderr for prompts to avoid deadlock with SyncWriter prompter := &prompt.Prompter{ Stdin: e.Stdin, - Stdout: e.rawStdout, - Stderr: e.rawStderr, + Stdout: e.Stdout, + Stderr: e.Stderr, } result := ast.NewVars() diff --git a/setup.go b/setup.go index ba92f77030..2fc3a6bf1e 100644 --- a/setup.go +++ b/setup.go @@ -19,7 +19,6 @@ import ( "github.com/go-task/task/v3/internal/fsext" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" - "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/internal/version" "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" @@ -180,17 +179,6 @@ func (e *Executor) setupStdFiles() { if e.Stderr == nil { e.Stderr = os.Stderr } - - // Keep raw references for interactive prompts - e.rawStdout = e.Stdout - e.rawStderr = e.Stderr - - // Wrap with synchronized writers when interactive mode is enabled AND we have a TTY - // to prevent output from interleaving with prompts - if e.Interactive && !e.NoTTY && (e.AssumeTerm || term.IsTerminal()) { - e.Stdout = output.NewSyncWriter(e.Stdout, &e.promptMutex) - e.Stderr = output.NewSyncWriter(e.Stderr, &e.promptMutex) - } } func (e *Executor) setupLogger() { From 0efebed8b8c5b4d531b4b1dda7a43753e225e0e0 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 16:21:52 +0100 Subject: [PATCH 10/16] refactor(interactive): replace --no-tty flag with --interactive --- completion/fish/task.fish | 2 +- completion/ps/task.ps1 | 2 +- completion/zsh/_task | 2 +- executor.go | 14 -------------- internal/flags/flags.go | 5 +---- requires.go | 28 ++++------------------------ website/src/docs/guide.md | 3 ++- website/src/docs/reference/cli.md | 15 +++++++-------- website/src/docs/reference/config.md | 2 +- 9 files changed, 18 insertions(+), 55 deletions(-) diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 431c410f6f..b7066bb999 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -86,7 +86,7 @@ complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions' complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON' complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON' -complete -c $GO_TASK_PROGNAME -l no-tty -d 'disable interactive prompts' +complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables' complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed" complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output' complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output' diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index fb6fe8ce4c..cb4ff7eb33 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -40,7 +40,7 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock { [CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'), [CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'), [CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'), - [CompletionResult]::new('--no-tty', '--no-tty', [CompletionResultType]::ParameterName, 'disable interactive prompts'), + [CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'), [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'), [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'), [CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'), diff --git a/completion/zsh/_task b/completion/zsh/_task index c0202c551b..6de70e6f08 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -71,7 +71,7 @@ _task() { '(-j --json)'{-j,--json}'[format task list as JSON]' '(--nested)--nested[nest namespaces when listing as JSON]' '(--no-status)--no-status[ignore status when listing as JSON]' - '(--no-tty)--no-tty[disable interactive prompts]' + '(--interactive)--interactive[prompt for missing required variables]' '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' diff --git a/executor.go b/executor.go index ddb841bedc..38eb0d4c0c 100644 --- a/executor.go +++ b/executor.go @@ -43,7 +43,6 @@ type ( DisableFuzzy bool AssumeYes bool AssumeTerm bool // Used for testing - NoTTY bool Interactive bool Dry bool Summary bool @@ -356,19 +355,6 @@ func (o *assumeTermOption) ApplyToExecutor(e *Executor) { e.AssumeTerm = o.assumeTerm } -// WithNoTTY tells the [Executor] to disable interactive prompts for variables. -func WithNoTTY(noTTY bool) ExecutorOption { - return &noTTYOption{noTTY} -} - -type noTTYOption struct { - noTTY bool -} - -func (o *noTTYOption) ApplyToExecutor(e *Executor) { - e.NoTTY = o.noTTY -} - // WithInteractive tells the [Executor] to prompt for missing required variables. func WithInteractive(interactive bool) ExecutorOption { return &interactiveOption{interactive} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 62c899f122..2395e8cc34 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -79,7 +79,6 @@ var ( ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration - NoTTY bool Interactive bool ) @@ -130,7 +129,7 @@ func init() { pflag.BoolVarP(&Silent, "silent", "s", false, "Disables echoing.") pflag.BoolVar(&DisableFuzzy, "disable-fuzzy", getConfig(config, func() *bool { return config.DisableFuzzy }, false), "Disables fuzzy matching for task names.") pflag.BoolVarP(&AssumeYes, "yes", "y", false, "Assume \"yes\" as answer to all prompts.") - pflag.BoolVar(&NoTTY, "no-tty", false, "Disable interactive prompts for variables.") + pflag.BoolVar(&Interactive, "interactive", getConfig(config, func() *bool { return config.Interactive }, false), "Prompt for missing required variables.") pflag.BoolVarP(&Parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.") pflag.BoolVarP(&Dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.") pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.") @@ -146,7 +145,6 @@ func init() { pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.") pflag.BoolVarP(&Failfast, "failfast", "F", getConfig(config, func() *bool { return &config.Failfast }, false), "When running tasks in parallel, stop all tasks if one fails.") pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.") - Interactive = getConfig(config, func() *bool { return config.Interactive }, false) pflag.BoolVar(&Experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.") // Gentle force experiment will override the force flag and add a new force-all flag @@ -256,7 +254,6 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithSilent(Silent), task.WithDisableFuzzy(DisableFuzzy), task.WithAssumeYes(AssumeYes), - task.WithNoTTY(NoTTY), task.WithInteractive(Interactive), task.WithDry(Dry || Status), task.WithSummary(Summary), diff --git a/requires.go b/requires.go index 2b30dcae35..47d9c124d6 100644 --- a/requires.go +++ b/requires.go @@ -9,26 +9,20 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -// collectAllRequiredVars traverses the dependency tree of all calls and collects -// all required variables that are missing. Returns a deduplicated list. func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidation, error) { visited := make(map[string]bool) varsMap := make(map[string]*ast.VarsWithValidation) var collect func(call *Call) error collect = func(call *Call) error { - // Always compile to resolve variables (also fetches the task) compiledTask, err := e.FastCompiledTask(call) if err != nil { return err } - // Always collect required vars from this task if compiledTask.Requires != nil { for _, v := range compiledTask.Requires.Vars { - // Check if var is already set if _, ok := compiledTask.Vars.Get(v.Name); !ok { - // Add to map if not already there if _, exists := varsMap[v.Name]; !exists { varsMap[v.Name] = v } @@ -36,14 +30,12 @@ func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidat } } - // Only skip recursion if already visited (to avoid infinite loops) - // We already collected the vars above, so we're good + // Check visited AFTER collecting vars to handle duplicate task calls with different vars if visited[call.Task] { return nil } visited[call.Task] = true - // Recurse into deps for _, dep := range compiledTask.Deps { depCall := &Call{ Task: dep.Task, @@ -58,14 +50,12 @@ func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidat return nil } - // Collect from all initial calls for _, call := range calls { if err := collect(call); err != nil { return nil, err } } - // Convert map to slice result := make([]*ast.VarsWithValidation, 0, len(varsMap)) for _, v := range varsMap { result = append(result, v) @@ -74,20 +64,12 @@ func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidat return result, nil } -// promptForAllVars prompts for all the given variables at once and returns -// a Vars object with all the values. func (e *Executor) promptForAllVars(vars []*ast.VarsWithValidation) (*ast.Vars, error) { - if len(vars) == 0 { + if len(vars) == 0 || !e.Interactive { return nil, nil } - // Don't prompt if interactive mode is disabled - if !e.Interactive { - return nil, nil - } - - // Don't prompt if NoTTY is set or we're not in a terminal - if e.NoTTY || (!e.AssumeTerm && !term.IsTerminal()) { + if !e.AssumeTerm && !term.IsTerminal() { return nil, nil } @@ -129,8 +111,7 @@ func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { var missingVars []errors.MissingVar for _, requiredVar := range t.Requires.Vars { - _, ok := t.Vars.Get(requiredVar.Name) - if !ok { + if _, ok := t.Vars.Get(requiredVar.Name); !ok { missingVars = append(missingVars, errors.MissingVar{ Name: requiredVar.Name, AllowedValues: requiredVar.Enum, @@ -165,7 +146,6 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { Name: requiredVar.Name, }) } - } if len(notAllowedValuesVars) > 0 { diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index 0adec5b8c2..94a8b4a6cd 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1181,7 +1181,8 @@ non-interactive environments like GitHub Actions, GitLab CI, and other CI pipelines where stdin/stdout are not connected to a terminal. In these cases, prompts are skipped and missing variables will cause an error as usual. -You can also explicitly disable prompts with `--no-tty` if needed. +You can enable prompts from the command line with `--interactive` or by setting +`interactive: true` in your `.taskrc.yml`. ::: diff --git a/website/src/docs/reference/cli.md b/website/src/docs/reference/cli.md index c59315ce35..84617fc4bd 100644 --- a/website/src/docs/reference/cli.md +++ b/website/src/docs/reference/cli.md @@ -301,18 +301,17 @@ Automatically answer "yes" to all prompts. task deploy --yes ``` -#### `--no-tty` +#### `--interactive` -Disable interactive prompts for missing variables. When a variable is marked -with `interactive: true` in the Taskfile and is not provided, Task will error -instead of prompting for input. +Enable interactive prompts for missing required variables. When a required +variable is not provided, Task will prompt for input instead of failing. -Note: Task automatically detects non-TTY environments (like CI pipelines) and -disables prompts. This flag is useful when you want to explicitly disable -prompts even when a TTY is available. +Task automatically detects non-TTY environments (like CI pipelines) and +skips prompts. This flag can also be set in `.taskrc.yml` to enable prompts +by default. ```bash -task deploy --no-tty +task deploy --interactive ``` ## Exit Codes diff --git a/website/src/docs/reference/config.md b/website/src/docs/reference/config.md index 0e30576800..8e518d2fb8 100644 --- a/website/src/docs/reference/config.md +++ b/website/src/docs/reference/config.md @@ -132,7 +132,7 @@ failfast: true When enabled, Task will display an interactive prompt for any missing required variable. Requires a TTY. Task automatically detects non-TTY environments (CI pipelines, etc.) and skips prompts. -- **CLI override**: [`--no-tty`](./cli.md#--no-tty) to disable prompts +- **CLI equivalent**: [`--interactive`](./cli.md#--interactive) ```yaml interactive: true From 7f4d5a5e4aa5014055726bbceb8ed6c07a4fcb1b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 16:54:46 +0100 Subject: [PATCH 11/16] feat(interactive): add just-in-time prompting for sequential task calls --- internal/prompt/prompt.go | 4 +- requires.go | 69 +++++++++++++++ task.go | 13 +++ testdata/interactive_vars/Taskfile.yml | 115 +++++++++++++++++++++---- 4 files changed, 181 insertions(+), 20 deletions(-) diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 5ced69b1fa..8338a5df5d 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -12,9 +12,7 @@ import ( "github.com/go-task/task/v3/errors" ) -var ( - ErrCancelled = errors.New("prompt cancelled") -) +var ErrCancelled = errors.New("prompt cancelled") var ( promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) // cyan bold diff --git a/requires.go b/requires.go index 47d9c124d6..6afd6da3d8 100644 --- a/requires.go +++ b/requires.go @@ -104,6 +104,75 @@ func (e *Executor) promptForAllVars(vars []*ast.VarsWithValidation) (*ast.Vars, return result, nil } +// promptForMissingVars prompts for any required vars that are missing from the task. +// It updates call.Vars with the prompted values and stores them in e.promptedVars for reuse. +// Returns true if any vars were prompted (caller should recompile the task). +func (e *Executor) promptForMissingVars(t *ast.Task, call *Call) (bool, error) { + if !e.Interactive || t.Requires == nil || len(t.Requires.Vars) == 0 { + return false, nil + } + + if !e.AssumeTerm && !term.IsTerminal() { + return false, nil + } + + // Find missing vars + var missing []*ast.VarsWithValidation + for _, v := range t.Requires.Vars { + if _, ok := t.Vars.Get(v.Name); !ok { + // Also check if we already prompted for this var + if e.promptedVars != nil { + if _, ok := e.promptedVars.Get(v.Name); ok { + continue + } + } + missing = append(missing, v) + } + } + + if len(missing) == 0 { + return false, nil + } + + prompter := &prompt.Prompter{ + Stdin: e.Stdin, + Stdout: e.Stdout, + Stderr: e.Stderr, + } + + for _, v := range missing { + var value string + var err error + + if len(v.Enum) > 0 { + value, err = prompter.Select(v.Name, v.Enum) + } else { + value, err = prompter.Text(v.Name) + } + + if err != nil { + if errors.Is(err, prompt.ErrCancelled) { + return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} + } + return false, err + } + + // Add to call.Vars so it's available for recompilation + if call.Vars == nil { + call.Vars = ast.NewVars() + } + call.Vars.Set(v.Name, ast.Var{Value: value}) + + // Store in promptedVars for reuse by other tasks + if e.promptedVars == nil { + e.promptedVars = ast.NewVars() + } + e.promptedVars.Set(v.Name, ast.Var{Value: value}) + } + + return true, nil +} + func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { if t.Requires == nil || len(t.Requires.Vars) == 0 { return nil diff --git a/task.go b/task.go index cd5d3b502c..d0a57da2ed 100644 --- a/task.go +++ b/task.go @@ -154,6 +154,19 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { return nil } + // Prompt for missing required vars (just-in-time for sequential task calls) + prompted, err := e.promptForMissingVars(t, call) + if err != nil { + return err + } + if prompted { + // Recompile with the new vars + t, err = e.FastCompiledTask(call) + if err != nil { + return err + } + } + if err := e.areTaskRequiredVarsSet(t); err != nil { return err } diff --git a/testdata/interactive_vars/Taskfile.yml b/testdata/interactive_vars/Taskfile.yml index a269a4e20b..49f68a2fa1 100644 --- a/testdata/interactive_vars/Taskfile.yml +++ b/testdata/interactive_vars/Taskfile.yml @@ -1,27 +1,108 @@ -version: "3" - -# Test case for the visited bug: -# - build is called twice with different vars -# - First call provides TARGET, second doesn't -# - BUG: we should prompt for TARGET but we don't +version: '3' tasks: - main: - desc: Main task that calls build twice with different vars + # Simple text input prompt + greet: + desc: Greet someone by name + requires: + vars: + - NAME + cmds: + - echo "Hello, {{.NAME}}!" + + # Enum selection (dropdown menu) + deploy: + desc: Deploy to an environment + requires: + vars: + - name: ENVIRONMENT + enum: [dev, staging, prod] + cmds: + - echo "Deploying to {{.ENVIRONMENT}}..." + + # Multiple variables at once + release: + desc: Create a release with version and environment + requires: + vars: + - VERSION + - name: ENVIRONMENT + enum: [dev, staging, prod] + cmds: + - echo "Releasing {{.VERSION}} to {{.ENVIRONMENT}}" + + # Nested dependencies - all prompts happen upfront + full-deploy: + desc: Full deployment pipeline with nested deps deps: - task: build - - task: build + - task: test cmds: - - echo "Main done" + - task: deploy build: - desc: Build task requiring TARGET requires: vars: - - name: TARGET - enum: - - linux - - windows - - darwin + - name: BUILD_MODE + enum: [debug, release] + cmds: + - echo "Building in {{.BUILD_MODE}} mode..." + + test: + requires: + vars: + - name: TEST_SUITE + enum: [unit, integration, e2e, all] + cmds: + - echo "Running {{.TEST_SUITE}} tests..." + + # Variable already set - no prompt shown + greet-world: + desc: Greet the world (no prompt needed) + vars: + NAME: World + requires: + vars: + - NAME + cmds: + - echo "Hello, {{.NAME}}!" + + # Complex scenario with multiple levels + pipeline: + desc: Run the full CI/CD pipeline + cmds: + - task: setup + - task: build + - task: test + - task: deploy + + setup: + requires: + vars: + - PROJECT_NAME + cmds: + - echo "Setting up project {{.PROJECT_NAME}}..." + + # Docker example with multiple selections + docker-build: + desc: Build a Docker image + requires: + vars: + - IMAGE_NAME + - IMAGE_TAG + - name: PLATFORM + enum: [linux/amd64, linux/arm64, linux/arm/v7] + cmds: + - echo "Building {{.IMAGE_NAME}}:{{.IMAGE_TAG}} for {{.PLATFORM}}" + + # Database migration example + db-migrate: + desc: Run database migrations + requires: + vars: + - name: DIRECTION + enum: [up, down] + - name: DATABASE + enum: [postgres, mysql, sqlite] cmds: - - echo "Building for target = {{.TARGET}} (mode={{.MODE}})" + - echo "Running {{.DIRECTION}} migrations on {{.DATABASE}}" From fa92cc14e5e4d30aafb9db4d7ce7151b6036fa65 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 17:17:31 +0100 Subject: [PATCH 12/16] refactor(interactive): simplify prompting functions and naming --- executor.go | 2 +- requires.go | 59 +++++++++++++++++++++++++++-------------------------- task.go | 13 +++--------- 3 files changed, 34 insertions(+), 40 deletions(-) diff --git a/executor.go b/executor.go index 38eb0d4c0c..c3977aad7a 100644 --- a/executor.go +++ b/executor.go @@ -70,7 +70,7 @@ type ( fuzzyModel *fuzzy.Model fuzzyModelOnce sync.Once - promptedVars *ast.Vars // vars collected via interactive prompts + promptedVars *ast.Vars // vars collected via interactive prompts concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex diff --git a/requires.go b/requires.go index 6afd6da3d8..0bc3e86647 100644 --- a/requires.go +++ b/requires.go @@ -9,7 +9,20 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidation, error) { +// promptDepsVars traverses the dependency tree, collects all missing required +// variables, and prompts for them upfront. This is used for deps which execute +// in parallel, so all prompts must happen before execution to avoid interleaving. +// Prompted values are stored in e.promptedVars for injection into task calls. +func (e *Executor) promptDepsVars(calls []*Call) error { + if !e.Interactive { + return nil + } + + if !e.AssumeTerm && !term.IsTerminal() { + return nil + } + + // Collect all missing vars from the dependency tree visited := make(map[string]bool) varsMap := make(map[string]*ast.VarsWithValidation) @@ -52,36 +65,24 @@ func (e *Executor) collectAllRequiredVars(calls []*Call) ([]*ast.VarsWithValidat for _, call := range calls { if err := collect(call); err != nil { - return nil, err + return err } } - result := make([]*ast.VarsWithValidation, 0, len(varsMap)) - for _, v := range varsMap { - result = append(result, v) - } - - return result, nil -} - -func (e *Executor) promptForAllVars(vars []*ast.VarsWithValidation) (*ast.Vars, error) { - if len(vars) == 0 || !e.Interactive { - return nil, nil - } - - if !e.AssumeTerm && !term.IsTerminal() { - return nil, nil + if len(varsMap) == 0 { + return nil } + // Prompt for all collected vars prompter := &prompt.Prompter{ Stdin: e.Stdin, Stdout: e.Stdout, Stderr: e.Stderr, } - result := ast.NewVars() + e.promptedVars = ast.NewVars() - for _, v := range vars { + for _, v := range varsMap { var value string var err error @@ -93,21 +94,21 @@ func (e *Executor) promptForAllVars(vars []*ast.VarsWithValidation) (*ast.Vars, if err != nil { if errors.Is(err, prompt.ErrCancelled) { - return nil, &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} + return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} } - return nil, err + return err } - result.Set(v.Name, ast.Var{Value: value}) + e.promptedVars.Set(v.Name, ast.Var{Value: value}) } - return result, nil + return nil } -// promptForMissingVars prompts for any required vars that are missing from the task. -// It updates call.Vars with the prompted values and stores them in e.promptedVars for reuse. +// promptTaskVars prompts for any missing required vars from a single task. +// Used for sequential task calls (cmds) where we can prompt just-in-time. // Returns true if any vars were prompted (caller should recompile the task). -func (e *Executor) promptForMissingVars(t *ast.Task, call *Call) (bool, error) { +func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { if !e.Interactive || t.Requires == nil || len(t.Requires.Vars) == 0 { return false, nil } @@ -120,7 +121,7 @@ func (e *Executor) promptForMissingVars(t *ast.Task, call *Call) (bool, error) { var missing []*ast.VarsWithValidation for _, v := range t.Requires.Vars { if _, ok := t.Vars.Get(v.Name); !ok { - // Also check if we already prompted for this var + // Skip if already prompted if e.promptedVars != nil { if _, ok := e.promptedVars.Get(v.Name); ok { continue @@ -157,13 +158,13 @@ func (e *Executor) promptForMissingVars(t *ast.Task, call *Call) (bool, error) { return false, err } - // Add to call.Vars so it's available for recompilation + // Add to call.Vars for recompilation if call.Vars == nil { call.Vars = ast.NewVars() } call.Vars.Set(v.Name, ast.Var{Value: value}) - // Store in promptedVars for reuse by other tasks + // Cache for reuse by other tasks if e.promptedVars == nil { e.promptedVars = ast.NewVars() } diff --git a/task.go b/task.go index d0a57da2ed..9eb9d72100 100644 --- a/task.go +++ b/task.go @@ -73,15 +73,8 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { return nil } - // Collect all required vars upfront and prompt for them all at once - requiredVars, err := e.collectAllRequiredVars(calls) - if err != nil { - return err - } - - // Prompt for all missing vars and store on executor - e.promptedVars, err = e.promptForAllVars(requiredVars) - if err != nil { + // Prompt for all required vars from deps upfront (parallel execution) + if err := e.promptDepsVars(calls); err != nil { return err } @@ -155,7 +148,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { } // Prompt for missing required vars (just-in-time for sequential task calls) - prompted, err := e.promptForMissingVars(t, call) + prompted, err := e.promptTaskVars(t, call) if err != nil { return err } From 6ea272d2e7c059dda9d68fae92f24ca9dd0582f2 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 17:25:30 +0100 Subject: [PATCH 13/16] refactor: rename internal/prompt to internal/input --- internal/{prompt => input}/prompt.go | 2 +- requires.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename internal/{prompt => input}/prompt.go (99%) diff --git a/internal/prompt/prompt.go b/internal/input/prompt.go similarity index 99% rename from internal/prompt/prompt.go rename to internal/input/prompt.go index 8338a5df5d..5b71298247 100644 --- a/internal/prompt/prompt.go +++ b/internal/input/prompt.go @@ -1,4 +1,4 @@ -package prompt +package input import ( "fmt" diff --git a/requires.go b/requires.go index 0bc3e86647..b78af9c090 100644 --- a/requires.go +++ b/requires.go @@ -4,7 +4,7 @@ import ( "slices" "github.com/go-task/task/v3/errors" - "github.com/go-task/task/v3/internal/prompt" + "github.com/go-task/task/v3/internal/input" "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/taskfile/ast" ) @@ -74,7 +74,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { } // Prompt for all collected vars - prompter := &prompt.Prompter{ + prompter := &input.Prompter{ Stdin: e.Stdin, Stdout: e.Stdout, Stderr: e.Stderr, @@ -93,7 +93,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { } if err != nil { - if errors.Is(err, prompt.ErrCancelled) { + if errors.Is(err, input.ErrCancelled) { return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} } return err @@ -135,7 +135,7 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { return false, nil } - prompter := &prompt.Prompter{ + prompter := &input.Prompter{ Stdin: e.Stdin, Stdout: e.Stdout, Stderr: e.Stderr, @@ -152,7 +152,7 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { } if err != nil { - if errors.Is(err, prompt.ErrCancelled) { + if errors.Is(err, input.ErrCancelled) { return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} } return false, err From 90061ecbd4b7438d426580bb4cec40d442fc895d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 17:33:55 +0100 Subject: [PATCH 14/16] refactor(interactive): extract Prompt method and reduce duplication --- internal/input/prompt.go | 8 ++++++ requires.go | 59 ++++++++++++---------------------------- 2 files changed, 26 insertions(+), 41 deletions(-) diff --git a/internal/input/prompt.go b/internal/input/prompt.go index 5b71298247..d48b5817a7 100644 --- a/internal/input/prompt.go +++ b/internal/input/prompt.go @@ -76,6 +76,14 @@ func (p *Prompter) Select(varName string, options []string) (string, error) { return model.options[model.cursor], nil } +// Prompt prompts for a variable value, using Select if enum is provided, Text otherwise +func (p *Prompter) Prompt(varName string, enum []string) (string, error) { + if len(enum) > 0 { + return p.Select(varName, enum) + } + return p.Text(varName) +} + // textModel is the Bubble Tea model for text input type textModel struct { varName string diff --git a/requires.go b/requires.go index b78af9c090..736b68739a 100644 --- a/requires.go +++ b/requires.go @@ -9,16 +9,24 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) +func (e *Executor) canPrompt() bool { + return e.Interactive && (e.AssumeTerm || term.IsTerminal()) +} + +func (e *Executor) newPrompter() *input.Prompter { + return &input.Prompter{ + Stdin: e.Stdin, + Stdout: e.Stdout, + Stderr: e.Stderr, + } +} + // promptDepsVars traverses the dependency tree, collects all missing required // variables, and prompts for them upfront. This is used for deps which execute // in parallel, so all prompts must happen before execution to avoid interleaving. // Prompted values are stored in e.promptedVars for injection into task calls. func (e *Executor) promptDepsVars(calls []*Call) error { - if !e.Interactive { - return nil - } - - if !e.AssumeTerm && !term.IsTerminal() { + if !e.canPrompt() { return nil } @@ -73,32 +81,17 @@ func (e *Executor) promptDepsVars(calls []*Call) error { return nil } - // Prompt for all collected vars - prompter := &input.Prompter{ - Stdin: e.Stdin, - Stdout: e.Stdout, - Stderr: e.Stderr, - } - + prompter := e.newPrompter() e.promptedVars = ast.NewVars() for _, v := range varsMap { - var value string - var err error - - if len(v.Enum) > 0 { - value, err = prompter.Select(v.Name, v.Enum) - } else { - value, err = prompter.Text(v.Name) - } - + value, err := prompter.Prompt(v.Name, v.Enum) if err != nil { if errors.Is(err, input.ErrCancelled) { return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} } return err } - e.promptedVars.Set(v.Name, ast.Var{Value: value}) } @@ -109,11 +102,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { // Used for sequential task calls (cmds) where we can prompt just-in-time. // Returns true if any vars were prompted (caller should recompile the task). func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { - if !e.Interactive || t.Requires == nil || len(t.Requires.Vars) == 0 { - return false, nil - } - - if !e.AssumeTerm && !term.IsTerminal() { + if !e.canPrompt() || t.Requires == nil || len(t.Requires.Vars) == 0 { return false, nil } @@ -135,22 +124,10 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { return false, nil } - prompter := &input.Prompter{ - Stdin: e.Stdin, - Stdout: e.Stdout, - Stderr: e.Stderr, - } + prompter := e.newPrompter() for _, v := range missing { - var value string - var err error - - if len(v.Enum) > 0 { - value, err = prompter.Select(v.Name, v.Enum) - } else { - value, err = prompter.Text(v.Name) - } - + value, err := prompter.Prompt(v.Name, v.Enum) if err != nil { if errors.Is(err, input.ErrCancelled) { return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} From f68626cdeea6aed685d5ae4ed9beb076e3a7c029 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 17:37:45 +0100 Subject: [PATCH 15/16] refactor(interactive): extract getMissingRequiredVars helper --- executor.go | 2 +- requires.go | 64 +++++++++++++++++++++++++++-------------------------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/executor.go b/executor.go index c3977aad7a..38eb0d4c0c 100644 --- a/executor.go +++ b/executor.go @@ -70,7 +70,7 @@ type ( fuzzyModel *fuzzy.Model fuzzyModelOnce sync.Once - promptedVars *ast.Vars // vars collected via interactive prompts + promptedVars *ast.Vars // vars collected via interactive prompts concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex diff --git a/requires.go b/requires.go index 736b68739a..7babdb4d13 100644 --- a/requires.go +++ b/requires.go @@ -41,13 +41,9 @@ func (e *Executor) promptDepsVars(calls []*Call) error { return err } - if compiledTask.Requires != nil { - for _, v := range compiledTask.Requires.Vars { - if _, ok := compiledTask.Vars.Get(v.Name); !ok { - if _, exists := varsMap[v.Name]; !exists { - varsMap[v.Name] = v - } - } + for _, v := range getMissingRequiredVars(compiledTask) { + if _, exists := varsMap[v.Name]; !exists { + varsMap[v.Name] = v } } @@ -106,18 +102,15 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { return false, nil } - // Find missing vars + // Find missing vars, excluding already prompted ones var missing []*ast.VarsWithValidation - for _, v := range t.Requires.Vars { - if _, ok := t.Vars.Get(v.Name); !ok { - // Skip if already prompted - if e.promptedVars != nil { - if _, ok := e.promptedVars.Get(v.Name); ok { - continue - } + for _, v := range getMissingRequiredVars(t) { + if e.promptedVars != nil { + if _, ok := e.promptedVars.Get(v.Name); ok { + continue } - missing = append(missing, v) } + missing = append(missing, v) } if len(missing) == 0 { @@ -151,29 +144,38 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { return true, nil } -func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { - if t.Requires == nil || len(t.Requires.Vars) == 0 { +// getMissingRequiredVars returns required vars that are not set in the task's vars. +func getMissingRequiredVars(t *ast.Task) []*ast.VarsWithValidation { + if t.Requires == nil { return nil } - - var missingVars []errors.MissingVar - for _, requiredVar := range t.Requires.Vars { - if _, ok := t.Vars.Get(requiredVar.Name); !ok { - missingVars = append(missingVars, errors.MissingVar{ - Name: requiredVar.Name, - AllowedValues: requiredVar.Enum, - }) + var missing []*ast.VarsWithValidation + for _, v := range t.Requires.Vars { + if _, ok := t.Vars.Get(v.Name); !ok { + missing = append(missing, v) } } + return missing +} + +func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { + missing := getMissingRequiredVars(t) + if len(missing) == 0 { + return nil + } - if len(missingVars) > 0 { - return &errors.TaskMissingRequiredVarsError{ - TaskName: t.Name(), - MissingVars: missingVars, + missingVars := make([]errors.MissingVar, len(missing)) + for i, v := range missing { + missingVars[i] = errors.MissingVar{ + Name: v.Name, + AllowedValues: v.Enum, } } - return nil + return &errors.TaskMissingRequiredVarsError{ + TaskName: t.Name(), + MissingVars: missingVars, + } } func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { From 4d1f26d1f3b98cf661b91542766462b061313f23 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 14 Dec 2025 17:39:24 +0100 Subject: [PATCH 16/16] refactor(input): rename prompt.go to input.go --- internal/input/{prompt.go => input.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/input/{prompt.go => input.go} (100%) diff --git a/internal/input/prompt.go b/internal/input/input.go similarity index 100% rename from internal/input/prompt.go rename to internal/input/input.go