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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,39 @@ proxydoctor-server
# Open http://localhost:8080
```

## Shell Completion

Tab completion is available for bash, zsh, fish, and PowerShell. Generate a
script and source it once per shell:

```bash
# bash (Linux)
proxydoctor completion bash | sudo tee /etc/bash_completion.d/proxydoctor >/dev/null

# bash (macOS)
proxydoctor completion bash > "$(brew --prefix)/etc/bash_completion.d/proxydoctor"

# zsh — save to a directory on your $fpath
proxydoctor completion zsh > "${fpath[1]}/_proxydoctor"

# fish
proxydoctor completion fish > ~/.config/fish/completions/proxydoctor.fish

# PowerShell
proxydoctor completion powershell >> $PROFILE
```

Start a new shell, then tab through subcommands and flags. Beyond flag *names*,
flag *values* complete too: `--checks` offers check IDs and categories
(`public_ip`, `dns_leak`, `network`, `all`, …), `--export` offers
`text`/`json`/`html`/`markdown`, and `--proxy-type` offers
`auto`/`http`/`https`/`socks4`/`socks5`.

> The generated script is keyed to the root command name `proxyctl`, so
> completion activates for that name. If you invoke the binary under a different
> name (the `proxydoctor` alias, or a raw `go build` binary), install the script
> for each name you use.

## Checks

Every check tells you **what it tests** and **what service it uses**.
Expand Down
118 changes: 118 additions & 0 deletions cmd/cli/commands/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package commands

import (
"sort"
"strings"

"github.com/francomano/proxydoctor/core/check"
checkspkg "github.com/francomano/proxydoctor/core/checks"
"github.com/francomano/proxydoctor/core/engine"
"github.com/spf13/cobra"
)

// Shell completion support (#22). Cobra already ships a `completion` command
// that emits bash/zsh/fish/powershell scripts, and those scripts complete flag
// *names* and subcommands out of the box. What they cannot do is suggest
// *values* for flags whose accepted set is dynamic or domain-specific — a user
// typing `proxydoctor diagnose --checks <Tab>` would get file completion before
// this change. RegisterFlagCompletionFunc wires the generated scripts to the
// helpers below so the shell offers the real check IDs, categories, export
// formats, and proxy types instead.

// completeCheckFilters completes the --checks flag with the IDs and categories
// of the default-registered checks plus the literal "all". A diagnosis can load
// extra checks via --plugins, but those are opt-in; the default set is the
// common case and the right thing to offer a user tabbing through a fresh
// invocation.
func completeCheckFilters(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
registry := engine.NewCheckRegistry()
if err := checkspkg.RegisterDefaults(registry); err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}

seen := make(map[string]struct{})
for id, checker := range registry.ListChecks() {
seen[id] = struct{}{}
if cat := string(checker.Category()); cat != "" {
seen[cat] = struct{}{}
}
}
seen["all"] = struct{}{}

candidates := make([]string, 0, len(seen))
for v := range seen {
candidates = append(candidates, v)
}
sort.Strings(candidates)

// --checks is comma-separated: only complete the segment the cursor is on.
if i := strings.LastIndex(toComplete, ","); i >= 0 {
prefix := toComplete[:i+1]
segment := toComplete[i+1:]
var matched []string
for _, c := range candidates {
if strings.HasPrefix(c, segment) {
matched = append(matched, prefix+c)
}
}
return matched, cobra.ShellCompDirectiveNoFileComp
}

var matched []string
for _, c := range candidates {
if strings.HasPrefix(c, toComplete) {
matched = append(matched, c)
}
}
return matched, cobra.ShellCompDirectiveNoFileComp
}

// completeExportFormat completes --export with the four supported formats.
func completeExportFormat(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
formats := []string{"text", "json", "html", "markdown"}
var matched []string
for _, f := range formats {
if strings.HasPrefix(f, toComplete) {
matched = append(matched, f)
}
}
return matched, cobra.ShellCompDirectiveNoFileComp
}

// completeProxyType completes --proxy-type with the values ParseProxyConfig
// accepts (see cmd/cli/commands/plugins.go).
func completeProxyType(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
types := []string{"auto", "http", "https", "socks4", "socks5"}
var matched []string
for _, t := range types {
if strings.HasPrefix(t, toComplete) {
matched = append(matched, t)
}
}
return matched, cobra.ShellCompDirectiveNoFileComp
}

// init wires the helpers into cobra via OnInitialize rather than a plain init()
// because Go runs package init()s in filename order — completion.go runs before
// diagnose.go and plugins.go, so the "checks"/"export"/"proxy-type" flags do not
// exist yet and RegisterFlagCompletionFunc would reject them with "flag does not
// exist". OnInitialize defers the calls to Execute time, after every init() has
// run and the flags are defined.
func init() {
cobra.OnInitialize(registerCompletionFuncs)
}

func registerCompletionFuncs() {
diagnoseCmd.RegisterFlagCompletionFunc("checks", completeCheckFilters)
diagnoseCmd.RegisterFlagCompletionFunc("export", completeExportFormat)
RootCmd.RegisterFlagCompletionFunc("proxy-type", completeProxyType)
}

// compile-time assertion that the helpers satisfy cobra's signature.
var _ cobra.CompletionFunc = completeCheckFilters
var _ cobra.CompletionFunc = completeExportFormat
var _ cobra.CompletionFunc = completeProxyType

// reference the check package so a future rename of the import set above does
// not silently strand the CompletionFunc assertions.
var _ = check.StatusPassed
95 changes: 95 additions & 0 deletions cmd/cli/commands/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package commands

import (
"sort"
"strings"
"testing"

"github.com/spf13/cobra"
)

// TestCompleteCheckFiltersReturnsIDsCategoriesAndAll guards #22: tab-completing
// --checks offers the default check IDs, their categories, and the literal
// "all" — not file completion. The default registry is the common case; plugins
// add more but are opt-in.
func TestCompleteCheckFiltersReturnsIDsCategoriesAndAll(t *testing.T) {
got, dir := completeCheckFilters(nil, nil, "")
if dir != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("got directive %v, want NoFileComp", dir)
}
for _, want := range []string{"all", "public_ip", "dns_resolve"} {
if !contains(got, want) {
t.Errorf("check-filter completions missing %q; got %v", want, got)
}
}
// At least one category must be offered alongside the IDs.
if !sort.StringsAreSorted(got) {
t.Errorf("expected sorted completions for stable shell display, got %v", got)
}
}

// TestCompleteCheckFiltersPrefixFilters verifies the helper narrows to the
// segment the cursor is on.
func TestCompleteCheckFiltersPrefixFilters(t *testing.T) {
got, _ := completeCheckFilters(nil, nil, "dns")
for _, c := range got {
if !strings.HasPrefix(c, "dns") {
t.Errorf("prefix-filter leaked non-matching candidate %q", c)
}
}
if !contains(got, "dns_resolve") {
t.Errorf("expected dns_resolve in prefix-filtered results, got %v", got)
}
if contains(got, "public_ip") {
t.Errorf("public_ip should not match prefix \"dns\", got %v", got)
}
}

// TestCompleteCheckFiltersCommaSeparated completes only the final segment of a
// comma-separated --checks value, preserving the already-typed prefix.
func TestCompleteCheckFiltersCommaSeparated(t *testing.T) {
got, _ := completeCheckFilters(nil, nil, "public_ip,dns")
for _, c := range got {
if !strings.HasPrefix(c, "public_ip,dns") {
t.Errorf("comma completion dropped the typed prefix: %q", c)
}
}
if !contains(got, "public_ip,dns_resolve") {
t.Errorf("expected public_ip,dns_resolve, got %v", got)
}
}

// TestCompleteExportFormat enumerates the four supported formats.
func TestCompleteExportFormat(t *testing.T) {
got, dir := completeExportFormat(nil, nil, "")
if dir != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("got directive %v, want NoFileComp", dir)
}
for _, want := range []string{"text", "json", "html", "markdown"} {
if !contains(got, want) {
t.Errorf("export completions missing %q, got %v", want, got)
}
}
}

// TestCompleteProxyType enumerates the five accepted proxy types.
func TestCompleteProxyType(t *testing.T) {
got, dir := completeProxyType(nil, nil, "")
if dir != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("got directive %v, want NoFileComp", dir)
}
for _, want := range []string{"auto", "http", "https", "socks4", "socks5"} {
if !contains(got, want) {
t.Errorf("proxy-type completions missing %q, got %v", want, got)
}
}
}

func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}