Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ brews:
homepage: https://github.com/fortrabbit/frbit-cli
description: Command-line interface for the fortrabbit public API
license: MIT
extra_install: |
generate_completions_from_executable(bin/"frbit", "completion")
test: |
system "#{bin}/frbit", "version"
repository:
Expand Down
16 changes: 16 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ frbit --help
frbit apps list --help
```

## Shell completion

Homebrew installs completion automatically. For other installation methods, run
the command for your shell once:

```sh
frbit completion install bash
frbit completion install fish
frbit completion install powershell
frbit completion install zsh
```

The bash and fish installers save to their auto-discovery directories. The
PowerShell installer adds the completion to its profile, and the zsh installer
saves it in `~/.zfunc/_frbit` and adds that directory to zsh's completion path.

## Authenticate

Create a personal API token in the fortrabbit dashboard, then sign in:
Expand Down
1 change: 1 addition & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,4 @@ case ":${PATH:-}:" in
*":$install_dir:"*) ;;
*) printf 'Add %s to PATH to run frbit.\n' "$install_dir" ;;
esac
printf 'For shell completion, run: frbit completion install <bash|fish|powershell|zsh>\n'
225 changes: 225 additions & 0 deletions internal/cmd/root/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package root

import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/spf13/cobra"
)

const zshCompletionMarker = "# frbit shell completion"

type completionGenerator func(*cobra.Command, io.Writer) error

func newCmdCompletion() *cobra.Command {
command := &cobra.Command{
Use: "completion",
Short: "Install shell completion",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), "Install shell completion with: frbit completion install <shell>")
return err
},
}

command.AddCommand(
newCmdGenerateCompletion("bash", false, func(root *cobra.Command, output io.Writer) error { return root.GenBashCompletion(output) }),
newCmdGenerateCompletion("fish", false, func(root *cobra.Command, output io.Writer) error { return root.GenFishCompletion(output, true) }),
newCmdGenerateCompletion("powershell", false, func(root *cobra.Command, output io.Writer) error { return root.GenPowerShellCompletion(output) }),
newCmdGenerateCompletion("zsh", false, func(root *cobra.Command, output io.Writer) error { return root.GenZshCompletion(output) }),
newCmdInstallCompletion(),
)

return command
}

func newCmdGenerateCompletion(shell string, hidden bool, generate completionGenerator) *cobra.Command {
return &cobra.Command{
Use: shell,
Short: fmt.Sprintf("Generate the autocompletion script for %s", shell),
Hidden: hidden,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
output := cmd.OutOrStdout()
if _, err := fmt.Fprintf(output, "# To install this completion, run: frbit completion install %s\n\n", shell); err != nil {
return err
}
if err := generate(cmd.Root(), output); err != nil {
return err
}
_, err := fmt.Fprintf(output, "\n# To install this completion, run: frbit completion install %s\n", shell)
return err
},
}
}

func newCmdInstallCompletion() *cobra.Command {
command := &cobra.Command{
Use: "install",
Short: "Install shell completion",
Args: cobra.NoArgs,
}
for _, shell := range []string{"bash", "fish", "powershell", "zsh"} {
shell := shell
command.AddCommand(&cobra.Command{
Use: shell,
Short: fmt.Sprintf("Install %s completion", shell),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return installCompletion(cmd.Root(), shell, cmd.OutOrStdout())
},
})
}
return command
}

func installCompletion(root *cobra.Command, shell string, output io.Writer) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("find home directory: %w", err)
}

switch shell {
case "bash":
return installGeneratedCompletion(root, filepath.Join(userDataDir(home), "bash-completion", "completions", "frbit"), func(root *cobra.Command, output io.Writer) error {
return root.GenBashCompletion(output)
}, output)
case "fish":
return installGeneratedCompletion(root, filepath.Join(userConfigDir(home), "fish", "completions", "frbit.fish"), func(root *cobra.Command, output io.Writer) error {
return root.GenFishCompletion(output, true)
}, output)
case "powershell":
completionPath := filepath.Join(userConfigDir(home), "frbit", "completion.ps1")
if err := installGeneratedCompletion(root, completionPath, func(root *cobra.Command, output io.Writer) error {
return root.GenPowerShellCompletion(output)
}, nil); err != nil {
return err
}
profilePath := powerShellProfilePath(home)
if err := configurePowerShell(profilePath, completionPath); err != nil {
return err
}
_, err := fmt.Fprintf(output, "Installed PowerShell completion to %s.\n", completionPath)
return err
case "zsh":
completionPath := filepath.Join(home, ".zfunc", "_frbit")
if err := installGeneratedCompletion(root, completionPath, func(root *cobra.Command, output io.Writer) error {
return root.GenZshCompletion(output)
}, nil); err != nil {
return err
}
zshDir := os.Getenv("ZDOTDIR")
if zshDir == "" {
zshDir = home
}
if err := configureZsh(filepath.Join(zshDir, ".zshrc")); err != nil {
return err
}
_, err := fmt.Fprintf(output, "Installed zsh completion to %s. Restart zsh or run exec zsh.\n", completionPath)
return err
default:
return fmt.Errorf("unsupported shell %q", shell)
}
}

func userDataDir(home string) string {
if directory := os.Getenv("XDG_DATA_HOME"); directory != "" {
return directory
}
return filepath.Join(home, ".local", "share")
}

func userConfigDir(home string) string {
if directory := os.Getenv("XDG_CONFIG_HOME"); directory != "" {
return directory
}
return filepath.Join(home, ".config")
}

func powerShellProfilePath(home string) string {
if runtime.GOOS == "windows" {
return filepath.Join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1")
}
return filepath.Join(userConfigDir(home), "powershell", "Microsoft.PowerShell_profile.ps1")
}

func installGeneratedCompletion(root *cobra.Command, path string, generate completionGenerator, output io.Writer) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create completion directory: %w", err)
}
temporaryFile, err := os.CreateTemp(filepath.Dir(path), ".frbit-")
if err != nil {
return fmt.Errorf("create completion file: %w", err)
}
temporaryPath := temporaryFile.Name()
defer os.Remove(temporaryPath)
if err := generate(root, temporaryFile); err != nil {
temporaryFile.Close()
return fmt.Errorf("generate completion: %w", err)
}
if err := temporaryFile.Chmod(0o644); err != nil {
temporaryFile.Close()
return fmt.Errorf("set completion permissions: %w", err)
}
if err := temporaryFile.Close(); err != nil {
return fmt.Errorf("close completion file: %w", err)
}
if err := os.Rename(temporaryPath, path); err != nil {
return fmt.Errorf("install completion: %w", err)
}
if output != nil {
_, err := fmt.Fprintf(output, "Installed completion to %s.\n", path)
return err
}
return nil
}

func configureZsh(zshrcPath string) error {
contents, err := os.ReadFile(zshrcPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", zshrcPath, err)
}
if strings.Contains(string(contents), zshCompletionMarker) {
return nil
}

file, err := os.OpenFile(zshrcPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open %s: %w", zshrcPath, err)
}
defer file.Close()

_, err = fmt.Fprintf(file, "\n%s\nfpath=(~/.zfunc $fpath)\nautoload -Uz compinit\ncompinit\n", zshCompletionMarker)
if err != nil {
return fmt.Errorf("configure zsh completion: %w", err)
}
return nil
}

func configurePowerShell(profilePath string, completionPath string) error {
contents, err := os.ReadFile(profilePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", profilePath, err)
}
if strings.Contains(string(contents), zshCompletionMarker) {
return nil
}
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
return fmt.Errorf("create PowerShell profile directory: %w", err)
}
file, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open %s: %w", profilePath, err)
}
defer file.Close()

_, err = fmt.Fprintf(file, "\n%s\n. '%s'\n", zshCompletionMarker, strings.ReplaceAll(completionPath, "'", "''"))
if err != nil {
return fmt.Errorf("configure PowerShell completion: %w", err)
}
return nil
}
1 change: 1 addition & 0 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func NewCmdRoot(factory *app.Factory) *cobra.Command {
command.PersistentFlags().String("profile", app.DefaultProfile, "Credential profile")

command.AddCommand(
newCmdCompletion(),
auth.NewCmdAuth(factory),
apps.NewCmdApps(factory),
mcp.NewCmdMCP(factory, nil),
Expand Down
104 changes: 104 additions & 0 deletions internal/cmd/root/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -551,6 +553,108 @@ func TestCreateReadsCompleteJSONPayloadFromStdin(t *testing.T) {
}
}

func TestCompletionHelpListsAllShells(t *testing.T) {
output := &bytes.Buffer{}
command := NewCmdRoot(testFactory(output))
command.SetArgs([]string{"completion", "--help"})
if err := command.Execute(); err != nil {
t.Fatal(err)
}
for _, shell := range []string{"bash", "fish", "powershell", "zsh", "install"} {
if !strings.Contains(output.String(), shell) {
t.Errorf("help = %q, does not contain %q", output.String(), shell)
}
}
}

func TestCompletionGeneratorsIncludeInstallHint(t *testing.T) {
tests := []struct {
shell string
script string
}{
{"bash", "_frbit"},
{"fish", "complete -c frbit"},
{"powershell", "Register-ArgumentCompleter"},
{"zsh", "#compdef frbit"},
}
for _, test := range tests {
output := &bytes.Buffer{}
command := NewCmdRoot(testFactory(output))
command.SetArgs([]string{"completion", test.shell})
if err := command.Execute(); err != nil {
t.Fatalf("completion %s: %v", test.shell, err)
}
want := "# To install this completion, run: frbit completion install " + test.shell + "\n\n"
if got := output.String(); !strings.HasPrefix(got, want) {
t.Errorf("%s output = %q, want prefix %q", test.shell, got, want)
}
if got := output.String(); !strings.Contains(got, test.script) {
t.Errorf("%s output = %q, want generated script containing %q", test.shell, got, test.script)
}
wantSuffix := "\n# To install this completion, run: frbit completion install " + test.shell + "\n"
if got := output.String(); !strings.HasSuffix(got, wantSuffix) {
t.Errorf("%s output = %q, want suffix %q", test.shell, got, wantSuffix)
}
}
}

func TestCompletionInstallWritesAndActivatesShellCompletions(t *testing.T) {
home := t.TempDir()
dataHome := filepath.Join(home, "data")
configHome := filepath.Join(home, "config")
t.Setenv("HOME", home)
t.Setenv("ZDOTDIR", home)
t.Setenv("XDG_DATA_HOME", dataHome)
t.Setenv("XDG_CONFIG_HOME", configHome)

tests := []struct {
shell string
completionPath string
completionText string
activationPath string
}{
{"bash", filepath.Join(dataHome, "bash-completion", "completions", "frbit"), "_frbit", ""},
{"fish", filepath.Join(configHome, "fish", "completions", "frbit.fish"), "complete -c frbit", ""},
{"powershell", filepath.Join(configHome, "frbit", "completion.ps1"), "Register-ArgumentCompleter", powerShellProfilePath(home)},
{"zsh", filepath.Join(home, ".zfunc", "_frbit"), "#compdef frbit", filepath.Join(home, ".zshrc")},
}
for _, test := range tests {
output := &bytes.Buffer{}
command := NewCmdRoot(testFactory(output))
command.SetArgs([]string{"completion", "install", test.shell})
if err := command.Execute(); err != nil {
t.Fatalf("install %s: %v", test.shell, err)
}
completion, err := os.ReadFile(test.completionPath)
if err != nil {
t.Fatalf("read %s completion: %v", test.shell, err)
}
if !strings.Contains(string(completion), test.completionText) {
t.Errorf("%s completion = %q, want %q", test.shell, completion, test.completionText)
}
if test.activationPath == "" {
continue
}
activation, err := os.ReadFile(test.activationPath)
if err != nil {
t.Fatalf("read %s activation: %v", test.shell, err)
}
if got := strings.Count(string(activation), zshCompletionMarker); got != 1 {
t.Errorf("%s activation marker count = %d, want 1", test.shell, got)
}
if err := command.Execute(); err != nil {
t.Fatalf("reinstall %s: %v", test.shell, err)
}
activation, err = os.ReadFile(test.activationPath)
if err != nil {
t.Fatalf("read %s activation after reinstall: %v", test.shell, err)
}
if got := strings.Count(string(activation), zshCompletionMarker); got != 1 {
t.Errorf("%s activation marker count after reinstall = %d, want 1", test.shell, got)
}
}
}

func testFactory(output *bytes.Buffer) *app.Factory {
return &app.Factory{
IOStreams: iostreams.IOStreams{In: strings.NewReader(""), Out: output, ErrOut: &bytes.Buffer{}, IsTTY: false},
Expand Down