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
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<group>_<action>_test.go` using `httptest.NewServer()`.
3. **Implement** the command in `cmd/<group>/<action>.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.<ServiceAPI>.<Method>(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().<ServiceAPI>.<Method>` 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()`.

Expand All @@ -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.
Expand Down
167 changes: 167 additions & 0 deletions cmd/apiutil/output.go
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions cmd/docs/docs.go
Original file line number Diff line number Diff line change
@@ -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.
}
36 changes: 36 additions & 0 deletions cmd/docs/generate.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading