From db4bbad3eba1480f954db288799692acd0ee11f9 Mon Sep 17 00:00:00 2001 From: Omid Astaraki Date: Mon, 16 Mar 2026 18:23:59 +0000 Subject: [PATCH 1/5] feat(apiutil): add output mode utilities for json, yaml, and table formats --- cmd/apiutil/output.go | 167 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 cmd/apiutil/output.go diff --git a/cmd/apiutil/output.go b/cmd/apiutil/output.go new file mode 100644 index 0000000..6d8a7c1 --- /dev/null +++ b/cmd/apiutil/output.go @@ -0,0 +1,167 @@ +package apiutil + +import ( + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// OutputMode represents the --output/-o flag value. +type OutputMode string + +const ( + // OutputJSON is the default output format: pretty-printed JSON. + OutputJSON OutputMode = "json" + // OutputYAML serialises the response as YAML. + OutputYAML OutputMode = "yaml" + // OutputTable renders results in a tab-separated table. + OutputTable OutputMode = "table" +) + +// AddOutputFlag registers the --output/-o flag on cmd. +func AddOutputFlag(cmd *cobra.Command) { + cmd.Flags().StringP("output", "o", "json", "Output format: json, yaml, table") +} + +// GetOutputMode reads the --output flag from cmd and returns the corresponding +// OutputMode. Defaults to OutputJSON for unknown values. +func GetOutputMode(cmd *cobra.Command) OutputMode { + mode, _ := cmd.Flags().GetString("output") + switch OutputMode(mode) { + case OutputYAML: + return OutputYAML + case OutputTable: + return OutputTable + default: + return OutputJSON + } +} + +// PrintOutput serialises v according to mode and writes it to cmd's output. +func PrintOutput(cmd *cobra.Command, v interface{}, mode OutputMode) error { + switch mode { + case OutputYAML: + out, err := yaml.Marshal(v) + if err != nil { + return fmt.Errorf("failed to marshal as YAML: %w", err) + } + cmd.Print(string(out)) + return nil + case OutputTable: + return printTable(cmd, v) + default: + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal as JSON: %w", err) + } + cmd.Println(string(out)) + return nil + } +} + +// PrintRawOutput parses data as JSON then re-renders it according to mode. +func PrintRawOutput(cmd *cobra.Command, data []byte, mode OutputMode) error { + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + return PrintOutput(cmd, v, mode) +} + +// printTable renders v in a tab-separated table. When v contains a "results" +// key, each element of that slice becomes a row. For other objects the key/value +// pairs are printed as a two-column table. Falls back to YAML for complex nested +// structures that cannot be rendered as a flat table. +func printTable(cmd *cobra.Command, v interface{}) error { + // Normalise to map via JSON round-trip. + raw, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("failed to serialise for table: %w", err) + } + + var top map[string]interface{} + if err := json.Unmarshal(raw, &top); err != nil { + // Not a JSON object — fall back to YAML. + return PrintOutput(cmd, v, OutputYAML) + } + + // If the response wraps a results array, render that. + if results, ok := top["results"].([]interface{}); ok { + return renderResultsTable(cmd, results) + } + + // Otherwise render the object itself as key→value rows. + return renderObjectTable(cmd, top) +} + +// renderResultsTable prints a table where each row is one element from results. +func renderResultsTable(cmd *cobra.Command, results []interface{}) error { + if len(results) == 0 { + cmd.Println("(no results)") + return nil + } + + // Collect a stable column order from the first element. + first, ok := results[0].(map[string]interface{}) + if !ok { + return PrintOutput(cmd, results, OutputYAML) + } + cols := tableColumns(first) + + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, strings.Join(cols, "\t")) + for _, item := range results { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + vals := make([]string, len(cols)) + for i, col := range cols { + vals[i] = fmt.Sprintf("%v", row[col]) + } + fmt.Fprintln(tw, strings.Join(vals, "\t")) + } + return tw.Flush() +} + +// renderObjectTable prints a two-column key/value table for a single object. +func renderObjectTable(cmd *cobra.Command, obj map[string]interface{}) error { + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "KEY\tVALUE") + for k, v := range obj { + fmt.Fprintf(tw, "%s\t%v\n", k, v) + } + return tw.Flush() +} + +// tableColumns returns a deterministic column order for a result row. The ID +// column always comes first when present; then mediaType; then title/name; +// then all remaining string/number fields in alphabetical order. +func tableColumns(row map[string]interface{}) []string { + priority := []string{"id", "mediaType", "title", "name"} + seen := map[string]bool{} + var cols []string + + for _, key := range priority { + if _, ok := row[key]; ok { + cols = append(cols, key) + seen[key] = true + } + } + // Add remaining scalar fields (skip nested objects/arrays). + for k, v := range row { + if seen[k] { + continue + } + switch v.(type) { + case map[string]interface{}, []interface{}: + continue + } + cols = append(cols, k) + } + return cols +} From 6ec40bbb9bc9124f4751fc55649a6e5b5bfad3cf Mon Sep 17 00:00:00 2001 From: Omid Astaraki Date: Mon, 16 Mar 2026 18:24:03 +0000 Subject: [PATCH 2/5] feat(movies,search): wire --output flag to movies get and search multi --- cmd/movies/get.go | 16 +++++- cmd/search/multi.go | 12 +++- tests/output_mode_test.go | 113 +++++++++++++++++++++++++++++++++++++ tests/search_multi_test.go | 7 ++- 4 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 tests/output_mode_test.go diff --git a/cmd/movies/get.go b/cmd/movies/get.go index 9e7ee6e..dfc3bb6 100644 --- a/cmd/movies/get.go +++ b/cmd/movies/get.go @@ -18,7 +18,10 @@ var getCmd = &cobra.Command{ seerr-cli movies get 603 # Get details in Spanish - seerr-cli movies get 603 --language es`, + seerr-cli movies get 603 --language es + + # Output as YAML + seerr-cli movies get 603 --output yaml`, RunE: func(cmd *cobra.Command, args []string) error { movieId, err := strconv.ParseInt(args[0], 10, 64) if err != nil { @@ -30,12 +33,21 @@ var getCmd = &cobra.Command{ language = "" } + mode := apiutil.GetOutputMode(cmd) res, r, err := seerrclient.New().MovieGet(int(movieId), language) - return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdGet") + if err != nil { + return apiutil.HandleResponse(cmd, r, err, res, viper.GetBool("verbose"), "MovieMovieIdGet") + } + + if viper.GetBool("verbose") && r != nil { + cmd.Printf("HTTP Status: %s\n", r.Status) + } + return apiutil.PrintOutput(cmd, res, mode) }, } func init() { getCmd.Flags().String("language", "en", "Language code") + apiutil.AddOutputFlag(getCmd) Cmd.AddCommand(getCmd) } diff --git a/cmd/search/multi.go b/cmd/search/multi.go index 8d5b7ae..69f7e0b 100644 --- a/cmd/search/multi.go +++ b/cmd/search/multi.go @@ -1,6 +1,7 @@ package search import ( + "seerr-cli/cmd/apiutil" "seerr-cli/internal/seerrclient" "github.com/spf13/cobra" @@ -14,7 +15,10 @@ var multiCmd = &cobra.Command{ seerr-cli search multi -q "The Matrix" # Search for "Christopher Nolan" on the second page - seerr-cli search multi -q "Christopher Nolan" --page 2`, + seerr-cli search multi -q "Christopher Nolan" --page 2 + + # Output as a table + seerr-cli search multi -q "matrix" --output table`, RunE: func(cmd *cobra.Command, args []string) error { query, _ := cmd.Flags().GetString("query") page, _ := cmd.Flags().GetInt("page") @@ -36,8 +40,9 @@ var multiCmd = &cobra.Command{ if viper.GetBool("verbose") { cmd.Printf("GET /api/v1/search\n") } - cmd.Println(string(b)) - return nil + + mode := apiutil.GetOutputMode(cmd) + return apiutil.PrintRawOutput(cmd, b, mode) }, } @@ -46,5 +51,6 @@ func init() { multiCmd.MarkFlagRequired("query") multiCmd.Flags().Int("page", 1, "Page number") multiCmd.Flags().String("language", "en", "Language code") + apiutil.AddOutputFlag(multiCmd) Cmd.AddCommand(multiCmd) } diff --git a/tests/output_mode_test.go b/tests/output_mode_test.go new file mode 100644 index 0000000..b53c64a --- /dev/null +++ b/tests/output_mode_test.go @@ -0,0 +1,113 @@ +package tests + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "seerr-cli/cmd" + "seerr-cli/cmd/apiutil" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetOutputFlag finds the named subcommand through the root command tree +// and resets its "output" flag to the default value "json". This is necessary +// because Cobra flag values persist across Execute() calls in the same process. +func resetOutputFlag(args []string) { + sub, _, _ := cmd.RootCmd.Find(args) + if sub != nil { + if f := sub.Flags().Lookup("output"); f != nil { + _ = f.Value.Set("json") + } + } +} + +func TestMoviesGetJSONOutputDefault(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"id":603,"title":"The Matrix"}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"movies", "get", "603"}) + err := command.Execute() + require.NoError(t, err) + + // Default output is JSON. + assert.Contains(t, b.String(), `"title"`) +} + +func TestMoviesGetYAMLOutput(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"id":603,"title":"The Matrix"}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + // Reset the --output flag after this test so subsequent tests that omit + // --output see the default "json" instead of "yaml". + t.Cleanup(func() { resetOutputFlag([]string{"movies", "get"}) }) + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"movies", "get", "603", "--output", "yaml"}) + err := command.Execute() + require.NoError(t, err) + + // YAML uses "key: value" syntax, not "key": "value". + assert.Contains(t, b.String(), "title:") + assert.NotContains(t, b.String(), `"title":`) +} + +func TestSearchMultiTableOutput(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"results":[{"id":1,"title":"The Matrix","mediaType":"movie"},{"id":2,"name":"Lost","mediaType":"tv"}]}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + // Reset the --output flag after this test so subsequent tests see the + // default "json" value instead of "table". + t.Cleanup(func() { resetOutputFlag([]string{"search", "multi"}) }) + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"search", "multi", "-q", "matrix", "--output", "table"}) + err := command.Execute() + require.NoError(t, err) + + // Table output includes results as tab-separated rows. + assert.Contains(t, b.String(), "movie") + assert.Contains(t, b.String(), "The Matrix") +} diff --git a/tests/search_multi_test.go b/tests/search_multi_test.go index 0bec8f8..3586c24 100644 --- a/tests/search_multi_test.go +++ b/tests/search_multi_test.go @@ -46,9 +46,10 @@ func TestSearchMultiMixedResults(t *testing.T) { assert.NoError(t, err) out := buf.String() - assert.Contains(t, out, `"mediaType":"movie"`) - assert.Contains(t, out, `"mediaType":"tv"`) - assert.Contains(t, out, `"mediaType":"person"`) + // Output is pretty-printed JSON; check for mediaType values as JSON strings. + assert.Contains(t, out, `"mediaType": "movie"`) + assert.Contains(t, out, `"mediaType": "tv"`) + assert.Contains(t, out, `"mediaType": "person"`) } // TestSearchMultiQueryEncoding verifies that spaces in the query are encoded From 71c40937e24ab3822a578596a4cc096570f8f3a9 Mon Sep 17 00:00:00 2001 From: Omid Astaraki Date: Mon, 16 Mar 2026 18:24:06 +0000 Subject: [PATCH 3/5] feat(doctor): add doctor command for checking CLI configuration and connectivity --- cmd/doctor/check.go | 202 +++++++++++++++++++++++++++++++++++++++++++ cmd/doctor/doctor.go | 23 +++++ cmd/root.go | 6 +- tests/doctor_test.go | 108 +++++++++++++++++++++++ 4 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 cmd/doctor/check.go create mode 100644 cmd/doctor/doctor.go create mode 100644 tests/doctor_test.go diff --git a/cmd/doctor/check.go b/cmd/doctor/check.go new file mode 100644 index 0000000..fa9261b --- /dev/null +++ b/cmd/doctor/check.go @@ -0,0 +1,202 @@ +package doctor + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "seerr-cli/cmd/apiutil" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CheckResult holds the outcome of a single doctor check. +type CheckResult struct { + Name string `json:"name"` + Status string `json:"status"` // "ok", "fail", "info", "skip" + Message string `json:"message"` +} + +const doctorHTTPTimeout = 5 * time.Second + +// Run executes all doctor checks against serverURL using the provided API key. +// Checks are short-circuited when the server is unreachable so that subsequent +// checks are marked as "skip" rather than reporting misleading errors. +func Run(serverURL, apiKey string) []CheckResult { + var results []CheckResult + + // 1 — Server configured. + if serverURL == "" { + results = append(results, CheckResult{ + Name: "server_configured", + Status: "fail", + Message: "server URL is not set (run: seerr-cli config set server )", + }) + return skipRemaining(results, []string{"server_reachable", "api_key_configured", "api_key_valid", "server_version"}) + } + results = append(results, CheckResult{ + Name: "server_configured", + Status: "ok", + Message: serverURL, + }) + + // 2 — Server reachable (no auth header). + statusPath := strings.TrimRight(serverURL, "/") + "/api/v1/status" + body, statusCode, err := doGet(statusPath, "") + if err != nil || statusCode >= 500 { + msg := fmt.Sprintf("GET %s failed", statusPath) + if err != nil { + msg = err.Error() + } + results = append(results, CheckResult{Name: "server_reachable", Status: "fail", Message: msg}) + return skipRemaining(results, []string{"api_key_configured", "api_key_valid", "server_version"}) + } + results = append(results, CheckResult{ + Name: "server_reachable", + Status: "ok", + Message: fmt.Sprintf("HTTP %d", statusCode), + }) + + // 3 — API key configured. + if apiKey == "" { + results = append(results, CheckResult{ + Name: "api_key_configured", + Status: "fail", + Message: "API key is not set (run: seerr-cli config set api-key )", + }) + return skipRemaining(results, []string{"api_key_valid", "server_version"}) + } + results = append(results, CheckResult{ + Name: "api_key_configured", + Status: "ok", + Message: maskKey(apiKey), + }) + + // 4 — API key valid (authenticated request). + _, authStatusCode, authErr := doGet(statusPath, apiKey) + if authErr != nil || authStatusCode >= 400 { + msg := fmt.Sprintf("HTTP %d", authStatusCode) + if authErr != nil { + msg = authErr.Error() + } + results = append(results, CheckResult{Name: "api_key_valid", Status: "fail", Message: msg}) + return skipRemaining(results, []string{"server_version"}) + } + results = append(results, CheckResult{ + Name: "api_key_valid", + Status: "ok", + Message: fmt.Sprintf("HTTP %d", authStatusCode), + }) + + // 5 — Server version (informational). + version := parseVersion(body) + results = append(results, CheckResult{ + Name: "server_version", + Status: "info", + Message: version, + }) + + return results +} + +// doGet performs an HTTP GET with a short timeout. Returns the body bytes, +// HTTP status code, and any transport-level error. +func doGet(url, apiKey string) ([]byte, int, error) { + client := &http.Client{Timeout: doctorHTTPTimeout} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, 0, err + } + if apiKey != "" { + req.Header.Set("X-Api-Key", apiKey) + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return body, resp.StatusCode, nil +} + +// parseVersion extracts the "version" field from a JSON body. +func parseVersion(body []byte) string { + var v map[string]interface{} + if err := json.Unmarshal(body, &v); err != nil { + return "unknown" + } + if ver, ok := v["version"].(string); ok && ver != "" { + return ver + } + return "unknown" +} + +// maskKey shows only the last four characters of an API key. +func maskKey(key string) string { + if len(key) <= 4 { + return strings.Repeat("*", len(key)) + } + return strings.Repeat("*", len(key)-4) + key[len(key)-4:] +} + +// skipRemaining appends skip results for any check names not yet in results. +func skipRemaining(results []CheckResult, names []string) []CheckResult { + for _, name := range names { + results = append(results, CheckResult{ + Name: name, + Status: "skip", + Message: "skipped due to earlier failure", + }) + } + return results +} + +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Run all doctor checks", + // This command is also the default action of the doctor parent command. + Hidden: true, +} + +// runChecks runs the checks and prints results according to --output mode. +func runChecks(cmd *cobra.Command, _ []string) error { + serverURL := viper.GetString("seerr.server") + apiKey := viper.GetString("seerr.api_key") + + results := Run(serverURL, apiKey) + + outputFlag, _ := cmd.Flags().GetString("output") + if outputFlag == "json" { + b, err := json.MarshalIndent(results, "", " ") + if err != nil { + return err + } + cmd.Println(string(b)) + return nil + } + + // Human-readable output. + for _, r := range results { + tag := fmt.Sprintf("[%s]", r.Status) + cmd.Printf("%-8s %-22s %s\n", tag, r.Name, r.Message) + } + return nil +} + +func init() { + Cmd.Flags().String("output", "text", "Output format: text, json") + Cmd.RunE = runChecks + + checkCmd.Flags().String("output", "text", "Output format: text, json") + checkCmd.RunE = runChecks + Cmd.AddCommand(checkCmd) +} + +// NormalizeServerURL is re-exported here for doctor to use without cycling imports. +func normalizeServerURL(raw string) string { + return apiutil.NormalizeServerURL(raw) +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go new file mode 100644 index 0000000..23f9986 --- /dev/null +++ b/cmd/doctor/doctor.go @@ -0,0 +1,23 @@ +// Package doctor provides the "doctor" command which verifies that the CLI is +// correctly configured and can reach the configured Seerr server. +package doctor + +import ( + "github.com/spf13/cobra" +) + +// Cmd is the parent command for all doctor subcommands. +var Cmd = &cobra.Command{ + Use: "doctor", + Short: "Check CLI configuration and server connectivity", + Long: `Run a series of checks to verify that seerr-cli is correctly configured and can reach the Seerr API.`, + Example: ` # Run all checks (human-readable output) + seerr-cli doctor + + # Output check results as JSON + seerr-cli doctor --output json`, +} + +func init() { + // The check subcommand is added in check.go's init(). +} diff --git a/cmd/root.go b/cmd/root.go index 8dc43f6..0bfc577 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,8 @@ import ( "seerr-cli/cmd/blocklist" "seerr-cli/cmd/collection" "seerr-cli/cmd/config" + "seerr-cli/cmd/docs" + "seerr-cli/cmd/doctor" "seerr-cli/cmd/issue" "seerr-cli/cmd/mcp" "seerr-cli/cmd/media" @@ -53,7 +55,7 @@ var RootCmd = &cobra.Command{ Short: "A CLI to interact with the Seerr API", Long: `A command line interface to call endpoints defined in the Seerr OpenAPI specification.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if cmd.Root() != cmd && (cmd.Name() == "config" || cmd.Parent().Name() == "config" || cmd.Name() == "help" || cmd.Name() == "completion" || cmd.Parent().Name() == "completion" || cmd.Name() == "mcp" || cmd.Parent().Name() == "mcp") { + if cmd.Root() != cmd && (cmd.Name() == "config" || cmd.Parent().Name() == "config" || cmd.Name() == "help" || cmd.Name() == "completion" || cmd.Parent().Name() == "completion" || cmd.Name() == "mcp" || cmd.Parent().Name() == "mcp" || cmd.Name() == "doctor" || cmd.Parent().Name() == "doctor" || cmd.Name() == "docs" || cmd.Parent().Name() == "docs") { return nil } if viper.GetString("seerr.server") == "" { @@ -86,6 +88,8 @@ func init() { viper.BindPFlag("verbose", RootCmd.PersistentFlags().Lookup("verbose")) RootCmd.AddCommand(config.Cmd) + RootCmd.AddCommand(doctor.Cmd) + RootCmd.AddCommand(docs.Cmd) RootCmd.AddCommand(mcp.Cmd) RootCmd.AddCommand(status.Cmd) RootCmd.AddCommand(users.Cmd) diff --git a/tests/doctor_test.go b/tests/doctor_test.go new file mode 100644 index 0000000..4216196 --- /dev/null +++ b/tests/doctor_test.go @@ -0,0 +1,108 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "seerr-cli/cmd" + "seerr-cli/cmd/apiutil" + "seerr-cli/cmd/doctor" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDoctorAllChecksPass(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"version":"2.0.0"}`) + })) + defer ts.Close() + + results := doctor.Run(ts.URL, "test-key") + + statusByName := map[string]string{} + for _, r := range results { + statusByName[r.Name] = r.Status + } + + assert.Equal(t, "ok", statusByName["server_configured"]) + assert.Equal(t, "ok", statusByName["server_reachable"]) + assert.Equal(t, "ok", statusByName["api_key_configured"]) + assert.Equal(t, "ok", statusByName["api_key_valid"]) + assert.Equal(t, "info", statusByName["server_version"]) +} + +func TestDoctorServerUnreachable(t *testing.T) { + // Use a URL that nothing is listening on. + results := doctor.Run("http://127.0.0.1:19999", "test-key") + + statusByName := map[string]string{} + for _, r := range results { + statusByName[r.Name] = r.Status + } + + assert.Equal(t, "ok", statusByName["server_configured"]) + assert.Equal(t, "fail", statusByName["server_reachable"]) + // Subsequent checks are skipped when the server is unreachable. + assert.Equal(t, "skip", statusByName["api_key_configured"]) + assert.Equal(t, "skip", statusByName["api_key_valid"]) + assert.Equal(t, "skip", statusByName["server_version"]) +} + +func TestDoctorAPIKeyInvalid(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Api-Key") == "" { + // Unauthenticated request returns 200 for reachability check. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{}`) + return + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer ts.Close() + + results := doctor.Run(ts.URL, "bad-key") + + statusByName := map[string]string{} + for _, r := range results { + statusByName[r.Name] = r.Status + } + + assert.Equal(t, "ok", statusByName["server_reachable"]) + assert.Equal(t, "fail", statusByName["api_key_valid"]) +} + +func TestDoctorCommandOutputJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"version":"2.0.0"}`) + })) + defer ts.Close() + + apiutil.OverrideServerURL = ts.URL + "/api/v1" + defer func() { apiutil.OverrideServerURL = "" }() + + viper.Set("seerr.server", ts.URL) + viper.Set("seerr.api_key", "test-key") + + b := bytes.NewBufferString("") + command := cmd.RootCmd + command.SetOut(b) + command.SetArgs([]string{"doctor", "--output", "json"}) + err := command.Execute() + require.NoError(t, err) + + var results []map[string]string + err = json.Unmarshal([]byte(b.String()), &results) + require.NoError(t, err, "output should be a parseable JSON array") + assert.NotEmpty(t, results) +} From 6aa57cbaa404fc7a04ea3039bae1748ef17cae62 Mon Sep 17 00:00:00 2001 From: Omid Astaraki Date: Mon, 16 Mar 2026 18:24:10 +0000 Subject: [PATCH 4/5] feat(docs): add docs generate command for CLI reference Markdown --- cmd/docs/docs.go | 16 ++++++++++++++++ cmd/docs/generate.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 cmd/docs/docs.go create mode 100644 cmd/docs/generate.go diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go new file mode 100644 index 0000000..7094b62 --- /dev/null +++ b/cmd/docs/docs.go @@ -0,0 +1,16 @@ +// Package docs provides commands for generating CLI reference documentation. +package docs + +import ( + "github.com/spf13/cobra" +) + +// Cmd is the parent command for documentation-related subcommands. +var Cmd = &cobra.Command{ + Use: "docs", + Short: "Generate CLI reference documentation", +} + +func init() { + // Subcommands are added in their respective files' init() functions. +} diff --git a/cmd/docs/generate.go b/cmd/docs/generate.go new file mode 100644 index 0000000..bac3ca7 --- /dev/null +++ b/cmd/docs/generate.go @@ -0,0 +1,36 @@ +package docs + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" +) + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate CLI reference documentation as Markdown", + Example: ` # Generate docs into the default directory + seerr-cli docs generate + + # Generate docs into a custom directory + seerr-cli docs generate --output-dir /tmp/cli-docs`, + RunE: func(cmd *cobra.Command, args []string) error { + dir, _ := cmd.Flags().GetString("output-dir") + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", dir, err) + } + root := cmd.Root() + if err := doc.GenMarkdownTree(root, dir); err != nil { + return fmt.Errorf("failed to generate docs: %w", err) + } + cmd.Printf("Documentation written to %s\n", dir) + return nil + }, +} + +func init() { + generateCmd.Flags().String("output-dir", "./docs/cli/", "Directory to write generated Markdown files") + Cmd.AddCommand(generateCmd) +} From 74611417550d50f93dba685005b0aa68b5426abd Mon Sep 17 00:00:00 2001 From: Omid Astaraki Date: Mon, 16 Mar 2026 18:24:13 +0000 Subject: [PATCH 5/5] docs(CLAUDE.md): add output conventions and seerrclient usage guidance --- CLAUDE.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 785287a..e5d250f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,10 +67,9 @@ Follow this pattern (see `cmd/status/system.go` as the canonical example): 1. **Look up** the endpoint in `open-api.yaml` and the corresponding service in `pkg/api/api_*.go`. 2. **Write a failing test** in `tests/__test.go` using `httptest.NewServer()`. 3. **Implement** the command in `cmd//.go`: - - Build `api.NewConfiguration()`, set `configuration.Servers` to `viper.GetString("server") + "/api/v1"`. - - Add `X-Api-Key` header via `configuration.AddDefaultHeader`. - - Call the appropriate `apiClient..(ctx).Execute()`. - - Print JSON with `cmd.Println(string(jsonRes))` — never use `fmt` (breaks test output capture). + - Use `seerrclient.New()` (from `internal/seerrclient`) to build the API client — never call `api.NewConfiguration()` directly. + - Call the appropriate method on the client (e.g. `MovieGet(id, lang)`) or `sc.Unwrap()..` for endpoints without a typed wrapper. + - Print output via `apiutil.PrintOutput(cmd, res, mode)` or `apiutil.HandleResponse` — never use `fmt` (breaks test output capture). - Respect `viper.GetBool("verbose")` for progress/URL/status output. 4. Register via `Cmd.AddCommand(...)` in the file's `init()`. @@ -90,6 +89,12 @@ Global flags (`--server`, `--api-key`, `--verbose`) are bound to Viper keys `ser - **Normal mode**: Raw pretty-printed JSON only (piping to `jq` must work). - **Verbose mode**: Include progress messages, target URL, HTTP status code before the JSON. +### Output Conventions + +Every command returning structured data must accept `--output`/`-o` with values `json` (default), `yaml`, `table`. Register the flag with `apiutil.AddOutputFlag(cmd)` in `init()` and read the mode with `apiutil.GetOutputMode(cmd)`. Commands returning deeply nested objects may fall back to YAML for `table` mode. Use `apiutil.PrintOutput(cmd, res, mode)` for typed structs and `apiutil.PrintRawOutput(cmd, data, mode)` for raw JSON bytes. + +**Note on test isolation**: Cobra flag values persist across `Execute()` calls in the same test process. Tests that set a non-default `--output` value must reset it in `t.Cleanup` using `resetOutputFlag([]string{"cmd", "sub"})` to avoid breaking subsequent tests that omit the flag. + ### Claude Usage Rules - Never add `Co-Authored-By`, `Generated with`, or any mention of Claude or Anthropic in commit messages or PR descriptions.