diff --git a/completion/fish/task.fish b/completion/fish/task.fish index c62b2ce3b0..8004f55a5b 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 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 46ef7bea9b..a2697881ed 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('--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 2562a6cf8e..b6daecbd07 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -90,6 +90,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]' + '(--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 03b951a166..d1ce4a679c 100644 --- a/executor.go +++ b/executor.go @@ -44,6 +44,7 @@ type ( DisableFuzzy bool AssumeYes bool AssumeTerm bool // Used for testing + Interactive bool Dry bool Summary bool Parallel bool @@ -70,6 +71,7 @@ type ( fuzzyModel *fuzzy.Model fuzzyModelOnce sync.Once + promptedVars *ast.Vars // vars collected via interactive prompts concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex @@ -367,6 +369,19 @@ func (o *assumeTermOption) ApplyToExecutor(e *Executor) { e.AssumeTerm = o.assumeTerm } +// 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/go.mod b/go.mod index 973d51e068..43638ae04f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.24.6 toolchain go1.25.6 require ( + charm.land/bubbles/v2 v2.0.0-rc.1 + charm.land/bubbletea/v2 v2.0.0-rc.2 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7 github.com/Ladicle/tabwriter v1.0.0 github.com/Masterminds/semver/v3 v3.4.0 github.com/alecthomas/chroma/v2 v2.23.0 @@ -45,6 +48,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect @@ -66,6 +70,15 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 // indirect + github.com/charmbracelet/x/ansi v0.11.1 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.5.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -84,18 +97,23 @@ require ( github.com/klauspost/compress v1.18.2 // 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.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/ulikunitz/xz v0.5.15 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect diff --git a/go.sum b/go.sum index fd7a90237e..05493f2b70 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM= +charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= +charm.land/bubbletea/v2 v2.0.0-rc.2 h1:TdTbUOFzbufDJmSz/3gomL6q+fR6HwfY+P13hXQzD7k= +charm.land/bubbletea/v2 v2.0.0-rc.2/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7 h1:059k1h5vvZ4ASinki9nmBguxu9Rq0UDDSa6q8LOUphk= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= @@ -38,6 +44,8 @@ github.com/alecthomas/chroma/v2 v2.23.0 h1:u/Orux1J0eLuZDeQ44froV8smumheieI0Eofh github.com/alecthomas/chroma/v2 v2.23.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= @@ -76,12 +84,34 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 h1:7Rs87fbKJoIIxsQS8YKJYGYa0tlsDwwb0twQjV1KB+g= +github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc= +github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk= +github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I= +github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -164,14 +194,20 @@ 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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 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/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -182,6 +218,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q= github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +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= @@ -208,6 +246,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/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +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/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= @@ -238,6 +278,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 6019e51d5f..c5671eb824 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -83,6 +83,7 @@ var ( Timeout time.Duration CacheExpiryDuration time.Duration RemoteCacheDir string + Interactive bool ) func init() { @@ -132,6 +133,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(&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.") @@ -281,6 +283,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithSilent(Silent), task.WithDisableFuzzy(DisableFuzzy), task.WithAssumeYes(AssumeYes), + task.WithInteractive(Interactive), task.WithDry(Dry || Status), task.WithSummary(Summary), task.WithParallel(Parallel), diff --git a/internal/input/input.go b/internal/input/input.go new file mode 100644 index 0000000000..50913ac66f --- /dev/null +++ b/internal/input/input.go @@ -0,0 +1,211 @@ +package input + +import ( + "fmt" + "io" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/go-task/task/v3/errors" +) + +var ErrCancelled = errors.New("prompt cancelled") + +var ( + 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 +type Prompter struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// 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 +} + +// 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 + textInput textinput.Model + value string + cancelled bool + done bool +} + +func newTextModel(varName string) textModel { + ti := textinput.New() + ti.Placeholder = "" + ti.CharLimit = 256 + ti.SetWidth(40) + ti.Focus() + + return textModel{ + varName: varName, + textInput: ti, + } +} + +func (m textModel) Init() tea.Cmd { + return tea.Batch(m.textInput.Focus(), textinput.Blink) +} + +func (m textModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.Keystroke() { + case "ctrl+c", "escape": + m.cancelled = true + m.done = true + return m, tea.Quit + case "enter": + 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() tea.View { + if m.done { + return tea.NewView("") + } + + prompt := promptStyle.Render(fmt.Sprintf("? Enter value for %s: ", m.varName)) + return tea.NewView(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.KeyPressMsg: + switch msg.Keystroke() { + case "ctrl+c", "escape": + m.cancelled = true + m.done = true + return m, tea.Quit + case "up", "shift+tab", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "tab", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter": + m.done = true + return m, tea.Quit + } + } + + return m, nil +} + +func (m selectModel) View() tea.View { + if m.done { + return tea.NewView("") + } + + var b strings.Builder + + 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(cursorStyle.Render("❯ ")) + b.WriteString(selectedStyle.Render(opt)) + } else { + b.WriteString(" " + opt) + } + b.WriteString("\n") + } + + b.WriteString(dimStyle.Render(" (↑/↓ to move, enter to select, esc to cancel)")) + + return tea.NewView(b.String()) +} diff --git a/requires.go b/requires.go index 119b073ba2..7babdb4d13 100644 --- a/requires.go +++ b/requires.go @@ -4,35 +4,180 @@ import ( "slices" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/input" + "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/taskfile/ast" ) -func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { - if t.Requires == nil || len(t.Requires.Vars) == 0 { +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.canPrompt() { return nil } - var missingVars []errors.MissingVar - for _, requiredVar := range t.Requires.Vars { - _, ok := t.Vars.Get(requiredVar.Name) - if !ok { - missingVars = append(missingVars, errors.MissingVar{ - Name: requiredVar.Name, - AllowedValues: requiredVar.Enum, - }) + // Collect all missing vars from the dependency tree + visited := make(map[string]bool) + varsMap := make(map[string]*ast.VarsWithValidation) + + var collect func(call *Call) error + collect = func(call *Call) error { + compiledTask, err := e.FastCompiledTask(call) + if err != nil { + return err + } + + for _, v := range getMissingRequiredVars(compiledTask) { + if _, exists := varsMap[v.Name]; !exists { + varsMap[v.Name] = v + } + } + + // Check visited AFTER collecting vars to handle duplicate task calls with different vars + if visited[call.Task] { + return nil + } + visited[call.Task] = true + + 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 } - if len(missingVars) > 0 { - return &errors.TaskMissingRequiredVarsError{ - TaskName: t.Name(), - MissingVars: missingVars, + for _, call := range calls { + if err := collect(call); err != nil { + return err } } + if len(varsMap) == 0 { + return nil + } + + prompter := e.newPrompter() + e.promptedVars = ast.NewVars() + + for _, v := range varsMap { + 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}) + } + return nil } +// 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) promptTaskVars(t *ast.Task, call *Call) (bool, error) { + if !e.canPrompt() || t.Requires == nil || len(t.Requires.Vars) == 0 { + return false, nil + } + + // Find missing vars, excluding already prompted ones + var missing []*ast.VarsWithValidation + for _, v := range getMissingRequiredVars(t) { + 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 := e.newPrompter() + + for _, v := range missing { + value, err := prompter.Prompt(v.Name, v.Enum) + if err != nil { + if errors.Is(err, input.ErrCancelled) { + return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} + } + return false, err + } + + // Add to call.Vars for recompilation + if call.Vars == nil { + call.Vars = ast.NewVars() + } + call.Vars.Set(v.Name, ast.Var{Value: value}) + + // Cache 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 +} + +// 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 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 + } + + missingVars := make([]errors.MissingVar, len(missing)) + for i, v := range missing { + missingVars[i] = errors.MissingVar{ + Name: v.Name, + AllowedValues: v.Enum, + } + } + + return &errors.TaskMissingRequiredVarsError{ + TaskName: t.Name(), + MissingVars: missingVars, + } +} + func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { if t.Requires == nil || len(t.Requires.Vars) == 0 { return nil @@ -50,7 +195,6 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { Name: requiredVar.Name, }) } - } if len(notAllowedValuesVars) > 0 { diff --git a/task.go b/task.go index 87e86d3e12..10365ea731 100644 --- a/task.go +++ b/task.go @@ -74,6 +74,11 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { return nil } + // Prompt for all required vars from deps upfront (parallel execution) + if err := e.promptDepsVars(calls); err != nil { + return err + } + regularCalls, watchCalls, err := e.splitRegularAndWatchCalls(calls...) if err != nil { return err @@ -121,6 +126,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 @@ -141,6 +159,19 @@ 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.promptTaskVars(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/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 12f5524aca..39312d8d2f 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -15,6 +15,7 @@ type TaskRC struct { Color *bool `yaml:"color"` 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"` @@ -59,5 +60,6 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Color = cmp.Or(other.Color, t.Color) 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/.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 new file mode 100644 index 0000000000..49f68a2fa1 --- /dev/null +++ b/testdata/interactive_vars/Taskfile.yml @@ -0,0 +1,108 @@ +version: '3' + +tasks: + # 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: test + cmds: + - task: deploy + + build: + requires: + vars: + - 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 "Running {{.DIRECTION}} migrations on {{.DATABASE}}" diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index d95baf9f9e..6c3eb912bf 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1233,6 +1233,65 @@ This is supported only for string variables. ::: +### Prompting for missing variables interactively + +If you want Task to prompt users for missing required variables instead of +failing, you can enable interactive mode in your `.taskrc.yml`: + +```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: + deploy: + requires: + vars: + - name: ENVIRONMENT + enum: [dev, staging, prod] + - VERSION + cmds: + - echo "Deploying {{.VERSION}} to {{.ENVIRONMENT}}" +``` + +```shell +$ task deploy +? Select value for ENVIRONMENT: +❯ 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 +is shown: + +```shell +$ task deploy ENVIRONMENT=prod VERSION=1.0.0 +Deploying 1.0.0 to prod +``` + +::: 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. + +You can enable prompts from the command line with `--interactive` or by setting +`interactive: true` in your `.taskrc.yml`. + +::: + ## 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..84617fc4bd 100644 --- a/website/src/docs/reference/cli.md +++ b/website/src/docs/reference/cli.md @@ -301,6 +301,19 @@ Automatically answer "yes" to all prompts. task deploy --yes ``` +#### `--interactive` + +Enable interactive prompts for missing required variables. When a required +variable is not provided, Task will prompt for input instead of failing. + +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 --interactive +``` + ## Exit Codes Task uses specific exit codes to indicate different types of errors: diff --git a/website/src/docs/reference/config.md b/website/src/docs/reference/config.md index 04a33a5de6..c804b94012 100644 --- a/website/src/docs/reference/config.md +++ b/website/src/docs/reference/config.md @@ -135,6 +135,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 equivalent**: [`--interactive`](./cli.md#--interactive) + +```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 9e54f95dc0..d0d4286943 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -655,7 +655,7 @@ tasks: #### `requires` - **Type**: `Requires` -- **Description**: Required variables with optional enums +- **Description**: Required variables with optional enum validation ```yaml tasks: @@ -680,6 +680,9 @@ tasks: - ./deploy.sh ``` +See [Prompting for missing variables interactively](/docs/guide#prompting-for-missing-variables-interactively) +for information on enabling interactive prompts for missing required variables. + #### `watch` - **Type**: `bool` diff --git a/website/src/public/schema-taskrc.json b/website/src/public/schema-taskrc.json index 3ca006e213..a41e45ff01 100644 --- a/website/src/public/schema-taskrc.json +++ b/website/src/public/schema-taskrc.json @@ -78,6 +78,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 e4bab495a3..28ae66110b 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -635,7 +635,7 @@ "name": { "type": "string" }, "enum": { "type": "array", "items": { "type": "string" } } }, - "required": ["name", "enum"], + "required": ["name"], "additionalProperties": false } ]