Implement records CLI commands (list, search, get, versions)#36
Implement records CLI commands (list, search, get, versions)#36
Conversation
Add user-facing CLI commands for all records operations. - records list: list user's records with --status, --community, --all - records search <query>: Elasticsearch query with --community, --all - records get <id>: full record details, supports --format bibtex|datacite - records versions <id>: list all versions of a record - Auto-pagination via --all flag with 10k ceiling warning - All commands output via Format() respecting --output and --fields Closes #11 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR implements the core records CLI commands for the Zenodo CLI tool, enabling users to list their records, search all Zenodo records, retrieve specific records in various formats, and view all versions of a record. The implementation integrates with the existing API client layer (from #9) and output formatters (from #8), providing a consistent command-line interface with pagination support, flexible output formats, and proper separation of data output from status messages.
Changes:
- Added four subcommands under
records:list,search,get, andversions - Implemented auto-pagination with
--allflag that respects rate limits and warns at the 10k ceiling - Added
--formatflag forrecords getto support BibTeX and DataCite XML exports via Accept headers - Integrated with the global
appCtxfor configuration (token, baseURL, output format, field selection)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| switch format { | ||
| case "bibtex": | ||
| data, err := client.GetRaw(fmt.Sprintf("/records/%d", id), "application/x-bibtex") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Println(string(data)) | ||
| return nil | ||
| case "datacite": | ||
| data, err := client.GetRaw(fmt.Sprintf("/records/%d", id), "application/vnd.datacite.datacite+xml") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Println(string(data)) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
The format flag handling has an inconsistency. When format is "json", it falls through to the default case and uses the output formatter with appCtx.Output, instead of using the Accept header approach like bibtex and datacite. This means --format json and --output json would behave differently under the hood. Consider adding a case for "json" that uses GetRaw with "application/json" Accept header for consistency, or explicitly document that --format json is equivalent to --output json.
| recordsSearchCmd.Flags().Bool("all", false, "Fetch all pages (up to 10k results)") | ||
|
|
||
| // records get flags | ||
| recordsGetCmd.Flags().String("format", "", "Response format: json, bibtex, datacite (default: uses --output)") |
There was a problem hiding this comment.
The format flag help text lists "json, bibtex, datacite" but doesn't mention "table" which is specified in requirement FR-2.3. Additionally, the help text should clarify the behavior: when format is empty, it uses the --output flag setting; when format is "bibtex" or "datacite", it requests that specific format from the API via Accept headers. The current text "(default: uses --output)" is somewhat ambiguous.
| recordsGetCmd.Flags().String("format", "", "Response format: json, bibtex, datacite (default: uses --output)") | |
| recordsGetCmd.Flags().String("format", "", "Response format: table, json, bibtex, datacite. When empty, uses --output; bibtex/datacite are requested via Accept headers.") |
| package cli | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "strconv" | ||
|
|
||
| "github.com/ran-codes/zenodo-cli/internal/api" | ||
| "github.com/ran-codes/zenodo-cli/internal/model" | ||
| "github.com/ran-codes/zenodo-cli/internal/output" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| var recordsCmd = &cobra.Command{ | ||
| Use: "records", | ||
| Short: "List, search, and view records", | ||
| } | ||
|
|
||
| var recordsListCmd = &cobra.Command{ | ||
| Use: "list", | ||
| Short: "List your records and drafts", | ||
| Long: `List the authenticated user's records and drafts. | ||
|
|
||
| Examples: | ||
| zenodo records list | ||
| zenodo records list --status draft | ||
| zenodo records list --community my-org --all`, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| client := api.NewClient(appCtx.BaseURL, appCtx.Token) | ||
| status, _ := cmd.Flags().GetString("status") | ||
| community, _ := cmd.Flags().GetString("community") | ||
| all, _ := cmd.Flags().GetBool("all") | ||
|
|
||
| params := api.RecordListParams{ | ||
| Status: status, | ||
| Community: community, | ||
| } | ||
|
|
||
| if all { | ||
| records, total, err := api.PaginateAll(func(page int) (*model.RecordSearchResult, error) { | ||
| params.Page = page | ||
| return client.ListUserRecords(params) | ||
| }) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "Warning: %v\n", err) | ||
| } | ||
| fmt.Fprintf(os.Stderr, "Total: %d records\n", total) | ||
| return output.Format(os.Stdout, records, appCtx.Output, appCtx.Fields) | ||
| } | ||
|
|
||
| result, err := client.ListUserRecords(params) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Fprintf(os.Stderr, "Showing %d of %d records\n", len(result.Hits.Hits), result.Hits.Total) | ||
| return output.Format(os.Stdout, result.Hits.Hits, appCtx.Output, appCtx.Fields) | ||
| }, | ||
| } | ||
|
|
||
| var recordsSearchCmd = &cobra.Command{ | ||
| Use: "search <query>", | ||
| Short: "Search published records", | ||
| Long: `Search all published records using Elasticsearch query syntax. | ||
|
|
||
| Examples: | ||
| zenodo records search "climate change" | ||
| zenodo records search --community my-org "dataset" | ||
| zenodo records search "publication_date:[2024-01-01 TO 2024-12-31]" --all`, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| client := api.NewClient(appCtx.BaseURL, appCtx.Token) | ||
| query := args[0] | ||
| community, _ := cmd.Flags().GetString("community") | ||
| all, _ := cmd.Flags().GetBool("all") | ||
|
|
||
| params := api.RecordListParams{ | ||
| Community: community, | ||
| } | ||
|
|
||
| if all { | ||
| records, total, err := api.PaginateAll(func(page int) (*model.RecordSearchResult, error) { | ||
| params.Page = page | ||
| return client.SearchRecords(query, params) | ||
| }) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "Warning: %v\n", err) | ||
| } | ||
| fmt.Fprintf(os.Stderr, "Total: %d records\n", total) | ||
| return output.Format(os.Stdout, records, appCtx.Output, appCtx.Fields) | ||
| } | ||
|
|
||
| result, err := client.SearchRecords(query, params) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Fprintf(os.Stderr, "Showing %d of %d records\n", len(result.Hits.Hits), result.Hits.Total) | ||
| return output.Format(os.Stdout, result.Hits.Hits, appCtx.Output, appCtx.Fields) | ||
| }, | ||
| } | ||
|
|
||
| var recordsGetCmd = &cobra.Command{ | ||
| Use: "get <id>", | ||
| Short: "Get a record by ID", | ||
| Long: `Retrieve full details of a published record. | ||
|
|
||
| Examples: | ||
| zenodo records get 12345 | ||
| zenodo records get 12345 --output json | ||
| zenodo records get 12345 --format bibtex`, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| id, err := strconv.Atoi(args[0]) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid record ID: %s", args[0]) | ||
| } | ||
|
|
||
| client := api.NewClient(appCtx.BaseURL, appCtx.Token) | ||
| format, _ := cmd.Flags().GetString("format") | ||
|
|
||
| // Handle non-JSON formats via Accept header. | ||
| switch format { | ||
| case "bibtex": | ||
| data, err := client.GetRaw(fmt.Sprintf("/records/%d", id), "application/x-bibtex") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Println(string(data)) | ||
| return nil | ||
| case "datacite": | ||
| data, err := client.GetRaw(fmt.Sprintf("/records/%d", id), "application/vnd.datacite.datacite+xml") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Println(string(data)) | ||
| return nil | ||
| } | ||
|
|
||
| record, err := client.GetRecord(id) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| return output.Format(os.Stdout, record, appCtx.Output, appCtx.Fields) | ||
| }, | ||
| } | ||
|
|
||
| var recordsVersionsCmd = &cobra.Command{ | ||
| Use: "versions <id>", | ||
| Short: "List all versions of a record", | ||
| Long: `List all versions of a record by its ID. | ||
|
|
||
| Examples: | ||
| zenodo records versions 12345`, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| id, err := strconv.Atoi(args[0]) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid record ID: %s", args[0]) | ||
| } | ||
|
|
||
| client := api.NewClient(appCtx.BaseURL, appCtx.Token) | ||
| result, err := client.ListVersions(id) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| return output.Format(os.Stdout, result.Hits.Hits, appCtx.Output, appCtx.Fields) | ||
| }, | ||
| } | ||
|
|
||
| func init() { | ||
| // records list flags | ||
| recordsListCmd.Flags().String("status", "", "Filter by status: draft, published") | ||
| recordsListCmd.Flags().String("community", "", "Filter by community ID") | ||
| recordsListCmd.Flags().Bool("all", false, "Fetch all pages (up to 10k results)") | ||
|
|
||
| // records search flags | ||
| recordsSearchCmd.Flags().String("community", "", "Filter by community ID") | ||
| recordsSearchCmd.Flags().Bool("all", false, "Fetch all pages (up to 10k results)") | ||
|
|
||
| // records get flags | ||
| recordsGetCmd.Flags().String("format", "", "Response format: json, bibtex, datacite (default: uses --output)") | ||
|
|
||
| recordsCmd.AddCommand(recordsListCmd) | ||
| recordsCmd.AddCommand(recordsSearchCmd) | ||
| recordsCmd.AddCommand(recordsGetCmd) | ||
| recordsCmd.AddCommand(recordsVersionsCmd) | ||
| rootCmd.AddCommand(recordsCmd) | ||
| } |
There was a problem hiding this comment.
The new records CLI commands lack test coverage. While the underlying API layer (internal/api/records.go) and output formatters (internal/output/) have comprehensive tests, the CLI commands themselves are untested. Consider adding tests that verify:
- Flag parsing and validation (e.g., status, community, all, format)
- Error handling when API calls fail
- Correct delegation to API client methods
- Output formatting via the output package
- stderr vs stdout output separation
This would align with the testing patterns established in other packages like internal/api, internal/config, and internal/output which all have comprehensive test files.
| format, _ := cmd.Flags().GetString("format") | ||
|
|
||
| // Handle non-JSON formats via Accept header. | ||
| switch format { | ||
| case "bibtex": | ||
| data, err := client.GetRaw(fmt.Sprintf("/records/%d", id), "application/x-bibtex") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Println(string(data)) | ||
| return nil | ||
| case "datacite": | ||
| data, err := client.GetRaw(fmt.Sprintf("/records/%d", id), "application/vnd.datacite.datacite+xml") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Println(string(data)) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
The format flag validation is missing. If a user provides an invalid format value (e.g., --format pdf), it silently falls through to the default behavior instead of returning an error. Consider adding validation to ensure format is one of the documented values ("bibtex", "datacite", or empty for default). Valid format values could be checked before the switch statement, or a default case could be added to return an error like: fmt.Errorf("unsupported format %q; supported: json, bibtex, datacite", format)
ELI5
This is where you can actually use the CLI to look at Zenodo records. You can list your own records, search all of Zenodo, get details on a specific record (including in BibTeX or DataCite format), and see all versions of a record. Add `--all` to get every result automatically paginated, or use `--fields id,title,doi` to pick which columns you care about. The output respects your `--output` choice (table/json/csv).
Summary
Code changes
Test plan
Closes #11
🤖 Generated with Claude Code