A step-by-step account of the path the agent took on a domain, written so another agent could follow it. `--step` already records where the agent was when the outcome occurred; `--attempt-trace` is the whole path. Sent for every outcome, not just `success`. The dead ends on a failed attempt are what stop the next agent from spending tokens on them. The field's value is set almost entirely by how it is described, so the schema description asks for a specific shape — one numbered line per step, each with the URL path, the label acted on, the action, and the observed result — and `skills/create-payment-credential/SKILL.md` carries a worked example. Agents match an example far more reliably than they follow prose. Deliberately no zod `.max()`. The API truncates past `REPORT_ATTEMPT_TRACE_MAX_LENGTH` (8000) and still records the report, so rejecting client-side would trade a long narrative for a lost outcome. `--step` and `--freeform-context` keep their `.max(500)` because the API rejects those outright. Both the description and the docs tell agents to keep the buyer's personal data out of it and write `[email]`/`[address]` instead. Requires the server-side `attempt_trace` field on `POST /agent_observations`, which ships separately and is not deployed yet. Until it is, the API ignores the extra key, so sending it is a no-op rather than an error. Test plan - `pnpm run test` — 310 tests pass, including new SDK coverage for sending `attempt_trace` in the body, omitting it, and passing an over-cap value through unchanged for the server to truncate. - `pnpm run typecheck` and `pnpm biome check .` clean. - `node packages/cli/dist/cli.js report --schema` shows `attemptTrace` with no `maxLength`, while `step`/`freeformContext` keep theirs. Committed-By-Agent: claude Orbit-Session-Id: e89d7110-bf81-4181-974b-21b0d5dc0c30 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
19 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.- Link Go SDK (
packages/sdk-go): Go equivalent of@stripe/link-sdk. It acceptsAccessTokenorGetAccessToken; it does not own OAuth state. Package name:link. @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 test:go # run the Go SDK 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/.
The Go SDK currently mirrors the Link API resources exposed by the TypeScript SDK. Until a server-owned OpenAPI schema is available, keep API changes aligned through implementation review and each package's unit tests.
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, report, 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.
report command
report --domain <d> --outcome <success|blocked|abandoned> --spend-request-id <lsrq_...> [--tag <t>]... [--step <s>] [--freeform-context <s>] [--attempt-trace <s>]— records the outcome of a purchase attempt. Options inpackages/cli/src/commands/report/schema.ts, SDK params inCreateReportParams. API endpoint:/agent_observations. Output policy isagent-only.--stepis where the agent was when the outcome occurred (max 500).--attempt-traceis the whole path it took, one numbered line per step, intended to be replayable by another agent. Both are optional and independent.--attempt-traceintentionally carries no zod.max(). The API truncates atREPORT_ATTEMPT_TRACE_MAX_LENGTH(8000, exported from the SDK) and still records the report, so client-side rejection would trade a long narrative for a lost outcome.--stepand--freeform-contextkeep their.max(500)because the API rejects those outright.
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) |