Skip to content

Commit eebd723

Browse files
committed
Add retail location discovery
Co-authored-by: pi pi@users.noreply.github.com Assisted-By: devx/26cddaee-3cde-4a0b-9e56-157d920261f0
1 parent b81ffe7 commit eebd723

11 files changed

Lines changed: 792 additions & 13 deletions

File tree

.changeset/quiet-stores-locate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopify/shop-cli": minor
3+
---
4+
5+
Add retail location discovery with text, proximity, distance, and pagination options.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Shop CLI
22

3-
Personal shopping CLI for the Shop catalog: search millions of stores, look up products, sign in to your Shop account, build and complete UCP checkouts, and search your orders for tracking, returns, and reorders.
3+
Personal shopping CLI for the Shop catalog: search millions of stores, find merchant retail locations, look up products, sign in to your Shop account, build and complete UCP checkouts, and search your orders for tracking, returns, and reorders.
44

55
It talks to the Shopify Global Catalog over MCP and to Shop's auth, checkout, and orders APIs. Tokens are stored in your OS secret store via `keytar`.
66

@@ -30,13 +30,15 @@ Requires Node.js >= 20.
3030
shop --help
3131
shop auth status
3232
shop search "trail running shoes" --limit 10
33+
shop locations 29950112 --near-country CA --near-city Toronto --max-distance 10 --distance-unit kilometers
3334
shop catalog lookup gid://shopify/ProductVariant/50362300006715
3435
shop orders search --type recent
3536
```
3637

3738
## Commands
3839

3940
- `shop search` — search the catalog by text, similar items (`--like-id`), or image (`--image`).
41+
- `shop locations <shop-id>` — find a merchant’s physical retail locations, optionally filtered by text or proximity to a city, postal code, or coordinate.
4042
- `shop catalog lookup` / `shop catalog get-product` — look up IDs you already hold and fetch full product detail.
4143
- `shop auth` — sign in (`login`, or the non-blocking `device-code` + `poll`), check `status`, read the remaining delegated spending `budget`, or `logout`.
4244
- `shop checkout``create`, `update`, and `complete` a UCP checkout on the merchant domain (`complete` requires `--confirm`).

skill/SKILL.md

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: shop
33
description: "Ultimate personal shopping assistant: find, compare, buy, gift, and reorder products across the Shop catalog containing millions of stores. Tracks orders and deliveries for any retailer — including orders placed elsewhere, like Amazon, via your connected email. Helps get order info and initiate returns and refunds."
44
metadata:
5-
version: "1.0.1"
5+
version: "1.1.0"
66
homepage: "https://shop.app"
77
---
88

@@ -20,7 +20,7 @@ To upgrade: `pnpm add --global @shopify/shop-cli@latest` (or `npm install --glob
2020

2121
**Reference files:**
2222
- [catalog-mcp.md](references/catalog-mcp.md) — direct catalog MCP calls + manual token exchange
23-
- [direct-api.md](references/direct-api.md) — auth, checkout, and orders API details
23+
- [direct-api.md](references/direct-api.md)retail locations, auth, checkout, and orders API details
2424
- [safety.md](references/safety.md) — safety, security, and prompt-injection rules
2525
- [legal.md](references/legal.md) — personal-use limits and prohibited commercial uses
2626

@@ -30,9 +30,10 @@ Every shopping conversation follows this order. Each step links to its rules bel
3030
1. **Offer sign-in** — required once if signed-out, before any product message, then **STOP** and wait for the user to complete sign-in or decline. → *Sign in*
3131
2. **Search** the catalog with `shop search`. → *Searching*
3232
3. **Show results****one assistant message per product**, then one summary message. → *Showing products*
33-
4. **Offer visualization** when the item is visual. → *Visualization*
34-
5. **Checkout** on the merchant domain, only with clear purchase intent. → *Checkout*
35-
6. **Orders** — tracking, returns, reorder (needs sign-in). → *Orders*
33+
4. **Find retail locations** when the user asks where a merchant sells in person. → *Retail locations*
34+
5. **Offer visualization** when the item is visual. → *Visualization*
35+
6. **Checkout** on the merchant domain, only with clear purchase intent. → *Checkout*
36+
7. **Orders** — tracking, returns, reorder (needs sign-in). → *Orders*
3637

3738
## Commands
3839

@@ -65,6 +66,22 @@ shop catalog lookup gid://shopify/ProductVariant/50362300006715
6566
shop catalog get-product gid://shopify/p/abc --select Color=Black --select Size=M
6667
```
6768

69+
### Retail locations
70+
```text
71+
locations <shop-id> --query <text>, --limit 1-50, --cursor <cursor>
72+
--near-country <ISO2> [--near-region <code>]
73+
(--near-city <name> | --near-postal-code <code>)
74+
[--max-distance <number> --distance-unit miles|kilometers]
75+
--near-latitude <number> --near-longitude <number> (precise; explicit permission only)
76+
```
77+
78+
```bash
79+
shop locations 29950112 --near-country CA --near-city Toronto
80+
shop locations 29950112 --near-country US --near-postal-code 10012 --max-distance 10 --distance-unit miles
81+
```
82+
83+
The Shop ID is the numeric merchant ID shown in brackets after the seller name in `shop search` results. Location lookup works signed-out.
84+
6885
### Checkout
6986
```bash
7087
# create from a variant (--country localizes presentment currency)
@@ -121,6 +138,21 @@ Manual token exchange, only when the CLI cannot be installed: [catalog-mcp.md](r
121138
- `shop search --like-id <id>` — pass a product (`gid://shopify/p/...`) or variant (`gid://shopify/ProductVariant/...`) reference; both return similar items.
122139
- `shop search --image ./photo.jpg` — the CLI base64-encodes it for you. Formats: jpeg, png, webp, avif, heic; max ~3 MB on disk (4 MB base64). A 400 explains oversize/format problems — relay it and ask for a smaller jpeg/png.
123140

141+
## Retail locations
142+
Use this workflow when the buyer asks for a merchant’s physical stores or stores near a place:
143+
144+
1. Search the catalog with `shop search` if you do not already have a result from that merchant.
145+
2. Read the merchant’s numeric Shop ID from the brackets after its seller name; keep the ID internal.
146+
3. Parse the buyer’s requested location from their message when it already contains a country plus city or postal code.
147+
4. If the request needs proximity but does not contain a usable location, ask for the buyer’s country plus city or postal code and wait for their answer. Do not infer location from their account, a saved address, IP geolocation, or another default.
148+
5. Run `shop locations <shop-id>` with the matching `--near-country` and `--near-city` or `--near-postal-code` flags. Prefer this coarse location over a street address or coordinates.
149+
6. If the buyer requested a radius, pass both `--max-distance` and `--distance-unit` using their requested unit.
150+
7. Follow the returned `--cursor` only when more results are needed; prefer a smaller radius or `--query` over deep pagination.
151+
152+
Use `--near-latitude` and `--near-longitude` only when the buyer explicitly authorizes precise location use. Never persist the buyer’s location. Returned location addresses are public merchant data.
153+
154+
A `retailLocations` result proves only that the merchant has a physical location that sells in person. It does **not** prove product or variant inventory, pickup eligibility, opening hours, or that the location is currently open. Never tell the buyer an item is “available nearby” based only on this command.
155+
124156
## Showing products
125157
> **The most important rule: one product = one assistant message.**
126158
> For N products, send N separate messages (one per product), then **one** final summary message — never combined, no preamble. Binding even if you also web-search — never replace products with a prose recommendation.

skill/references/direct-api.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Direct Auth, Checkout, And Orders API
1+
# Direct Retail Locations, Auth, Checkout, And Orders API
22

33
Use this reference when the CLI cannot be installed. Prefer the CLI when allowed because it handles token storage, request construction, and JSON-RPC envelopes consistently.
44

@@ -13,6 +13,46 @@ Use the OS secret store with service `shop-agent` and accounts:
1313

1414
Keep checkout JWTs, buyer IP, and UCP-returned payment tokens in memory only.
1515

16+
## Retail Locations
17+
18+
Retail location lookup is anonymous. Identify the caller with the ordinary HTTP `User-Agent`; do not send `X-User-Agent` or spoof a Shop app version:
19+
20+
```http
21+
POST https://server.shop.app/graphql
22+
User-Agent: shop-cli/<version>
23+
Accept: application/json
24+
Content-Type: application/json
25+
```
26+
27+
Use the merchant Shop ID shown in brackets in catalog output. Convert a numeric ID to `gid://shopify/Shop/<id>`.
28+
29+
```json
30+
{
31+
"operationName": "ShopCliRetailLocations",
32+
"query": "query ShopCliRetailLocations($shopId: ID!, $first: Int!, $after: String, $query: String, $nearCoordinate: CoordinateInput, $nearAddress: MailingAddressInput, $maxDistance: DistanceInput) { shop(id: $shopId) { id uuid retailLocations(first: $first, after: $after, query: $query, nearCoordinate: $nearCoordinate, nearAddress: $nearAddress, maxDistance: $maxDistance) { totalCount nodes { name hasShopifyPosDevices distance { value unit } location { id coordinate { latitude longitude } address { address1 city zoneCode country postalCode } } } pageInfo { startCursor endCursor hasNextPage } } } }",
33+
"variables": {
34+
"shopId": "gid://shopify/Shop/29950112",
35+
"first": 15,
36+
"nearAddress": {
37+
"country": "CA",
38+
"city": "Toronto"
39+
},
40+
"maxDistance": {
41+
"value": 10,
42+
"unit": "KILOMETERS"
43+
}
44+
}
45+
}
46+
```
47+
48+
`nearCoordinate` uses `{ "latitude": 43.65, "longitude": -79.38 }`. Use either `nearCoordinate` or `nearAddress`, never both. For address proximity, require an ISO alpha-2 `country` plus `city` or `postalCode`; `zoneCode` is optional. `maxDistance` requires an explicit proximity input and a `MILES` or `KILOMETERS` unit. Use `pageInfo.endCursor` as `after` only when `hasNextPage` is true.
49+
50+
Parse a country plus city or postal code already present in the buyer’s request. If proximity is requested without a usable location, ask for that coarse location and wait for the answer. Do not infer it from the buyer’s account, a saved address, IP geolocation, or another default.
51+
52+
Prefer coarse city/postal proximity. Use coordinates only with explicit permission, never persist the buyer’s location, and never send a street address merely to find nearby stores. Returned merchant addresses are public merchant data.
53+
54+
A result proves only that the merchant has a physical location that sells in person. It does not prove item inventory, pickup eligibility, opening hours, or that the store is open. Never claim an item is available nearby from this response alone.
55+
1656
## Device Authorization
1757

1858
Request a device code:

src/cli.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { Command } from 'commander'
66
import { AuthClient } from './auth.js'
77
import { CLI_VERSION, COUNTRY_ACCOUNT, DEFAULT_COUNTRY } from './constants.js'
88
import { toErrorMessage } from './errors.js'
9-
import { renderCatalogResult, renderCheckoutMessages } from './render.js'
9+
import { renderCatalogResult, renderCheckoutMessages, renderLocationsResult } from './render.js'
1010
import { ShopCatalogClient } from './shop-client.js'
11+
import type { RetailLocationDistanceUnit } from './shop-client.js'
1112
import { clearStoredAuth, KeytarSecretStore, MemorySecretStore, setCountry } from './storage.js'
1213
import type { FetchLike, SecretStore } from './types.js'
1314

@@ -37,12 +38,12 @@ export function createProgram(deps: CliDependencies = {}): Command {
3738

3839
program
3940
.name('shop')
40-
.description('Shop personal shopping CLI for catalog search, auth, checkout, and order search')
41+
.description('Shop personal shopping CLI for catalog search, retail locations, auth, checkout, and orders')
4142
.version(CLI_VERSION)
4243
.option('--country <code>', 'Buyer country for this call (catalog context signal, not a ships-to filter). Transient; use `shop config set-country` to persist a default.', DEFAULT_COUNTRY)
4344
.option('--profile-url <url>', 'UCP agent profile URL for global catalog calls')
4445
.option('--memory-store', 'Use in-memory token storage for tests and dry runs')
45-
.option('--format <format>', 'Output format for catalog results: md (default) or json. Auth and checkout always emit JSON; orders emit markdown.', parseFormat, 'md')
46+
.option('--format <format>', 'Output format for catalog and location results: md (default) or json. Auth and checkout always emit JSON; orders emit markdown.', parseFormat, 'md')
4647
.showHelpAfterError()
4748

4849
program
@@ -100,6 +101,40 @@ export function createProgram(deps: CliDependencies = {}): Command {
100101
})
101102
})
102103

104+
program
105+
.command('locations')
106+
.description('Find a merchant’s physical retail locations, optionally within a distance of an address or coordinate')
107+
.argument('<shop-id>', 'Numeric Shop ID shown in catalog results, or gid://shopify/Shop/<id>')
108+
.option('-l, --limit <number>', 'Results per page, 1-50', parseLimit, 15)
109+
.option('--cursor <cursor>', 'Pagination cursor from a previous locations response')
110+
.option('--query <text>', 'Filter locations by name or address text')
111+
.option('--near-country <ISO2>', 'Country for proximity search')
112+
.option('--near-region <code>', 'Region or state code for proximity search')
113+
.option('--near-city <name>', 'City for proximity search')
114+
.option('--near-postal-code <code>', 'Postal code for proximity search')
115+
.option('--near-latitude <number>', 'Latitude for precise proximity search', parseFiniteNumber)
116+
.option('--near-longitude <number>', 'Longitude for precise proximity search', parseFiniteNumber)
117+
.option('--max-distance <number>', 'Maximum distance from the supplied address or coordinate', parseFiniteNumber)
118+
.option('--distance-unit <unit>', 'Distance unit: miles or kilometers', parseDistanceUnit)
119+
.action(async (shopId: string, options) => {
120+
await runLocationsAction({ stdout, stderr, exit }, program, async () =>
121+
resolveClient(deps, program).retailLocations({
122+
shopId,
123+
limit: options.limit,
124+
cursor: options.cursor,
125+
query: options.query,
126+
nearCoordinate: buildNearCoordinate(options.nearLatitude, options.nearLongitude),
127+
nearAddress: buildNearAddress(
128+
options.nearCountry,
129+
options.nearRegion,
130+
options.nearCity,
131+
options.nearPostalCode,
132+
),
133+
maxDistance: buildMaxDistance(options.maxDistance, options.distanceUnit),
134+
}),
135+
)
136+
})
137+
103138
const catalog = program
104139
.command('catalog')
105140
.description('Direct global catalog MCP tools (lookup and product detail; use `shop search` to search)')
@@ -479,6 +514,25 @@ async function runTextAction(
479514
}
480515
}
481516

517+
async function runLocationsAction(
518+
io: Required<Pick<CliDependencies, 'exit'>> & Pick<CliDependencies, 'stdout' | 'stderr'>,
519+
program: Command,
520+
action: () => Promise<unknown>,
521+
): Promise<void> {
522+
try {
523+
const result = await action()
524+
const format = program.optsWithGlobals<GlobalOptions>().format ?? 'md'
525+
io.stdout?.write(
526+
format === 'json'
527+
? `${JSON.stringify(result, null, 2)}\n`
528+
: `${renderLocationsResult(result)}\n`,
529+
)
530+
} catch (error) {
531+
io.stderr?.write(`# Error\n\n${toErrorMessage(error)}\n`)
532+
io.exit(1)
533+
}
534+
}
535+
482536
// Catalog read commands default to compact markdown; --format json prints raw JSON.
483537
async function runCatalogAction(
484538
io: Required<Pick<CliDependencies, 'exit'>> & Pick<CliDependencies, 'stdout' | 'stderr'>,
@@ -534,6 +588,54 @@ const parseLimit = parseBoundedInt(1, 50)
534588
const parsePrice = parseBoundedInt(0)
535589
const parseQuantity = parseBoundedInt(1)
536590

591+
function parseFiniteNumber(value: string): number {
592+
const parsed = Number(value)
593+
if (!Number.isFinite(parsed)) throw new Error(`Invalid number: "${value}"`)
594+
return parsed
595+
}
596+
597+
function parseDistanceUnit(value: string): RetailLocationDistanceUnit {
598+
if (value === 'miles') return 'MILES'
599+
if (value === 'kilometers') return 'KILOMETERS'
600+
throw new Error(`Invalid distance unit "${value}". Use "miles" or "kilometers".`)
601+
}
602+
603+
function buildNearCoordinate(
604+
latitude?: number,
605+
longitude?: number,
606+
): { latitude: number; longitude: number } | undefined {
607+
if (latitude === undefined && longitude === undefined) return undefined
608+
if (latitude === undefined || longitude === undefined) {
609+
throw new Error('--near-latitude and --near-longitude must be provided together')
610+
}
611+
return { latitude, longitude }
612+
}
613+
614+
function buildNearAddress(
615+
country?: string,
616+
region?: string,
617+
city?: string,
618+
postalCode?: string,
619+
): { country: string; region?: string; city?: string; postalCode?: string } | undefined {
620+
if (!country && !region && !city && !postalCode) return undefined
621+
if (!country) throw new Error('--near-country is required for address proximity')
622+
if (!city && !postalCode) {
623+
throw new Error('Address proximity requires --near-city or --near-postal-code')
624+
}
625+
return { country, region, city, postalCode }
626+
}
627+
628+
function buildMaxDistance(
629+
value?: number,
630+
unit?: RetailLocationDistanceUnit,
631+
): { value: number; unit: RetailLocationDistanceUnit } | undefined {
632+
if (value === undefined && unit === undefined) return undefined
633+
if (value === undefined || unit === undefined) {
634+
throw new Error('--max-distance and --distance-unit must be provided together')
635+
}
636+
return { value, unit }
637+
}
638+
537639
function commaList(value: string): string[] {
538640
return value
539641
.split(',')

src/constants.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import { readFileSync } from 'node:fs'
2+
3+
const packageJson = JSON.parse(
4+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
5+
) as { version?: unknown }
6+
if (typeof packageJson.version !== 'string') throw new Error('package.json must define a version')
7+
18
export const CLIENT_ID = '5c733ab2-1903-400a-891e-7ba20c09e2a3'
29
export const DEFAULT_AGENT_NAME = 'Shop CLI'
310
export const DEFAULT_COUNTRY = 'US'
411
export const DEFAULT_PROFILE_URL =
512
'https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json'
613
export const GLOBAL_CATALOG_MCP_URL = 'https://catalog.shopify.com/api/ucp/mcp'
7-
export const CLI_VERSION = '0.1.0'
14+
export const SHOP_GRAPHQL_URL = 'https://server.shop.app/graphql'
15+
export const CLI_VERSION = packageJson.version
816
export const USER_AGENT = `shop-cli/${CLI_VERSION}`
917
// Authenticated global-catalog access uses a brokered RFC 8693 token exchange:
1018
// audience=api.shopify.com + requested_token_type=...access_token returns a

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { createProgram, main } from './cli.js'
22
export type { CliDependencies } from './cli.js'
3-
export { renderCatalogResult, withUtm } from './render.js'
3+
export { renderCatalogResult, renderLocationsResult, withUtm } from './render.js'
44
export { ShopCatalogClient } from './shop-client.js'
55
export type {
66
CatalogGetProductInput,
@@ -9,5 +9,8 @@ export type {
99
CheckoutCreateInput,
1010
CheckoutCompleteInput,
1111
OrderSearchInput,
12+
RetailLocationDistanceUnit,
13+
RetailLocationsInput,
14+
RetailLocationsResult,
1215
ShopCatalogClientOptions,
1316
} from './shop-client.js'

0 commit comments

Comments
 (0)