diff --git a/.changeset/shiny-clowns-leave.md b/.changeset/shiny-clowns-leave.md new file mode 100644 index 0000000..ec5ff16 --- /dev/null +++ b/.changeset/shiny-clowns-leave.md @@ -0,0 +1,5 @@ +--- +"@stripe/link-cli": minor +--- + +Support `metadata` in `spend-request create` command diff --git a/CLAUDE.md b/CLAUDE.md index b9eaaff..4598dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ CLI command is `spend-request` (user-facing). Implemented in `packages/cli/src/c Key input field notes: - CLI input uses `payment_method_id`; mapped to `payment_details` when calling the SDK - `context` requires min 100 characters; `amount` is in cents with max 500000 +- `--metadata` (create only) is a repeatable `key:value` flag (CLI) or a `{ key: value }` object (MCP/agent), merged into a single `metadata` string→string map. Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. Reuses `parseKvString` from `line-item-parser.ts`. - `--test` flag creates testmode credentials (real testmode SPT from test card data) instead of livemode ones - `create --request-approval` and `request-approval` both show an approval URL in interactive mode and poll until approved/denied/expired/failed/canceled. In JSON mode (`--format json`), they return immediately with an `_next.command` for `spend-request retrieve`. - `retrieve --interval ` polls until approved/denied/expired/succeeded/failed/canceled. If `--timeout` is reached or `--max-attempts` is exhausted while the request is still non-terminal, it exits non-zero with `POLLING_TIMEOUT`. diff --git a/README.md b/README.md index e1ee7f4..7d67cc1 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,18 @@ link-cli spend-request create ... \ In MCP/agent mode, pass as a structured object. +#### Metadata + +Attach arbitrary string data to a spend request with the repeatable `--metadata` flag (`key:value` format). Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. + +```bash +link-cli spend-request create ... \ + --metadata "order_id:ord_123" \ + --metadata "team:growth" +``` + +In MCP/agent mode, pass `metadata` as a structured `{ key: value }` object. + #### Credential types By default, a spend request provisions a virtual card. For merchants that support the [Machine Payments Protocol](https://mpp.dev) (HTTP 402) and the Stripe payment method, instead pass `--credential-type "shared_payment_token"`. diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 823937e..0648d02 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -323,6 +323,38 @@ describe('production mode', () => { expect(request.network_id).toBe('net_prod_abc'); }); + it('merges repeatable --metadata flags into a metadata object in POST body', async () => { + setNextResponse(200, BASE_REQUEST); + + const result = await runProdCli( + 'spend-request', + 'create', + '--payment-method-id', + 'pd_prod_test', + '--merchant-name', + 'Test Merchant', + '--merchant-url', + 'https://example.com', + '--context', + VALID_CONTEXT, + '--amount', + '5000', + '--metadata', + 'order_id:ord_123', + '--metadata', + 'team:growth', + '--no-request-approval', + '--json', + ); + + expect(result.exitCode).toBe(0); + const sentBody = JSON.parse(lastRequest.body); + expect(sentBody.metadata).toEqual({ + order_id: 'ord_123', + team: 'growth', + }); + }); + it('sends test flag in POST body when --test is used', async () => { setNextResponse(200, BASE_REQUEST); diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index bcd3749..8224f47 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -11,6 +11,7 @@ import { Cli, z } from 'incur'; import React from 'react'; import { writeCredentialFile } from '../../utils/credential-output'; import { + parseKvString, parseLineItemFlag, parseTotalFlag, } from '../../utils/line-item-parser'; @@ -154,6 +155,21 @@ export function createSpendRequestCli( : opts.approvalDetail : undefined; + // Merge repeatable metadata flags into a single flat object: strings from + // flags are parsed as key:value, objects from MCP pass through. + let metadata: Record | undefined; + if (opts.metadata?.length) { + metadata = {}; + for (const item of opts.metadata as unknown[]) { + Object.assign( + metadata, + typeof item === 'string' + ? parseKvString(item) + : (item as Record), + ); + } + } + const createParams = { payment_details: opts.paymentMethodId, credential_type: credentialType, @@ -169,6 +185,7 @@ export function createSpendRequestCli( test: opts.test ? true : undefined, approve: opts.approve ? true : undefined, approval_details: approvalDetails, + metadata, }; const outputFile = opts.outputFile; diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index 30c036a..3459042 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -78,6 +78,12 @@ export const createOptions = z.object({ .describe( 'Approval details object (MCP/agent: pass as object; CLI: pass as JSON string). Required fields: approved_at (unix timestamp), approval_method (click|programmatic|voice), app_name, external_user_id. Optional: ip_address, user_agent, device_type (mobile|web), agent_log_id, external_user_name, external_session_id, authentication_method (biometric_face|biometric_fingerprint|passkey).', ), + metadata: z + .array(z.union([z.string(), z.record(z.string(), z.string())])) + .default([]) + .describe( + 'Metadata key:value pair (repeatable). Attaches arbitrary string data to the spend request. Max 50 keys, key <= 40 chars, value <= 500 chars. Example: "order_id:ord_123"', + ), }); export const listOptions = z.object({ diff --git a/packages/sdk/src/resources/__tests__/spend-request.test.ts b/packages/sdk/src/resources/__tests__/spend-request.test.ts index 83372a7..7f76c15 100644 --- a/packages/sdk/src/resources/__tests__/spend-request.test.ts +++ b/packages/sdk/src/resources/__tests__/spend-request.test.ts @@ -117,6 +117,23 @@ describe('SpendRequestResource', () => { expect(result.network_id).toBe('net_abc'); }); + it('serializes metadata in POST body', async () => { + const paramsWithMetadata: CreateSpendRequestParams = { + ...validParams, + metadata: { order_id: 'ord_123', team: 'growth' }, + }; + mockFetchResponse(200, spendRequestResponse); + + await repo.createSpendRequest(paramsWithMetadata); + + const [, opts] = mockFetch.mock.calls[0]; + const sentBody = JSON.parse(opts.body); + expect(sentBody.metadata).toEqual({ + order_id: 'ord_123', + team: 'growth', + }); + }); + it('serializes test flag in POST body when true', async () => { const paramsWithTest: CreateSpendRequestParams = { ...validParams, diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 6fc1c6d..e70bba3 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -66,6 +66,7 @@ export interface CreateSpendRequestParams { test?: boolean; approve?: boolean; approval_details?: ApprovalDetail; + metadata?: Record; } export interface UpdateSpendRequestParams { diff --git a/plugins/link/.claude-plugin/plugin.json b/plugins/link/.claude-plugin/plugin.json index 4ceea6d..2a96971 100644 --- a/plugins/link/.claude-plugin/plugin.json +++ b/plugins/link/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.9.0", + "version": "0.10.1", "description": "Authenticate with Link, create spend requests, and retrieve one-time-use card or shared payment token credentials for user-approved purchases.", "author": { "name": "Stripe" diff --git a/plugins/link/.codex-plugin/plugin.json b/plugins/link/.codex-plugin/plugin.json index 101063b..00c8768 100644 --- a/plugins/link/.codex-plugin/plugin.json +++ b/plugins/link/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.9.0", + "version": "0.10.1", "description": "Secure, one-time-use payment credentials from Link", "author": { "name": "Stripe", diff --git a/plugins/link/.cursor-plugin/plugin.json b/plugins/link/.cursor-plugin/plugin.json index 89bcf3a..5f3264a 100644 --- a/plugins/link/.cursor-plugin/plugin.json +++ b/plugins/link/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "link", "displayName": "Stripe Link", - "version": "0.9.0", + "version": "0.10.1", "description": "Get secure, one-time-use payment credentials from a Link wallet so agents can complete purchases on your behalf.", "author": { "name": "Stripe" diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 87f7ae7..ce6cca3 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.9.0 +version: 0.10.1 name: create-payment-credential description: | Gets secure, one-time-use payment credentials (cards, tokens) from a Link wallet so agents can complete purchases on behalf of users. Use when the user says "get me a card", "buy something", "pay for X", "make a purchase", "I need to pay", "complete checkout", or asks to transact on any merchant site. Use when the user asks to connect or log in to or sign up for their Link account. @@ -173,6 +173,8 @@ Recommend the user approves with the [Link app](https://link.com/download). Show **Approval details:** For delegated/pre-approved flows, pass `--approval-detail` as a JSON object (MCP/agent) or JSON string (CLI). Required fields: `approved_at` (unix timestamp), `approval_method` (`click`|`programmatic`|`voice`), `app_name`, `external_user_id`. Optional: `ip_address`, `user_agent`, `device_type` (`mobile`|`web`), `agent_log_id`, `external_user_name`, `external_session_id`, `authentication_method` (`biometric_face`|`biometric_fingerprint`|`passkey`). +**Metadata:** Attach arbitrary string data with the repeatable `--metadata "key:value"` flag (CLI) or a `{ key: value }` object (MCP/agent). Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. Example: `--metadata "order_id:ord_123" --metadata "team:growth"`. + ### Step 5: Complete payment **Card:** Run `link-cli spend-request retrieve --include card` to get the `card` object with `number`, `cvc`, `exp_month`, `exp_year`, `billing_address` (name, line1, line2, city, state, postal_code, country), and `valid_until` (Unix timestamp — the card stops working after this time). Enter these details into the merchant's checkout form.