Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/doc-search-json-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add typed JSON output and schema discovery to `doc search`.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -5220,9 +5220,18 @@
"description": "Increase the verbosity of the output. May include sensitive data.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
}
],
"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 <value>'?: 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 <value>'?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The search query.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '--query <value>': string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"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 <value>'?: 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 <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The search query.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '--query <value>': string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"help": {
Expand Down
96 changes: 94 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4573,10 +4573,14 @@ Query the shopify.dev vector store and print the most relevant documentation chu

```
USAGE
$ shopify doc search --query <value> [--api-name <value>] [--api-version <value>] [--json-schema] [--no-color]
[--no-input] [--verbose]
$ shopify doc search --query <value> [--api-name <value>] [--api-version <value>] [-j] [--json-schema]
[--no-color] [--no-input] [--verbose]

FLAGS
-j, --json
Output the result as JSON. Automatically disables color output.
[env: SHOPIFY_FLAG_JSON]

--api-name=<value>
Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are
ignored.
Expand Down Expand Up @@ -4611,11 +4615,99 @@ DESCRIPTION
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`.

Use `--json-schema` to print the result, error, and event schemas.

Output from `--json` conforms to the `DocSearchResult` schema.

```json
{
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/DocumentationSearchEntry"
},
"description": "The top matching chunks from one search request."
},
"pageInfo": {
"$ref": "#/definitions/PageInfo"
}
},
"required": [
"results",
"pageInfo"
],
"additionalProperties": false,
"title": "DocSearchResult",
"definitions": {
"DocumentationSearchEntry": {
"type": "object",
"properties": {
"score": {
"type": "number",
"description": "The relevance score returned by shopify.dev."
},
"content": {
"type": "string",
"description": "The matching documentation chunk."
},
"url": {
"type": "string",
"format": "uri",
"description": "The URL of the matching document."
},
"title": {
"type": "string",
"description": "The title of the matching document."
},
"domain": {
"type": [
"string",
"null"
],
"description": "The documentation domain, or null when unavailable."
}
},
"required": [
"score",
"content",
"url",
"title",
"domain"
],
"additionalProperties": false
},
"PageInfo": {
"type": "object",
"properties": {
"hasNextPage": {
"type": [
"boolean",
"null"
],
"description": "Whether more results are available, or null when unknown."
}
},
"required": [
"hasNextPage"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
```

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

# return typed documentation results as a JSON object

$ shopify doc search --query "subscribe to webhooks" --json
```

## `shopify help [command] [flags]`
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6206,10 +6206,12 @@
],
"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`.",
"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`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DocSearchResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/DocumentationSearchEntry\"\n },\n \"description\": \"The top matching chunks from one search request.\"\n },\n \"pageInfo\": {\n \"$ref\": \"#/definitions/PageInfo\"\n }\n },\n \"required\": [\n \"results\",\n \"pageInfo\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DocSearchResult\",\n \"definitions\": {\n \"DocumentationSearchEntry\": {\n \"type\": \"object\",\n \"properties\": {\n \"score\": {\n \"type\": \"number\",\n \"description\": \"The relevance score returned by shopify.dev.\"\n },\n \"content\": {\n \"type\": \"string\",\n \"description\": \"The matching documentation chunk.\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"The URL of the matching document.\"\n },\n \"title\": {\n \"type\": \"string\",\n \"description\": \"The title of the matching document.\"\n },\n \"domain\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The documentation domain, or null when unavailable.\"\n }\n },\n \"required\": [\n \"score\",\n \"content\",\n \"url\",\n \"title\",\n \"domain\"\n ],\n \"additionalProperties\": false\n },\n \"PageInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"hasNextPage\": {\n \"type\": [\n \"boolean\",\n \"null\"\n ],\n \"description\": \"Whether more results are available, or null when unknown.\"\n }\n },\n \"required\": [\n \"hasNextPage\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"descriptionWithMarkdown": "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 "
"# 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",
"# return typed documentation results as a JSON object\nshopify doc search --query \"subscribe to webhooks\" --json"
],
"flags": {
"api-name": {
Expand All @@ -6228,6 +6230,15 @@
"name": "api-version",
"type": "option"
},
"json": {
"allowNo": false,
"char": "j",
"description": "Output the result as JSON. Automatically disables color output.",
"env": "SHOPIFY_FLAG_JSON",
"hidden": false,
"name": "json",
"type": "boolean"
},
"json-schema": {
"allowNo": false,
"description": "Print the command's JSON schemas.",
Expand Down
186 changes: 186 additions & 0 deletions packages/cli/src/cli/commands/doc/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import DocSearch from './search.js'
import {docSearchJsonOutputSchema} from '../../services/commands/doc/types.js'
import {shopifyFetch, Response} from '@shopify/cli-kit/node/http'
import {launchCLI} from '@shopify/cli-kit/node/cli-launcher'
import {ShopifyConfig} from '@shopify/cli-kit/node/custom-oclif-loader'
import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/http')>()),
shopifyFetch: vi.fn(),
}))

const entry = {score: 0.99, content: 'About webhooks', url: 'https://shopify.dev/x', title: 'Webhooks', domain: null}
const body = `[\n {"title":"Webhooks","url":"https://shopify.dev/x","content":"About webhooks","score":0.99,"domain":null}\n]`

beforeEach(() => {
vi.stubEnv('CI', '1')
vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1')
vi.mocked(shopifyFetch).mockResolvedValue(new Response(body))
vi.spyOn(process, 'exit').mockReturnValue(undefined as never)
})

afterEach(() => {
vi.unstubAllEnvs()
mockAndCaptureOutput().clear()
})

describe('doc search command', () => {
test('preserves the exact response bytes on stdout without JSON selection', async () => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await DocSearch.run(['--query', 'webhooks', '--no-input'], import.meta.url)
expect(stdout()).toBe(`${body}\n`)
expect(stderr()).toBe('')
})
})

test.each([
'not JSON',
JSON.stringify([{...entry, title: null}]),
JSON.stringify([{score: entry.score, content: entry.content, url: entry.url, domain: entry.domain}]),
])('preserves raw successful responses outside the JSON schema: %s', async (response) => {
vi.mocked(shopifyFetch).mockResolvedValue(new Response(response))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await DocSearch.run(['--query', 'webhooks', '--no-input'], import.meta.url)
expect(stdout()).toBe(`${response}\n`)
expect(stderr()).toBe('')
expect(process.exit).not.toHaveBeenCalled()
})
})

test.each([
JSON.stringify([{...entry, title: null}]),
JSON.stringify([{score: entry.score, content: entry.content, url: entry.url, domain: entry.domain}]),
])('reports incompatible search data as one fatal JSON document: %s', async (response) => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
vi.mocked(shopifyFetch).mockResolvedValue(new Response(response))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)
expect(JSON.parse(stdout())).toEqual({
error: {type: 'abort', message: 'Search returned an invalid documentation response.'},
})
expect(stderr()).toBe('')
expect(process.exit).toHaveBeenCalledWith(1)
})
})

test.each([{flags: []}, {flags: ['--no-input']}])(
'writes one result object with JSON and flags $flags',
async ({flags}) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await DocSearch.run(
['--query', 'webhooks', '--api-name', 'admin', '--api-version', 'latest', '--json', ...flags],
import.meta.url,
)
expect(JSON.parse(stdout())).toEqual({results: [entry], pageInfo: {hasNextPage: null}})
expect(stderr()).toBe('')
expect(shopifyFetch).toHaveBeenCalledWith(
'https://shopify.dev/assistant/search?query=webhooks&api_name=admin&api_version=latest',
{
headers: {Accept: 'application/json', 'X-Shopify-Surface': 'cli'},
},
)
})
},
)

test('supports the shared JSON environment flag', async () => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await DocSearch.run(['--query', 'webhooks'], import.meta.url)
expect(JSON.parse(stdout())).toEqual({results: [entry], pageInfo: {hasNextPage: null}})
expect(stderr()).toBe('')
})
})

test('reports missing required input before making a request', async () => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await DocSearch.run(['--json', '--no-input'], import.meta.url)
expect(JSON.parse(stdout())).toHaveProperty('error')
expect(process.exit).toHaveBeenCalledWith(2)
expect(stderr()).toBe('')
expect(shopifyFetch).not.toHaveBeenCalled()
})
})

test.each([{results: []}, {results: [{...entry, score: 0, domain: 'admin'}]}])(
'encodes empty results and nullable metadata: $results',
async ({results}) => {
vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(results)))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)
expect(JSON.parse(stdout())).toEqual({results, pageInfo: {hasNextPage: null}})
expect(stderr()).toBe('')
})
},
)

test.each([
{
status: 400,
statusText: 'Bad Request',
response: '{"error":"Invalid api_version"}',
message: 'Search failed: Invalid api_version',
},
{
status: 500,
statusText: 'Internal Server Error',
response: '<html>nope</html>',
message: 'Search failed: 500 Internal Server Error',
},
{
status: 200,
statusText: 'OK',
response: 'not JSON',
message: 'Search returned an invalid documentation response.',
},
])('reports $status failures through the shared error document', async ({status, statusText, response, message}) => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
vi.mocked(shopifyFetch).mockResolvedValue(new Response(response, {status, statusText}))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await expect(DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)).resolves.toBeUndefined()
expect(process.exit).toHaveBeenCalledWith(1)
expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message}})
expect(stderr()).toBe('')
})
})

test('keeps the transport failure guidance and nonzero exit', async () => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
vi.mocked(shopifyFetch).mockRejectedValue(new Error('getaddrinfo ENOTFOUND shopify.dev'))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await expect(DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)).resolves.toBeUndefined()
expect(process.exit).toHaveBeenCalledWith(1)
expect(JSON.parse(stdout())).toEqual({
error: {
type: 'abort',
message: 'Could not reach shopify.dev to run the search.',
tryMessage: 'Check your network connection and try again.',
},
})
expect(stderr()).toBe('')
})
})

test('exposes the schema in help and through the launcher without a search request', async () => {
expect(DocSearch.jsonOutputSchema).toBe(docSearchJsonOutputSchema)
expect(DocSearch.flags.json).toBeDefined()
expect(DocSearch.description).toContain('Output from `--json` conforms to the `DocSearchResult` schema.')
vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await launchCLI({
moduleURL: import.meta.url,
argv: ['doc', 'search', '--json-schema'],
lazyCommandLoader: async () => DocSearch,
})
const schema = JSON.parse(stdout())
expect(schema.definitions.Result.required).toEqual(['results', 'pageInfo'])
expect(schema.definitions.Result.properties.results.items.properties.domain.type).toEqual(['string', 'null'])
expect(schema.definitions.Result.additionalProperties).toBe(false)
expect(stderr()).toBe('')
expect(shopifyFetch).not.toHaveBeenCalled()
})
})
})
Loading
Loading