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
483 changes: 483 additions & 0 deletions packages/cli/src/__tests__/ucp.test.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createServeCli } from './commands/serve';
import { createShippingAddressCli } from './commands/shipping-address';
import { createSpendRequestCli } from './commands/spend-request';
import { createTransactionsCli } from './commands/transactions';
import { createUcpCli } from './commands/ucp';
import { createUserInfoCli } from './commands/user-info';
import { createWebBotAuthCli } from './commands/web-bot-auth';
import { ResourceFactory } from './utils/resource-factory';
Expand Down Expand Up @@ -153,6 +154,7 @@ if (!transactionsCli) {
authStorage,
),
);
cli.command(createUcpCli());
cli.command(createServeCli(cli));
}

Expand Down
242 changes: 242 additions & 0 deletions packages/cli/src/commands/ucp/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { UcpResource, type UcpTransport } from '@stripe/link-sdk';
import { Cli, z } from 'incur';
import {
businessOption,
cartCreateOptions,
cartGetOptions,
cartUpdateOptions,
catalogLookupOptions,
catalogSearchOptions,
checkoutCompleteOptions,
checkoutCreateOptions,
checkoutGetOptions,
checkoutUpdateOptions,
orderGetOptions,
} from './schema';

function createUcpResource(opts: {
profileUrl?: string;
clientId?: string;
clientSecret?: string;
accessToken?: string;
transport?: UcpTransport;
}): UcpResource {
return new UcpResource({
auth: {
profileUrl: opts.profileUrl,
clientId: opts.clientId,
clientSecret: opts.clientSecret,
accessToken: opts.accessToken,
},
transport: opts.transport,
});
}

export function createUcpCli() {
const cli = Cli.create('ucp', {
description: 'Universal Commerce Protocol (UCP) commands',
});

// --- discover ---

cli.command('discover', {
description: 'See what operations a business supports before calling them',
args: z.object({
business: z
.string()
.describe(
'Business URL to discover (e.g. https://merchant.example.com)',
),
}),
options: z.object({
profileUrl: z
.string()
.optional()
.describe(
'Agent profile URL (not required for discovery, but needed for tool negotiation)',
),
}),
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource({ profileUrl: c.options.profileUrl });
return resource.discover(c.args.business);
},
});

// --- catalog search ---

const catalogCli = Cli.create('catalog', {
description:
'Search for products, enumerate variants and options, check availability',
});

catalogCli.command('search', {
description: 'Search a business catalog over UCP',
options: catalogSearchOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
return resource.catalogSearch(c.options.business, {
query: c.options.query,
limit: c.options.limit,
cursor: c.options.cursor,
});
},
});

catalogCli.command('lookup', {
description: 'Batch lookup products/variants by ID',
options: catalogLookupOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
const ids = c.options.ids.split(',').map((id) => id.trim());
return resource.catalogLookup(c.options.business, { ids });
},
});

cli.command(catalogCli);

// --- cart ---

const cartCli = Cli.create('cart', {
description: 'Build a shoppable cart with line items and cost estimates',
});

cartCli.command('create', {
description: 'Create a new cart with line items',
options: cartCreateOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
if (c.options.input) {
const parsed = JSON.parse(c.options.input);
return resource.cartCreate(c.options.business, parsed);
}
const lineItems = JSON.parse(c.options.lineItems);
return resource.cartCreate(c.options.business, {
line_items: lineItems,
});
},
});

cartCli.command('get', {
description: 'Fetch a cart by ID',
options: cartGetOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
return resource.cartGet(c.options.business, c.options.id);
},
});

cartCli.command('update', {
description: 'Update an existing cart',
options: cartUpdateOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
if (c.options.input) {
const parsed = JSON.parse(c.options.input);
return resource.cartUpdate(c.options.business, c.options.id, parsed);
}
const params: Record<string, unknown> = {};
if (c.options.lineItems) {
params.line_items = JSON.parse(c.options.lineItems);
}
return resource.cartUpdate(c.options.business, c.options.id, params);
},
});

cli.command(cartCli);

// --- checkout ---

const checkoutCli = Cli.create('checkout', {
description:
'Complete a purchase, pick fulfillment options, confirm payment',
});

checkoutCli.command('create', {
description:
'Create a checkout from line_items, or convert a cart with --cart-id',
options: checkoutCreateOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
if (c.options.input) {
const parsed = JSON.parse(c.options.input);
return resource.checkoutCreate(c.options.business, parsed);
}
const params: Record<string, unknown> = {};
if (c.options.cartId) {
params.cart_id = c.options.cartId;
}
if (c.options.lineItems) {
params.line_items = JSON.parse(c.options.lineItems);
}
return resource.checkoutCreate(c.options.business, params);
},
});

checkoutCli.command('get', {
description: 'Fetch a checkout by ID',
options: checkoutGetOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
return resource.checkoutGet(c.options.business, c.options.id);
},
});

checkoutCli.command('update', {
description: 'Update an existing checkout (fulfillment, buyer info, etc.)',
options: checkoutUpdateOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
const parsed = JSON.parse(c.options.input);
return resource.checkoutUpdate(c.options.business, c.options.id, parsed);
},
});

checkoutCli.command('complete', {
description:
'Complete a checkout and place the order. May return requires_escalation with a continue_url for browser-based payment.',
options: checkoutCompleteOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
const params = c.options.input ? JSON.parse(c.options.input) : undefined;
return resource.checkoutComplete(
c.options.business,
c.options.id,
params,
);
},
});

cli.command(checkoutCli);

// --- order ---

const orderCli = Cli.create('order', {
description:
'Retrieve order state including fulfillment tracking and delivery status',
});

orderCli.command('get', {
description:
'Get the current state of an order, including fulfillment expectations, tracking events, and line item status',
options: orderGetOptions,
outputPolicy: 'all' as const,
async run(c) {
const resource = createUcpResource(c.options);
return resource.orderGet(c.options.business, c.options.id);
},
});

cli.command(orderCli);

return cli;
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/ucp/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { z } from 'incur';

export const businessOption = z.object({
business: z
.string()
.describe(
'Business URL to interact with (e.g. https://merchant.example.com)',
),
profileUrl: z
.string()
.describe('Agent profile URL that identifies this agent to the merchant'),
clientId: z
.string()
.optional()
.describe('OAuth client_id for agent-authenticated requests'),
clientSecret: z
.string()
.optional()
.describe('OAuth client_secret for agent-authenticated requests'),
accessToken: z
.string()
.optional()
.describe('OAuth Bearer token for user-authenticated requests'),
transport: z
.enum(['auto', 'rest', 'mcp'])
.optional()
.describe(
'Transport to use: auto (prefer REST, fall back to MCP), rest, or mcp',
),
});

export const catalogSearchOptions = businessOption.extend({
query: z.string().describe('Free-text search query (e.g. "boots")'),
limit: z.number().optional().describe('Maximum number of results to return'),
cursor: z
.string()
.optional()
.describe('Pagination cursor from a prior response'),
});

export const catalogLookupOptions = businessOption.extend({
ids: z
.string()
.describe('Comma-separated list of product/variant IDs to look up'),
});

export const cartCreateOptions = businessOption.extend({
lineItems: z
.string()
.describe(
'Line items as JSON array (e.g. \'[{"variant_id":"gid://123","quantity":1}]\')',
),
input: z
.string()
.optional()
.describe('Full operation payload as JSON (overrides other fields)'),
});

export const cartGetOptions = businessOption.extend({
id: z.string().describe('Cart ID'),
});

export const cartUpdateOptions = businessOption.extend({
id: z.string().describe('Cart ID'),
lineItems: z.string().optional().describe('Updated line items as JSON array'),
input: z
.string()
.optional()
.describe('Full operation payload as JSON (overrides other fields)'),
});

export const checkoutCreateOptions = businessOption.extend({
cartId: z.string().optional().describe('Cart ID to convert to checkout'),
lineItems: z
.string()
.optional()
.describe('Line items as JSON array (alternative to --cart-id)'),
input: z
.string()
.optional()
.describe('Full operation payload as JSON (overrides other fields)'),
});

export const checkoutGetOptions = businessOption.extend({
id: z.string().describe('Checkout ID'),
});

export const checkoutUpdateOptions = businessOption.extend({
id: z.string().describe('Checkout ID'),
input: z.string().describe('Update payload as JSON'),
});

export const checkoutCompleteOptions = businessOption.extend({
id: z.string().describe('Checkout ID'),
input: z
.string()
.optional()
.describe('Complete payload as JSON (e.g. payment method details)'),
});

export const orderGetOptions = businessOption.extend({
id: z.string().describe('Order ID (returned from checkout complete)'),
});
Loading
Loading