From f7c68898ece034962e1d5db195abf1f1d974748c Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:59:41 +0200 Subject: [PATCH] feat(cli): add shell flag-value completion for --checks/--export/--proxy-type Cobra already ships a completion command that completes subcommands and flag names. It cannot, however, suggest values for flags whose accepted set is domain-specific: --checks fell back to file completion. Register flag-value completion functions via cobra.OnInitialize (deferred to Execute time so the flags exist when registration runs, since package init order is alphabetical and completion.go runs before the flags are defined): - --checks: default check IDs + their categories + the literal "all", with comma-segment completion so only the final segment is narrowed. - --export: text/json/html/markdown. - --proxy-type: auto/http/https/socks4/socks5. Adds unit tests for each helper (prefix filtering, comma handling, exhaustive value sets) and a README Shell Completion section with per-shell install instructions. Closes #22 --- README.md | 33 ++++++++ cmd/cli/commands/completion.go | 118 ++++++++++++++++++++++++++++ cmd/cli/commands/completion_test.go | 95 ++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 cmd/cli/commands/completion.go create mode 100644 cmd/cli/commands/completion_test.go diff --git a/README.md b/README.md index 3069be9..24485c6 100644 --- a/README.md +++ b/README.md @@ -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**. diff --git a/cmd/cli/commands/completion.go b/cmd/cli/commands/completion.go new file mode 100644 index 0000000..5a772eb --- /dev/null +++ b/cmd/cli/commands/completion.go @@ -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 ` 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 \ No newline at end of file diff --git a/cmd/cli/commands/completion_test.go b/cmd/cli/commands/completion_test.go new file mode 100644 index 0000000..c9e7d1b --- /dev/null +++ b/cmd/cli/commands/completion_test.go @@ -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 +} \ No newline at end of file