diff --git a/.changeset/add-doc-search-command.md b/.changeset/add-doc-search-command.md new file mode 100644 index 00000000000..8fd2b2da645 --- /dev/null +++ b/.changeset/add-doc-search-command.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add `shopify doc search` to return relevant shopify.dev documentation chunks as JSON. diff --git a/docs-shopify.dev/commands/interfaces/doc-search.interface.ts b/docs-shopify.dev/commands/interfaces/doc-search.interface.ts new file mode 100644 index 00000000000..c153524f8d5 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/doc-search.interface.ts @@ -0,0 +1,36 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `doc search` command: + * @publicDocs + */ +export interface docsearch { + /** + * Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored. + * @environment SHOPIFY_FLAG_API_NAME + */ + '--api-name '?: string + + /** + * Limit results to a specific API version (for example: 2025-10, latest, current). + * @environment SHOPIFY_FLAG_API_VERSION + */ + '--api-version '?: string + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * The search query. + * @environment SHOPIFY_FLAG_QUERY + */ + '--query ': string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index b83f906791c..e1c8ee7cc63 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2742,6 +2742,61 @@ "value": "export interface configautoupgradestatus {\n\n}" } }, + "docsearch": { + "docs-shopify.dev/commands/interfaces/doc-search.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts", + "name": "docsearch", + "description": "The following flags are available for the `doc search` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--api-name ", + "value": "string", + "description": "Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_API_NAME" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--api-version ", + "value": "string", + "description": "Limit results to a specific API version (for example: 2025-10, latest, current).", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_API_VERSION" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--query ", + "value": "string", + "description": "The search query.", + "environmentValue": "SHOPIFY_FLAG_QUERY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + } + ], + "value": "export interface docsearch {\n /**\n * Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored.\n * @environment SHOPIFY_FLAG_API_NAME\n */\n '--api-name '?: string\n\n /**\n * Limit results to a specific API version (for example: 2025-10, latest, current).\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The search query.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '--query ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "help": { "docs-shopify.dev/commands/interfaces/help.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/help.interface.ts", diff --git a/packages/cli/README.md b/packages/cli/README.md index 62a97253975..7acacf69324 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -39,6 +39,7 @@ * [`shopify config autoupgrade off`](#shopify-config-autoupgrade-off) * [`shopify config autoupgrade on`](#shopify-config-autoupgrade-on) * [`shopify config autoupgrade status`](#shopify-config-autoupgrade-status) +* [`shopify doc search`](#shopify-doc-search) * [`shopify help [command] [flags]`](#shopify-help-command-flags) * [`shopify hydrogen build`](#shopify-hydrogen-build) * [`shopify hydrogen check RESOURCE`](#shopify-hydrogen-check-resource) @@ -1213,6 +1214,35 @@ DESCRIPTION Run `shopify config autoupgrade on` or `shopify config autoupgrade off` to configure it. ``` +## `shopify doc search` + +Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`. + +``` +USAGE + $ shopify doc search --query [--api-name ] [--api-version ] [--no-color] [--verbose] + +FLAGS + --api-name= [env: SHOPIFY_FLAG_API_NAME] Limit results to a specific API (for example: admin, storefront, + hydrogen, functions). Unrecognized values are ignored. + --api-version= [env: SHOPIFY_FLAG_API_VERSION] Limit results to a specific API version (for example: 2025-10, + latest, current). + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --query= (required) [env: SHOPIFY_FLAG_QUERY] The search query. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic + discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To + download a full document verbatim, use `doc fetch`. + +EXAMPLES + # search shopify.dev for a topic + shopify doc search --query "subscribe to webhooks" + # narrow the search to a specific API and version + shopify doc search --query "create a product" --api-name admin --api-version latest +``` + ## `shopify help [command] [flags]` Display help for Shopify CLI diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c8654ed3cf6..66642ffcfde 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3435,6 +3435,68 @@ "strict": true, "summary": "Watch and prints out changes to an app." }, + "doc:search": { + "aliases": [ + ], + "args": { + }, + "description": "Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`.", + "enableJsonFlag": false, + "examples": [ + "# search shopify.dev for a topic\n shopify doc search --query \"subscribe to webhooks\"\n\n # narrow the search to a specific API and version\n shopify doc search --query \"create a product\" --api-name admin --api-version latest\n " + ], + "flags": { + "api-name": { + "description": "Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored.", + "env": "SHOPIFY_FLAG_API_NAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "api-name", + "type": "option" + }, + "api-version": { + "description": "Limit results to a specific API version (for example: 2025-10, latest, current).", + "env": "SHOPIFY_FLAG_API_VERSION", + "hasDynamicHelp": false, + "multiple": false, + "name": "api-version", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "query": { + "description": "The search query.", + "env": "SHOPIFY_FLAG_QUERY", + "hasDynamicHelp": false, + "multiple": false, + "name": "query", + "required": true, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "doc:search", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true + }, "docs:generate": { "aliases": [ ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 8cb2b10d164..c646561d365 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -94,6 +94,9 @@ "scope": "shopify", "topicSeparator": " ", "topics": { + "doc": { + "description": "Search and fetch documentation from shopify.dev." + }, "hydrogen": { "description": "Build Hydrogen storefronts." }, diff --git a/packages/cli/src/cli/commands/doc/search.ts b/packages/cli/src/cli/commands/doc/search.ts new file mode 100644 index 00000000000..c9911fea502 --- /dev/null +++ b/packages/cli/src/cli/commands/doc/search.ts @@ -0,0 +1,41 @@ +import {docSearchService} from '../../services/commands/doc/search.js' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {Flags} from '@oclif/core' + +export default class DocSearch extends Command { + static description = + 'Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`.' + + static examples = [ + `# search shopify.dev for a topic + shopify doc search --query "subscribe to webhooks" + + # narrow the search to a specific API and version + shopify doc search --query "create a product" --api-name admin --api-version latest + `, + ] + + static flags = { + ...globalFlags, + query: Flags.string({ + description: 'The search query.', + env: 'SHOPIFY_FLAG_QUERY', + required: true, + }), + 'api-name': Flags.string({ + description: + 'Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored.', + env: 'SHOPIFY_FLAG_API_NAME', + }), + 'api-version': Flags.string({ + description: 'Limit results to a specific API version (for example: 2025-10, latest, current).', + env: 'SHOPIFY_FLAG_API_VERSION', + }), + } + + async run(): Promise { + const {flags} = await this.parse(DocSearch) + await docSearchService(flags.query, flags['api-name'], flags['api-version']) + } +} diff --git a/packages/cli/src/cli/services/commands/doc/search.test.ts b/packages/cli/src/cli/services/commands/doc/search.test.ts new file mode 100644 index 00000000000..0a6433e30ff --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/search.test.ts @@ -0,0 +1,86 @@ +import {docSearchService} from './search.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' + +vi.mock('@shopify/cli-kit/node/http') +// Only stub `outputResult`; keep the rest of the module real. Blanket-mocking it +// would also mock `stringifyMessage`, which `AbortError`'s constructor relies on — +// that would silently empty out every thrown error message. +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({ + ...(await importOriginal()), + outputResult: vi.fn(), +})) + +const okResponse = (body: string) => + ({ok: true, status: 200, statusText: 'OK', text: () => Promise.resolve(body)}) as any + +const errorResponse = (status: number, statusText: string, body: string) => + ({ok: false, status, statusText, text: () => Promise.resolve(body)}) as any + +const resultsBody = + '[{"score":0.99,"content":"About webhooks","url":"https://shopify.dev/x","title":"Webhooks","domain":null}]' + +beforeEach(() => { + vi.mocked(shopifyFetch).mockResolvedValue(okResponse(resultsBody)) +}) + +describe('docSearchService', () => { + test('requests the search endpoint with the query and prints the raw JSON body', async () => { + await docSearchService('webhooks') + + expect(shopifyFetch).toHaveBeenCalledWith('https://shopify.dev/assistant/search?query=webhooks', { + headers: {Accept: 'application/json', 'X-Shopify-Surface': 'cli'}, + }) + expect(outputResult).toHaveBeenCalledWith(resultsBody) + }) + + test('includes api_name and api_version params when provided', async () => { + await docSearchService('create a product', 'admin', 'latest') + + expect(shopifyFetch).toHaveBeenCalledWith( + 'https://shopify.dev/assistant/search?query=create+a+product&api_name=admin&api_version=latest', + {headers: {Accept: 'application/json', 'X-Shopify-Surface': 'cli'}}, + ) + }) + + test('URL-encodes queries with spaces and special characters', async () => { + await docSearchService('a & b?') + + expect(shopifyFetch).toHaveBeenCalledWith('https://shopify.dev/assistant/search?query=a+%26+b%3F', { + headers: {Accept: 'application/json', 'X-Shopify-Surface': 'cli'}, + }) + }) + + test('surfaces the server error message from a non-ok JSON response', async () => { + vi.mocked(shopifyFetch).mockResolvedValue( + errorResponse( + 400, + 'Bad Request', + '{"error":"Invalid api_version \'2025-01\' for api_name \'admin\'. Available versions: 2026-07"}', + ), + ) + + await expect(docSearchService('products', 'admin', '2025-01')).rejects.toThrowError( + /Invalid api_version '2025-01' for api_name 'admin'\. Available versions: 2026-07/, + ) + expect(outputResult).not.toHaveBeenCalled() + }) + + test('falls back to the status line when a non-ok response is not JSON', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(errorResponse(500, 'Internal Server Error', 'nope')) + + await expect(docSearchService('products')).rejects.toThrowError(AbortError) + await expect(docSearchService('products')).rejects.toThrowError(/500 Internal Server Error/) + expect(outputResult).not.toHaveBeenCalled() + }) + + test('reports a friendly error when the request cannot reach shopify.dev', async () => { + vi.mocked(shopifyFetch).mockRejectedValue(new Error('getaddrinfo ENOTFOUND shopify.dev')) + + await expect(docSearchService('products')).rejects.toThrowError(AbortError) + await expect(docSearchService('products')).rejects.toThrowError(/Could not reach shopify\.dev/) + expect(outputResult).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/cli/services/commands/doc/search.ts b/packages/cli/src/cli/services/commands/doc/search.ts new file mode 100644 index 00000000000..a663ffe17e1 --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/search.ts @@ -0,0 +1,50 @@ +import {shopifyFetch, type Response} from '@shopify/cli-kit/node/http' +import {outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' + +// The dev-assistant search endpoint queries the shopify.dev vector store and +// returns an array of matching documentation chunks as JSON. +const SEARCH_URL = 'https://shopify.dev/assistant/search' + +// Identifies the CLI as the calling surface to shopify.dev, so traffic +// originating from the CLI can be attributed as such. +const SURFACE_HEADER = 'X-Shopify-Surface' +const SURFACE = 'cli' + +export async function docSearchService(query: string, apiName?: string, apiVersion?: string) { + const params = new URLSearchParams({query}) + if (apiName) params.append('api_name', apiName) + if (apiVersion) params.append('api_version', apiVersion) + + let response: Response + try { + response = await shopifyFetch(`${SEARCH_URL}?${params.toString()}`, { + headers: {Accept: 'application/json', [SURFACE_HEADER]: SURFACE}, + }) + } catch { + // shopifyFetch retries transient failures; reaching here means the request + // could not complete (offline, DNS failure, TLS error, timeout, etc.). + throw new AbortError( + 'Could not reach shopify.dev to run the search.', + 'Check your network connection and try again.', + ) + } + + const body = await response.text() + + if (!response.ok) { + // The endpoint returns a JSON `{error}` body for 400s (e.g. an invalid api_version + // lists the valid versions) — surface it directly instead of a bare status code. + let message = `${response.status} ${response.statusText}` + try { + const parsed = JSON.parse(body) + if (parsed?.error) message = parsed.error + } catch (parseError) { + // Body wasn't JSON; fall back to the status line. Rethrow anything unexpected. + if (!(parseError instanceof SyntaxError)) throw parseError + } + throw new AbortError(`Search failed: ${message}`) + } + + outputResult(body) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ff506419c63..51e203e1b3d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,7 @@ import KitchenSinkStatic from './cli/commands/kitchen-sink/static.js' import KitchenSink from './cli/commands/kitchen-sink/index.js' import Doctor from './cli/commands/doctor-release/doctor-release.js' import DoctorTheme from './cli/commands/doctor-release/theme/index.js' +import DocSearch from './cli/commands/doc/search.js' import DocsGenerate from './cli/commands/docs/generate.js' import HelpCommand from './cli/commands/help.js' import List from './cli/commands/notifications/list.js' @@ -157,6 +158,7 @@ export const COMMANDS: any = { 'kitchen-sink:static': KitchenSinkStatic, 'doctor-release': Doctor, 'doctor-release:theme': DoctorTheme, + 'doc:search': DocSearch, 'docs:generate': DocsGenerate, 'notifications:list': List, 'notifications:generate': Generate, diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 4a121e660fe..aea57d18e66 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -49,6 +49,8 @@ │ ├─ off │ ├─ on │ └─ status +├─ doc +│ └─ search ├─ help ├─ hydrogen │ ├─ build