Skip to content

Commit 41ea267

Browse files
authored
Merge pull request #8798 from Shopify/codex/doc-search-json
Add typed JSON output to doc search
2 parents db6cac1 + c23fef7 commit 41ea267

12 files changed

Lines changed: 489 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli': minor
3+
---
4+
5+
Add typed JSON output and schema discovery to `doc search`.

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5283,9 +5283,18 @@
52835283
"description": "Increase the verbosity of the output. May include sensitive data.",
52845284
"isOptional": true,
52855285
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
5286+
},
5287+
{
5288+
"filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts",
5289+
"syntaxKind": "PropertySignature",
5290+
"name": "-j, --json",
5291+
"value": "''",
5292+
"description": "Output the result as JSON. Automatically disables color output.",
5293+
"isOptional": true,
5294+
"environmentValue": "SHOPIFY_FLAG_JSON"
52865295
}
52875296
],
5288-
"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}"
5297+
"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}"
52895298
}
52905299
},
52915300
"help": {

‎packages/cli/README.md‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5145,10 +5145,14 @@ Query the shopify.dev vector store and print the most relevant documentation chu
51455145
51465146
```
51475147
USAGE
5148-
$ shopify doc search --query <value> [--api-name <value>] [--api-version <value>] [--json-schema] [--no-color]
5149-
[--no-input] [--verbose]
5148+
$ shopify doc search --query <value> [--api-name <value>] [--api-version <value>] [-j] [--json-schema]
5149+
[--no-color] [--no-input] [--verbose]
51505150

51515151
FLAGS
5152+
-j, --json
5153+
Output the result as JSON. Automatically disables color output.
5154+
[env: SHOPIFY_FLAG_JSON]
5155+
51525156
--api-name=<value>
51535157
Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are
51545158
ignored.
@@ -5183,11 +5187,99 @@ DESCRIPTION
51835187
discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To
51845188
download a full document verbatim, use `doc fetch`.
51855189

5190+
Use `--json-schema` to print the result, error, and event schemas.
5191+
5192+
Output from `--json` conforms to the `DocSearchResult` schema.
5193+
5194+
```json
5195+
{
5196+
"type": "object",
5197+
"properties": {
5198+
"results": {
5199+
"type": "array",
5200+
"items": {
5201+
"$ref": "#/definitions/DocumentationSearchEntry"
5202+
},
5203+
"description": "The top matching chunks from one search request."
5204+
},
5205+
"pageInfo": {
5206+
"$ref": "#/definitions/PageInfo"
5207+
}
5208+
},
5209+
"required": [
5210+
"results",
5211+
"pageInfo"
5212+
],
5213+
"additionalProperties": false,
5214+
"title": "DocSearchResult",
5215+
"definitions": {
5216+
"DocumentationSearchEntry": {
5217+
"type": "object",
5218+
"properties": {
5219+
"score": {
5220+
"type": "number",
5221+
"description": "The relevance score returned by shopify.dev."
5222+
},
5223+
"content": {
5224+
"type": "string",
5225+
"description": "The matching documentation chunk."
5226+
},
5227+
"url": {
5228+
"type": "string",
5229+
"format": "uri",
5230+
"description": "The URL of the matching document."
5231+
},
5232+
"title": {
5233+
"type": "string",
5234+
"description": "The title of the matching document."
5235+
},
5236+
"domain": {
5237+
"type": [
5238+
"string",
5239+
"null"
5240+
],
5241+
"description": "The documentation domain, or null when unavailable."
5242+
}
5243+
},
5244+
"required": [
5245+
"score",
5246+
"content",
5247+
"url",
5248+
"title",
5249+
"domain"
5250+
],
5251+
"additionalProperties": false
5252+
},
5253+
"PageInfo": {
5254+
"type": "object",
5255+
"properties": {
5256+
"hasNextPage": {
5257+
"type": [
5258+
"boolean",
5259+
"null"
5260+
],
5261+
"description": "Whether more results are available, or null when unknown."
5262+
}
5263+
},
5264+
"required": [
5265+
"hasNextPage"
5266+
],
5267+
"additionalProperties": false
5268+
}
5269+
},
5270+
"$schema": "http://json-schema.org/draft-07/schema#"
5271+
}
5272+
```
5273+
51865274
EXAMPLES
51875275
# search shopify.dev for a topic
51885276
shopify doc search --query "subscribe to webhooks"
51895277
# narrow the search to a specific API and version
51905278
shopify doc search --query "create a product" --api-name admin --api-version latest
5279+
5280+
# return typed documentation results as a JSON object
5281+
5282+
$ shopify doc search --query "subscribe to webhooks" --json
51915283
```
51925284
51935285
## `shopify help [command] [flags]`

‎packages/cli/oclif.manifest.json‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6267,10 +6267,12 @@
62676267
],
62686268
"args": {
62696269
},
6270-
"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`.",
6270+
"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```",
6271+
"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`.",
62716272
"enableJsonFlag": false,
62726273
"examples": [
6273-
"# 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 "
6274+
"# 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",
6275+
"# return typed documentation results as a JSON object\nshopify doc search --query \"subscribe to webhooks\" --json"
62746276
],
62756277
"flags": {
62766278
"api-name": {
@@ -6289,6 +6291,15 @@
62896291
"name": "api-version",
62906292
"type": "option"
62916293
},
6294+
"json": {
6295+
"allowNo": false,
6296+
"char": "j",
6297+
"description": "Output the result as JSON. Automatically disables color output.",
6298+
"env": "SHOPIFY_FLAG_JSON",
6299+
"hidden": false,
6300+
"name": "json",
6301+
"type": "boolean"
6302+
},
62926303
"json-schema": {
62936304
"allowNo": false,
62946305
"description": "Print the command's JSON schemas.",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import DocSearch from './search.js'
2+
import {docSearchJsonOutputSchema} from '../../services/commands/doc/types.js'
3+
import {shopifyFetch, Response} from '@shopify/cli-kit/node/http'
4+
import {launchCLI} from '@shopify/cli-kit/node/cli-launcher'
5+
import {ShopifyConfig} from '@shopify/cli-kit/node/custom-oclif-loader'
6+
import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
7+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
8+
9+
vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({
10+
...(await importOriginal<typeof import('@shopify/cli-kit/node/http')>()),
11+
shopifyFetch: vi.fn(),
12+
}))
13+
14+
const entry = {score: 0.99, content: 'About webhooks', url: 'https://shopify.dev/x', title: 'Webhooks', domain: null}
15+
const body = `[\n {"title":"Webhooks","url":"https://shopify.dev/x","content":"About webhooks","score":0.99,"domain":null}\n]`
16+
17+
beforeEach(() => {
18+
vi.stubEnv('CI', '1')
19+
vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1')
20+
vi.mocked(shopifyFetch).mockResolvedValue(new Response(body))
21+
vi.spyOn(process, 'exit').mockReturnValue(undefined as never)
22+
})
23+
24+
afterEach(() => {
25+
vi.unstubAllEnvs()
26+
mockAndCaptureOutput().clear()
27+
})
28+
29+
describe('doc search command', () => {
30+
test('preserves the exact response bytes on stdout without JSON selection', async () => {
31+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
32+
await DocSearch.run(['--query', 'webhooks', '--no-input'], import.meta.url)
33+
expect(stdout()).toBe(`${body}\n`)
34+
expect(stderr()).toBe('')
35+
})
36+
})
37+
38+
test.each([
39+
'not JSON',
40+
JSON.stringify([{...entry, title: null}]),
41+
JSON.stringify([{score: entry.score, content: entry.content, url: entry.url, domain: entry.domain}]),
42+
])('preserves raw successful responses outside the JSON schema: %s', async (response) => {
43+
vi.mocked(shopifyFetch).mockResolvedValue(new Response(response))
44+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
45+
await DocSearch.run(['--query', 'webhooks', '--no-input'], import.meta.url)
46+
expect(stdout()).toBe(`${response}\n`)
47+
expect(stderr()).toBe('')
48+
expect(process.exit).not.toHaveBeenCalled()
49+
})
50+
})
51+
52+
test.each([
53+
JSON.stringify([{...entry, title: null}]),
54+
JSON.stringify([{score: entry.score, content: entry.content, url: entry.url, domain: entry.domain}]),
55+
])('reports incompatible search data as one fatal JSON document: %s', async (response) => {
56+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
57+
vi.mocked(shopifyFetch).mockResolvedValue(new Response(response))
58+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
59+
await DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)
60+
expect(JSON.parse(stdout())).toEqual({
61+
error: {type: 'abort', message: 'Search returned an invalid documentation response.'},
62+
})
63+
expect(stderr()).toBe('')
64+
expect(process.exit).toHaveBeenCalledWith(1)
65+
})
66+
})
67+
68+
test.each([{flags: []}, {flags: ['--no-input']}])(
69+
'writes one result object with JSON and flags $flags',
70+
async ({flags}) => {
71+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
72+
await DocSearch.run(
73+
['--query', 'webhooks', '--api-name', 'admin', '--api-version', 'latest', '--json', ...flags],
74+
import.meta.url,
75+
)
76+
expect(JSON.parse(stdout())).toEqual({results: [entry], pageInfo: {hasNextPage: null}})
77+
expect(stderr()).toBe('')
78+
expect(shopifyFetch).toHaveBeenCalledWith(
79+
'https://shopify.dev/assistant/search?query=webhooks&api_name=admin&api_version=latest',
80+
{
81+
headers: {Accept: 'application/json', 'X-Shopify-Surface': 'cli'},
82+
},
83+
)
84+
})
85+
},
86+
)
87+
88+
test('supports the shared JSON environment flag', async () => {
89+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
90+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
91+
await DocSearch.run(['--query', 'webhooks'], import.meta.url)
92+
expect(JSON.parse(stdout())).toEqual({results: [entry], pageInfo: {hasNextPage: null}})
93+
expect(stderr()).toBe('')
94+
})
95+
})
96+
97+
test('reports missing required input before making a request', async () => {
98+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
99+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
100+
await DocSearch.run(['--json', '--no-input'], import.meta.url)
101+
expect(JSON.parse(stdout())).toHaveProperty('error')
102+
expect(process.exit).toHaveBeenCalledWith(2)
103+
expect(stderr()).toBe('')
104+
expect(shopifyFetch).not.toHaveBeenCalled()
105+
})
106+
})
107+
108+
test.each([{results: []}, {results: [{...entry, score: 0, domain: 'admin'}]}])(
109+
'encodes empty results and nullable metadata: $results',
110+
async ({results}) => {
111+
vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(results)))
112+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
113+
await DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)
114+
expect(JSON.parse(stdout())).toEqual({results, pageInfo: {hasNextPage: null}})
115+
expect(stderr()).toBe('')
116+
})
117+
},
118+
)
119+
120+
test.each([
121+
{
122+
status: 400,
123+
statusText: 'Bad Request',
124+
response: '{"error":"Invalid api_version"}',
125+
message: 'Search failed: Invalid api_version',
126+
},
127+
{
128+
status: 500,
129+
statusText: 'Internal Server Error',
130+
response: '<html>nope</html>',
131+
message: 'Search failed: 500 Internal Server Error',
132+
},
133+
{
134+
status: 200,
135+
statusText: 'OK',
136+
response: 'not JSON',
137+
message: 'Search returned an invalid documentation response.',
138+
},
139+
])('reports $status failures through the shared error document', async ({status, statusText, response, message}) => {
140+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
141+
vi.mocked(shopifyFetch).mockResolvedValue(new Response(response, {status, statusText}))
142+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
143+
await expect(DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)).resolves.toBeUndefined()
144+
expect(process.exit).toHaveBeenCalledWith(1)
145+
expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message}})
146+
expect(stderr()).toBe('')
147+
})
148+
})
149+
150+
test('keeps the transport failure guidance and nonzero exit', async () => {
151+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
152+
vi.mocked(shopifyFetch).mockRejectedValue(new Error('getaddrinfo ENOTFOUND shopify.dev'))
153+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
154+
await expect(DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)).resolves.toBeUndefined()
155+
expect(process.exit).toHaveBeenCalledWith(1)
156+
expect(JSON.parse(stdout())).toEqual({
157+
error: {
158+
type: 'abort',
159+
message: 'Could not reach shopify.dev to run the search.',
160+
tryMessage: 'Check your network connection and try again.',
161+
},
162+
})
163+
expect(stderr()).toBe('')
164+
})
165+
})
166+
167+
test('exposes the schema in help and through the launcher without a search request', async () => {
168+
expect(DocSearch.jsonOutputSchema).toBe(docSearchJsonOutputSchema)
169+
expect(DocSearch.flags.json).toBeDefined()
170+
expect(DocSearch.description).toContain('Output from `--json` conforms to the `DocSearchResult` schema.')
171+
vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []})
172+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
173+
await launchCLI({
174+
moduleURL: import.meta.url,
175+
argv: ['doc', 'search', '--json-schema'],
176+
lazyCommandLoader: async () => DocSearch,
177+
})
178+
const schema = JSON.parse(stdout())
179+
expect(schema.definitions.Result.required).toEqual(['results', 'pageInfo'])
180+
expect(schema.definitions.Result.properties.results.items.properties.domain.type).toEqual(['string', 'null'])
181+
expect(schema.definitions.Result.additionalProperties).toBe(false)
182+
expect(stderr()).toBe('')
183+
expect(shopifyFetch).not.toHaveBeenCalled()
184+
})
185+
})
186+
})

0 commit comments

Comments
 (0)