cmd/tailscale: add shell tab-completion

The approach is lifted from cobra: `tailscale completion bash` emits a bash
script for configuring the shell's autocomplete:

    . <( tailscale completion bash )

so that typing:

    tailscale st<TAB>

invokes:

    tailscale completion __complete -- st

RELNOTE=tailscale CLI now supports shell tab-completion

Fixes #3793

Signed-off-by: Paul Scott <paul@tailscale.com>
This commit is contained in:
Paul Scott 2024-02-29 22:56:25 +00:00 committed by Paul Scott
parent 21a0fe1b9b
commit 82394debb7
32 changed files with 2393 additions and 18 deletions

View File

@ -37,7 +37,7 @@ while [ "$#" -gt 1 ]; do
--extra-small) --extra-small)
shift shift
ldflags="$ldflags -w -s" ldflags="$ldflags -w -s"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube" tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion"
;; ;;
--box) --box)
shift shift

View File

@ -22,6 +22,7 @@
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/paths" "tailscale.com/paths"
"tailscale.com/version/distro" "tailscale.com/version/distro"
@ -197,6 +198,7 @@ func newRootCmd() *ffcli.Command {
whoisCmd, whoisCmd,
debugCmd, debugCmd,
driveCmd, driveCmd,
idTokenCmd,
}, },
FlagSet: rootfs, FlagSet: rootfs,
Exec: func(ctx context.Context, args []string) error { Exec: func(ctx context.Context, args []string) error {
@ -206,11 +208,6 @@ func newRootCmd() *ffcli.Command {
return flag.ErrHelp return flag.ErrHelp
}, },
} }
if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands,
idTokenCmd,
)
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology { if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd) rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
@ -222,6 +219,8 @@ func newRootCmd() *ffcli.Command {
} }
return true return true
}) })
ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc)
return rootCmd return rootCmd
} }
@ -303,9 +302,12 @@ func usageFunc(c *ffcli.Command) string {
return usageFuncOpt(c, true) return usageFuncOpt(c, true)
} }
// hidden is the prefix that hides subcommands and flags from --help output when
// found at the start of the subcommand's LongHelp or flag's Usage.
const hidden = "HIDDEN: "
func usageFuncOpt(c *ffcli.Command, withDefaults bool) string { func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
var b strings.Builder var b strings.Builder
const hiddenPrefix = "HIDDEN: "
if c.ShortHelp != "" { if c.ShortHelp != "" {
fmt.Fprintf(&b, "%s\n\n", c.ShortHelp) fmt.Fprintf(&b, "%s\n\n", c.ShortHelp)
@ -319,8 +321,7 @@ func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
} }
fmt.Fprintf(&b, "\n") fmt.Fprintf(&b, "\n")
if c.LongHelp != "" { if help := strings.TrimPrefix(c.LongHelp, hidden); help != "" {
help, _ := strings.CutPrefix(c.LongHelp, hiddenPrefix)
fmt.Fprintf(&b, "%s\n\n", help) fmt.Fprintf(&b, "%s\n\n", help)
} }
@ -328,7 +329,7 @@ func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
fmt.Fprintf(&b, "SUBCOMMANDS\n") fmt.Fprintf(&b, "SUBCOMMANDS\n")
tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
for _, subcommand := range c.Subcommands { for _, subcommand := range c.Subcommands {
if strings.HasPrefix(subcommand.LongHelp, hiddenPrefix) { if strings.HasPrefix(subcommand.LongHelp, hidden) {
continue continue
} }
fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp) fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp)
@ -343,7 +344,7 @@ func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
c.FlagSet.VisitAll(func(f *flag.Flag) { c.FlagSet.VisitAll(func(f *flag.Flag) {
var s string var s string
name, usage := flag.UnquoteUsage(f) name, usage := flag.UnquoteUsage(f)
if strings.HasPrefix(usage, hiddenPrefix) { if strings.HasPrefix(usage, hidden) {
return return
} }
if isBoolFlag(f) { if isBoolFlag(f) {

View File

@ -24,9 +24,9 @@
var configureHostCmd = &ffcli.Command{ var configureHostCmd = &ffcli.Command{
Name: "configure-host", Name: "configure-host",
Exec: runConfigureSynology, Exec: runConfigureSynology,
ShortUsage: "tailscale configure-host", ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage,
ShortHelp: synologyConfigureCmd.ShortHelp, ShortHelp: synologyConfigureCmd.ShortHelp,
LongHelp: synologyConfigureCmd.LongHelp, LongHelp: hidden + synologyConfigureCmd.LongHelp,
FlagSet: (func() *flag.FlagSet { FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("configure-host") fs := newFlagSet("configure-host")
return fs return fs

View File

@ -48,7 +48,8 @@
Name: "debug", Name: "debug",
Exec: runDebug, Exec: runDebug,
ShortUsage: "tailscale debug <debug-flags | subcommand>", ShortUsage: "tailscale debug <debug-flags | subcommand>",
LongHelp: `HIDDEN: "tailscale debug" contains misc debug facilities; it is not a stable interface.`, ShortHelp: "Debug commands",
LongHelp: hidden + `"tailscale debug" contains misc debug facilities; it is not a stable interface.`,
FlagSet: (func() *flag.FlagSet { FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("debug") fs := newFlagSet("debug")
fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME") fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME")

View File

@ -0,0 +1,160 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && !ts_omit_completion
// Package ffcomplete provides shell tab-completion of subcommands, flags and
// arguments for Go programs written with [ffcli].
//
// The shell integration scripts have been extracted from Cobra
// (https://cobra.dev/), whose authors deserve most of the credit for this work.
// These shell completion functions invoke `$0 completion __complete -- ...`
// which is wired up to [Complete].
package ffcomplete
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete/internal"
"tailscale.com/tempfork/spf13/cobra"
)
type compOpts struct {
showFlags bool
showDescs bool
}
func newFS(name string, opts *compOpts) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.BoolVar(&opts.showFlags, "flags", true, "Suggest flag completions with subcommands")
fs.BoolVar(&opts.showDescs, "descs", true, "Include flag, subcommand, and other descriptions in completions")
return fs
}
// Inject adds the 'completion' subcommand to the root command which provide the
// user with shell scripts for calling `completion __command` to provide
// tab-completion suggestions.
//
// root.Name needs to match the command that the user is tab-completing for the
// shell script to work as expected by default.
//
// The hide function is called with the __complete Command instance to provide a
// hook to omit it from the help output, if desired.
func Inject(root *ffcli.Command, hide func(*ffcli.Command), usageFunc func(*ffcli.Command) string) {
var opts compOpts
compFS := newFS("completion", &opts)
completeCmd := &ffcli.Command{
Name: "__complete",
ShortUsage: root.Name + " completion __complete -- <args to complete...>",
ShortHelp: "Tab-completion suggestions for interactive shells",
UsageFunc: usageFunc,
FlagSet: compFS,
Exec: func(ctx context.Context, args []string) error {
// Set up debug logging for the rest of this function call.
if t := os.Getenv("BASH_COMP_DEBUG_FILE"); t != "" {
tf, err := os.OpenFile(t, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("opening debug file: %w", err)
}
defer func(origW io.Writer, origPrefix string, origFlags int) {
log.SetOutput(origW)
log.SetFlags(origFlags)
log.SetPrefix(origPrefix)
tf.Close()
}(log.Writer(), log.Prefix(), log.Flags())
log.SetOutput(tf)
log.SetFlags(log.Lshortfile)
log.SetPrefix("debug: ")
}
// Send back the results to the shell.
words, dir, err := internal.Complete(root, args, opts.showFlags, opts.showDescs)
if err != nil {
dir = ShellCompDirectiveError
}
for _, word := range words {
fmt.Println(word)
}
fmt.Println(":" + strconv.Itoa(int(dir)))
return err
},
}
if hide != nil {
hide(completeCmd)
}
root.Subcommands = append(
root.Subcommands,
&ffcli.Command{
Name: "completion",
ShortUsage: root.Name + " completion <shell> [--flags] [--descs]",
ShortHelp: "Shell tab-completion scripts.",
LongHelp: fmt.Sprintf(cobra.UsageTemplate, root.Name),
// Print help if run without args.
Exec: func(ctx context.Context, args []string) error { return flag.ErrHelp },
// Omit the '__complete' subcommand from the 'completion' help.
UsageFunc: func(c *ffcli.Command) string {
// Filter the subcommands to omit '__complete'.
s := make([]*ffcli.Command, 0, len(c.Subcommands))
for _, sub := range c.Subcommands {
if !strings.HasPrefix(sub.Name, "__") {
s = append(s, sub)
}
}
// Swap in the filtered subcommands list for the rest of the call.
defer func(r []*ffcli.Command) { c.Subcommands = r }(c.Subcommands)
c.Subcommands = s
// Render the usage.
if usageFunc == nil {
return ffcli.DefaultUsageFunc(c)
}
return usageFunc(c)
},
Subcommands: append(
scriptCmds(root, usageFunc),
completeCmd,
),
},
)
}
// Flag registers a completion function for the flag in fs with given name.
// comp will always called with a 1-element slice.
//
// comp will be called to return suggestions when the user tries to tab-complete
// '--name=<TAB>' or '--name <TAB>' for the commands using fs.
func Flag(fs *flag.FlagSet, name string, comp CompleteFunc) {
f := fs.Lookup(name)
if f == nil {
panic(fmt.Errorf("ffcomplete.Flag: flag %s not found", name))
}
if internal.CompleteFlags == nil {
internal.CompleteFlags = make(map[*flag.Flag]CompleteFunc)
}
internal.CompleteFlags[f] = comp
}
// Args registers a completion function for the args of cmd.
//
// comp will be called to return suggestions when the user tries to tab-complete
// `prog <TAB>` or `prog subcmd arg1 <TAB>`, for example.
func Args(cmd *ffcli.Command, comp CompleteFunc) {
if internal.CompleteCmds == nil {
internal.CompleteCmds = make(map[*ffcli.Command]CompleteFunc)
}
internal.CompleteCmds[cmd] = comp
}

View File

@ -0,0 +1,17 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && ts_omit_completion
package ffcomplete
import (
"flag"
"github.com/peterbourgon/ff/v3/ffcli"
)
func Inject(root *ffcli.Command, hide func(*ffcli.Command), usageFunc func(*ffcli.Command) string) {}
func Flag(fs *flag.FlagSet, name string, comp CompleteFunc) {}
func Args(cmd *ffcli.Command, comp CompleteFunc) *ffcli.Command { return cmd }

View File

@ -0,0 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ffcomplete
import (
"strings"
"tailscale.com/cmd/tailscale/cli/ffcomplete/internal"
"tailscale.com/tempfork/spf13/cobra"
)
type ShellCompDirective = cobra.ShellCompDirective
const (
ShellCompDirectiveError = cobra.ShellCompDirectiveError
ShellCompDirectiveNoSpace = cobra.ShellCompDirectiveNoSpace
ShellCompDirectiveNoFileComp = cobra.ShellCompDirectiveNoFileComp
ShellCompDirectiveFilterFileExt = cobra.ShellCompDirectiveFilterFileExt
ShellCompDirectiveFilterDirs = cobra.ShellCompDirectiveFilterDirs
ShellCompDirectiveKeepOrder = cobra.ShellCompDirectiveKeepOrder
ShellCompDirectiveDefault = cobra.ShellCompDirectiveDefault
)
// CompleteFunc is used to return tab-completion suggestions to the user as they
// are typing command-line instructions. It returns the list of things to
// suggest and an additional directive to the shell about what extra
// functionality to enable.
type CompleteFunc = internal.CompleteFunc
// LastArg returns the last element of args, or the empty string if args is
// empty.
func LastArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[len(args)-1]
}
// Fixed returns a CompleteFunc which suggests the given words.
func Fixed(words ...string) CompleteFunc {
return func(args []string) ([]string, cobra.ShellCompDirective, error) {
match := LastArg(args)
matches := make([]string, 0, len(words))
for _, word := range words {
if strings.HasPrefix(word, match) {
matches = append(matches, word)
}
}
return matches, cobra.ShellCompDirectiveNoFileComp, nil
}
}
// FilesWithExtensions returns a CompleteFunc that tells the shell to limit file
// suggestions to those with the given extensions.
func FilesWithExtensions(exts ...string) CompleteFunc {
return func(args []string) ([]string, cobra.ShellCompDirective, error) {
return exts, cobra.ShellCompDirectiveFilterFileExt, nil
}
}

View File

@ -0,0 +1,256 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package internal
import (
"flag"
"fmt"
"strings"
"github.com/peterbourgon/ff/v3"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/tempfork/spf13/cobra"
)
var (
CompleteCmds map[*ffcli.Command]CompleteFunc
CompleteFlags map[*flag.Flag]CompleteFunc
)
type CompleteFunc func([]string) ([]string, cobra.ShellCompDirective, error)
// Complete returns the autocomplete suggestions for the root program and args.
//
// The returned words do not necessarily need to be prefixed with the last arg
// which is being completed. For example, '--bool-flag=' will have completions
// 'true' and 'false'.
//
// "HIDDEN: " is trimmed from the start of Flag Usage's.
func Complete(root *ffcli.Command, args []string, startFlags, descs bool) (words []string, dir cobra.ShellCompDirective, err error) {
// Explicitly log panics.
defer func() {
if r := recover(); r != nil {
if rerr, ok := err.(error); ok {
err = fmt.Errorf("panic: %w", rerr)
} else {
err = fmt.Errorf("panic: %v", r)
}
}
}()
// Set up the arguments.
if len(args) == 0 {
args = []string{""}
}
// Completion criteria.
completeArg := args[len(args)-1]
args = args[:len(args)-1]
emitFlag := startFlags || strings.HasPrefix(completeArg, "-")
emitArgs := true
// Traverse the command-tree to find the cmd command whose
// subcommand, flags, or arguments are being completed.
cmd := root
walk:
for {
// Ensure there's a flagset with ContinueOnError set.
if cmd.FlagSet == nil {
cmd.FlagSet = flag.NewFlagSet(cmd.Name, flag.ContinueOnError)
}
cmd.FlagSet.Init(cmd.FlagSet.Name(), flag.ContinueOnError)
// Manually split the args so we know when we're completing flags/args.
flagArgs, argArgs, flagNeedingValue := splitFlagArgs(cmd.FlagSet, args)
if flagNeedingValue != "" {
completeArg = flagNeedingValue + "=" + completeArg
emitFlag = true
}
args = argArgs
// Parse the flags.
err := ff.Parse(cmd.FlagSet, flagArgs, cmd.Options...)
if err != nil {
return nil, 0, fmt.Errorf("%s flag parsing: %w", cmd.Name, err)
}
if cmd.FlagSet.NArg() > 0 {
// This shouldn't happen if splitFlagArgs is accurately finding the
// split between flags and args.
_ = false
}
if len(args) == 0 {
break
}
// Check if the first argument is actually a subcommand.
for _, sub := range cmd.Subcommands {
if strings.EqualFold(sub.Name, args[0]) {
args = args[1:]
cmd = sub
continue walk
}
}
break
}
if len(args) > 0 {
emitFlag = false
}
// Complete '-flag=...'. If the args ended with '-flag ...' we will have
// rewritten to '-flag=...' by now.
if emitFlag && strings.HasPrefix(completeArg, "-") && strings.Contains(completeArg, "=") {
// Don't complete '-flag' later on as the
// flag name is terminated by a '='.
emitFlag = false
emitArgs = false
dashFlag, completeVal, _ := strings.Cut(completeArg, "=")
_, f := cutDash(dashFlag)
flag := cmd.FlagSet.Lookup(f)
if flag != nil {
if comp := CompleteFlags[flag]; comp != nil {
// Complete custom flag values.
var err error
words, dir, err = comp([]string{completeVal})
if err != nil {
return nil, 0, fmt.Errorf("completing %s flag %s: %w", cmd.Name, flag.Name, err)
}
} else if isBoolFlag(flag) {
// Complete true/false.
for _, vals := range [][]string{
{"true", "TRUE", "True", "1"},
{"false", "FALSE", "False", "0"},
} {
for _, val := range vals {
if strings.HasPrefix(val, completeVal) {
words = append(words, val)
break
}
}
}
}
}
}
// Complete '-flag...'.
if emitFlag {
used := make(map[string]struct{})
cmd.FlagSet.Visit(func(f *flag.Flag) {
used[f.Name] = struct{}{}
})
cd, cf := cutDash(completeArg)
cmd.FlagSet.VisitAll(func(f *flag.Flag) {
if !strings.HasPrefix(f.Name, cf) {
return
}
// Skip flags already set by the user.
if _, seen := used[f.Name]; seen {
return
}
// Suggest single-dash '-v' for single-char flags and
// double-dash '--verbose' for longer.
d := cd
if (d == "" || d == "-") && cf == "" && len(f.Name) > 1 {
d = "--"
}
if descs {
_, usage := flag.UnquoteUsage(f)
usage = strings.TrimPrefix(usage, "HIDDEN: ")
if usage != "" {
words = append(words, d+f.Name+"\t"+usage)
return
}
}
words = append(words, d+f.Name)
})
}
if emitArgs {
// Complete 'sub...'.
for _, sub := range cmd.Subcommands {
if strings.HasPrefix(sub.Name, completeArg) {
if descs {
if sub.ShortHelp != "" {
words = append(words, sub.Name+"\t"+sub.ShortHelp)
continue
}
}
words = append(words, sub.Name)
}
}
// Complete custom args.
if comp := CompleteCmds[cmd]; comp != nil {
w, d, err := comp(append(args, completeArg))
if err != nil {
return nil, 0, fmt.Errorf("completing %s args: %w", cmd.Name, err)
}
dir = d
words = append(words, w...)
}
}
// Strip any descriptions if they were suppressed.
if !descs {
for i := range words {
words[i], _, _ = strings.Cut(words[i], "\t")
}
}
return words, dir, nil
}
// splitFlagArgs separates a list of command-line arguments into arguments
// comprising flags and their values, preceding arguments to be passed to the
// command. This follows the stdlib 'flag' parsing conventions. If the final
// argument is a flag name which takes a value but has no value specified, it is
// omitted from flagArgs and argArgs and instead returned in needValue.
func splitFlagArgs(fs *flag.FlagSet, args []string) (flagArgs, argArgs []string, flagNeedingValue string) {
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
return args[:i], args[i+1:], ""
}
d, f := cutDash(a)
if d == "" {
return args[:i], args[i:], ""
}
if strings.Contains(f, "=") {
continue
}
flag := fs.Lookup(f)
if flag == nil {
return args[:i], args[i:], ""
}
if isBoolFlag(flag) {
continue
}
// Consume an extra argument for the flag value.
if i == len(args)-1 {
return args[:i], nil, args[i]
}
i++
}
return args, nil, ""
}
func cutDash(s string) (dashes, flag string) {
if strings.HasPrefix(s, "-") {
if strings.HasPrefix(s[1:], "-") {
return "--", s[2:]
}
return "-", s[1:]
}
return "", s
}
func isBoolFlag(f *flag.Flag) bool {
bf, ok := f.Value.(interface {
IsBoolFlag() bool
})
return ok && bf.IsBoolFlag()
}

View File

@ -0,0 +1,219 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package internal_test
import (
_ "embed"
"flag"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/cmd/tailscale/cli/ffcomplete/internal"
)
func newFlagSet(name string, errh flag.ErrorHandling, flags func(fs *flag.FlagSet)) *flag.FlagSet {
fs := flag.NewFlagSet(name, errh)
if flags != nil {
flags(fs)
}
return fs
}
func TestComplete(t *testing.T) {
t.Parallel()
// Build our test program in testdata.
root := &ffcli.Command{
Name: "prog",
FlagSet: newFlagSet("prog", flag.ContinueOnError, func(fs *flag.FlagSet) {
fs.Bool("v", false, "verbose")
fs.Bool("root-bool", false, "root `bool`")
fs.String("root-str", "", "some `text`")
}),
Subcommands: []*ffcli.Command{
{
Name: "debug",
ShortHelp: "Debug data",
FlagSet: newFlagSet("prog debug", flag.ExitOnError, func(fs *flag.FlagSet) {
fs.String("cpu-profile", "", "write cpu profile to `file`")
fs.Bool("debug-bool", false, "debug bool")
fs.Int("level", 0, "a number")
fs.String("enum", "", "a flag that takes several specific values")
ffcomplete.Flag(fs, "enum", ffcomplete.Fixed("alpha", "beta", "charlie"))
}),
},
func() *ffcli.Command {
cmd := &ffcli.Command{
Name: "ping",
FlagSet: newFlagSet("prog ping", flag.ContinueOnError, func(fs *flag.FlagSet) {
fs.String("until", "", "when pinging should end")
ffcomplete.Flag(fs, "until", ffcomplete.Fixed("forever", "direct"))
}),
}
ffcomplete.Args(cmd, ffcomplete.Fixed("jupiter", "neptune", "venus"))
return cmd
}(),
},
}
tests := []struct {
args []string
showFlags bool
showDescs bool
wantComp []string
wantDir ffcomplete.ShellCompDirective
}{
{
args: []string{"deb"},
wantComp: []string{"debug"},
},
{
args: []string{"deb"},
showDescs: true,
wantComp: []string{"debug\tDebug data"},
},
{
args: []string{"-"},
wantComp: []string{"--root-bool", "--root-str", "-v"},
},
{
args: []string{"--"},
wantComp: []string{"--root-bool", "--root-str", "--v"},
},
{
args: []string{"-r"},
wantComp: []string{"-root-bool", "-root-str"},
},
{
args: []string{"--r"},
wantComp: []string{"--root-bool", "--root-str"},
},
{
args: []string{"--root-str=s", "--r"},
wantComp: []string{"--root-bool"}, // omits --root-str which is already set
},
{
// '--' disables flag parsing, so we shouldn't suggest flags.
args: []string{"--", "--root"},
wantComp: nil,
},
{
// '--' is used as the value of '--root-str'.
args: []string{"--root-str", "--", "--r"},
wantComp: []string{"--root-bool"},
},
{
// '--' here is a flag value, so doesn't disable flag parsing.
args: []string{"--root-str", "--", "--root"},
wantComp: []string{"--root-bool"},
},
{
// Equivalent to '--root-str=-- -- --r' meaning '--r' is not
// a flag because it's preceded by a '--' argument:
// https://go.dev/play/p/UCtftQqVhOD.
args: []string{"--root-str", "--", "--", "--r"},
wantComp: nil,
},
{
args: []string{"--root-bool="},
wantComp: []string{"true", "false"},
},
{
args: []string{"--root-bool=t"},
wantComp: []string{"true"},
},
{
args: []string{"--root-bool=T"},
wantComp: []string{"TRUE"},
},
{
args: []string{"debug", "--de"},
wantComp: []string{"--debug-bool"},
},
{
args: []string{"debug", "--enum="},
wantComp: []string{"alpha", "beta", "charlie"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"debug", "--enum=al"},
wantComp: []string{"alpha"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"debug", "--level", ""},
wantComp: nil,
},
{
args: []string{"debug", "--enum", "b"},
wantComp: []string{"beta"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"debug", "--enum", "al"},
wantComp: []string{"alpha"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", ""},
showFlags: true,
wantComp: []string{"--until", "jupiter", "neptune", "venus"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", ""},
showFlags: true,
showDescs: true,
wantComp: []string{
"--until\twhen pinging should end",
"jupiter",
"neptune",
"venus",
},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", ""},
wantComp: []string{"jupiter", "neptune", "venus"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", "j"},
wantComp: []string{"jupiter"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
}
// Run the tests.
for _, test := range tests {
test := test
name := strings.Join(test.args, "␣")
if test.showFlags {
name += "+flags"
}
if test.showDescs {
name += "+descs"
}
t.Run(name, func(t *testing.T) {
// Capture the binary
complete, dir, err := internal.Complete(root, test.args, test.showFlags, test.showDescs)
if err != nil {
t.Fatalf("completion error: %s", err)
}
// Test the results match our expectation.
if test.wantComp != nil {
if diff := cmp.Diff(test.wantComp, complete); diff != "" {
t.Errorf("unexpected completion directives (-want +got):\n%s", diff)
}
}
if test.wantDir != dir {
t.Errorf("got shell completion directive %[1]d (%[1]s), want %[2]d (%[2]s)", dir, test.wantDir)
}
})
}
}

View File

@ -0,0 +1,85 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && !ts_omit_completion && !ts_omit_completion_scripts
package ffcomplete
import (
"context"
"flag"
"os"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/tempfork/spf13/cobra"
)
func compCmd(fs *flag.FlagSet) string {
var s strings.Builder
s.WriteString("completion __complete")
fs.VisitAll(func(f *flag.Flag) {
s.WriteString(" --")
s.WriteString(f.Name)
s.WriteString("=")
s.WriteString(f.Value.String())
})
s.WriteString(" --")
return s.String()
}
func scriptCmds(root *ffcli.Command, usageFunc func(*ffcli.Command) string) []*ffcli.Command {
nameForVar := root.Name
nameForVar = strings.ReplaceAll(nameForVar, "-", "_")
nameForVar = strings.ReplaceAll(nameForVar, ":", "_")
var (
bashFS = newFS("bash", &compOpts{})
zshFS = newFS("zsh", &compOpts{})
fishFS = newFS("fish", &compOpts{})
pwshFS = newFS("powershell", &compOpts{})
)
return []*ffcli.Command{
{
Name: "bash",
ShortHelp: "Generate bash shell completion script",
ShortUsage: ". <( " + root.Name + " completion bash )",
UsageFunc: usageFunc,
FlagSet: bashFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptBash(os.Stdout, root.Name, compCmd(bashFS), nameForVar)
},
},
{
Name: "zsh",
ShortHelp: "Generate zsh shell completion script",
ShortUsage: ". <( " + root.Name + " completion zsh )",
UsageFunc: usageFunc,
FlagSet: zshFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptZsh(os.Stdout, root.Name, compCmd(zshFS), nameForVar)
},
},
{
Name: "fish",
ShortHelp: "Generate fish shell completion script",
ShortUsage: root.Name + " completion fish | source",
UsageFunc: usageFunc,
FlagSet: fishFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptFish(os.Stdout, root.Name, compCmd(fishFS), nameForVar)
},
},
{
Name: "powershell",
ShortHelp: "Generate powershell completion script",
ShortUsage: root.Name + " completion powershell | Out-String | Invoke-Expression",
UsageFunc: usageFunc,
FlagSet: pwshFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptPowershell(os.Stdout, root.Name, compCmd(pwshFS), nameForVar)
},
},
}
}

View File

@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && !ts_omit_completion && ts_omit_completion_scripts
package ffcomplete
import "github.com/peterbourgon/ff/v3/ffcli"
func scriptCmds(root *ffcli.Command, usageFunc func(*ffcli.Command) string) []*ffcli.Command {
return nil
}

View File

@ -26,6 +26,7 @@
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"tailscale.com/client/tailscale/apitype" "tailscale.com/client/tailscale/apitype"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/net/tsaddr" "tailscale.com/net/tsaddr"
"tailscale.com/syncs" "tailscale.com/syncs"
@ -418,6 +419,7 @@ func (v *onConflict) Set(s string) error {
skip: skip conflicting files: leave them in the taildrop inbox and print an error. get any non-conflicting files skip: skip conflicting files: leave them in the taildrop inbox and print an error. get any non-conflicting files
overwrite: overwrite existing file overwrite: overwrite existing file
rename: write to a new number-suffixed filename`) rename: write to a new number-suffixed filename`)
ffcomplete.Flag(fs, "conflict", ffcomplete.Fixed("skip", "overwrite", "rename"))
return fs return fs
})(), })(),
} }

View File

@ -8,16 +8,21 @@
"errors" "errors"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
) )
var idTokenCmd = &ffcli.Command{ var idTokenCmd = &ffcli.Command{
Name: "id-token", Name: "id-token",
ShortUsage: "tailscale id-token <aud>", ShortUsage: "tailscale id-token <aud>",
ShortHelp: "Fetch an OIDC id-token for the Tailscale machine", ShortHelp: "Fetch an OIDC id-token for the Tailscale machine",
LongHelp: hidden,
Exec: runIDToken, Exec: runIDToken,
} }
func runIDToken(ctx context.Context, args []string) error { func runIDToken(ctx context.Context, args []string) error {
if !envknob.UseWIPCode() {
return errors.New("tailscale id-token: works-in-progress require TAILSCALE_USE_WIP_CODE=1 envvar")
}
if len(args) != 1 { if len(args) != 1 {
return errors.New("usage: tailscale id-token <aud>") return errors.New("usage: tailscale id-token <aud>")
} }

View File

@ -10,8 +10,10 @@
"io" "io"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
) )
var ncCmd = &ffcli.Command{ var ncCmd = &ffcli.Command{
@ -21,6 +23,27 @@
Exec: runNC, Exec: runNC,
} }
func init() {
ffcomplete.Args(ncCmd, func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
if len(args) > 1 {
return nil, ffcomplete.ShellCompDirectiveNoFileComp, nil
}
return completeHostOrIP(ffcomplete.LastArg(args))
})
}
func completeHostOrIP(arg string) ([]string, ffcomplete.ShellCompDirective, error) {
st, err := localClient.Status(context.Background())
if err != nil {
return nil, 0, err
}
nodes := make([]string, 0, len(st.Peer))
for _, node := range st.Peer {
nodes = append(nodes, strings.TrimSuffix(node.DNSName, "."))
}
return nodes, ffcomplete.ShellCompDirectiveNoFileComp, nil
}
func runNC(ctx context.Context, args []string) error { func runNC(ctx context.Context, args []string) error {
st, err := localClient.Status(ctx) st, err := localClient.Status(ctx)
if err != nil { if err != nil {

View File

@ -17,6 +17,7 @@
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@ -59,6 +60,15 @@
})(), })(),
} }
func init() {
ffcomplete.Args(pingCmd, func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
if len(args) > 1 {
return nil, ffcomplete.ShellCompDirectiveNoFileComp, nil
}
return completeHostOrIP(ffcomplete.LastArg(args))
})
}
var pingArgs struct { var pingArgs struct {
num int num int
size int size int

View File

@ -10,10 +10,12 @@
"fmt" "fmt"
"net/netip" "net/netip"
"os/exec" "os/exec"
"strings"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/web" "tailscale.com/client/web"
"tailscale.com/clientupdate" "tailscale.com/clientupdate"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/net/netutil" "tailscale.com/net/netutil"
"tailscale.com/net/tsaddr" "tailscale.com/net/tsaddr"
@ -74,9 +76,24 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
setf.BoolVar(&setArgs.advertiseConnector, "advertise-connector", false, "offer to be an app connector for domain specific internet traffic for the tailnet") setf.BoolVar(&setArgs.advertiseConnector, "advertise-connector", false, "offer to be an app connector for domain specific internet traffic for the tailnet")
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates") setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates")
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version") setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version")
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information") setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, hidden+"allow management plane to gather device posture information")
setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252") setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252")
ffcomplete.Flag(setf, "exit-node", func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
st, err := localClient.Status(context.Background())
if err != nil {
return nil, 0, err
}
nodes := make([]string, 0, len(st.Peer))
for _, node := range st.Peer {
if !node.ExitNodeOption {
continue
}
nodes = append(nodes, strings.TrimSuffix(node.DNSName, "."))
}
return nodes, ffcomplete.ShellCompDirectiveNoFileComp, nil
})
if safesocket.GOOSUsesPeerCreds(goos) { if safesocket.GOOSUsesPeerCreds(goos) {
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
} }

View File

@ -13,6 +13,7 @@
"time" "time"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/ipn" "tailscale.com/ipn"
) )
@ -35,6 +36,34 @@
Exec: switchProfile, Exec: switchProfile,
} }
func init() {
ffcomplete.Args(switchCmd, func(s []string) (words []string, dir ffcomplete.ShellCompDirective, err error) {
_, all, err := localClient.ProfileStatus(context.Background())
if err != nil {
return nil, 0, err
}
seen := make(map[string]bool, 3*len(all))
wordfns := []func(prof ipn.LoginProfile) string{
func(prof ipn.LoginProfile) string { return string(prof.ID) },
func(prof ipn.LoginProfile) string { return prof.NetworkProfile.DomainName },
func(prof ipn.LoginProfile) string { return prof.Name },
}
for _, wordfn := range wordfns {
for _, prof := range all {
word := wordfn(prof)
if seen[word] {
continue
}
seen[word] = true
words = append(words, fmt.Sprintf("%s\tid: %s, tailnet: %s, account: %s", word, prof.ID, prof.NetworkProfile.DomainName, prof.Name))
}
}
return words, ffcomplete.ShellCompDirectiveNoFileComp, nil
})
}
var switchArgs struct { var switchArgs struct {
list bool list bool
} }

View File

@ -105,7 +105,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server") upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes") upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes")
upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel") upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "HIDDEN: install host routes to other Tailscale nodes") upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, hidden+"install host routes to other Tailscale nodes")
upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node") upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node")
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node") upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")

View File

@ -34,8 +34,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
github.com/miekg/dns from tailscale.com/net/dns/recursive github.com/miekg/dns from tailscale.com/net/dns/recursive
💣 github.com/mitchellh/go-ps from tailscale.com/cmd/tailscale/cli+ 💣 github.com/mitchellh/go-ps from tailscale.com/cmd/tailscale/cli+
github.com/peterbourgon/ff/v3 from github.com/peterbourgon/ff/v3/ffcli github.com/peterbourgon/ff/v3 from github.com/peterbourgon/ff/v3/ffcli+
github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli+
github.com/peterbourgon/ff/v3/internal from github.com/peterbourgon/ff/v3 github.com/peterbourgon/ff/v3/internal from github.com/peterbourgon/ff/v3
github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli
github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+ github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+
@ -78,6 +78,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/clientupdate from tailscale.com/client/web+ tailscale.com/clientupdate from tailscale.com/client/web+
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscale/cli/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
tailscale.com/control/controlbase from tailscale.com/control/controlhttp tailscale.com/control/controlbase from tailscale.com/control/controlhttp
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
tailscale.com/control/controlknobs from tailscale.com/net/portmapper tailscale.com/control/controlknobs from tailscale.com/net/portmapper
@ -119,6 +121,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+ 💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/syncs from tailscale.com/cmd/tailscale/cli+ tailscale.com/syncs from tailscale.com/cmd/tailscale/cli+
tailscale.com/tailcfg from tailscale.com/client/tailscale+ tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tempfork/spf13/cobra from tailscale.com/cmd/tailscale/cli/ffcomplete+
tailscale.com/tka from tailscale.com/client/tailscale+ tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/control/controlhttp+ tailscale.com/tstime from tailscale.com/control/controlhttp+

View File

@ -0,0 +1,174 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.

View File

@ -0,0 +1,10 @@
# github.com/spf13/cobra
This package contains a copy of the Apache 2.0-licensed shell scripts that Cobra
uses to integrate tab-completion into bash, zsh, fish and powershell, and the
constants that interface with them. We are re-using these scripts to implement
similar tab-completion for ffcli and the standard library flag package.
The shell scripts were Go constants in the Cobra code, but we have extracted
them into separate files to facilitate gzipping them, and have removed the
activeHelp functionality from them.

View File

@ -0,0 +1,139 @@
// Copyright 2013-2023 The Cobra Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package cobra contains shell scripts and constants copied from
// https://github.com/spf13/cobra for use in our own shell tab-completion logic.
package cobra
import (
"fmt"
"strings"
)
// ShellCompDirective is a bit map representing the different behaviors the shell
// can be instructed to have once completions have been provided.
type ShellCompDirective int
const (
// ShellCompDirectiveError indicates an error occurred and completions should be ignored.
ShellCompDirectiveError ShellCompDirective = 1 << iota
// ShellCompDirectiveNoSpace indicates that the shell should not add a space
// after the completion even if there is a single completion provided.
ShellCompDirectiveNoSpace
// ShellCompDirectiveNoFileComp indicates that the shell should not provide
// file completion even when no completion is provided.
ShellCompDirectiveNoFileComp
// ShellCompDirectiveFilterFileExt indicates that the provided completions
// should be used as file extension filters.
ShellCompDirectiveFilterFileExt
// ShellCompDirectiveFilterDirs indicates that only directory names should
// be provided in file completion. To request directory names within another
// directory, the returned completions should specify the directory within
// which to search.
ShellCompDirectiveFilterDirs
// ShellCompDirectiveKeepOrder indicates that the shell should preserve the order
// in which the completions are provided
ShellCompDirectiveKeepOrder
// ===========================================================================
// All directives using iota should be above this one.
// For internal use.
shellCompDirectiveMaxValue
// ShellCompDirectiveDefault indicates to let the shell perform its default
// behavior after completions have been provided.
// This one must be last to avoid messing up the iota count.
ShellCompDirectiveDefault ShellCompDirective = 0
)
// Returns a string listing the different directive enabled in the specified parameter
func (d ShellCompDirective) String() string {
var directives []string
if d&ShellCompDirectiveError != 0 {
directives = append(directives, "ShellCompDirectiveError")
}
if d&ShellCompDirectiveNoSpace != 0 {
directives = append(directives, "ShellCompDirectiveNoSpace")
}
if d&ShellCompDirectiveNoFileComp != 0 {
directives = append(directives, "ShellCompDirectiveNoFileComp")
}
if d&ShellCompDirectiveFilterFileExt != 0 {
directives = append(directives, "ShellCompDirectiveFilterFileExt")
}
if d&ShellCompDirectiveFilterDirs != 0 {
directives = append(directives, "ShellCompDirectiveFilterDirs")
}
if d&ShellCompDirectiveKeepOrder != 0 {
directives = append(directives, "ShellCompDirectiveKeepOrder")
}
if len(directives) == 0 {
directives = append(directives, "ShellCompDirectiveDefault")
}
if d >= shellCompDirectiveMaxValue {
return fmt.Sprintf("ERROR: unexpected ShellCompDirective value: %d", d)
}
return strings.Join(directives, " | ")
}
const UsageTemplate = `To load completions:
Bash:
$ source <(%[1]s completion bash)
# To load completions for each session, execute once:
# Linux:
$ %[1]s completion bash > /etc/bash_completion.d/%[1]s
# macOS:
$ %[1]s completion bash > $(brew --prefix)/etc/bash_completion.d/%[1]s
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ %[1]s completion zsh > "${fpath[1]}/_%[1]s"
# You will need to start a new shell for this setup to take effect.
fish:
$ %[1]s completion fish | source
# To load completions for each session, execute once:
$ %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish
PowerShell:
PS> %[1]s completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> %[1]s completion powershell > %[1]s.ps1
# and source this file from your PowerShell profile.
The shell scripts and this help message have been adapted from the
Cobra project (https://cobra.dev, https://github.com/spf13/cobra)
under the Apache-2.0 license. Thank you for making these available.
`

View File

@ -0,0 +1,333 @@
# Copyright 2013-2023 The Cobra Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# bash completion V2 for %-36[1]s -*- shell-script -*-
__%[1]s_debug()
{
if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then
echo "$*" >> "${BASH_COMP_DEBUG_FILE}"
fi
}
# Macs have bash3 for which the bash-completion package doesn't include
# _init_completion. This is a minimal version of that function.
__%[1]s_init_completion()
{
COMPREPLY=()
_get_comp_words_by_ref "$@" cur prev words cword
}
# This function calls the %[1]s program to obtain the completion
# results and the directive. It fills the 'out' and 'directive' vars.
__%[1]s_get_completion_results() {
local requestComp lastParam lastChar args
# Prepare the command to request completions for the program.
# Calling ${words[0]} instead of directly %[1]s allows handling aliases
args=("${words[@]:1}")
requestComp="${words[0]} %[2]s ${args[*]}"
lastParam=${words[$((${#words[@]}-1))]}
lastChar=${lastParam:$((${#lastParam}-1)):1}
__%[1]s_debug "lastParam ${lastParam}, lastChar ${lastChar}"
if [[ -z ${cur} && ${lastChar} != = ]]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go method.
__%[1]s_debug "Adding extra empty parameter"
requestComp="${requestComp} ''"
fi
# When completing a flag with an = (e.g., %[1]s -n=<TAB>)
# bash focuses on the part after the =, so we need to remove
# the flag part from $cur
if [[ ${cur} == -*=* ]]; then
cur="${cur#*=}"
fi
__%[1]s_debug "Calling ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval "${requestComp}" 2>/dev/null)
# Extract the directive integer at the very end of the output following a colon (:)
directive=${out##*:}
# Remove the directive
out=${out%%:*}
if [[ ${directive} == "${out}" ]]; then
# There is not directive specified
directive=0
fi
__%[1]s_debug "The completion directive is: ${directive}"
__%[1]s_debug "The completions are: ${out}"
}
__%[1]s_process_completion_results() {
local shellCompDirectiveError=%[3]d
local shellCompDirectiveNoSpace=%[4]d
local shellCompDirectiveNoFileComp=%[5]d
local shellCompDirectiveFilterFileExt=%[6]d
local shellCompDirectiveFilterDirs=%[7]d
local shellCompDirectiveKeepOrder=%[8]d
if (((directive & shellCompDirectiveError) != 0)); then
# Error code. No completion.
__%[1]s_debug "Received error from custom completion go code"
return
else
if (((directive & shellCompDirectiveNoSpace) != 0)); then
if [[ $(type -t compopt) == builtin ]]; then
__%[1]s_debug "Activating no space"
compopt -o nospace
else
__%[1]s_debug "No space directive not supported in this version of bash"
fi
fi
if (((directive & shellCompDirectiveKeepOrder) != 0)); then
if [[ $(type -t compopt) == builtin ]]; then
# no sort isn't supported for bash less than < 4.4
if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then
__%[1]s_debug "No sort directive not supported in this version of bash"
else
__%[1]s_debug "Activating keep order"
compopt -o nosort
fi
else
__%[1]s_debug "No sort directive not supported in this version of bash"
fi
fi
if (((directive & shellCompDirectiveNoFileComp) != 0)); then
if [[ $(type -t compopt) == builtin ]]; then
__%[1]s_debug "Activating no file completion"
compopt +o default
else
__%[1]s_debug "No file completion directive not supported in this version of bash"
fi
fi
fi
# Separate activeHelp from normal completions
local completions=()
while IFS='' read -r comp; do
completions+=("$comp")
done <<<"${out}"
if (((directive & shellCompDirectiveFilterFileExt) != 0)); then
# File extension filtering
local fullFilter filter filteringCmd
# Do not use quotes around the $completions variable or else newline
# characters will be kept.
for filter in ${completions[*]}; do
fullFilter+="$filter|"
done
filteringCmd="_filedir $fullFilter"
__%[1]s_debug "File filtering command: $filteringCmd"
$filteringCmd
elif (((directive & shellCompDirectiveFilterDirs) != 0)); then
# File completion for directories only
local subdir
subdir=${completions[0]}
if [[ -n $subdir ]]; then
__%[1]s_debug "Listing directories in $subdir"
pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return
else
__%[1]s_debug "Listing directories in ."
_filedir -d
fi
else
__%[1]s_handle_completion_types
fi
__%[1]s_handle_special_char "$cur" :
__%[1]s_handle_special_char "$cur" =
# Print the activeHelp statements before we finish
if ((${#activeHelp[*]} != 0)); then
printf "\n";
printf "%%s\n" "${activeHelp[@]}"
printf "\n"
# The prompt format is only available from bash 4.4.
# We test if it is available before using it.
if (x=${PS1@P}) 2> /dev/null; then
printf "%%s" "${PS1@P}${COMP_LINE[@]}"
else
# Can't print the prompt. Just print the
# text the user had typed, it is workable enough.
printf "%%s" "${COMP_LINE[@]}"
fi
fi
}
__%[1]s_handle_completion_types() {
__%[1]s_debug "__%[1]s_handle_completion_types: COMP_TYPE is $COMP_TYPE"
case $COMP_TYPE in
37|42)
# Type: menu-complete/menu-complete-backward and insert-completions
# If the user requested inserting one completion at a time, or all
# completions at once on the command-line we must remove the descriptions.
# https://github.com/spf13/cobra/issues/1508
local tab=$'\t' comp
while IFS='' read -r comp; do
[[ -z $comp ]] && continue
# Strip any description
comp=${comp%%%%$tab*}
# Only consider the completions that match
if [[ $comp == "$cur"* ]]; then
COMPREPLY+=("$comp")
fi
done < <(printf "%%s\n" "${completions[@]}")
;;
*)
# Type: complete (normal completion)
__%[1]s_handle_standard_completion_case
;;
esac
}
__%[1]s_handle_standard_completion_case() {
local tab=$'\t' comp
# Short circuit to optimize if we don't have descriptions
if [[ "${completions[*]}" != *$tab* ]]; then
IFS=$'\n' read -ra COMPREPLY -d '' < <(compgen -W "${completions[*]}" -- "$cur")
return 0
fi
local longest=0
local compline
# Look for the longest completion so that we can format things nicely
while IFS='' read -r compline; do
[[ -z $compline ]] && continue
# Strip any description before checking the length
comp=${compline%%%%$tab*}
# Only consider the completions that match
[[ $comp == "$cur"* ]] || continue
COMPREPLY+=("$compline")
if ((${#comp}>longest)); then
longest=${#comp}
fi
done < <(printf "%%s\n" "${completions[@]}")
# If there is a single completion left, remove the description text
if ((${#COMPREPLY[*]} == 1)); then
__%[1]s_debug "COMPREPLY[0]: ${COMPREPLY[0]}"
comp="${COMPREPLY[0]%%%%$tab*}"
__%[1]s_debug "Removed description from single completion, which is now: ${comp}"
COMPREPLY[0]=$comp
else # Format the descriptions
__%[1]s_format_comp_descriptions $longest
fi
}
__%[1]s_handle_special_char()
{
local comp="$1"
local char=$2
if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then
local word=${comp%%"${comp##*${char}}"}
local idx=${#COMPREPLY[*]}
while ((--idx >= 0)); do
COMPREPLY[idx]=${COMPREPLY[idx]#"$word"}
done
fi
}
__%[1]s_format_comp_descriptions()
{
local tab=$'\t'
local comp desc maxdesclength
local longest=$1
local i ci
for ci in ${!COMPREPLY[*]}; do
comp=${COMPREPLY[ci]}
# Properly format the description string which follows a tab character if there is one
if [[ "$comp" == *$tab* ]]; then
__%[1]s_debug "Original comp: $comp"
desc=${comp#*$tab}
comp=${comp%%%%$tab*}
# $COLUMNS stores the current shell width.
# Remove an extra 4 because we add 2 spaces and 2 parentheses.
maxdesclength=$(( COLUMNS - longest - 4 ))
# Make sure we can fit a description of at least 8 characters
# if we are to align the descriptions.
if ((maxdesclength > 8)); then
# Add the proper number of spaces to align the descriptions
for ((i = ${#comp} ; i < longest ; i++)); do
comp+=" "
done
else
# Don't pad the descriptions so we can fit more text after the completion
maxdesclength=$(( COLUMNS - ${#comp} - 4 ))
fi
# If there is enough space for any description text,
# truncate the descriptions that are too long for the shell width
if ((maxdesclength > 0)); then
if ((${#desc} > maxdesclength)); then
desc=${desc:0:$(( maxdesclength - 1 ))}
desc+="…"
fi
comp+=" ($desc)"
fi
COMPREPLY[ci]=$comp
__%[1]s_debug "Final comp: $comp"
fi
done
}
__start_%[1]s()
{
local cur prev words cword split
COMPREPLY=()
# Call _init_completion from the bash-completion package
# to prepare the arguments properly
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion -n =: || return
else
__%[1]s_init_completion -n =: || return
fi
__%[1]s_debug
__%[1]s_debug "========= starting completion logic =========="
__%[1]s_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword"
# The user could have moved the cursor backwards on the command-line.
# We need to trigger completion from the $cword location, so we need
# to truncate the command-line ($words) up to the $cword location.
words=("${words[@]:0:$cword+1}")
__%[1]s_debug "Truncated words[*]: ${words[*]},"
local out directive
__%[1]s_get_completion_results
__%[1]s_process_completion_results
}
if [[ $(type -t compopt) = "builtin" ]]; then
complete -o default -F __start_%[1]s %[1]s
else
complete -o default -o nospace -F __start_%[1]s %[1]s
fi
# ex: ts=4 sw=4 et filetype=sh

Binary file not shown.

View File

@ -0,0 +1,248 @@
# Copyright 2013-2023 The Cobra Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# fish completion for %-36[1]s -*- shell-script -*-
function __%[1]s_debug
set -l file "$BASH_COMP_DEBUG_FILE"
if test -n "$file"
echo "$argv" >> $file
end
end
function __%[1]s_perform_completion
__%[1]s_debug "Starting __%[1]s_perform_completion"
# Extract all args except the last one
set -l args (commandline -opc)
# Extract the last arg and escape it in case it is a space
set -l lastArg (string escape -- (commandline -ct))
__%[1]s_debug "args: $args"
__%[1]s_debug "last arg: $lastArg"
set -l requestComp "$args[1] %[3]s $args[2..-1] $lastArg"
__%[1]s_debug "Calling $requestComp"
set -l results (eval $requestComp 2> /dev/null)
# Some programs may output extra empty lines after the directive.
# Let's ignore them or else it will break completion.
# Ref: https://github.com/spf13/cobra/issues/1279
for line in $results[-1..1]
if test (string trim -- $line) = ""
# Found an empty line, remove it
set results $results[1..-2]
else
# Found non-empty line, we have our proper output
break
end
end
set -l comps $results[1..-2]
set -l directiveLine $results[-1]
# For Fish, when completing a flag with an = (e.g., <program> -n=<TAB>)
# completions must be prefixed with the flag
set -l flagPrefix (string match -r -- '-.*=' "$lastArg")
__%[1]s_debug "Comps: $comps"
__%[1]s_debug "DirectiveLine: $directiveLine"
__%[1]s_debug "flagPrefix: $flagPrefix"
for comp in $comps
printf "%%s%%s\n" "$flagPrefix" "$comp"
end
printf "%%s\n" "$directiveLine"
end
# this function limits calls to __%[1]s_perform_completion, by caching the result behind $__%[1]s_perform_completion_once_result
function __%[1]s_perform_completion_once
__%[1]s_debug "Starting __%[1]s_perform_completion_once"
if test -n "$__%[1]s_perform_completion_once_result"
__%[1]s_debug "Seems like a valid result already exists, skipping __%[1]s_perform_completion"
return 0
end
set --global __%[1]s_perform_completion_once_result (__%[1]s_perform_completion)
if test -z "$__%[1]s_perform_completion_once_result"
__%[1]s_debug "No completions, probably due to a failure"
return 1
end
__%[1]s_debug "Performed completions and set __%[1]s_perform_completion_once_result"
return 0
end
# this function is used to clear the $__%[1]s_perform_completion_once_result variable after completions are run
function __%[1]s_clear_perform_completion_once_result
__%[1]s_debug ""
__%[1]s_debug "========= clearing previously set __%[1]s_perform_completion_once_result variable =========="
set --erase __%[1]s_perform_completion_once_result
__%[1]s_debug "Successfully erased the variable __%[1]s_perform_completion_once_result"
end
function __%[1]s_requires_order_preservation
__%[1]s_debug ""
__%[1]s_debug "========= checking if order preservation is required =========="
__%[1]s_perform_completion_once
if test -z "$__%[1]s_perform_completion_once_result"
__%[1]s_debug "Error determining if order preservation is required"
return 1
end
set -l directive (string sub --start 2 $__%[1]s_perform_completion_once_result[-1])
__%[1]s_debug "Directive is: $directive"
set -l shellCompDirectiveKeepOrder %[9]d
set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) %% 2)
__%[1]s_debug "Keeporder is: $keeporder"
if test $keeporder -ne 0
__%[1]s_debug "This does require order preservation"
return 0
end
__%[1]s_debug "This doesn't require order preservation"
return 1
end
# This function does two things:
# - Obtain the completions and store them in the global __%[1]s_comp_results
# - Return false if file completion should be performed
function __%[1]s_prepare_completions
__%[1]s_debug ""
__%[1]s_debug "========= starting completion logic =========="
# Start fresh
set --erase __%[1]s_comp_results
__%[1]s_perform_completion_once
__%[1]s_debug "Completion results: $__%[1]s_perform_completion_once_result"
if test -z "$__%[1]s_perform_completion_once_result"
__%[1]s_debug "No completion, probably due to a failure"
# Might as well do file completion, in case it helps
return 1
end
set -l directive (string sub --start 2 $__%[1]s_perform_completion_once_result[-1])
set --global __%[1]s_comp_results $__%[1]s_perform_completion_once_result[1..-2]
__%[1]s_debug "Completions are: $__%[1]s_comp_results"
__%[1]s_debug "Directive is: $directive"
set -l shellCompDirectiveError %[4]d
set -l shellCompDirectiveNoSpace %[5]d
set -l shellCompDirectiveNoFileComp %[6]d
set -l shellCompDirectiveFilterFileExt %[7]d
set -l shellCompDirectiveFilterDirs %[8]d
if test -z "$directive"
set directive 0
end
set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) %% 2)
if test $compErr -eq 1
__%[1]s_debug "Received error directive: aborting."
# Might as well do file completion, in case it helps
return 1
end
set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) %% 2)
set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) %% 2)
if test $filefilter -eq 1; or test $dirfilter -eq 1
__%[1]s_debug "File extension filtering or directory filtering not supported"
# Do full file completion instead
return 1
end
set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) %% 2)
set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) %% 2)
__%[1]s_debug "nospace: $nospace, nofiles: $nofiles"
# If we want to prevent a space, or if file completion is NOT disabled,
# we need to count the number of valid completions.
# To do so, we will filter on prefix as the completions we have received
# may not already be filtered so as to allow fish to match on different
# criteria than the prefix.
if test $nospace -ne 0; or test $nofiles -eq 0
set -l prefix (commandline -t | string escape --style=regex)
__%[1]s_debug "prefix: $prefix"
set -l completions (string match -r -- "^$prefix.*" $__%[1]s_comp_results)
set --global __%[1]s_comp_results $completions
__%[1]s_debug "Filtered completions are: $__%[1]s_comp_results"
# Important not to quote the variable for count to work
set -l numComps (count $__%[1]s_comp_results)
__%[1]s_debug "numComps: $numComps"
if test $numComps -eq 1; and test $nospace -ne 0
# We must first split on \t to get rid of the descriptions to be
# able to check what the actual completion will be.
# We don't need descriptions anyway since there is only a single
# real completion which the shell will expand immediately.
set -l split (string split --max 1 \t $__%[1]s_comp_results[1])
# Fish won't add a space if the completion ends with any
# of the following characters: @=/:.,
set -l lastChar (string sub -s -1 -- $split)
if not string match -r -q "[@=/:.,]" -- "$lastChar"
# In other cases, to support the "nospace" directive we trick the shell
# by outputting an extra, longer completion.
__%[1]s_debug "Adding second completion to perform nospace directive"
set --global __%[1]s_comp_results $split[1] $split[1].
__%[1]s_debug "Completions are now: $__%[1]s_comp_results"
end
end
if test $numComps -eq 0; and test $nofiles -eq 0
# To be consistent with bash and zsh, we only trigger file
# completion when there are no other completions
__%[1]s_debug "Requesting file completion"
return 1
end
end
return 0
end
# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves
# so we can properly delete any completions provided by another script.
# Only do this if the program can be found, or else fish may print some errors; besides,
# the existing completions will only be loaded if the program can be found.
if type -q "%[2]s"
# The space after the program name is essential to trigger completion for the program
# and not completion of the program name itself.
# Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish.
complete --do-complete "%[2]s " > /dev/null 2>&1
end
# Remove any pre-existing completions for the program since we will be handling all of them.
complete -c %[2]s -e
# this will get called after the two calls below and clear the $__%[1]s_perform_completion_once_result global
complete -c %[2]s -n '__%[1]s_clear_perform_completion_once_result'
# The call to __%[1]s_prepare_completions will setup __%[1]s_comp_results
# which provides the program's completion choices.
# If this doesn't require order preservation, we don't use the -k flag
complete -c %[2]s -n 'not __%[1]s_requires_order_preservation && __%[1]s_prepare_completions' -f -a '$__%[1]s_comp_results'
# otherwise we use the -k flag
complete -k -c %[2]s -n '__%[1]s_requires_order_preservation && __%[1]s_prepare_completions' -f -a '$__%[1]s_comp_results'

Binary file not shown.

View File

@ -0,0 +1,72 @@
package cobra
import (
"bytes"
"compress/gzip"
_ "embed"
"fmt"
"io"
)
//go:generate go run gen.go
//go:embed comp.bash.gz
var compBash string
func ScriptBash(w io.Writer, name, compCmd, nameForVar string) error {
return fmtgz(
w, compBash,
name, compCmd,
ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp,
ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, ShellCompDirectiveKeepOrder,
)
}
//go:embed comp.zsh.gz
var compZsh string
func ScriptZsh(w io.Writer, name, compCmd, nameForVar string) error {
return fmtgz(
w, compZsh,
name, compCmd,
ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp,
ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, ShellCompDirectiveKeepOrder,
)
}
//go:embed comp.fish.gz
var compFish string
func ScriptFish(w io.Writer, name, compCmd, nameForVar string) error {
return fmtgz(
w, compFish,
nameForVar, name, compCmd,
ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp,
ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, ShellCompDirectiveKeepOrder,
)
}
//go:embed comp.ps1.gz
var compPowershell string
func ScriptPowershell(w io.Writer, name, compCmd, nameForVar string) error {
return fmtgz(
w, compPowershell,
name, nameForVar, compCmd,
ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp,
ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, ShellCompDirectiveKeepOrder,
)
}
func fmtgz(w io.Writer, formatgz string, args ...any) error {
f, err := gzip.NewReader(bytes.NewBufferString(formatgz))
if err != nil {
return fmt.Errorf("decompressing script: %w", err)
}
format, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf("decompressing script: %w", err)
}
_, err = fmt.Fprintf(w, string(format), args...)
return err
}

View File

@ -0,0 +1,259 @@
# Copyright 2013-2023 The Cobra Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# powershell completion for %-36[1]s -*- shell-script -*-
function __%[1]s_debug {
if ($env:BASH_COMP_DEBUG_FILE) {
"$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE"
}
}
filter __%[1]s_escapeStringWithSpecialChars {
$_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&'
}
[scriptblock]${__%[2]sCompleterBlock} = {
param(
$WordToComplete,
$CommandAst,
$CursorPosition
)
# Get the current command line and convert into a string
$Command = $CommandAst.CommandElements
$Command = "$Command"
__%[1]s_debug ""
__%[1]s_debug "========= starting completion logic =========="
__%[1]s_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition"
# The user could have moved the cursor backwards on the command-line.
# We need to trigger completion from the $CursorPosition location, so we need
# to truncate the command-line ($Command) up to the $CursorPosition location.
# Make sure the $Command is longer then the $CursorPosition before we truncate.
# This happens because the $Command does not include the last space.
if ($Command.Length -gt $CursorPosition) {
$Command=$Command.Substring(0,$CursorPosition)
}
__%[1]s_debug "Truncated command: $Command"
$ShellCompDirectiveError=%[4]d
$ShellCompDirectiveNoSpace=%[5]d
$ShellCompDirectiveNoFileComp=%[6]d
$ShellCompDirectiveFilterFileExt=%[7]d
$ShellCompDirectiveFilterDirs=%[8]d
$ShellCompDirectiveKeepOrder=%[9]d
# Prepare the command to request completions for the program.
# Split the command at the first space to separate the program and arguments.
$Program,$Arguments = $Command.Split(" ",2)
$RequestComp="$Program %[3]s $Arguments"
__%[1]s_debug "RequestComp: $RequestComp"
# we cannot use $WordToComplete because it
# has the wrong values if the cursor was moved
# so use the last argument
if ($WordToComplete -ne "" ) {
$WordToComplete = $Arguments.Split(" ")[-1]
}
__%[1]s_debug "New WordToComplete: $WordToComplete"
# Check for flag with equal sign
$IsEqualFlag = ($WordToComplete -Like "--*=*" )
if ( $IsEqualFlag ) {
__%[1]s_debug "Completing equal sign flag"
# Remove the flag part
$Flag,$WordToComplete = $WordToComplete.Split("=",2)
}
if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) {
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go method.
__%[1]s_debug "Adding extra empty parameter"
# PowerShell 7.2+ changed the way how the arguments are passed to executables,
# so for pre-7.2 or when Legacy argument passing is enabled we need to use
# `"`" to pass an empty argument, a "" or '' does not work!!!
if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or
($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or
(($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and
$PSNativeCommandArgumentPassing -eq 'Legacy')) {
$RequestComp="$RequestComp" + ' `"`"'
} else {
$RequestComp="$RequestComp" + ' ""'
}
}
__%[1]s_debug "Calling $RequestComp"
# First disable ActiveHelp which is not supported for Powershell
${env:%[10]s}=0
#call the command store the output in $out and redirect stderr and stdout to null
# $Out is an array contains each line per element
Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null
# get directive from last line
[int]$Directive = $Out[-1].TrimStart(':')
if ($Directive -eq "") {
# There is no directive specified
$Directive = 0
}
__%[1]s_debug "The completion directive is: $Directive"
# remove directive (last element) from out
$Out = $Out | Where-Object { $_ -ne $Out[-1] }
__%[1]s_debug "The completions are: $Out"
if (($Directive -band $ShellCompDirectiveError) -ne 0 ) {
# Error code. No completion.
__%[1]s_debug "Received error from custom completion go code"
return
}
$Longest = 0
[Array]$Values = $Out | ForEach-Object {
#Split the output in name and description
$Name, $Description = $_.Split("`t",2)
__%[1]s_debug "Name: $Name Description: $Description"
# Look for the longest completion so that we can format things nicely
if ($Longest -lt $Name.Length) {
$Longest = $Name.Length
}
# Set the description to a one space string if there is none set.
# This is needed because the CompletionResult does not accept an empty string as argument
if (-Not $Description) {
$Description = " "
}
@{Name="$Name";Description="$Description"}
}
$Space = " "
if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) {
# remove the space here
__%[1]s_debug "ShellCompDirectiveNoSpace is called"
$Space = ""
}
if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or
(($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) {
__%[1]s_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported"
# return here to prevent the completion of the extensions
return
}
$Values = $Values | Where-Object {
# filter the result
$_.Name -like "$WordToComplete*"
# Join the flag back if we have an equal sign flag
if ( $IsEqualFlag ) {
__%[1]s_debug "Join the equal sign flag back to the completion value"
$_.Name = $Flag + "=" + $_.Name
}
}
# we sort the values in ascending order by name if keep order isn't passed
if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) {
$Values = $Values | Sort-Object -Property Name
}
if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) {
__%[1]s_debug "ShellCompDirectiveNoFileComp is called"
if ($Values.Length -eq 0) {
# Just print an empty string here so the
# shell does not start to complete paths.
# We cannot use CompletionResult here because
# it does not accept an empty string as argument.
""
return
}
}
# Get the current mode
$Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function
__%[1]s_debug "Mode: $Mode"
$Values | ForEach-Object {
# store temporary because switch will overwrite $_
$comp = $_
# PowerShell supports three different completion modes
# - TabCompleteNext (default windows style - on each key press the next option is displayed)
# - Complete (works like bash)
# - MenuComplete (works like zsh)
# You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function <mode>
# CompletionResult Arguments:
# 1) CompletionText text to be used as the auto completion result
# 2) ListItemText text to be displayed in the suggestion list
# 3) ResultType type of completion result
# 4) ToolTip text for the tooltip with details about the object
switch ($Mode) {
# bash like
"Complete" {
if ($Values.Length -eq 1) {
__%[1]s_debug "Only one completion left"
# insert space after value
[System.Management.Automation.CompletionResult]::new($($comp.Name | __%[1]s_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
} else {
# Add the proper number of spaces to align the descriptions
while($comp.Name.Length -lt $Longest) {
$comp.Name = $comp.Name + " "
}
# Check for empty description and only add parentheses if needed
if ($($comp.Description) -eq " " ) {
$Description = ""
} else {
$Description = " ($($comp.Description))"
}
[System.Management.Automation.CompletionResult]::new("$($comp.Name)$Description", "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)")
}
}
# zsh like
"MenuComplete" {
# insert space after value
# MenuComplete will automatically show the ToolTip of
# the highlighted value at the bottom of the suggestions.
[System.Management.Automation.CompletionResult]::new($($comp.Name | __%[1]s_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
}
# TabCompleteNext and in case we get something unknown
Default {
# Like MenuComplete but we don't want to add a space here because
# the user need to press space anyway to get the completion.
# Description will not be shown because that's not possible with TabCompleteNext
[System.Management.Automation.CompletionResult]::new($($comp.Name | __%[1]s_escapeStringWithSpecialChars), "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
}
}
}
}
Register-ArgumentCompleter -CommandName '%[1]s' -ScriptBlock ${__%[2]sCompleterBlock}

Binary file not shown.

View File

@ -0,0 +1,198 @@
#compdef %[1]s
compdef _%[1]s %[1]s
# Copyright 2013-2023 The Cobra Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# zsh completion for %-36[1]s -*- shell-script -*-
__%[1]s_debug()
{
local file="$BASH_COMP_DEBUG_FILE"
if [[ -n ${file} ]]; then
echo "$*" >> "${file}"
fi
}
_%[1]s()
{
local shellCompDirectiveError=%[3]d
local shellCompDirectiveNoSpace=%[4]d
local shellCompDirectiveNoFileComp=%[5]d
local shellCompDirectiveFilterFileExt=%[6]d
local shellCompDirectiveFilterDirs=%[7]d
local shellCompDirectiveKeepOrder=%[8]d
local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder
local -a completions
__%[1]s_debug "\n========= starting completion logic =========="
__%[1]s_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}"
# The user could have moved the cursor backwards on the command-line.
# We need to trigger completion from the $CURRENT location, so we need
# to truncate the command-line ($words) up to the $CURRENT location.
# (We cannot use $CURSOR as its value does not work when a command is an alias.)
words=("${=words[1,CURRENT]}")
__%[1]s_debug "Truncated words[*]: ${words[*]},"
lastParam=${words[-1]}
lastChar=${lastParam[-1]}
__%[1]s_debug "lastParam: ${lastParam}, lastChar: ${lastChar}"
# For zsh, when completing a flag with an = (e.g., %[1]s -n=<TAB>)
# completions must be prefixed with the flag
setopt local_options BASH_REMATCH
if [[ "${lastParam}" =~ '-.*=' ]]; then
# We are dealing with a flag with an =
flagPrefix="-P ${BASH_REMATCH}"
fi
# Prepare the command to obtain completions
requestComp="${words[1]} %[2]s ${words[2,-1]}"
if [ "${lastChar}" = "" ]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go completion code.
__%[1]s_debug "Adding extra empty parameter"
requestComp="${requestComp} \"\""
fi
__%[1]s_debug "About to call: eval ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval ${requestComp} 2>/dev/null)
__%[1]s_debug "completion output: ${out}"
# Extract the directive integer following a : from the last line
local lastLine
while IFS='\n' read -r line; do
lastLine=${line}
done < <(printf "%%s\n" "${out[@]}")
__%[1]s_debug "last line: ${lastLine}"
if [ "${lastLine[1]}" = : ]; then
directive=${lastLine[2,-1]}
# Remove the directive including the : and the newline
local suffix
(( suffix=${#lastLine}+2))
out=${out[1,-$suffix]}
else
# There is no directive specified. Leave $out as is.
__%[1]s_debug "No directive found. Setting do default"
directive=0
fi
__%[1]s_debug "directive: ${directive}"
__%[1]s_debug "completions: ${out}"
__%[1]s_debug "flagPrefix: ${flagPrefix}"
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
__%[1]s_debug "Completion received error. Ignoring completions."
return
fi
while IFS='\n' read -r comp; do
if [ -n "$comp" ]; then
# If requested, completions are returned with a description.
# The description is preceded by a TAB character.
# For zsh's _describe, we need to use a : instead of a TAB.
# We first need to escape any : as part of the completion itself.
comp=${comp//:/\\:}
local tab="$(printf '\t')"
comp=${comp//$tab/:}
__%[1]s_debug "Adding completion: ${comp}"
completions+=${comp}
lastComp=$comp
fi
done < <(printf "%%s\n" "${out[@]}")
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
__%[1]s_debug "Activating nospace."
noSpace="-S ''"
fi
if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then
__%[1]s_debug "Activating keep order."
keepOrder="-V"
fi
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
# File extension filtering
local filteringCmd
filteringCmd='_files'
for filter in ${completions[@]}; do
if [ ${filter[1]} != '*' ]; then
# zsh requires a glob pattern to do file filtering
filter="\*.$filter"
fi
filteringCmd+=" -g $filter"
done
filteringCmd+=" ${flagPrefix}"
__%[1]s_debug "File filtering command: $filteringCmd"
_arguments '*:filename:'"$filteringCmd"
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
# File completion for directories only
local subdir
subdir="${completions[1]}"
if [ -n "$subdir" ]; then
__%[1]s_debug "Listing directories in $subdir"
pushd "${subdir}" >/dev/null 2>&1
else
__%[1]s_debug "Listing directories in ."
fi
local result
_arguments '*:dirname:_files -/'" ${flagPrefix}"
result=$?
if [ -n "$subdir" ]; then
popd >/dev/null 2>&1
fi
return $result
else
__%[1]s_debug "Calling _describe"
if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then
__%[1]s_debug "_describe found some completions"
# Return the success of having called _describe
return 0
else
__%[1]s_debug "_describe did not find completions."
__%[1]s_debug "Checking if we should do file completion."
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
__%[1]s_debug "deactivating file completion"
# We must return an error code here to let zsh know that there were no
# completions found by _describe; this is what will trigger other
# matching algorithms to attempt to find completions.
# For example zsh can match letters in the middle of words.
return 1
else
# Perform file completion
__%[1]s_debug "Activating file completion"
# We must return the result of this command, so it must be the
# last command, or else we must store its result to return it.
_arguments '*:filename:_files'" ${flagPrefix}"
fi
fi
fi
}
# don't run the completion function when being source-ed or eval-ed
if [ "$funcstack[1]" = "_%[1]s" ]; then
_%[1]s
fi

Binary file not shown.

View File

@ -0,0 +1,42 @@
//go:build gen
package main
import (
"compress/gzip"
"fmt"
"io"
"os"
)
func main() {
for _, name := range []string{"comp.bash", "comp.zsh", "comp.fish", "comp.ps1"} {
err := compress(name)
if err != nil {
fmt.Fprintln(os.Stderr, "compressing "+name+":", err)
os.Exit(1)
}
}
}
func compress(name string) error {
src, err := os.Open(name)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(name + ".gz")
if err != nil {
return err
}
defer dst.Close()
z := gzip.NewWriter(dst)
_, err = io.Copy(z, src)
if err != nil {
return err
}
return z.Close()
}