18 KiB
CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
Project Overview
Link CLI — lets agents get secure, one-time-use payment credentials from a Link wallet. pnpm + Turborepo monorepo:
@stripe/link-sdk(packages/sdk): Typed Link API client and resource implementations. It acceptsaccessTokenorgetAccessToken; it does not own OAuth state. Entry:src/index.ts.@stripe/link-cli(packages/cli): Commander.js + Ink/React CLI that consumes@stripe/link-sdk. Entry:src/cli.tsx.
Commands
pnpm install # install dependencies
pnpm run build # build all packages (turbo)
pnpm run dev # watch mode
pnpm run test # run all tests
pnpm run typecheck # type-check all packages
pnpm biome check . # lint + format check (CI)
pnpm run check # lint + format with auto-fix
Run a single test:
cd packages/cli && pnpm vitest run src/utils/__tests__/line-item-parser.test.ts
The CLI integration tests in packages/cli/src/__tests__/cli.test.ts run against the compiled dist/cli.js. Run pnpm run build before running them if the source has changed.
Run the CLI locally:
node packages/cli/dist/cli.js <command>
Architecture
SDK Resources
Defined in packages/sdk/src/resources/interfaces.ts:
ISpendRequestResource— CRUD + request-approval for spend requests
The SDK only accepts credentials. Device authorization, refresh-token
persistence, login state, and auth-specific errors live under
packages/cli/src/auth/.
CLI Command Structure
Commands in packages/cli/src/cli.tsx (incur framework). Each has two output modes:
- Interactive (default): Ink/React components from
packages/cli/src/commands/ - JSON (
--format json): JSON to stdout, errors as JSON withcodeandmessagefields with exit code 1
Commands: auth login|logout|status, user-info retrieve, spend-request create|update|retrieve|request-approval|cancel, payment-methods list, shipping-address list, mpp pay|decode, serve.
The CLI also runs as an MCP server (--mcp) and serves skill files via skills subcommand, both provided by incur.
When changing commands, flags, or schema descriptions, always update all four together: README.md, skills/create-payment-credential/SKILL.md, the schema description strings in the relevant schema.ts file, and CLAUDE.md. These can easily drift apart.
Input is passed via flags. Define options in the command's zod schema — incur registers CLI flags automatically from the schema.
auth login
auth login --client-name <name>— optional flag to identify the agent or app; shown in the user's Link app as<name> on <hostname>. Defined inloginOptionsinpackages/cli/src/commands/auth/schema.ts.auth login --interval <seconds> [--timeout <seconds>] [--max-attempts <n>]— when--intervalis provided, the command yields the verification code immediately then polls inline until authenticated or timed out. Without--interval, returns the code with a_nexthint for separate polling viaauth status.- The token endpoint echoes
scopeandauthorization_detailsback with the tokens on login/refresh. These are persisted in the credential file (part ofAuthTokens) and surfaced onauth statusin both interactive and JSON modes, only when present. packages/cli/src/auth/auth-resource.tsowns device authorization, token parsing, refresh, and revocation.ResourceFactoryexposes the resulting access token to SDK resources throughgetAccessToken.
auth upgrade
auth upgrade— takes the same flags asauth login(reusesloginOptions;--client-name,--scope,--source-actions,--authorization-detail,--interval/--timeout/--max-attempts) and starts a new device-authorization requesting a superset of the current access. Implemented alongsideloginincreateAuthCli(packages/cli/src/commands/auth/index.tsx);auth loginis unchanged. The device-auth tail (initiate → yield code → poll) is shared withloginvia thestartDeviceAuthAndPollhelper.- Where
auth loginbails out with "already logged in" when a valid session exists,auth upgradenever bails: it refreshes the existing token, merges the requestedscope/authorization_detailswith the currently granted access viacomputeMergedAccess(packages/cli/src/auth/merge-access.ts, returningmergedScope+mergedAuthorizationDetails), and initiates device auth for the union. - If the existing token is invalid or absent, it writes a warning to stderr and includes a
warningfield in the JSON yield, then continues with only the requested access (never hard-fails).--source-actionsare folded intoauthorization_detailsbefore merging (viabuildAuthorizationDetails), sosourcemerges bytypelike any other detail. - Deferred session replacement (key invariant). Upgrade does not clear or revoke the current session up front — the existing grant stays valid throughout the pending approval, so a failed
initiateDeviceAuthor an abandoned approval leaves it usable. The refreshed tokens are persisted; the pending device-auth record is flaggedreplaces_existing_session(field on the CLI-ownedPendingDeviceAuthinpackages/cli/src/auth/storage.ts).pollAuthStatuscompletes a flagged pending even whileisAuthenticated()is true (it doesn't report the old session as done), and on success swaps in the new tokens and revokes the old grant. The interactive path does the same via the<Login>revokeRefreshTokenOnSuccessprop. Abandon → the flagged pending expires (auto-cleared bygetPendingDeviceAuth) and the old session remains. - Scope-token comparison for the merge tolerates commas (the token endpoint echoes
scopeback comma-delimited) — but only insidemerge-access.ts.auth login's--scopeparsing (normalizeScopeInputinscopes.ts) remains strictly space-separated, sologinis genuinely unchanged.
spend-request command
CLI command is spend-request (user-facing). Implemented in packages/cli/src/commands/spend-request/. SDK interfaces: ISpendRequestResource, CreateSpendRequestParams, UpdateSpendRequestParams. API endpoint: /spend_requests.
Key input field notes:
- CLI input uses
payment_method_id; mapped topayment_detailswhen calling the SDK --execution-method link_pay_tokenand--merchant-account-id acct_...are a create-only pair for Link Pay Token checkout. The agent reads the account ID fromdata-stripe-merchant-accountin the AI-agent steering DOM before creating the request; Link resolves the canonical merchant identity. LPT usescredential_type: card, cannot use--testor--network-id, and must not accept agent-provided merchant name or URL. Never add the target fields to the update path.contextrequires min 100 characters;amountis in cents with max 500000--metadata(create only) is a repeatablekey:valueflag (CLI) or a{ key: value }object (MCP/agent), merged into a singlemetadatastring→string map. Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. ReusesparseKvStringfromline-item-parser.ts.--testflag creates testmode credentials (real testmode SPT from test card data) instead of livemode onescreate --request-approvalandrequest-approvalboth 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.commandforspend-request retrieve.retrieve --interval <seconds>polls until approved/denied/expired/succeeded/failed/canceled, or untilrequires_actionwith a non-auto_resumeresolution (auto_resumeis polled through transparently). If--timeoutis reached or--max-attemptsis exhausted while the request is still non-terminal, it exits non-zero withPOLLING_TIMEOUT.- Both
createandretrieve(including--request-approval/request-approvalpolling andretrieve --intervalpolling) can returnstatus: 'requires_action'withstatus_details.requires_action.next_action(type,display_message,action_url,resolution).resolution: 'auto_resume'(currently onlynext_action.type: 'three_d_secure') means polling continues transparently — the request resolves on its own. Any other resolution stops polling immediately; the caller must have the user complete the action, then create a new spend request. cancel <id>cancels a spend request. Can cancel fromcreated,pending_approval, orapprovedstates. Returns the spend request withstatus: "canceled".--approval-detail— optional JSON object (MCP/agent) or JSON string (CLI) with approval details for delegated flows. Required fields:approved_at(unix timestamp int),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). Sent asapproval_detailsin the API request body.cardcredentials includebilling_address(name, line1, line2, city, state, postal_code, country) andvalid_until(ISO date string — when the card expires/stops working)--output-file <path>onretrieveorcreatewrites full card credentials to a local file (0600 permissions) and redacts card data in stdout.--forceallows overwriting an existing file.createalso accepts an undocumented--expires-at <unix_seconds>to override the default 12-hour spend request expiration (3 hours to 7 days in the future). It's deliberately excluded from--schema/--llms-fulloutput and from README/SKILL.md: it's gated to an allow-list of OAuth clients server-side, and most callers get a 400 ("expires_at is not supported for this client") if they try it — don't document or suggest it to general agents.
user-info retrieve
user-info retrievereturns the existing identity fields and can includeagent_wallet_spend_limitsandagent_wallet_verification_requirementenrichment.- Spend limits contain per-transaction, daily, and 30-day values. Finite values are cents because
/userinfodoes not return currency. Anulllimit or remaining amount explicitly means unlimited;usedremains numeric. - Either enrichment object can be omitted independently when enrichment is disabled or unavailable. Do not interpret omission as unlimited or as a default verification status.
- Verification status is one of
not_required,ssn_verification,identity_verification,contact_support, orcomplete.action_urlis nullable and directs the user to the required action when present. This is informational and does not change spend-request orrequires_actionhandling.
mpp pay
mpp pay <url> --context <ctx> [-X <method>] [-d <body>] [-H <header>]... [--amount <cents>] [--payment-method-id <id>] [--test]— handles the full MPP flow end-to-end: probes the URL for a 402 challenge, parses thewww-authenticateheader to extract network_id and amount, creates a spend request (credential_type: shared_payment_token), gets user approval, retrieves the SPT, and pays. Amount/currency are derived from the 402 challenge;--amountoverrides.--contextis required (min 100 chars) — describe the purchase and rationale. Default payment method is used unless--payment-method-idis specified.mpp pay <url> --spend-request-id <id> [--method <method>] [--data <body>] [--header <header>]...— backward-compat mode: uses a pre-approved spend request directly, skipping creation/approval.--headeris repeatable and uses"Name: Value"format.Content-Type: application/jsonis auto-applied when--datais provided; user-provided headers take precedence.- The SPT is one-time-use — a failed payment requires running
mpp payagain (creates a new spend request). - In agent mode the full flow yields
_next.pay_argv({ command: 'mpp', args: [...] }) alongside_next.pay_command.pay_argvis authoritative — it holds the raw values and is meant to be invoked without a shell.pay_commandis the compatibility string and every dynamic part of it (url, method, body, each header, spend-request id) must go throughshellQuotefrompackages/cli/src/utils/shell-quote.ts. See "Security: shell-quoting command strings". - Implemented in
packages/cli/src/commands/mpp/— pay.tsx (logic), schema.ts (input/output schema), index.tsx (incur registration).
demo command
demo [--only-card] [--only-spt]— Interactive demo of both payment flows. Always uses--testmode (no real charges). Shows a menu to choose: virtual card flow, SPT/machine payment flow, or both.--only-cardand--only-sptskip the menu. Requires a TTY (no JSON output mode).
onboard command
onboard— Guided setup: authenticates (skips if already logged in), checks payment methods (prompts to add one if missing, shows picker if multiple), shows app download QR code, then runs the full demo. Requires a TTY.
serve command
serve [--port <n>] [--host <host>]— HTTP server that exposes the CLI's MCP endpoint. Implemented inpackages/cli/src/commands/serve/index.ts. The handler forwards torootCli.fetch()(incur), but is a privilege boundary:requireAuthonly proves the CLI owner is authenticated, not that the HTTP caller is authorized.
Code Conventions
- ESM everywhere —
"type": "module"in all package.json files - Biome — 2-space indent, single quotes, organized imports
- tsup — ESM output; Node 20 target for the SDK and Node 18 target for the CLI
- Vitest — test files in
__tests__/directories adjacent to source - TypeScript strict mode —
tsconfig.base.jsonat root - React 18 + Ink 5 for interactive rendering
conffor local auth token storage
Global Flags
| Flag | Effect |
|---|---|
--auth <path> |
Store auth credentials in a specific file instead of the default platform config location. auth login writes to this file; all other commands read from it. Parsed from process.argv and stripped before incur processes flags. |
Security: Terminal Output Sanitization
Server-returned strings can contain ANSI escape sequences or control characters that spoof the terminal approval UI. Sanitization is handled automatically via sanitizeDeep() from packages/cli/src/utils/sanitize-text.ts:
- SDK-resource data — sanitized automatically at the
sanitizeResource()proxy boundary inpackages/cli/src/utils/resource-factory.ts. All server data flowing through SDK resources (spend-request, payment-methods, sources, etc.) issanitizeDeep()'d before reaching components or the incur formatter, in every output format. - Commands using
useAsyncActionhook — sanitized automatically. The hook callssanitizeDeep()on all returned data before it reaches components. - Commands with manual state management (e.g.
create.tsx,retrieve.tsx,request-approval.tsx,mpp/pay.tsx) — must callsanitizeDeep()on API responses before callingsetRequest()/setState(). - Attacker-controlled data that does NOT flow through an SDK resource — must be sanitized at its own parse boundary.
mpp paysanitizes the HTTP response inreadPayResult()(pay.tsx);mpp decodesanitizes the parsedWWW-Authenticatechallenge indecodeStripeChallenge()(decode.ts). These bypass the resource factory, so the return value of the parse/fetch helper is the chokepoint — sanitizing there covers both the interactive Ink render and the agent (toon/yaml/md) output at once.
JSON output mode (--format json) is not affected — JSON.stringify encodes escape sequences as Unicode literals.
Security: Shell-Quoting Command Strings
Any string the CLI emits for an agent to run (instruction, _next.command, _next.pay_command) is a shell-injection sink. Agents commonly execute these through Bash, so interpolating an unquoted value there gives whoever controls that value command execution on the agent's host — even though the value was safe as an argv entry. Sanitization does not help: $(...), backticks and ; are ordinary printable characters.
Rules:
- Every dynamic value interpolated into a command string goes through
shellQuote()frompackages/cli/src/utils/shell-quote.ts, or the whole argv list throughshellCommand(). This applies to server-issued IDs too — uniform treatment removes the "is this field trusted?" judgment call from future edits. - Prefer emitting a structured continuation next to the string (
_next.pay_argv={ command, args }) and point agents at it. A list of arguments has no seam to smuggle syntax through; a string always does. - Naive
'${value}'wrapping is not quoting — a single'in the value closes it and escapes. - Regression coverage lives in
packages/cli/src/utils/__tests__/shell-quote.test.ts(bash round-trip) and the_next continuation quotingblock inpackages/cli/src/__tests__/cli.test.ts.
Environment Variables
| Variable | Effect |
|---|---|
LINK_AUTH_FILE |
Same as --auth — override the auth credential file path (flag takes precedence) |
LINK_ACCESS_TOKEN |
Use this access token directly, bypassing auth storage |
LINK_REFRESH_TOKEN |
Refresh token to use when LINK_ACCESS_TOKEN is expired |
LINK_NO_REFRESH |
When set, never auto-refresh the access token — error instead |
LINK_API_BASE_URL |
Override API base URL |
LINK_AUTH_BASE_URL |
Override auth base URL |
LINK_HTTP_PROXY |
Route all SDK requests through an HTTP proxy (requires undici installed) |