From 5192598a5f0ba96fe97c5a6ae94ff78c6ef3b256 Mon Sep 17 00:00:00 2001 From: Rafik Abdulwahab Date: Mon, 31 Aug 2026 16:54:14 +0200 Subject: [PATCH 1/2] FR-6405: Look into frbit completion zsh output maybe buggy --- .goreleaser.yaml | 2 + docs/cli.md | 16 +++ install.sh | 1 + internal/cmd/root/completion.go | 225 ++++++++++++++++++++++++++++++++ internal/cmd/root/root.go | 1 + internal/cmd/root/root_test.go | 131 +++++++++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 internal/cmd/root/completion.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8dfcb04..b5e3215 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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: diff --git a/docs/cli.md b/docs/cli.md index fd01cd7..fd1671d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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: diff --git a/install.sh b/install.sh index 50dbfaf..f326271 100755 --- a/install.sh +++ b/install.sh @@ -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 \n' diff --git a/internal/cmd/root/completion.go b/internal/cmd/root/completion.go new file mode 100644 index 0000000..21206b5 --- /dev/null +++ b/internal/cmd/root/completion.go @@ -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 ") + 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 +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index c6a7416..d868794 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -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), diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index cbc79cf..e91d916 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -551,6 +553,135 @@ 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 TestCompletionInstallZshWritesCompletionAndConfiguresZsh(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ZDOTDIR", home) + + output := &bytes.Buffer{} + command := NewCmdRoot(testFactory(output)) + command.SetArgs([]string{"completion", "install", "zsh"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + + completionPath := filepath.Join(home, ".zfunc", "_frbit") + completion, err := os.ReadFile(completionPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(completion), "#compdef frbit") { + t.Errorf("completion = %q, want zsh completion", completion) + } + + zshrcPath := filepath.Join(home, ".zshrc") + zshrc, err := os.ReadFile(zshrcPath) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(zshrc), zshCompletionMarker); got != 1 { + t.Errorf("completion marker count = %d, want 1", got) + } + + if err := command.Execute(); err != nil { + t.Fatal(err) + } + zshrc, err = os.ReadFile(zshrcPath) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(zshrc), zshCompletionMarker); got != 1 { + t.Errorf("completion marker count after reinstall = %d, want 1", got) + } +} + +func TestCompletionInstallWritesScriptsForOtherShells(t *testing.T) { + home := t.TempDir() + dataHome := filepath.Join(home, "data") + configHome := filepath.Join(home, "config") + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_CONFIG_HOME", configHome) + + tests := []struct { + shell string + path string + want 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"}, + } + 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.path) + if err != nil { + t.Fatalf("read %s completion: %v", test.shell, err) + } + if !strings.Contains(string(completion), test.want) { + t.Errorf("%s completion = %q, want %q", test.shell, completion, test.want) + } + } + + profile, err := os.ReadFile(powerShellProfilePath(home)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(profile), zshCompletionMarker) { + t.Errorf("PowerShell profile = %q, want completion setup", profile) + } +} + func testFactory(output *bytes.Buffer) *app.Factory { return &app.Factory{ IOStreams: iostreams.IOStreams{In: strings.NewReader(""), Out: output, ErrOut: &bytes.Buffer{}, IsTTY: false}, From 1b348fecf3f3b120792d1d4870454d0c3dfafd9f Mon Sep 17 00:00:00 2001 From: Rafik Abdulwahab Date: Tue, 1 Sep 2026 10:35:26 +0200 Subject: [PATCH 2/2] update completion activation tests --- internal/cmd/root/root_test.go | 93 ++++++++++++---------------------- 1 file changed, 33 insertions(+), 60 deletions(-) diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index e91d916..536b127 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -598,64 +598,25 @@ func TestCompletionGeneratorsIncludeInstallHint(t *testing.T) { } } -func TestCompletionInstallZshWritesCompletionAndConfiguresZsh(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("ZDOTDIR", home) - - output := &bytes.Buffer{} - command := NewCmdRoot(testFactory(output)) - command.SetArgs([]string{"completion", "install", "zsh"}) - if err := command.Execute(); err != nil { - t.Fatal(err) - } - - completionPath := filepath.Join(home, ".zfunc", "_frbit") - completion, err := os.ReadFile(completionPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(completion), "#compdef frbit") { - t.Errorf("completion = %q, want zsh completion", completion) - } - - zshrcPath := filepath.Join(home, ".zshrc") - zshrc, err := os.ReadFile(zshrcPath) - if err != nil { - t.Fatal(err) - } - if got := strings.Count(string(zshrc), zshCompletionMarker); got != 1 { - t.Errorf("completion marker count = %d, want 1", got) - } - - if err := command.Execute(); err != nil { - t.Fatal(err) - } - zshrc, err = os.ReadFile(zshrcPath) - if err != nil { - t.Fatal(err) - } - if got := strings.Count(string(zshrc), zshCompletionMarker); got != 1 { - t.Errorf("completion marker count after reinstall = %d, want 1", got) - } -} - -func TestCompletionInstallWritesScriptsForOtherShells(t *testing.T) { +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 - path string - want string + 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"}, + {"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{} @@ -664,21 +625,33 @@ func TestCompletionInstallWritesScriptsForOtherShells(t *testing.T) { if err := command.Execute(); err != nil { t.Fatalf("install %s: %v", test.shell, err) } - completion, err := os.ReadFile(test.path) + completion, err := os.ReadFile(test.completionPath) if err != nil { t.Fatalf("read %s completion: %v", test.shell, err) } - if !strings.Contains(string(completion), test.want) { - t.Errorf("%s completion = %q, want %q", test.shell, completion, test.want) + 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) } - } - - profile, err := os.ReadFile(powerShellProfilePath(home)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(profile), zshCompletionMarker) { - t.Errorf("PowerShell profile = %q, want completion setup", profile) } }