diff --git a/.changeset/first-version.md b/.changeset/first-version.md index 7a6f7b1..b948586 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -7,6 +7,7 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Commands derive from the routes of the Meilisearch API and of the Management API, verb first and resource plural: `meili get health`, `meili get indexes`, `meili get indexes --id movies`. - Documents: `meili get documents --index `, narrowed by `--ids` or `--filter`; `meili add documents --index --file ` and `meili update documents --index --file `, which read the documents from a file, from stdin with `--file -` or from `--data`; `meili delete documents --index (--ids | --filter | --all) --yes`. - Indexes: `meili get indexes [--id ]`; `meili add indexes --id [--primary-key ]`, `meili update indexes --id [--primary-key ] [--new-id ]`, which renames the index, and `meili delete indexes --id --yes`. +- Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. - `meili schema` prints the index of the command tree as JSON, one entry per command with its routes, and `meili schema --command "get indexes"` prints one command in full, with its key actions and whether it returns a task. - Connection through `--project-url` and `--project-api-key`, or `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY`; `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` for the Management API. - Several projects in a config file, `~/.config/meili/config.json` or `MEILI_CONFIG`, chosen with `--project` on the command line and never deduced; `meili config list`, `config set` and `config unset` manage the file. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 8d46018..5e6f3c2 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -28,7 +28,7 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i 7. **Values.** A scalar is passed as it is. A boolean is a bare flag, `--retrieve-vectors`. An array of scalars is comma-separated, `--fields id,title`, as the API's own GET syntax. An object or a nested array is JSON text, `--typo-tolerance '{"enabled":false}'`. The value `null` sends a JSON null, `--expires-at null`, `--stop-words null`. 8. **Body input.** On every route with a body, `--file ` or `--data ''` replaces the body fields; `--file -` reads stdin. Path and query flags stay valid next to them. A body-field flag given together with `--file` or `--data` overrides that field, as a flag overrides its environment variable. When a route's body is not an object of named fields but the value itself, the array of identifiers of `delete documents --ids` or the documents of `add documents`, the body is that value, and its shape selects the route among the ones the command covers: `delete documents --data '[1,2]'` deletes by identifiers, `--data '{"filter":…}'` deletes by filter. Documents are sent as JSON, the only content type the document declares. 9. **Routes that are not implemented.** The `GET` twin of a `POST` route that takes the same parameters in a body (search, similar, the list of documents); the unit route of a resource whose list filters by identifiers (documents, tasks, batches); the settings sub-routes, since `update settings` sends any subset and `null` resets one setting; `GET /api/v2/meilisearch-versions`. Each is recorded without command in the exceptions. -10. **Experimental routes** are out of the contract until the feature is stable. Implementing one is a contract change: an exception row with the reason. `compact` is the one exception today. +10. **Experimental routes** are out of the contract until the feature is stable. Implementing one is a contract change: an exception row with the reason. `compact` is the one exception today. A body field or a query parameter that an experimental feature gates is out of the contract the same way: the command declares no flag for it, and `--data` or `--file` still carries it to the API. `useNetwork` on `get documents`, and `media`, `personalize`, `useNetwork` and `inlineRule` on `search`. These stay out of the exceptions table, which lists routes. 11. **Destructive commands.** A command of the verb `delete` takes `--yes` and refuses to run without it: `cli_usage`, exit 2, before any network call. `--yes` is a flag to write, never a question the CLI asks, see `output.md`. | Routes | Command | diff --git a/oclif.manifest.json b/oclif.manifest.json index b4d405f..208e8b1 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -2705,6 +2705,410 @@ "schema.js" ] }, + "search": { + "aliases": [], + "args": {}, + "description": "Search the documents of an index, with --q and the search parameters of the route.", + "examples": [ + "<%= config.bin %> <%= command.id %> --index movies --q nosferatu", + "<%= config.bin %> <%= command.id %> --index movies --q nosferatu --output json", + "<%= config.bin %> <%= command.id %> --index movies --q horror --filter 'rating > 4' --sort rating:desc --limit 5", + "<%= config.bin %> <%= command.id %> --index movies --facets genres --attributes-to-retrieve id,title", + "<%= config.bin %> <%= command.id %> --index movies --data '{\"q\":\"nosferatu\",\"hybrid\":{\"embedder\":\"default\",\"semanticRatio\":0.5}}'" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "index": { + "description": "The index to search in.", + "name": "index", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "q": { + "description": "The search terms. Omit it for a placeholder search, which returns everything.", + "name": "q", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Number of documents to skip. Ignored when --page or --hits-per-page is given.", + "name": "offset", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "limit": { + "description": "Maximum number of documents to return, 20 by default. Ignored when --page or --hits-per-page is given.", + "name": "limit", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "page": { + "description": "Page to return, 1-indexed. The response then carries totalHits and totalPages instead of estimatedTotalHits.", + "name": "page", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "hits-per-page": { + "description": "Number of documents per page, used with --page. 0 counts without returning any.", + "name": "hits-per-page", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "attributes-to-retrieve": { + "description": "Document attributes to return, comma-separated. Defaults to the displayed ones.", + "name": "attributes-to-retrieve", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "attributes-to-crop": { + "description": "Attributes to crop in _formatted, comma-separated, each optionally as `attribute:length`.", + "name": "attributes-to-crop", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "crop-length": { + "description": "Maximum number of words in a cropped value, 10 by default.", + "name": "crop-length", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "crop-marker": { + "description": "String marking where a cropped value was cut, `…` by default.", + "name": "crop-marker", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "attributes-to-highlight": { + "description": "Attributes to highlight in _formatted, comma-separated.", + "name": "attributes-to-highlight", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "highlight-pre-tag": { + "description": "String inserted before each highlighted term, `` by default.", + "name": "highlight-pre-tag", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "highlight-post-tag": { + "description": "String inserted after each highlighted term, `` by default.", + "name": "highlight-post-tag", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "show-matches-position": { + "description": "Add _matchesPosition to each hit, the byte offset of every matched term.", + "name": "show-matches-position", + "allowNo": false, + "type": "boolean" + }, + "filter": { + "description": "Filter expression, for example `rating > 4`. Its array-of-arrays form goes through --data.", + "name": "filter", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "sort": { + "description": "Attributes to sort on, comma-separated, each as `attribute:asc` or `:desc`.", + "name": "sort", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "distinct": { + "description": "Return one document per value of this attribute, which must be filterable.", + "name": "distinct", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "facets": { + "description": "Attributes to count in facetDistribution, comma-separated. Patterns such as `*` are accepted.", + "name": "facets", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "matching-strategy": { + "description": "How query terms are relaxed when there are not enough results.", + "name": "matching-strategy", + "hasDynamicHelp": false, + "multiple": false, + "options": [ + "last", + "all", + "frequency" + ], + "type": "option" + }, + "attributes-to-search-on": { + "description": "Restrict the search to these searchable attributes, comma-separated.", + "name": "attributes-to-search-on", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "ranking-score-threshold": { + "description": "Drop the hits whose ranking score is below this value, between 0.0 and 1.0.", + "name": "ranking-score-threshold", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "locales": { + "description": "ISO-639 locales of the query, comma-separated, instead of auto-detection.", + "name": "locales", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "hybrid": { + "description": "Hybrid search as JSON, for example `{\"embedder\":\"default\",\"semanticRatio\":0.5}`.", + "name": "hybrid", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "vector": { + "description": "Query vector for a user-provided embedder, comma-separated numbers.", + "name": "vector", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "retrieve-vectors": { + "description": "Add _vectors to each hit, the embeddings of the document.", + "name": "retrieve-vectors", + "allowNo": false, + "type": "boolean" + }, + "show-ranking-score": { + "description": "Add _rankingScore to each hit, between 0.0 and 1.0.", + "name": "show-ranking-score", + "allowNo": false, + "type": "boolean" + }, + "show-ranking-score-details": { + "description": "Add _rankingScoreDetails to each hit, the score of every ranking rule.", + "name": "show-ranking-score-details", + "allowNo": false, + "type": "boolean" + }, + "show-performance-details": { + "description": "Add performanceDetails to the response, a timing breakdown of the query.", + "name": "show-performance-details", + "allowNo": false, + "type": "boolean" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "search", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "POST /indexes/{index_uid}/search" + ], + "keyActions": [ + "search" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "search.js" + ] + }, "update:documents": { "aliases": [], "args": {}, diff --git a/src/commands/search.ts b/src/commands/search.ts new file mode 100644 index 0000000..a684ce9 --- /dev/null +++ b/src/commands/search.ts @@ -0,0 +1,215 @@ +import { Flags } from "@oclif/core"; +import type { HybridSearch, SearchParams } from "meilisearch"; +import { type Api, BaseCommand } from "../lib/base-command.js"; +import { readJsonObjectBody } from "../lib/input.js"; + +export default class Search extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["POST /indexes/{index_uid}/search"]; + + static keyActions = ["search"]; + + static override description = + "Search the documents of an index, with --q and the search parameters of the route."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --index movies --q nosferatu", + "<%= config.bin %> <%= command.id %> --index movies --q nosferatu --output json", + "<%= config.bin %> <%= command.id %> --index movies --q horror --filter 'rating > 4' --sort rating:desc --limit 5", + "<%= config.bin %> <%= command.id %> --index movies --facets genres --attributes-to-retrieve id,title", + '<%= config.bin %> <%= command.id %> --index movies --data \'{"q":"nosferatu","hybrid":{"embedder":"default","semanticRatio":0.5}}\'', + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + index: Flags.string({ + required: true, + description: "The index to search in.", + helpValue: "", + }), + q: Flags.string({ + description: "The search terms. Omit it for a placeholder search, which returns everything.", + helpValue: "", + }), + offset: Flags.integer({ + description: "Number of documents to skip. Ignored when --page or --hits-per-page is given.", + helpValue: "", + }), + limit: Flags.integer({ + description: + "Maximum number of documents to return, 20 by default. Ignored when --page or --hits-per-page is given.", + helpValue: "", + }), + page: Flags.integer({ + description: + "Page to return, 1-indexed. The response then carries totalHits and totalPages instead of estimatedTotalHits.", + helpValue: "", + }), + "hits-per-page": Flags.integer({ + description: + "Number of documents per page, used with --page. 0 counts without returning any.", + helpValue: "", + }), + "attributes-to-retrieve": Flags.string({ + description: + "Document attributes to return, comma-separated. Defaults to the displayed ones.", + helpValue: "", + }), + "attributes-to-crop": Flags.string({ + description: + "Attributes to crop in _formatted, comma-separated, each optionally as `attribute:length`.", + helpValue: "", + }), + "crop-length": Flags.integer({ + description: "Maximum number of words in a cropped value, 10 by default.", + helpValue: "", + }), + "crop-marker": Flags.string({ + description: "String marking where a cropped value was cut, `…` by default.", + helpValue: "", + }), + "attributes-to-highlight": Flags.string({ + description: "Attributes to highlight in _formatted, comma-separated.", + helpValue: "", + }), + "highlight-pre-tag": Flags.string({ + description: "String inserted before each highlighted term, `` by default.", + helpValue: "", + }), + "highlight-post-tag": Flags.string({ + description: "String inserted after each highlighted term, `` by default.", + helpValue: "", + }), + "show-matches-position": Flags.boolean({ + description: "Add _matchesPosition to each hit, the byte offset of every matched term.", + }), + filter: Flags.string({ + description: + "Filter expression, for example `rating > 4`. Its array-of-arrays form goes through --data.", + helpValue: "", + }), + sort: Flags.string({ + description: "Attributes to sort on, comma-separated, each as `attribute:asc` or `:desc`.", + helpValue: "", + }), + distinct: Flags.string({ + description: "Return one document per value of this attribute, which must be filterable.", + helpValue: "", + }), + facets: Flags.string({ + description: + "Attributes to count in facetDistribution, comma-separated. Patterns such as `*` are accepted.", + helpValue: "", + }), + "matching-strategy": Flags.option({ + options: ["last", "all", "frequency"] as const, + description: "How query terms are relaxed when there are not enough results.", + })(), + "attributes-to-search-on": Flags.string({ + description: "Restrict the search to these searchable attributes, comma-separated.", + helpValue: "", + }), + "ranking-score-threshold": Flags.string({ + description: "Drop the hits whose ranking score is below this value, between 0.0 and 1.0.", + helpValue: "", + }), + locales: Flags.string({ + description: "ISO-639 locales of the query, comma-separated, instead of auto-detection.", + helpValue: "", + }), + hybrid: Flags.string({ + description: + 'Hybrid search as JSON, for example `{"embedder":"default","semanticRatio":0.5}`.', + helpValue: "", + }), + vector: Flags.string({ + description: "Query vector for a user-provided embedder, comma-separated numbers.", + helpValue: "", + }), + "retrieve-vectors": Flags.boolean({ + description: "Add _vectors to each hit, the embeddings of the document.", + }), + "show-ranking-score": Flags.boolean({ + description: "Add _rankingScore to each hit, between 0.0 and 1.0.", + }), + "show-ranking-score-details": Flags.boolean({ + description: "Add _rankingScoreDetails to each hit, the score of every ranking rule.", + }), + "show-performance-details": Flags.boolean({ + description: "Add performanceDetails to the response, a timing breakdown of the query.", + }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(Search); + const fromFlags: SearchParams = { + q: flags.q, + offset: flags.offset, + limit: flags.limit, + page: flags.page, + hitsPerPage: flags["hits-per-page"], + attributesToRetrieve: flags["attributes-to-retrieve"]?.split(","), + attributesToCrop: flags["attributes-to-crop"]?.split(","), + cropLength: flags["crop-length"], + cropMarker: flags["crop-marker"], + attributesToHighlight: flags["attributes-to-highlight"]?.split(","), + highlightPreTag: flags["highlight-pre-tag"], + highlightPostTag: flags["highlight-post-tag"], + showMatchesPosition: flags["show-matches-position"] ? true : undefined, + filter: flags.filter, + sort: flags.sort?.split(","), + distinct: flags.distinct, + facets: flags.facets?.split(","), + matchingStrategy: flags["matching-strategy"], + attributesToSearchOn: flags["attributes-to-search-on"]?.split(","), + rankingScoreThreshold: asNumber(flags["ranking-score-threshold"]) as number | undefined, + locales: flags.locales?.split(","), + hybrid: asJson(flags.hybrid) as HybridSearch | undefined, + vector: flags.vector?.split(",").map(asNumber) as number[] | undefined, + retrieveVectors: flags["retrieve-vectors"] ? true : undefined, + showRankingScore: flags["show-ranking-score"] ? true : undefined, + showRankingScoreDetails: flags["show-ranking-score-details"] ? true : undefined, + showPerformanceDetails: flags["show-performance-details"] ? true : undefined, + }; + + // The body of --file or --data first, then the flags, which override a field of it (rule 8). + // The undefined entries are dropped rather than spread: an absent flag would otherwise + // erase the field the body carries, since JSON.stringify leaves an undefined value out. + const params = { + ...readJsonObjectBody(flags), + ...Object.fromEntries(Object.entries(fromFlags).filter(([, value]) => value !== undefined)), + } as SearchParams; + + await this.output(await this.meilisearch.index(flags.index).search(undefined, params)); + } +} + +/** + * The two conversions a flag needs before it can be a body field (rule 7): the + * text of a number, and the JSON text of an object. Text that does not convert + * is sent as it is, so that the API names the value it refused, with its own + * code, rather than the CLI judging a value the API owns. The CLI only refuses + * what it cannot send at all, as `--data` and `--file`, which it has to parse + * before it can merge them. + */ +function asNumber(value: string | undefined): number | string | undefined { + if (value === undefined) return undefined; + const text = value.trim(); + // `Number` alone would not do: it reads `0x1` as 1 and `1e999` as Infinity, + // which JSON writes as null. Both turn what the agent typed into another + // value, silently. Only text that spells a decimal number becomes one. + return DECIMAL.test(text) && Number.isFinite(Number(text)) ? Number(text) : value; +} + +const DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +function asJson(value: string | undefined): unknown { + if (value === undefined) return undefined; + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/test/commands/__snapshots__/search.test.ts.snap b/test/commands/__snapshots__/search.test.ts.snap new file mode 100644 index 0000000..50e66f7 --- /dev/null +++ b/test/commands/__snapshots__/search.test.ts.snap @@ -0,0 +1,145 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili search > passes the API error of a missing index through and exits 4 1`] = ` +{ + "exitCode": 4, + "stderr": "{"error":{"code":"index_not_found","type":"invalid_request","message":"Index \`missing\` not found.","link":"https://docs.meilisearch.com/errors#index_not_found"}} +", + "stdout": "", +} +`; + +exports[`meili search > prints its help without any network call 1`] = ` +"Search the documents of an index, with --q and the search parameters of the route. + +USAGE + $ meili search --index [-o ] [--debug] + [--project-url ] [--project-api-key ] [--project ] [--q + ] [--offset ] [--limit ] [--page ] [--hits-per-page ] + [--attributes-to-retrieve ] [--attributes-to-crop ] + [--crop-length ] [--crop-marker ] [--attributes-to-highlight + ] [--highlight-pre-tag ] [--highlight-post-tag ] + [--show-matches-position] [--filter ] [--sort ] [--distinct + ] [--facets ] [--matching-strategy last|all|frequency] + [--attributes-to-search-on ] [--ranking-score-threshold ] + [--locales ] [--hybrid ] [--vector ] + [--retrieve-vectors] [--show-ranking-score] [--show-ranking-score-details] + [--show-performance-details] [--file | --data ] + +FLAGS + -o, --output= json (default): one document, the API + body as it is, indented in a terminal, + compact when piped. ndjson: one item + per line, list commands only, streams + with --all. + --attributes-to-crop= Attributes to crop in _formatted, + comma-separated, each optionally as + \`attribute:length\`. + --attributes-to-highlight= Attributes to highlight in _formatted, + comma-separated. + --attributes-to-retrieve= Document attributes to return, + comma-separated. Defaults to the + displayed ones. + --attributes-to-search-on= Restrict the search to these + searchable attributes, + comma-separated. + --crop-length= Maximum number of words in a cropped + value, 10 by default. + --crop-marker= String marking where a cropped value + was cut, \`…\` by default. + --data= Inline JSON body. + --debug Print every HTTP request and response + on stderr, secrets redacted. + --distinct= Return one document per value of this + attribute, which must be filterable. + --facets= Attributes to count in + facetDistribution, comma-separated. + Patterns such as \`*\` are accepted. + --file= Read the body from a file. \`-\` reads + from stdin. + --filter= Filter expression, for example \`rating + > 4\`. Its array-of-arrays form goes + through --data. + --highlight-post-tag= String inserted after each highlighted + term, \`\` by default. + --highlight-pre-tag= String inserted before each + highlighted term, \`\` by default. + --hits-per-page= Number of documents per page, used + with --page. 0 counts without + returning any. + --hybrid= Hybrid search as JSON, for example + \`{"embedder":"default","semanticRatio" + :0.5}\`. + --index= (required) The index to search in. + --limit= Maximum number of documents to return, + 20 by default. Ignored when --page or + --hits-per-page is given. + --locales= ISO-639 locales of the query, + comma-separated, instead of + auto-detection. + --matching-strategy=