diff --git a/internal/cli/clients.go b/internal/cli/clients.go new file mode 100644 index 0000000..b599e56 --- /dev/null +++ b/internal/cli/clients.go @@ -0,0 +1,114 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "runtime" + "text/tabwriter" + + "github.com/go-authgate/agent-scanner/internal/discovery" + "github.com/spf13/cobra" +) + +func newClientsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "clients", + Short: "List all supported AI agent clients", + Long: "Lists all AI agent clients that agent-scanner " + + "can discover, with platform support information.", + Args: cobra.NoArgs, + RunE: runClients, + } + cmd.Flags(). + BoolVar(&clientsFlags.CurrentOS, "current-os", false, + "Show only clients supported on the current OS") + cmd.Flags(). + BoolVar(&clientsFlags.JSON, "json", false, + "Output results as JSON") + return cmd +} + +func runClients(cmd *cobra.Command, _ []string) error { + clients := discovery.GetAllSupportedClients() + + currentOS := clientsFlags.CurrentOS + if currentOS { + clients = filterByOS(clients, runtime.GOOS) + } + + w := cmd.OutOrStdout() + if clientsFlags.JSON { + return writeClientsJSON(w, clients) + } + return writeClientsTable(w, clients, !currentOS) +} + +func filterByOS( + clients []discovery.ClientPlatformInfo, + goos string, +) []discovery.ClientPlatformInfo { + filtered := make([]discovery.ClientPlatformInfo, 0, len(clients)) + for _, c := range clients { + if isSupportedOn(c, goos) { + filtered = append(filtered, c) + } + } + return filtered +} + +func isSupportedOn(c discovery.ClientPlatformInfo, goos string) bool { + switch goos { + case "darwin": + return c.MacOS + case "linux": + return c.Linux + case "windows": + return c.Windows + default: + return false + } +} + +func writeClientsTable( + w io.Writer, + clients []discovery.ClientPlatformInfo, + showPlatforms bool, +) error { + tw := tabwriter.NewWriter(w, 0, 0, 4, ' ', 0) + + if showPlatforms { + fmt.Fprintln(tw, "CLIENT\tmacOS\tLinux\tWindows") + for _, c := range clients { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + c.Name, + checkMark(c.MacOS), + checkMark(c.Linux), + checkMark(c.Windows), + ) + } + } else { + fmt.Fprintln(tw, "CLIENT\tSUPPORTED") + for _, c := range clients { + fmt.Fprintf(tw, "%s\t✓\n", c.Name) + } + } + + return tw.Flush() +} + +func checkMark(supported bool) string { + if supported { + return "✓" + } + return "-" +} + +func writeClientsJSON( + w io.Writer, + clients []discovery.ClientPlatformInfo, +) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(clients) +} diff --git a/internal/cli/clients_test.go b/internal/cli/clients_test.go new file mode 100644 index 0000000..7d35907 --- /dev/null +++ b/internal/cli/clients_test.go @@ -0,0 +1,126 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/go-authgate/agent-scanner/internal/discovery" +) + +var testClients = []discovery.ClientPlatformInfo{ + {Name: "alpha", MacOS: true, Linux: true, Windows: true}, + {Name: "beta", MacOS: true, Linux: false, Windows: false}, + {Name: "gamma", MacOS: false, Linux: true, Windows: false}, +} + +func TestIsSupportedOn(t *testing.T) { + c := discovery.ClientPlatformInfo{ + Name: "test", MacOS: true, Linux: false, Windows: true, + } + + tests := []struct { + goos string + want bool + }{ + {"darwin", true}, + {"linux", false}, + {"windows", true}, + {"freebsd", false}, + } + for _, tt := range tests { + if got := isSupportedOn(c, tt.goos); got != tt.want { + t.Errorf("isSupportedOn(%q) = %v, want %v", + tt.goos, got, tt.want) + } + } +} + +func TestFilterByOS(t *testing.T) { + got := filterByOS(testClients, "linux") + if len(got) != 2 { + t.Fatalf("expected 2 clients, got %d", len(got)) + } + if got[0].Name != "alpha" || got[1].Name != "gamma" { + t.Errorf("unexpected clients: %v", got) + } +} + +func TestFilterByOS_UnknownOS(t *testing.T) { + got := filterByOS(testClients, "plan9") + if len(got) != 0 { + t.Errorf("expected 0 clients for plan9, got %d", len(got)) + } +} + +func TestWriteClientsTable_AllPlatforms(t *testing.T) { + var buf bytes.Buffer + err := writeClientsTable(&buf, testClients, true) + if err != nil { + t.Fatal(err) + } + out := buf.String() + + if !strings.Contains(out, "macOS") { + t.Error("expected platform headers in output") + } + if !strings.Contains(out, "alpha") { + t.Error("expected client name in output") + } + // beta is macOS-only + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 4 { // header + 3 clients + t.Errorf("expected 4 lines, got %d", len(lines)) + } +} + +func TestWriteClientsTable_CurrentOSOnly(t *testing.T) { + var buf bytes.Buffer + filtered := filterByOS(testClients, "darwin") + err := writeClientsTable(&buf, filtered, false) + if err != nil { + t.Fatal(err) + } + out := buf.String() + + if !strings.Contains(out, "SUPPORTED") { + t.Error("expected SUPPORTED header") + } + if strings.Contains(out, "macOS") { + t.Error("should not show platform columns") + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 3 { // header + alpha + beta + t.Errorf("expected 3 lines, got %d", len(lines)) + } +} + +func TestWriteClientsJSON(t *testing.T) { + var buf bytes.Buffer + err := writeClientsJSON(&buf, testClients) + if err != nil { + t.Fatal(err) + } + + var got []discovery.ClientPlatformInfo + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(got) != len(testClients) { + t.Errorf("expected %d clients, got %d", + len(testClients), len(got)) + } + if got[0].Name != "alpha" { + t.Errorf("expected first client alpha, got %q", got[0].Name) + } +} + +func TestCheckMark(t *testing.T) { + if checkMark(true) != "✓" { + t.Error("expected ✓ for true") + } + if checkMark(false) != "-" { + t.Error("expected - for false") + } +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go index dab3a81..74651a0 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -33,10 +33,17 @@ type MCPServerFlags struct { ScanInterval int } +// ClientsFlags holds clients-specific flags. +type ClientsFlags struct { + CurrentOS bool + JSON bool +} + var ( commonFlags CommonFlags scanFlags ScanFlags mcpServerFlags MCPServerFlags + clientsFlags ClientsFlags ) // addCommonFlags registers flags shared across scan/inspect commands. diff --git a/internal/cli/root.go b/internal/cli/root.go index 9f55a23..c6d6f34 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,6 +33,7 @@ func init() { rootCmd.AddCommand(newInspectCmd()) rootCmd.AddCommand(newMCPServerCmd()) rootCmd.AddCommand(newInstallCmd()) + rootCmd.AddCommand(newClientsCmd()) } // Execute runs the root command. diff --git a/internal/discovery/clients_all.go b/internal/discovery/clients_all.go new file mode 100644 index 0000000..f55fb2d --- /dev/null +++ b/internal/discovery/clients_all.go @@ -0,0 +1,31 @@ +package discovery + +// ClientPlatformInfo describes a client's name and which platforms support it. +type ClientPlatformInfo struct { + Name string `json:"name"` + MacOS bool `json:"macos"` + Linux bool `json:"linux"` + Windows bool `json:"windows"` +} + +// GetAllSupportedClients returns the full catalog of well-known AI agent clients +// with their platform availability. This is independent of build tags. +// +// When adding a new client to a platform-specific clients_.go file, +// also update this table. The TestAllClientsIncludesCurrentPlatform test +// catches omissions for the current build platform. +func GetAllSupportedClients() []ClientPlatformInfo { + return []ClientPlatformInfo{ + {Name: "windsurf", MacOS: true, Linux: true, Windows: true}, + {Name: "cursor", MacOS: true, Linux: true, Windows: true}, + {Name: "vscode", MacOS: true, Linux: true, Windows: true}, + {Name: "claude", MacOS: true, Linux: false, Windows: true}, + {Name: "claude code", MacOS: true, Linux: true, Windows: true}, + {Name: "gemini cli", MacOS: true, Linux: true, Windows: true}, + {Name: "kiro", MacOS: true, Linux: true, Windows: true}, + {Name: "opencode", MacOS: true, Linux: true, Windows: false}, + {Name: "codex", MacOS: true, Linux: true, Windows: true}, + {Name: "antigravity", MacOS: true, Linux: true, Windows: false}, + {Name: "openclaw", MacOS: true, Linux: true, Windows: true}, + } +} diff --git a/internal/discovery/clients_all_test.go b/internal/discovery/clients_all_test.go new file mode 100644 index 0000000..901b871 --- /dev/null +++ b/internal/discovery/clients_all_test.go @@ -0,0 +1,83 @@ +package discovery + +import ( + "runtime" + "testing" +) + +func TestAllClientsIncludesCurrentPlatform(t *testing.T) { + all := GetAllSupportedClients() + current := GetWellKnownClients() + + if len(all) == 0 { + t.Fatal("GetAllSupportedClients() returned empty list") + } + + allByName := make(map[string]ClientPlatformInfo, len(all)) + for _, c := range all { + allByName[c.Name] = c + } + + // Forward: every platform client must appear in the full catalog. + for _, c := range current { + info, ok := allByName[c.Name] + if !ok { + t.Errorf( + "client %q in GetWellKnownClients() "+ + "but missing from GetAllSupportedClients()", + c.Name) + continue + } + + switch runtime.GOOS { + case "darwin": + if !info.MacOS { + t.Errorf("client %q should have MacOS=true", c.Name) + } + case "linux": + if !info.Linux { + t.Errorf("client %q should have Linux=true", c.Name) + } + case "windows": + if !info.Windows { + t.Errorf("client %q should have Windows=true", c.Name) + } + } + } + + // Reverse: every catalog entry claiming current-platform support + // must appear in GetWellKnownClients(). + currentByName := make(map[string]bool, len(current)) + for _, c := range current { + currentByName[c.Name] = true + } + for _, c := range all { + claimed := false + switch runtime.GOOS { + case "darwin": + claimed = c.MacOS + case "linux": + claimed = c.Linux + case "windows": + claimed = c.Windows + } + if claimed && !currentByName[c.Name] { + t.Errorf( + "client %q claims %s support in "+ + "GetAllSupportedClients() but missing "+ + "from GetWellKnownClients()", + c.Name, runtime.GOOS) + } + } +} + +func TestAllClientsNoDuplicates(t *testing.T) { + all := GetAllSupportedClients() + seen := make(map[string]bool, len(all)) + for _, c := range all { + if seen[c.Name] { + t.Errorf("duplicate client name: %q", c.Name) + } + seen[c.Name] = true + } +}