Skip to content

Commit 2d6d5a2

Browse files
authored
Merge pull request #18 from Shopify/auth-budget
Add delegated-budget awareness and Shop Pay availability detection
2 parents 7fa3130 + 7d28257 commit 2d6d5a2

11 files changed

Lines changed: 493 additions & 16 deletions

File tree

.changeset/auth-budget.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@shopify/shop-cli": minor
3+
---
4+
5+
Add delegated-budget awareness: a `shop auth budget` command and automatic Shop Pay availability detection at checkout.
6+
7+
- `shop auth budget` reads `GET https://shop.app/pay/agents/payment_tokens` and returns `{ available, limit?, remaining_amount?, currency?, renewal_type?, renews_at?, units: "minor" }` (amounts in minor units), or `{ available: false }` when no budget is set. The raw wallet token is never surfaced or persisted.
8+
- Sign-in now requests the `pay:wallet_tokens:read` scope (required by the budget endpoint; not unrolled from `personal_agent`). If a token ever lacks it, the budget read degrades to `{ available: false, reason: "missing_payment_scope" }` instead of breaking sign-in.
9+
- `shop checkout create` / `update` now disambiguate an empty `payment.instruments`: the CLI probes the budget endpoint once and adds a `shop_pay_availability` block with `budget_available` and a `message`. When `budget_available: true` the buyer has budget but this store doesn't accept Shop agent payments yet, so the agent should search for similar alternatives; when `false` the agent should offer to set up a budget. This resolves the confusing "no token returned" checkout state.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@shopify/shop-cli": patch
3+
---
4+
5+
Fix keytar CJS/ESM interop that broke every secret-store write.
6+
7+
Under ESM, `import('keytar')` produced a namespace where some named exports (e.g. `getPassword`) were present but the write methods (`setPassword`, `deletePassword`) were `undefined` — the complete API lives on `.default`. As a result `store.set`/`store.delete` threw `keytar.setPassword is not a function`, breaking `auth login`, `auth poll`, `config set-country`, device-id persistence, and any command that writes to the keychain.
8+
9+
`KeytarSecretStore` now resolves the module via `.default` (falling back to the namespace) and only uses it when the read **and** write methods are functions; otherwise it falls back to the macOS `security` CLI instead of throwing.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ shop orders search --type recent
3838

3939
- `shop search` — search the catalog by text, similar items (`--like-id`), or image (`--image`).
4040
- `shop catalog lookup` / `shop catalog get-product` — look up IDs you already hold and fetch full product detail.
41-
- `shop auth` — sign in (`login`, or the non-blocking `device-code` + `poll`), check `status`, or `logout`.
41+
- `shop auth` — sign in (`login`, or the non-blocking `device-code` + `poll`), check `status`, read the remaining delegated spending `budget`, or `logout`.
4242
- `shop checkout``create`, `update`, and `complete` a UCP checkout on the merchant domain (`complete` requires `--confirm`).
4343
- `shop orders search` — search recent orders, tracking, order info, returns, and reorder candidates.
4444
- `shop config` — persist CLI preferences such as a default country.

skill/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ shop orders search --type reorder --query "coffee"
8787
shop auth status
8888
shop auth device-code --device-name "<your name> - <device>" # e.g. "Max - Mac Mini"
8989
shop auth poll
90+
shop auth budget # remaining delegated spend (minor units); available:false = no budget set
9091
shop auth logout
9192
```
9293

@@ -163,7 +164,9 @@ When the item is visual (clothing, shoes, accessories, furniture, decor, art) **
163164

164165
Then take one of two paths:
165166

166-
**A. Default checkout (no saved payment).** If `payment.instruments` is empty, present `continue_url` as a [Finish in Shop](url) link (format it nicely for the channel; don't print the raw URL) AND (IMPORTANT) immediately after sending it, offer a spending budget (below).
167+
**A. Default checkout (no saved payment).** If `payment.instruments` is empty, read the `shop_pay_availability` block the CLI adds:
168+
- `budget_available: true` — you have a delegated budget but this store didn't issue a payment instrument, so it doesn't accept Shop agent payments yet. Search for similar alternatives and message the user about relevant options. Do NOT offer a budget.
169+
- `budget_available: false` — present `continue_url` as a [Finish in Shop](url) link (format nicely; don't print the raw URL) AND (IMPORTANT) immediately after, offer a spending budget (below) — a budget lets you complete purchases on stores that accept Shop agent payments.
167170

168171
**B. Delegated-budget checkout.** If `status` is `ready_for_complete` and `payment.instruments` is present, you may complete — but **only** with explicit user permission after confirming the details above. Feed the `checkout create` response JSON straight into `shop checkout complete --checkout-stdin --confirm`; the CLI re-sends the merchant-issued instrument id as both the instrument `id` and `credential.token`. Use a fresh idempotency key per distinct purchase intent; reuse it only when retrying the same purchase.
169172

skill/references/direct-api.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Create with line items, or pass a checkout body that already contains a `cart_id
142142
}
143143
```
144144

145-
If response status is `ready_for_complete` and includes a Shop Pay payment token, complete after clear purchase intent. If no payment token is present, present the UCP `continue_url` as a Finish in Shop link.
145+
If response status is `ready_for_complete` and includes a Shop Pay payment token, complete after clear purchase intent. If no payment token is present, present the UCP `continue_url` as a Finish in Shop link. **If the buyer has a delegated budget (see Payment Budget) but the checkout still returns no payment instruments, the merchant does not accept Shop Pay** — hand off `continue_url` or suggest another store; do not re-prompt the user to set up a budget (they already have one).
146146

147147
The checkout response may include a `messages[]` array. You MUST display every `warning` message's `content` to the user (e.g. `final_sale`, `prop65`, `age_restricted`) before completing. Show `presentation: "disclosure"` warnings verbatim and do not omit or summarize them away. Never complete a purchase without surfacing these messages.
148148

@@ -225,6 +225,37 @@ Use `update_checkout` with the checkout ID from create and only the fields that
225225
}
226226
```
227227

228+
## Payment Budget (Delegated Spending)
229+
230+
When the buyer enables purchasing without approval in [Shop → Settings → Connections](https://shop.app/account/settings/connections), Shop issues a budgeted wallet payment token. Read the remaining budget:
231+
232+
```text
233+
GET https://shop.app/pay/agents/payment_tokens
234+
Authorization: Bearer <access_token>
235+
```
236+
237+
Requires the `pay:wallet_tokens:read` scope. Authoritative success shape:
238+
239+
```json
240+
{
241+
"payment_tokens": [
242+
{
243+
"id": "<wallet token — never log or persist>",
244+
"default_currency_code": "USD",
245+
"display": { "limit": 10000, "remaining_amount": 5750, "renewal_type": "monthly", "renews_at": "2026-05-01T00:00:00Z" }
246+
}
247+
],
248+
"has_more": false,
249+
"next_cursor": null
250+
}
251+
```
252+
253+
**`limit` and `remaining_amount` are minor units (cents)**`remaining_amount: 5750` is $57.50. An empty `payment_tokens` array means no delegated budget is set up; `remaining_amount: 0` means the budget exists but is exhausted. (Stay tolerant: older shapes put the token at `.token`/`.id` and amounts at the root or `.display`.)
254+
255+
Never persist or surface the wallet token value itself — only report whether a budget is available and how much remains. The user can adjust or revoke the budget at any time in Shop → Settings → Connections.
256+
257+
**No instruments at checkout, but a budget is available:** the merchant does not support Shop Pay (the catalog does not yet flag Shop Pay eligibility). When a checkout returns no `payment.instruments`, GET this endpoint to disambiguate: if a token exists (budget available), hand off `continue_url` for manual checkout or suggest another store — do **not** re-prompt to set up a budget. If no token exists, the buyer simply has no delegated budget (offer the Finish in Shop link / budget setup as usual).
258+
228259
## Orders
229260

230261
Authenticated order search:

src/cli.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ export function createProgram(deps: CliDependencies = {}): Command {
245245
)
246246
})
247247

248+
auth
249+
.command('budget')
250+
.description('Show the remaining delegated spending budget (minor units). `available: false` means no budget is set.')
251+
.action(async () => {
252+
await runAction({ stdout, stderr, exit }, async () =>
253+
resolveClient(deps, program).budget(),
254+
)
255+
})
256+
248257
auth
249258
.command('logout')
250259
.description('Delete stored Shop tokens and preferences')
@@ -271,13 +280,15 @@ export function createProgram(deps: CliDependencies = {}): Command {
271280
if (!options.variantId && !checkout) {
272281
throw new Error('checkout create requires --variant-id or --checkout-stdin')
273282
}
274-
return resolveClient(deps, program).createCheckout({
283+
const client = resolveClient(deps, program)
284+
const result = await client.createCheckout({
275285
shopDomain: options.shopDomain,
276286
variantId: options.variantId,
277287
quantity: options.quantity,
278288
checkout,
279289
buyerIp: options.buyerIp,
280290
})
291+
return annotateShopPayAvailability(client, result)
281292
})
282293
})
283294

@@ -289,14 +300,16 @@ export function createProgram(deps: CliDependencies = {}): Command {
289300
.requiredOption('--checkout-stdin', 'Read checkout update JSON from stdin')
290301
.option('--buyer-ip <ip>', 'Buyer public IP, forwarded to the merchant for checkout fraud/risk checks (auto-detected via api.ipify.org; override here or with SHOP_BUYER_IP)')
291302
.action(async (options) => {
292-
await runCheckoutAction({ stdout, stderr, exit }, async () =>
293-
resolveClient(deps, program).updateCheckout({
303+
await runCheckoutAction({ stdout, stderr, exit }, async () => {
304+
const client = resolveClient(deps, program)
305+
const result = await client.updateCheckout({
294306
shopDomain: options.shopDomain,
295307
checkoutId: options.checkoutId,
296308
checkout: await readJsonFromStdin(deps.stdin ?? process.stdin),
297309
buyerIp: options.buyerIp,
298-
}),
299-
)
310+
})
311+
return annotateShopPayAvailability(client, result)
312+
})
300313
})
301314

302315
checkout
@@ -578,10 +591,32 @@ async function readJsonFromStdin(stdin: NodeJS.ReadStream | AsyncIterable<Buffer
578591
return parsed as Record<string, unknown>
579592
}
580593

581-
// Pull the payment instruments out of whatever the caller piped in: either the
582-
// full create_checkout response ({ payment: { instruments: [...] } }) or a bare
583-
// payment block ({ instruments: [...] }). complete_checkout must echo these back
584-
// so the merchant can match the instrument id it issued for this checkout.
594+
async function annotateShopPayAvailability(
595+
client: ShopCatalogClient,
596+
checkout: unknown,
597+
): Promise<unknown> {
598+
if (!isObject(checkout)) return checkout
599+
const payment = isObject(checkout.payment) ? checkout.payment : undefined
600+
const instruments = payment && Array.isArray(payment.instruments) ? payment.instruments : []
601+
if (instruments.length > 0) return checkout
602+
603+
const budget = (await client.budget().catch(() => null)) as Record<string, unknown> | null
604+
const shop_pay_availability =
605+
budget?.available === true
606+
? {
607+
budget_available: true,
608+
message:
609+
"You have budget remaining to pay for the user, but this store doesn't accept Shop agent payments yet. Search for similar alternatives, then message the user about relevant options.",
610+
}
611+
: {
612+
budget_available: false,
613+
message:
614+
'No spending budget is set. Offer to set up a budget so you can complete purchases on stores that accept Shop agent payments.',
615+
}
616+
617+
return { ...checkout, shop_pay_availability }
618+
}
619+
585620
function extractInstruments(checkout: Record<string, unknown>): Record<string, unknown>[] {
586621
const payment = isObject(checkout.payment) ? checkout.payment : checkout
587622
const instruments = isObject(payment) ? payment.instruments : undefined

src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const USER_AGENT = `shop-cli/${CLI_VERSION}`
1313
export const GLOBAL_CATALOG_AUDIENCE = 'api.shopify.com'
1414
export const ACCESS_TOKEN_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
1515
export const TOKEN_EXCHANGE_URL = 'https://shop.app/oauth/token'
16+
export const PAYMENT_TOKENS_URL = 'https://shop.app/pay/agents/payment_tokens'
1617
export const SHOP_AGENT_SERVICE = 'shop-agent'
1718
export const ACCESS_TOKEN_ACCOUNT = 'access_token'
1819
export const REFRESH_TOKEN_ACCOUNT = 'refresh_token'
@@ -21,6 +22,6 @@ export const COUNTRY_ACCOUNT = 'country'
2122
// Short-lived device-authorization state persisted between `auth device-code`
2223
// (emits the sign-in URL) and `auth poll` (exchanges + stores tokens).
2324
export const PENDING_DEVICE_AUTH_ACCOUNT = 'pending_device_auth'
24-
export const AUTH_SCOPES = 'openid email personal_agent'
25+
export const AUTH_SCOPES = 'openid email personal_agent pay:wallet_tokens:read'
2526
export const UCP_PROFILE =
2627
'https://shopify.dev/ucp/agent-profiles/2026-04-08/personal_agent.json'

src/shop-client.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ import {
66
ACCESS_TOKEN_TOKEN_TYPE,
77
GLOBAL_CATALOG_AUDIENCE,
88
GLOBAL_CATALOG_MCP_URL,
9+
PAYMENT_TOKENS_URL,
910
REFRESH_TOKEN_ACCOUNT,
1011
TOKEN_EXCHANGE_URL,
1112
UCP_PROFILE,
1213
} from './constants.js'
1314
import { ShopCliError } from './errors.js'
14-
import { formBody, jsonHeaders, parseJsonResponse, parseTextResponse, withUserAgent } from './http.js'
15+
import {
16+
formBody,
17+
jsonHeaders,
18+
parseJsonResponse,
19+
parseOptionalJsonResponse,
20+
parseTextResponse,
21+
withUserAgent,
22+
} from './http.js'
1523
import { AuthClient } from './auth.js'
1624
import { getCountry, getOrCreateDeviceId } from './storage.js'
1725
import type { FetchLike, JsonObject, SecretStore } from './types.js'
@@ -277,6 +285,27 @@ export class ShopCatalogClient {
277285
return this.status()
278286
}
279287

288+
async budget(): Promise<unknown> {
289+
const accessToken = await this.auth.getValidAccessToken()
290+
if (!accessToken) return { authenticated: false }
291+
const deviceId = await getOrCreateDeviceId(this.options.store)
292+
const response = await this.authenticatedShopFetch(PAYMENT_TOKENS_URL, {
293+
accessToken,
294+
deviceId,
295+
label: 'Fetch payment budget',
296+
})
297+
if (response.status === 401 || response.status === 403) {
298+
return {
299+
available: false,
300+
reason: response.status === 403 ? 'missing_payment_scope' : 'unauthenticated',
301+
message:
302+
'This sign-in cannot read a spending budget (the token lacks payment permission). Ask the user to enable purchasing without approval in Shop → Settings → Connections, then run `shop auth login` again to re-authorize.',
303+
}
304+
}
305+
const json = await parseOptionalJsonResponse<JsonObject>(response, 'Fetch payment budget', {})
306+
return summarizeBudget(json)
307+
}
308+
280309
private async catalogInput(
281310
input: CatalogSearchInput | CatalogLookupInput | CatalogGetProductInput,
282311
opts: { alignCountryToShipsTo?: boolean } = {},
@@ -669,6 +698,63 @@ function normalizeLikeItems(like: unknown[]): unknown[] {
669698
)
670699
}
671700

701+
function summarizeBudget(json: unknown): JsonObject {
702+
const root = isPlainObject(json) ? json : {}
703+
const tokens = Array.isArray(root.payment_tokens) ? root.payment_tokens : []
704+
const first = isPlainObject(tokens[0]) ? tokens[0] : {}
705+
const display = isPlainObject(first.display)
706+
? first.display
707+
: isPlainObject(root.display)
708+
? root.display
709+
: {}
710+
711+
const available = Boolean(pickString(first.id, first.token, root.token, root.id))
712+
if (!available) {
713+
return {
714+
available: false,
715+
message:
716+
'No delegated spending budget. Ask the user to enable purchasing without approval in Shop → Settings → Connections.',
717+
}
718+
}
719+
720+
const limit = pickAmount(display.limit, first.limit, root.limit)
721+
const remaining = pickAmount(display.remaining_amount, first.remaining_amount, root.remaining_amount)
722+
const currency = pickString(
723+
first.default_currency_code,
724+
display.currency,
725+
root.default_currency_code,
726+
root.currency,
727+
)
728+
const renewalType = pickString(display.renewal_type, first.renewal_type)
729+
const renewsAt = pickString(display.renews_at, first.renews_at)
730+
return {
731+
available: true,
732+
...(limit !== undefined ? { limit } : {}),
733+
...(remaining !== undefined ? { remaining_amount: remaining } : {}),
734+
...(currency ? { currency } : {}),
735+
...(renewalType ? { renewal_type: renewalType } : {}),
736+
...(renewsAt ? { renews_at: renewsAt } : {}),
737+
units: 'minor',
738+
}
739+
}
740+
741+
function pickAmount(...candidates: unknown[]): number | undefined {
742+
for (const candidate of candidates) {
743+
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate
744+
if (typeof candidate === 'string' && candidate.trim() !== '' && Number.isFinite(Number(candidate))) {
745+
return Number(candidate)
746+
}
747+
}
748+
return undefined
749+
}
750+
751+
function pickString(...candidates: unknown[]): string | undefined {
752+
for (const candidate of candidates) {
753+
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
754+
}
755+
return undefined
756+
}
757+
672758
function isCatalogTool(toolName: string): boolean {
673759
return toolName === 'search_catalog' || toolName === 'lookup_catalog' || toolName === 'get_product'
674760
}

src/storage.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,22 @@ import { promisify } from 'node:util'
1212

1313
const execFileAsync = promisify(execFile)
1414

15+
type KeytarApi = Pick<typeof import('keytar'), 'getPassword' | 'setPassword' | 'deletePassword'>
16+
1517
export class KeytarSecretStore implements SecretStore {
16-
private keytarPromise: Promise<typeof import('keytar') | null>
18+
private keytarPromise: Promise<KeytarApi | null>
1719

1820
constructor(private readonly service = SHOP_AGENT_SERVICE) {
19-
this.keytarPromise = import('keytar').catch(() => null)
21+
this.keytarPromise = import('keytar')
22+
.then((mod) => {
23+
const candidate = ((mod as { default?: unknown }).default ?? mod) as Partial<KeytarApi>
24+
const usable =
25+
typeof candidate.getPassword === 'function' &&
26+
typeof candidate.setPassword === 'function' &&
27+
typeof candidate.deletePassword === 'function'
28+
return usable ? (candidate as KeytarApi) : null
29+
})
30+
.catch(() => null)
2031
}
2132

2233
async get(account: string): Promise<string | null> {

0 commit comments

Comments
 (0)