diff --git a/.changeset/ninety-groups-start.md b/.changeset/ninety-groups-start.md new file mode 100644 index 0000000..2877bdd --- /dev/null +++ b/.changeset/ninety-groups-start.md @@ -0,0 +1,5 @@ +--- +"@stripe/link-cli": minor +--- + +Adds local stdio mcp server and agent-friendly formatting diff --git a/.gitignore b/.gitignore index fd086d5..550c075 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ dist/ .claude/skills/link-cli CLAUDE.local.md docs/ -packages/cli/README.md \ No newline at end of file +packages/cli/README.md +.claude/ +.codex/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ce07d82 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "link-cli": { + "command": "pnpx", + "args": ["link-cli", "--mcp"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index cca6a85..69c1c3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,25 +43,21 @@ Defined in `packages/sdk/src/resources/interfaces.ts`: ### CLI Command Structure -Commands in `packages/cli/src/cli.tsx` (Commander.js). Each has two output modes: +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** (`--output-json`): JSON to stdout, errors as JSON to stderr with exit code 1 +- **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1 -Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval`, `payment-methods list`, `mpp pay`, `skill`. +Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval`, `payment-methods list`, `mpp pay|decode`. -**When adding a new command, always update `configureRootHelp` in `packages/cli/src/utils/configure-root-help.ts`** to include it in the root help output. Pass the command as a parameter and add it to the appropriate section (or a new one). +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/link-cli/SKILL.md`, the schema description strings in the relevant `schema.ts` file, and `CLAUDE.md`. These can easily drift apart. +**When changing commands, flags, or schema descriptions, always update all three 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: flags OR `--json` (mutually exclusive) via `resolveInput` in `packages/cli/src/utils/json-options.ts`. - -**`InputSchema` and `.strict()` gotcha:** `resolveInput` validates input with `z.object(...).strict()`, which rejects any key not defined in the schema. This means every field that can be passed via `--json` must be defined in the command's `InputSchema` — including boolean flags like `request_approval`. If a field is only registered as a standalone `.option()` call, it will be rejected when using `--json`. - -**Always add new flags/options via `InputSchema`, never via standalone `.option()` calls.** Define the field in the relevant `InputSchema` with its `flag`, `schema`, and `description` — `registerSchemaOptions` will register the Commander option automatically. Standalone `.option()` calls bypass schema validation and break `--json` input. +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 ` — optional flag to identify the agent or app; shown in the user's Link app as ` on `. Defined in `LOGIN_INPUT_SCHEMA` in `packages/cli/src/commands/auth/schema.ts`. +- `auth login --client-name ` — optional flag to identify the agent or app; shown in the user's Link app as ` on `. Defined in `loginOptions` in `packages/cli/src/commands/auth/schema.ts`. ### spend-request command @@ -69,18 +65,16 @@ 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 -- `request_approval` is part of `CREATE_INPUT_SCHEMA` (not a separate Commander flag) so it works via both `--json` and `--request-approval` flag -- `test` is part of `CREATE_INPUT_SCHEMA` — pass `--test` or `"test": true` in JSON to create testmode credentials (real testmode SPT from test card data) instead of livemode ones - `context` requires min 100 characters; `amount` is in cents with max 50000 -- `create --request-approval` and `request-approval` both show an approval URL in interactive mode and poll until approved/denied/expired/failed. In JSON mode (`--output-json`), they block silently and return the final `SpendRequest` when complete. -- The `request-approval` command now returns `SpendRequest` (not `RequestApprovalResponse`) — output schema updated to `SPEND_REQUEST_OUTPUT_SCHEMA` +- `--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. In JSON mode (`--format json`), they block silently and return the final `SpendRequest` when complete. - `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (unix timestamp — when the card expires/stops working) ### mpp pay - `mpp pay --spend-request-id [--method ] [--data ] [--header
]...` — completes the 402 flow: retrieves the spend request with `include: ['shared_payment_token']`, probes the URL, parses the `www-authenticate` stripe challenge, builds the `Authorization: Payment` credential, and retries. `--header` is repeatable and uses `"Name: Value"` format. `Content-Type: application/json` is auto-applied when `--data` is provided; user-provided headers take precedence. - Requires an approved spend request with `credential_type: "shared_payment_token"`. The SPT is one-time-use — a failed payment requires a new spend request. -- Implemented in `packages/cli/src/commands/mpp/` — pay.tsx (logic), schema.ts (input/output schema), index.tsx (Commander registration). +- Implemented in `packages/cli/src/commands/mpp/` — pay.tsx (logic), schema.ts (input/output schema), index.tsx (incur registration). ## Code Conventions diff --git a/README.md b/README.md index 981f509..e5ad87d 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ By default, a spend request provisions a virtual card. For merchants that suppor The approved spend request includes a `card` object with `number`, `cvc`, `exp_month`, `exp_year`, `billing_address`, and `valid_until`. Enter these into the merchant's checkout form. ```bash -link-cli spend-request retrieve lsrq_001 --output-json +link-cli spend-request retrieve lsrq_001 --format json ``` By default, retrieving a spend request will not include card details. Use the `--include=card` to see unmasked card details. @@ -76,7 +76,7 @@ link-cli mpp pay https://climate.stripe.dev/api/contribute \ --spend-request-id lsrq_001 \ --method POST \ --data '{"amount":100}' \ - --output-json + --format json ``` ## Advanced @@ -84,14 +84,14 @@ link-cli mpp pay https://climate.stripe.dev/api/contribute \ ### Authentication ```bash -link-cli auth login --client-name "Claude Code" --output-json # identify the connecting agent -link-cli auth status --output-json # check auth status -link-cli auth logout --output-json # disconnect +link-cli auth login --client-name "Claude Code" --format json # identify the connecting agent +link-cli auth status --format json # check auth status +link-cli auth logout --format json # disconnect ``` When `--client-name` is provided, the name is shown in the Link app when the user approves the connection — e.g. `Claude Code on my-macbook` instead of `link-cli on my-macbook`. -`auth status --output-json` includes an `update` field when a newer version is available: +`auth status --format json` includes an `update` field when a newer version is available: ```json { @@ -119,32 +119,18 @@ A spend request moves through: **create** → **request approval** → **approve # Update before approval link-cli spend-request update lsrq_001 \ --merchant-url https://press.stripe.com/working-in-public \ - --output-json + --format json # Request approval separately (alternative to create --request-approval) -link-cli spend-request request-approval lsrq_001 --output-json +link-cli spend-request request-approval lsrq_001 --format json # Retrieve at any time (includes card credentials once approved) -link-cli spend-request retrieve lsrq_001 --output-json +link-cli spend-request retrieve lsrq_001 --format json ``` -### JSON +### Output formats -All commands accept `--json` for structured input (mutually exclusive with flags): - -```bash -link-cli spend-request create --json '{ - "payment_method_id": "csmrpd_xxx", - "merchant_name": "Stripe Press", - "merchant_url": "https://press.stripe.com/working-in-public", - "context": "Purchasing '\''Working in Public'\'' from press.stripe.com. The user initiated this purchase through the shopping assistant.", - "amount": 3500, - "line_items": [{ "name": "Working in Public", "unit_amount": 3500, "quantity": 1 }], - "totals": [{ "type": "total", "display_text": "Total", "amount": 3500 }] -}' --output-json -``` - -All commands also accept `--output-json` for structured JSON output. Errors are returned as JSON to stderr with exit code 1. +All commands accept `--format json` for structured JSON output. Other formats: `yaml`, `md`, `jsonl`, `toon` (default). Errors are returned as JSON with `code` and `message` fields, with exit code 1. ### MPP @@ -156,7 +142,7 @@ link-cli mpp pay https://climate.stripe.dev/api/contribute \ --method POST \ --data '{"amount":100}' \ --header "X-Custom: value" \ - --output-json + --format json ``` Use `mpp decode` to validate a raw `WWW-Authenticate` header and extract the `network_id` needed for `shared_payment_token` spend requests: @@ -164,7 +150,7 @@ Use `mpp decode` to validate a raw `WWW-Authenticate` header and extract the `ne ```bash link-cli mpp decode \ --challenge 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge", request="..."' \ - --output-json + --format json ``` ### Environment variables diff --git a/packages/cli/package.json b/packages/cli/package.json index 54d7f51..f7e9f03 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,7 +28,7 @@ "dev": "tsx src/cli.tsx" }, "dependencies": { - "commander": "^12.1.0", + "incur": "^0.4.1", "ink": "^5.2.1", "ink-spinner": "^5.0.0", "mppx": "^0.5.7", diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 6491c8f..194cbd0 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -212,18 +212,22 @@ describe('production mode', () => { 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', + '--line-item', + 'name:Widget,unit_amount:5000,quantity:1', + '--total', + 'type:total,display_text:Total,amount:5000', + '--no-request-approval', '--json', - JSON.stringify({ - payment_method_id: 'pd_prod_test', - merchant_name: 'Test Merchant', - merchant_url: 'https://example.com', - context: VALID_CONTEXT, - amount: 5000, - line_items: [{ name: 'Widget', unit_amount: 5000, quantity: 1 }], - totals: [{ type: 'total', display_text: 'Total', amount: 5000 }], - request_approval: false, - }), - '--output-json', ); expect(result.exitCode).toBe(0); @@ -252,23 +256,27 @@ describe('production mode', () => { 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', + '--line-item', + 'name:Widget,unit_amount:5000,quantity:1', + '--total', + 'type:total,display_text:Total,amount:5000', + '--no-request-approval', '--json', - JSON.stringify({ - payment_method_id: 'pd_prod_test', - merchant_name: 'Test Merchant', - merchant_url: 'https://example.com', - context: VALID_CONTEXT, - amount: 5000, - line_items: [{ name: 'Widget', unit_amount: 5000, quantity: 1 }], - totals: [{ type: 'total', display_text: 'Total', amount: 5000 }], - request_approval: false, - }), - '--output-json', ); expect(result.exitCode).toBe(0); - const request = parseJson(result.stdout) as Record; - expect(request.id).toBe('lsrq_from_api'); + const output = parseJson(result.stdout) as Record[]; + expect(output[0].id).toBe('lsrq_from_api'); }); it('sends credential_type and network_id in HTTP POST body', async () => { @@ -281,18 +289,18 @@ describe('production mode', () => { const result = await runProdCli( 'spend-request', 'create', + '--payment-method-id', + 'pd_prod_test', + '--context', + VALID_CONTEXT, + '--amount', + '5000', + '--credential-type', + 'shared_payment_token', + '--network-id', + 'net_prod_abc', + '--no-request-approval', '--json', - JSON.stringify({ - payment_method_id: 'pd_prod_test', - merchant_name: 'Test Merchant', - merchant_url: 'https://example.com', - context: VALID_CONTEXT, - amount: 5000, - credential_type: 'shared_payment_token', - network_id: 'net_prod_abc', - request_approval: false, - }), - '--output-json', ); expect(result.exitCode).toBe(0); @@ -300,7 +308,8 @@ describe('production mode', () => { expect(sentBody.credential_type).toBe('shared_payment_token'); expect(sentBody.network_id).toBe('net_prod_abc'); - const request = parseJson(result.stdout) as Record; + const output = parseJson(result.stdout) as Record[]; + const request = output[0]; expect(request.credential_type).toBe('shared_payment_token'); expect(request.network_id).toBe('net_prod_abc'); }); @@ -311,17 +320,19 @@ describe('production mode', () => { 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', + '--no-request-approval', + '--test', '--json', - JSON.stringify({ - payment_method_id: 'pd_prod_test', - merchant_name: 'Test Merchant', - merchant_url: 'https://example.com', - context: VALID_CONTEXT, - amount: 5000, - request_approval: false, - test: true, - }), - '--output-json', ); expect(result.exitCode).toBe(0); @@ -335,16 +346,18 @@ describe('production mode', () => { 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', + '--no-request-approval', '--json', - JSON.stringify({ - payment_method_id: 'pd_prod_test', - merchant_name: 'Test Merchant', - merchant_url: 'https://example.com', - context: VALID_CONTEXT, - amount: 5000, - request_approval: false, - }), - '--output-json', ); expect(result.exitCode).toBe(0); @@ -377,27 +390,25 @@ describe('production mode', () => { '--total', 'type:total,display_text:Total,amount:5000', '--request-approval', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); - // POST to create, then GET poll + // POST to create — returns immediately, no polling expect(requests[0].method).toBe('POST'); expect(requests[0].url).toBe('/spend_requests'); const sentBody = JSON.parse(requests[0].body); expect(sentBody.request_approval).toBe(true); - expect(requests[1].method).toBe('GET'); - expect(requests[1].url).toBe('/spend_requests/lsrq_prod_001'); - - // Two JSON objects: initial SpendRequest (with approval_url) then final SpendRequest - const lines = result.stdout.trim().split('\n\n').filter(Boolean); - expect(lines.length).toBe(2); - const initial = parseJson(lines[0]) as Record; - const final = parseJson(lines[1]) as Record; - expect(initial.approval_url).toBe( + + // Single result with _next polling hint + const output = parseJson(result.stdout) as Record[]; + expect(output.length).toBe(1); + expect(output[0].approval_url).toBe( 'https://app.link.com/approve/lsrq_prod_001', ); - expect(final.status).toBe('approved'); + const next = output[0]._next as Record; + expect(next.command).toContain('spend-request retrieve'); + expect(next.command).toContain('--interval'); }); it('surfaces API error messages', async () => { @@ -420,12 +431,12 @@ describe('production mode', () => { 'name:Widget,unit_amount:5000,quantity:1', '--total', 'type:total,display_text:Total,amount:5000', - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toContain('Invalid payment details'); + const output = result.stdout + result.stderr; + expect(output).toContain('Invalid payment details'); }); }); @@ -441,7 +452,7 @@ describe('production mode', () => { 'pd_updated', '--merchant-url', 'https://updated.com', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); @@ -467,12 +478,12 @@ describe('production mode', () => { 'lsrq_prod_001', '--payment-method-id', 'pd_new', - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toContain('pending_approval'); + const output = result.stdout + result.stderr; + expect(output).toContain('pending_approval'); }); }); @@ -488,11 +499,11 @@ describe('production mode', () => { 'spend-request', 'request-approval', 'lsrq_prod_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); - // POST to request_approval, then GET poll + // POST to request_approval — returns immediately, no polling expect(requests[0].method).toBe('POST'); expect(requests[0].url).toBe( '/spend_requests/lsrq_prod_001/request_approval', @@ -500,18 +511,16 @@ describe('production mode', () => { expect(requests[0].headers.authorization).toBe( 'Bearer prod_test_access_token', ); - expect(requests[1].method).toBe('GET'); - expect(requests[1].url).toBe('/spend_requests/lsrq_prod_001'); - - // Two JSON objects: initial (with approval_url) then final SpendRequest - const lines = result.stdout.trim().split('\n\n').filter(Boolean); - expect(lines.length).toBe(2); - const initial = parseJson(lines[0]) as Record; - const final = parseJson(lines[1]) as Record; - expect(initial.approval_url).toBe( + + // Single result with _next polling hint + const output = parseJson(result.stdout) as Record[]; + expect(output.length).toBe(1); + expect(output[0].approval_url).toBe( 'https://app.link.com/approve/lsrq_prod_001', ); - expect(final.status).toBe('approved'); + const next = output[0]._next as Record; + expect(next.command).toContain('spend-request retrieve'); + expect(next.command).toContain('--interval'); }); it('surfaces API errors for request-approval', async () => { @@ -523,12 +532,12 @@ describe('production mode', () => { 'spend-request', 'request-approval', 'lsrq_prod_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toContain('pending_approval'); + const output = result.stdout + result.stderr; + expect(output).toContain('pending_approval'); }); }); @@ -538,7 +547,7 @@ describe('production mode', () => { 'spend-request', 'retrieve', 'lsrq_prod_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); @@ -566,11 +575,12 @@ describe('production mode', () => { 'spend-request', 'retrieve', 'lsrq_prod_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); - const request = parseJson(result.stdout) as Record; + const output = parseJson(result.stdout) as Record[]; + const request = output[0]; expect(request.status).toBe('approved'); const card = request.card as Record; expect(card.brand).toBe('Visa'); @@ -605,11 +615,12 @@ describe('production mode', () => { 'spend-request', 'retrieve', 'lsrq_prod_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); - const request = parseJson(result.stdout) as Record; + const output = parseJson(result.stdout) as Record[]; + const request = output[0]; expect(request.status).toBe('approved'); const card = request.card as Record; expect(card.number).toBe('4242424242424242'); @@ -631,12 +642,12 @@ describe('production mode', () => { 'spend-request', 'retrieve', 'lsrq_nonexistent', - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toContain('not found'); + const output = result.stdout + result.stderr; + expect(output).toContain('not found'); }); }); @@ -664,23 +675,23 @@ describe('production mode', () => { 'login', '--client-name', ' ', - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('client_name'); + const output = result.stdout + result.stderr; + expect(output).toMatch(/client.?name|non-empty/i); }); - it('sends client_hint in device/code request when --client-name is provided', async () => { + it('sends client_hint and returns immediately with _next polling hint', async () => { setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); - setResponseForUrl('/device/token', 200, TOKEN_RESPONSE); const result = await runProdCli( 'auth', 'login', '--client-name', 'My Agent', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); @@ -691,35 +702,25 @@ describe('production mode', () => { const params = new URLSearchParams(deviceCodeRequest?.body); expect(params.get('client_hint')).toBe('My Agent'); expect(params.get('connection_label')).toContain('My Agent on '); - }, 15_000); - it('sends client_hint when --client-name is provided via --json', async () => { - setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); - setResponseForUrl('/device/token', 200, TOKEN_RESPONSE); - - const result = await runProdCli( - 'auth', - 'login', - '--json', - JSON.stringify({ client_name: 'My Agent' }), - '--output-json', - ); - - expect(result.exitCode).toBe(0); - const deviceCodeRequest = requests.find((r) => - r.url.includes('/device/code'), + // Returns immediately with verification URL and _next hint + const output = parseJson(result.stdout) as Record[]; + expect(output.length).toBe(1); + expect(output[0].verification_url).toBe( + 'https://app.link.com/device/setup?code=apple-grape', ); - expect(deviceCodeRequest).toBeDefined(); - const params = new URLSearchParams(deviceCodeRequest?.body); - expect(params.get('client_hint')).toBe('My Agent'); - }, 15_000); + expect(output[0].passphrase).toBe('apple-grape'); + const next = output[0]._next as Record; + expect(next.command).toContain('auth status'); + expect(next.until).toContain('authenticated'); + }); }); describe('auth logout', () => { it('sends POST to /device/revoke with refresh token then clears auth', async () => { setResponseForUrl('/device/revoke', 200, 'ok'); - const result = await runProdCli('auth', 'logout', '--output-json'); + const result = await runProdCli('auth', 'logout', '--format', 'json'); expect(result.exitCode).toBe(0); const parsed = parseJson(result.stdout) as Record; @@ -740,7 +741,7 @@ describe('production mode', () => { error: 'server_error', }); - const result = await runProdCli('auth', 'logout', '--output-json'); + const result = await runProdCli('auth', 'logout', '--format', 'json'); expect(result.exitCode).toBe(0); const parsed = parseJson(result.stdout) as Record; @@ -750,7 +751,7 @@ describe('production mode', () => { it('succeeds when no auth tokens are stored', async () => { storage.clearAuth(); - const result = await runProdCli('auth', 'logout', '--output-json'); + const result = await runProdCli('auth', 'logout', '--format', 'json'); expect(result.exitCode).toBe(0); const parsed = parseJson(result.stdout) as Record; @@ -773,14 +774,19 @@ describe('production mode', () => { 'pd_test', '-m', 'Nike', + '--merchant-url', + 'https://example.com', '--context', - 'Test', - '--output-json', + VALID_CONTEXT, + '--amount', + '5000', + '--no-request-approval', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toContain('Not authenticated'); + const output = result.stdout + result.stderr; + expect(output).toContain('Not authenticated'); }); }); @@ -830,7 +836,7 @@ describe('production mode', () => { `http://127.0.0.1:${merchantPort}/api/charge`, '--spend-request-id', 'lsrq_spt_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); @@ -857,13 +863,16 @@ describe('production mode', () => { `http://127.0.0.1:${merchantPort}/api/charge`, '--spend-request-id', 'lsrq_spt_001', - '--output-json', + '--format', + 'json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toContain('Payment submission failed with status 401'); - expect(err.error).toContain('spt rejected'); + const err = parseJson(result.stdout) as { message: string }; + expect(err.message).toContain( + 'Payment submission failed with status 401', + ); + expect(err.message).toContain('spt rejected'); expect(merchantRequests).toHaveLength(2); }); @@ -880,7 +889,8 @@ describe('production mode', () => { `http://127.0.0.1:${merchantPort}/api/charge`, '--spend-request-id', 'lsrq_spt_001', - '--output-json', + '--format', + 'json', ); expect(result.exitCode).toBe(0); @@ -898,7 +908,7 @@ describe('production mode', () => { `http://127.0.0.1:${merchantPort}/api/endpoint`, '--spend-request-id', 'lsrq_spt_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); @@ -920,12 +930,12 @@ describe('production mode', () => { `http://127.0.0.1:${merchantPort}/api/endpoint`, '--spend-request-id', 'lsrq_spt_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toMatch(/stripe/i); + const output = result.stdout + result.stderr; + expect(output).toMatch(/stripe/i); }); it('spend request not approved exits 1 with error', async () => { @@ -940,12 +950,12 @@ describe('production mode', () => { `http://127.0.0.1:${merchantPort}/api/endpoint`, '--spend-request-id', 'lsrq_spt_001', - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toMatch(/approved/i); + const output = result.stdout + result.stderr; + expect(output).toMatch(/approved/i); }); it('sends POST with data when --data is provided', async () => { @@ -960,7 +970,7 @@ describe('production mode', () => { 'lsrq_spt_001', '--data', '{"amount":100}', - '--output-json', + '--json', ); expect(merchantRequests[0].method).toBe('POST'); @@ -981,7 +991,7 @@ describe('production mode', () => { 'X-Custom-Header: hello', '--header', 'X-Another: world', - '--output-json', + '--json', ); expect(merchantRequests[0].headers['x-custom-header']).toBe('hello'); @@ -1000,7 +1010,7 @@ describe('production mode', () => { 'lsrq_spt_001', '--data', '{"amount":100}', - '--output-json', + '--json', ); expect(merchantRequests[0].headers['content-type']).toContain( @@ -1022,7 +1032,7 @@ describe('production mode', () => { 'hello', '--header', 'Content-Type: text/plain', - '--output-json', + '--json', ); expect(merchantRequests[0].headers['content-type']).toContain( @@ -1053,7 +1063,7 @@ describe('production mode', () => { 'decode', '--challenge', WWW_AUTHENTICATE_MULTI, - '--output-json', + '--json', ); expect(result.exitCode).toBe(0); @@ -1090,12 +1100,12 @@ describe('production mode', () => { 'decode', '--challenge', invalidChallenge, - '--output-json', + '--json', ); expect(result.exitCode).toBe(1); - const err = parseJson(result.stderr) as { error: string }; - expect(err.error).toMatch(/networkId/i); + const output = result.stdout + result.stderr; + expect(output).toMatch(/networkId/i); }); }); }); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 6eacbaf..7da1bb8 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,11 +1,8 @@ -import { Command } from 'commander'; -import updateNotifier from 'update-notifier'; -import { registerAuthCommands } from './commands/auth'; -import { registerMppCommands } from './commands/mpp'; -import { registerPaymentMethodsCommands } from './commands/payment-methods'; -import { registerSkillCommand } from './commands/skill'; -import { registerSpendRequestCommands } from './commands/spend-request'; -import { configureRootHelp } from './utils/configure-root-help'; +import { Cli } from 'incur'; +import { createAuthCli } from './commands/auth'; +import { createMppCli } from './commands/mpp'; +import { createPaymentMethodsCli } from './commands/payment-methods'; +import { createSpendRequestCli } from './commands/spend-request'; import { ResourceFactory } from './utils/resource-factory'; declare const __CLI_VERSION__: string; @@ -20,58 +17,24 @@ const defaultHeaders = { 'X-Build-Number': buildNumber, }; -const program = new Command(); - -// Check early so verbose is available before commander parses subcommands const verbose = process.argv.includes('--verbose'); const factory = new ResourceFactory({ verbose, defaultHeaders }); const authRepo = factory.createAuthResource(); const spendRequestRepo = factory.createSpendRequestResource(); -program - .name('link-cli') - .description( +const cli = Cli.create('link-cli', { + description: 'Create a secure, one-time payment credential from a Link wallet to let agents complete purchases on behalf of users.', - ) - .version(`${cliVersion} (build ${buildNumber})`) - .option('--verbose', 'Print API request and response details to stderr') - .helpCommand(false) - .configureOutput({ - outputError: (str, write) => { - write(str); - const isJsonMode = process.argv.includes('--output-json'); - if (str.includes('unknown command') && !isJsonMode) { - write("\nRun 'link-cli --help' to see available commands.\n"); - write("Run 'link-cli --skill' for full instructions.\n"); - } - }, - }); - -const notifier = updateNotifier({ - pkg: { name: cliName, version: cliVersion }, + version: `${cliVersion} (build ${buildNumber})`, }); -const authCommand = registerAuthCommands(program, authRepo, notifier.update); -const spendRequestCommand = registerSpendRequestCommands( - program, - spendRequestRepo, -); -const paymentMethodsCommand = registerPaymentMethodsCommands(program, () => - factory.createPaymentMethodsResource(), -); - -const skillCommand = registerSkillCommand(program); -const mppCommand = registerMppCommands(program, spendRequestRepo); - -configureRootHelp( - program, - authCommand, - spendRequestCommand, - paymentMethodsCommand, - skillCommand, - mppCommand, +cli.command(createAuthCli(authRepo)); +cli.command(createSpendRequestCli(spendRequestRepo)); +cli.command( + createPaymentMethodsCli(() => factory.createPaymentMethodsResource()), ); +cli.command(createMppCli(spendRequestRepo)); -notifier.notify(); +cli.serve(); -program.parse(); +export default cli; diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index c51eb24..0c6df67 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -1,184 +1,163 @@ import { storage } from '@stripe/link-sdk'; -import type { Command } from 'commander'; +import { Cli } from 'incur'; +import { render } from 'ink'; import React from 'react'; import type { IAuthResource } from '../../auth/types'; -import { - executeCommand, - outputErrors, - outputJson, -} from '../../utils/execute-command'; -import { buildInputHelp, buildOutputHelp } from '../../utils/help-text'; -import { - ValidationError, - registerSchemaOptions, - resolveInput, -} from '../../utils/json-options'; import { Login } from './login'; import { Logout } from './logout'; -import { - AUTH_STATUS_SCHEMA, - LOGIN_INPUT_SCHEMA, - LOGIN_SCHEMA, - LOGOUT_SCHEMA, -} from './schema'; +import { loginOptions, statusOptions } from './schema'; import { AuthStatus } from './status'; -export function registerAuthCommands( - program: Command, - authResource: IAuthResource, - updateInfo?: { current: string; latest: string }, -): Command { - const authCommand = program - .command('auth') - .description('Authentication commands') - .helpCommand(false); +export function createAuthCli(authResource: IAuthResource) { + const cli = Cli.create('auth', { + description: 'Authentication commands', + }); - const loginCmd = authCommand - .command('login') - .description('Authenticate with Link'); + cli.command('login', { + description: 'Authenticate with Link', + options: loginOptions, + outputPolicy: 'agent-only' as const, + async *run(c) { + const clientName = c.options.clientName?.trim(); + if (!clientName || clientName.length === 0) { + return c.error({ + code: 'INVALID_INPUT', + message: 'client-name must be a non-empty string', + }); + } - registerSchemaOptions(loginCmd, LOGIN_INPUT_SCHEMA); + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} + />, + ); + waitUntilExit().then(() => + resolve({ authenticated: true, token_type: 'Bearer' }), + ); + }); + } - loginCmd - .option( - '--json ', - `JSON input (keys: ${Object.keys(LOGIN_INPUT_SCHEMA).join(', ')})`, - ) - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText( - 'after', - buildInputHelp(LOGIN_INPUT_SCHEMA) + buildOutputHelp(LOGIN_SCHEMA), - ) - .action(async (options: Record) => { - let input: Record = {}; - try { - input = resolveInput(options, LOGIN_INPUT_SCHEMA); - } catch (err) { - if (err instanceof ValidationError) { - outputErrors(err.errors, !!options.outputJson); - process.exit(1); + // Agent mode: initiate device auth, store pending state, return immediately. + // The agent drives the polling loop via `auth status --interval`. + const authRequest = await authResource.initiateDeviceAuth(clientName); + storage.setPendingDeviceAuth({ + device_code: authRequest.device_code, + interval: authRequest.interval, + expires_at: Date.now() + authRequest.expires_in * 1000, + verification_url: authRequest.verification_url_complete, + passphrase: authRequest.user_code, + }); + yield { + verification_url: authRequest.verification_url_complete, + passphrase: authRequest.user_code, + instruction: + 'Present the verification_url to the user and ask them to approve in the Link app. Then call `auth status --interval 5 --max-attempts 60` to poll until authenticated. Do not wait for the user to reply — start polling immediately.', + _next: { + command: 'auth status --interval 5 --max-attempts 60', + poll_interval_seconds: authRequest.interval, + until: 'authenticated is true', + }, + }; + }, + }); + + cli.command('logout', { + description: 'Log out from Link', + outputPolicy: 'agent-only' as const, + async run(c) { + const auth = storage.getAuth(); + if (auth?.refresh_token) { + try { + await authResource.revokeToken(auth.refresh_token); + } catch { + // best-effort: clear local storage regardless } - throw err; } + storage.clearAuth(); + storage.clearPendingDeviceAuth(); + storage.deleteConfig(); + const result = { authenticated: false }; - const clientName = input.client_name as string; + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} />, + ); + waitUntilExit().then(() => resolve(result)); + }); + } - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - const authRequest = await authResource.initiateDeviceAuth(clientName); - outputJson({ - verification_url: authRequest.verification_url_complete, - passphrase: authRequest.user_code, - }); + return result; + }, + }); - const pollInterval = authRequest.interval * 1000; - const expiresAt = Date.now() + authRequest.expires_in * 1000; - const startTime = Date.now(); + cli.command('status', { + description: 'Check authentication status', + options: statusOptions, + outputPolicy: 'agent-only' as const, + async *run(c) { + const opts = c.options; + const interval = opts.interval; + const maxAttempts = opts.maxAttempts; + const deadline = Date.now() + opts.timeout * 1000; + let attempts = 0; - while (Date.now() < expiresAt) { - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000); - process.stderr.write( - `${JSON.stringify({ - type: 'waiting', - command: 'auth_login', - elapsed_seconds: elapsedSeconds, - verification_url: authRequest.verification_url_complete, - passphrase: authRequest.user_code, - })}\n`, - ); - const tokens = await authResource.pollDeviceAuth( - authRequest.device_code, - ); - if (tokens) { - storage.setAuth(tokens); - return { authenticated: true, token_type: tokens.token_type }; - } + while (true) { + // If there's a pending device auth, try one poll to see if the user approved. + const pending = storage.getPendingDeviceAuth(); + if (pending && !storage.isAuthenticated()) { + const tokens = await authResource.pollDeviceAuth(pending.device_code); + if (tokens) { + storage.setAuth(tokens); + storage.clearPendingDeviceAuth(); } - throw new Error('Device authorization timed out'); - }, - renderFn: () => ( - {}} - /> - ), - }); - }); + } - authCommand - .command('logout') - .description('Log out from Link') - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText('after', buildOutputHelp(LOGOUT_SCHEMA)) - .action(async (options: { outputJson?: boolean }) => { - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - const auth = storage.getAuth(); - if (auth?.refresh_token) { - try { - await authResource.revokeToken(auth.refresh_token); - } catch { - // best-effort: clear local storage regardless - } - } - storage.clearAuth(); - storage.deleteConfig(); - return { authenticated: false }; - }, - renderFn: () => ( - {}} /> - ), - }); - }); + const auth = storage.getAuth(); + if (auth) { + yield { + authenticated: true, + access_token: `${auth.access_token.substring(0, 20)}...`, + token_type: auth.token_type, + credentials_path: storage.getPath(), + }; + return; + } - authCommand - .command('status') - .description('Check authentication status') - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText('after', buildOutputHelp(AUTH_STATUS_SCHEMA)) - .action(async (options: { outputJson?: boolean }) => { - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - const auth = storage.getAuth(); - const update = updateInfo + const currentPending = storage.getPendingDeviceAuth(); + const status = { + authenticated: false, + credentials_path: storage.getPath(), + ...(currentPending ? { - current_version: updateInfo.current, - latest_version: updateInfo.latest, - update_command: 'npm install -g @stripe/link-cli', + pending: true, + verification_url: currentPending.verification_url, + passphrase: currentPending.passphrase, } - : undefined; - if (auth) { - return { - authenticated: true, - access_token: `${auth.access_token.substring(0, 20)}...`, - token_type: auth.token_type, - credentials_path: storage.getPath(), - ...(update && { update }), - }; - } - return { - authenticated: false, - credentials_path: storage.getPath(), - ...(update && { update }), - }; - }, - renderFn: () => {}} />, - }); - }); + : {}), + }; + + attempts++; + const shouldStop = + interval <= 0 || + (maxAttempts > 0 && attempts >= maxAttempts) || + Date.now() >= deadline; + + if (shouldStop) { + yield status; + return; + } + + // Yield current status as MCP progress notification, then wait + yield status; + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + } + }, + }); - return authCommand; + return cli; } diff --git a/packages/cli/src/commands/auth/schema.ts b/packages/cli/src/commands/auth/schema.ts index 17e7b34..cb11f37 100644 --- a/packages/cli/src/commands/auth/schema.ts +++ b/packages/cli/src/commands/auth/schema.ts @@ -1,50 +1,27 @@ -import { z } from 'zod'; -import type { InputSchema, OutputSchema } from '../../utils/json-options'; +import { z } from 'incur'; -export const LOGIN_INPUT_SCHEMA: InputSchema = { - client_name: { - schema: z.string().trim().min(1), - flag: '--client-name ', - description: 'Agent or app name shown in the Link app', - jsonDescription: - 'Shown to the user when approving the device connection — use a short, recognizable name (e.g. "Personal Assistant")', - defaultValue: 'Link CLI', - }, -}; +export const loginOptions = z.object({ + clientName: z + .string() + .default('Link CLI') + .describe( + 'Agent or app name shown in the Link app when approving the device connection', + ), +}); -export const LOGIN_SCHEMA: OutputSchema = { - authenticated: { - outputExample: 'true', - description: 'Is the user authenticated with Link', - }, - token_type: { outputExample: '"..."', description: 'Token type' }, -}; - -export const LOGOUT_SCHEMA: OutputSchema = { - authenticated: { - outputExample: 'false', - description: 'Is the user authenticated with Link', - }, -}; - -export const AUTH_STATUS_SCHEMA: OutputSchema = { - authenticated: { - outputExample: 'true', - description: 'Is the user authenticated with Link', - }, - access_token: { - outputExample: '"liwltoken_abdec12345..." (truncated)', - description: 'Access token (truncated)', - }, - token_type: { outputExample: '"Bearer"', description: 'Token type' }, - credentials_path: { - outputExample: '"~/.link-cli-nodejs/config.json"', - description: 'Path to credentials file', - }, - update: { - outputExample: - '{ "current_version": "0.1.2", "latest_version": "0.2.0", "update_command": "npm install -g @stripe/link-cli" }', - description: - 'Present only when a newer version is available — run update_command to upgrade', - }, -}; +export const statusOptions = z.object({ + interval: z.coerce + .number() + .default(0) + .describe( + 'Poll interval in seconds. When > 0, polls until authenticated or timeout is reached, yielding status on each attempt.', + ), + maxAttempts: z.coerce + .number() + .default(0) + .describe('Max poll attempts. 0 = unlimited (use timeout instead).'), + timeout: z.coerce + .number() + .default(300) + .describe('Polling timeout in seconds.'), +}); diff --git a/packages/cli/src/commands/mpp/index.tsx b/packages/cli/src/commands/mpp/index.tsx index 69db09a..8e4c0f3 100644 --- a/packages/cli/src/commands/mpp/index.tsx +++ b/packages/cli/src/commands/mpp/index.tsx @@ -1,146 +1,105 @@ import type { ISpendRequestResource } from '@stripe/link-sdk'; -import type { Command } from 'commander'; +import { storage } from '@stripe/link-sdk'; +import { Cli, z } from 'incur'; +import { render } from 'ink'; import React from 'react'; -import { - executeCommand, - outputError, - outputErrors, -} from '../../utils/execute-command'; -import { buildInputHelp, buildOutputHelp } from '../../utils/help-text'; -import { - ValidationError, - registerSchemaOptions, - resolveInput, -} from '../../utils/json-options'; -import { requireAuth } from '../../utils/require-auth'; import { decodeStripeChallenge } from './decode'; import { DecodeChallengeView } from './decode-view'; import { MppPay, runMppPay } from './pay'; -import { - DECODE_INPUT_SCHEMA, - DECODE_OUTPUT_SCHEMA, - PAY_INPUT_SCHEMA, - PAY_OUTPUT_SCHEMA, -} from './schema'; +import { decodeOptions, payOptions } from './schema'; -export function registerMppCommands( - program: Command, - repository: ISpendRequestResource, -): Command { - const mppCommand = program - .command('mpp') - .description('Machine payment protocol (MPP) commands') - .helpCommand(false); +export function createMppCli(repository: ISpendRequestResource) { + const cli = Cli.create('mpp', { + description: 'Machine payment protocol (MPP) commands', + }); - const payCmd = mppCommand - .command('pay ') - .description( + cli.command('pay', { + description: 'Complete a machine payment protocol (MPP) payment using an approved spend request', - ); - - registerSchemaOptions(payCmd, PAY_INPUT_SCHEMA); - - payCmd - .option( - '--json ', - `JSON input (keys: ${Object.keys(PAY_INPUT_SCHEMA).join(', ')})`, - ) - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText( - 'after', - buildInputHelp(PAY_INPUT_SCHEMA) + buildOutputHelp(PAY_OUTPUT_SCHEMA), - ) - .action(async (url: string, options) => { - requireAuth(); - - let resolved: Record = {}; - try { - resolved = resolveInput(options, PAY_INPUT_SCHEMA); - } catch (err) { - if (err instanceof ValidationError) - process.stderr.write(`${err.errors.join('\n')}\n`); - process.stderr.write( - `${JSON.stringify({ error: (err as Error).message })}\n`, - ); - process.exit(1); + args: z.object({ + url: z.string().describe('URL to pay'), + }), + options: payOptions, + alias: { method: 'X', data: 'd', header: 'H' }, + outputPolicy: 'agent-only' as const, + async run(c) { + if (!storage.isAuthenticated()) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'Not authenticated. Run "link-cli auth login" first.', + cta: { + commands: [ + { command: 'auth login', description: 'Log in to Link' }, + ], + }, + }); } - const spendRequestId = resolved.spend_request_id as string; - const method = resolved.method as string | undefined; - const data = resolved.data as string | undefined; - const headers = resolved.headers as string[] | undefined; + const url = c.args.url; + const opts = c.options; + const method = opts.method; + const data = opts.data; + const headers = opts.header?.length ? opts.header : undefined; - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - return runMppPay( - url, - spendRequestId, - method, - data, - headers, - repository, + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} + />, ); - }, - renderFn: () => ( - {}} - /> - ), - }); - }); + waitUntilExit().then(async () => { + resolve( + await runMppPay( + url, + opts.spendRequestId, + method, + data, + headers, + repository, + ), + ); + }); + }); + } - const decodeCmd = mppCommand - .command('decode') - .description( - 'Decode a stripe WWW-Authenticate challenge and extract network_id', - ); + return runMppPay( + url, + opts.spendRequestId, + method, + data, + headers, + repository, + ); + }, + }); - registerSchemaOptions(decodeCmd, DECODE_INPUT_SCHEMA); + cli.command('decode', { + description: + 'Decode a stripe WWW-Authenticate challenge and extract network_id', + options: decodeOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const decoded = decodeStripeChallenge(c.options.challenge); - decodeCmd - .option( - '--json ', - `JSON input (keys: ${Object.keys(DECODE_INPUT_SCHEMA).join(', ')})`, - ) - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText( - 'after', - buildInputHelp(DECODE_INPUT_SCHEMA) + - buildOutputHelp(DECODE_OUTPUT_SCHEMA), - ) - .action(async (options) => { - let resolved: Record = {}; - try { - resolved = resolveInput(options, DECODE_INPUT_SCHEMA); - } catch (err) { - if (err instanceof ValidationError) - outputErrors(err.errors, !!options.outputJson); - outputError((err as Error).message); + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + , + ); + waitUntilExit().then(() => resolve(decoded)); + }); } - const challenge = resolved.challenge as string; - - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => decodeStripeChallenge(challenge), - renderFn: () => ( - - ), - }); - }); + return decoded; + }, + }); - return mppCommand; + return cli; } diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index f2bb1f2..bdea2a9 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -5,7 +5,6 @@ import { Credential, Method } from 'mppx'; import { Mppx, Transport } from 'mppx/client'; import { Methods as StripeMethods } from 'mppx/stripe'; import React, { useEffect, useState } from 'react'; -import { outputError } from '../../utils/execute-command'; import { getStripeChargeChallengeFromResponse } from './decode'; export type PayResult = { @@ -92,32 +91,23 @@ export async function runMppPay( }); if (!spendRequest) { - outputError(`Spend request ${spendRequestId} not found`); + throw new Error(`Spend request ${spendRequestId} not found`); } - if ( - (spendRequest as NonNullable).credential_type !== - 'shared_payment_token' - ) { - const type = - (spendRequest as NonNullable).credential_type ?? - 'card'; - outputError( + if (spendRequest.credential_type !== 'shared_payment_token') { + const type = spendRequest.credential_type ?? 'card'; + throw new Error( `Spend request ${spendRequestId} must have credential_type 'shared_payment_token' (current: '${type}')`, ); } - if ( - (spendRequest as NonNullable).status !== 'approved' - ) { - outputError( - `Spend request must be approved (current status: ${(spendRequest as NonNullable).status})`, + if (spendRequest.status !== 'approved') { + throw new Error( + `Spend request must be approved (current status: ${spendRequest.status})`, ); } - const sptObj = (spendRequest as NonNullable) - .shared_payment_token; - if (!sptObj) { - outputError('Spend request does not have a shared payment token'); + if (!spendRequest.shared_payment_token) { + throw new Error('Spend request does not have a shared payment token'); } - const spt = (sptObj as NonNullable).id; + const spt = spendRequest.shared_payment_token.id; // 2. Determine method const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET'); diff --git a/packages/cli/src/commands/mpp/schema.ts b/packages/cli/src/commands/mpp/schema.ts index 7159595..8addb21 100644 --- a/packages/cli/src/commands/mpp/schema.ts +++ b/packages/cli/src/commands/mpp/schema.ts @@ -1,74 +1,29 @@ -import { z } from 'zod'; -import type { InputSchema, OutputSchema } from '../../utils/json-options'; +import { z } from 'incur'; -export const DECODE_OUTPUT_SCHEMA: OutputSchema = { - id: { outputExample: '"ch_123"', description: 'Challenge ID' }, - realm: { - outputExample: '"merchant.example"', - description: 'Challenge realm', - }, - method: { outputExample: '"stripe"', description: 'Payment method' }, - intent: { outputExample: '"charge"', description: 'Challenge intent' }, - network_id: { - outputExample: '"net_prod_123"', - description: 'Extracted Stripe network ID', - }, - request_json: { - outputExample: - '{"networkId":"net_prod_123","amount":"1000","currency":"usd","decimals":2,"paymentMethodTypes":["card"]}', - description: - 'Decoded request payload from the stripe challenge before normalization', - }, -}; +export const payOptions = z.object({ + spendRequestId: z + .string() + .describe( + 'Approved spend request ID with credential_type "shared_payment_token"', + ), + method: z + .string() + .optional() + .describe('HTTP method (default: GET, or POST if --data is provided)'), + data: z + .string() + .optional() + .describe('Request body (implies POST if --method is not set)'), + header: z + .array(z.string()) + .default([]) + .describe('Request header in "Name: Value" format (repeatable)'), +}); -export const PAY_INPUT_SCHEMA: InputSchema = { - spend_request_id: { - schema: z.string().min(1), - flag: '--spend-request-id ', - description: - 'Approved spend request ID with shared_payment_token credential', - jsonDescription: - 'Must be an approved spend request with credential_type "shared_payment_token" — the SPT is one-time use; create a new request if payment fails', - required: true, - }, - method: { - schema: z.string().min(1), - flag: '--method ', - alias: '-X', - description: 'HTTP method (default: GET, or POST if --data is provided)', - }, - data: { - schema: z.string().min(1), - flag: '--data ', - alias: '-d', - description: 'Request body (implies POST if --method is not set)', - }, - headers: { - schema: z.array(z.string().min(1)), - flag: '--header
', - alias: '-H', - description: 'Request header in "Name: Value" format (repeatable)', - jsonDescription: - 'Repeatable; "Name: Value" format — Content-Type is auto-set when --data is provided; user headers take precedence', - }, -}; - -export const DECODE_INPUT_SCHEMA: InputSchema = { - challenge: { - schema: z.string().min(1), - flag: '--challenge
', - description: 'Raw WWW-Authenticate header value to decode', - jsonDescription: +export const decodeOptions = z.object({ + challenge: z + .string() + .describe( 'Raw WWW-Authenticate header value; may include multiple payment challenges', - required: true, - }, -}; - -export const PAY_OUTPUT_SCHEMA: OutputSchema = { - status: { outputExample: '200', description: 'HTTP response status code' }, - headers: { - outputExample: '{"content-type":"application/json"}', - description: 'Response headers', - }, - body: { outputExample: '"..."', description: 'Response body' }, -}; + ), +}); diff --git a/packages/cli/src/commands/payment-methods/index.tsx b/packages/cli/src/commands/payment-methods/index.tsx index 0ba0f4a..3504916 100644 --- a/packages/cli/src/commands/payment-methods/index.tsx +++ b/packages/cli/src/commands/payment-methods/index.tsx @@ -1,64 +1,77 @@ import type { IPaymentMethodsResource } from '@stripe/link-sdk'; -import type { Command } from 'commander'; +import { storage } from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import { render } from 'ink'; import React from 'react'; -import { executeCommand, outputJson } from '../../utils/execute-command'; -import { buildOutputHelp } from '../../utils/help-text'; -import { requireAuth } from '../../utils/require-auth'; import { AddPaymentMethod, WALLET_URL } from './add'; import { PaymentMethodsList } from './list'; -import { PAYMENT_METHOD_SCHEMA } from './schema'; -export function registerPaymentMethodsCommands( - program: Command, +export function createPaymentMethodsCli( createResource: () => IPaymentMethodsResource, -): Command { - const paymentMethodsCommand = program - .command('payment-methods') - .description('Payment methods management commands') - .helpCommand(false); +) { + const cli = Cli.create('payment-methods', { + description: 'Payment methods management commands', + }); - paymentMethodsCommand - .command('list') - .description('List all payment methods on your account') - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText('after', buildOutputHelp(PAYMENT_METHOD_SCHEMA, true)) - .action(async (options: { outputJson?: boolean }) => { - requireAuth(); + cli.command('list', { + description: 'List all payment methods on your account', + outputPolicy: 'agent-only' as const, + async run(c) { + if (!storage.isAuthenticated()) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'Not authenticated. Run "link-cli auth login" first.', + cta: { + commands: [ + { command: 'auth login', description: 'Log in to Link' }, + ], + }, + }); + } const resource = createResource(); - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - return resource.listPaymentMethods(); - }, - renderFn: () => ( - {}} /> - ), - }); - }); + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} />, + ); + waitUntilExit().then(async () => { + resolve(await resource.listPaymentMethods()); + }); + }); + } - paymentMethodsCommand - .command('add') - .description('Open the Link wallet to add a new payment method') - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .action(async (options: { outputJson?: boolean }) => { - requireAuth(); + return resource.listPaymentMethods(); + }, + }); - if (options.outputJson) { - outputJson({ url: WALLET_URL }); - } else { - const { render } = await import('ink'); - const { waitUntilExit } = render(); - await waitUntilExit(); + cli.command('add', { + description: 'Open the Link wallet to add a new payment method', + outputPolicy: 'agent-only' as const, + async run(c) { + if (!storage.isAuthenticated()) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'Not authenticated. Run "link-cli auth login" first.', + cta: { + commands: [ + { command: 'auth login', description: 'Log in to Link' }, + ], + }, + }); } - }); - return paymentMethodsCommand; + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render(); + waitUntilExit().then(() => resolve({ url: WALLET_URL })); + }); + } + + return { url: WALLET_URL }; + }, + }); + + return cli; } diff --git a/packages/cli/src/commands/payment-methods/schema.ts b/packages/cli/src/commands/payment-methods/schema.ts index d65f063..0d97fdb 100644 --- a/packages/cli/src/commands/payment-methods/schema.ts +++ b/packages/cli/src/commands/payment-methods/schema.ts @@ -1,25 +1,2 @@ -import type { OutputSchema } from '../../utils/json-options'; - -export const PAYMENT_METHOD_SCHEMA: OutputSchema = { - id: { outputExample: '"..."', description: 'Payment method ID' }, - type: { - outputExample: '"card|bank_account"', - description: 'Payment method type', - }, - is_default: { - outputExample: 'boolean', - description: 'Whether this is the default payment method', - }, - nickname: { - outputExample: '"..."', - description: 'Optional nickname for the payment method', - }, - card_details: { - outputExample: '{ brand, last4, exp_month, exp_year }', - description: 'Present when type is card', - }, - bank_account_details: { - outputExample: '{ last4, bank_name }', - description: 'Present when type is bank_account', - }, -}; +// Payment methods has no input options beyond what incur provides. +// Output is an array of payment method objects from the SDK. diff --git a/packages/cli/src/commands/skill/index.ts b/packages/cli/src/commands/skill/index.ts deleted file mode 100644 index 069ca49..0000000 --- a/packages/cli/src/commands/skill/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Command } from 'commander'; - -declare const SKILL_CONTENT: string; -declare const __CLI_VERSION__: string; -declare const __BUILD_NUMBER__: string; - -export function registerSkillCommand(program: Command): Command { - return program - .command('skill') - .description('Output the Link CLI skill file') - .action(() => { - let content = SKILL_CONTENT; - try { - const version = `${__CLI_VERSION__}+${__BUILD_NUMBER__}`; - content = SKILL_CONTENT.replace( - '---\n', - `---\ncli_version: "${version}"\n`, - ); - } catch { - process.stderr.write( - 'Warning: could not resolve cli_version — skill output without version\n', - ); - } - }); -} diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 339dc3e..09843bd 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -4,308 +4,344 @@ import type { LineItem, Total, } from '@stripe/link-sdk'; -import type { Command } from 'commander'; +import { storage } from '@stripe/link-sdk'; +import { Cli, z } from 'incur'; +import { render } from 'ink'; import React from 'react'; import { - executeCommand, - outputError, - outputErrors, - outputJson, -} from '../../utils/execute-command'; -import { buildInputHelp, buildOutputHelp } from '../../utils/help-text'; -import { - ValidationError, - registerSchemaOptions, - resolveInput, -} from '../../utils/json-options'; -import { pollUntilApproved } from '../../utils/poll-until-approved'; -import { requireAuth } from '../../utils/require-auth'; + parseLineItemFlag, + parseTotalFlag, +} from '../../utils/line-item-parser'; import { CreateSpendRequest } from './create'; import { RequestApproval } from './request-approval'; import { RetrieveSpendRequest } from './retrieve'; -import { - CREATE_INPUT_SCHEMA, - RETRIEVE_INPUT_SCHEMA, - SPEND_REQUEST_OUTPUT_SCHEMA, - UPDATE_INPUT_SCHEMA, -} from './schema'; +import { createOptions, retrieveOptions, updateOptions } from './schema'; import { UpdateSpendRequest } from './update'; -export function registerSpendRequestCommands( - program: Command, - repository: ISpendRequestResource, -): Command { - const spendRequestCommand = program - .command('spend-request') - .description('Spend request management commands') - .helpCommand(false); - - const createCmd = spendRequestCommand - .command('create') - .description('Create a new spend request'); - - registerSchemaOptions(createCmd, CREATE_INPUT_SCHEMA); - - createCmd - .option( - '--json ', - `JSON input (keys: ${Object.keys(CREATE_INPUT_SCHEMA).join(', ')})`, - ) - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText( - 'after', - buildInputHelp(CREATE_INPUT_SCHEMA) + - buildOutputHelp(SPEND_REQUEST_OUTPUT_SCHEMA), - ) - .action(async (options) => { - requireAuth(); +export function createSpendRequestCli(repository: ISpendRequestResource) { + const cli = Cli.create('spend-request', { + description: 'Spend request management commands', + }); - let resolved: Record = {}; - try { - resolved = resolveInput(options, CREATE_INPUT_SCHEMA); - } catch (err) { - if (err instanceof ValidationError) - outputErrors(err.errors, !!options.outputJson); - outputError((err as Error).message); + cli.command('create', { + description: 'Create a new spend request', + options: createOptions, + alias: { merchantName: 'm' }, + outputPolicy: 'agent-only' as const, + async *run(c) { + if (!storage.isAuthenticated()) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'Not authenticated. Run "link-cli auth login" first.', + cta: { + commands: [ + { command: 'auth login', description: 'Log in to Link' }, + ], + }, + }); } - const requestApproval = !!resolved.request_approval; - - const credentialType = resolved.credential_type as - | CredentialType - | undefined; - const networkId = resolved.network_id as string | undefined; + const opts = c.options; + const requestApproval = !!opts.requestApproval; + const credentialType = opts.credentialType as CredentialType | undefined; + const networkId = opts.networkId; if (credentialType === 'shared_payment_token' && !networkId) { - outputError( - 'network-id is required when credential-type is shared_payment_token', - ); + return c.error({ + code: 'INVALID_INPUT', + message: + 'network-id is required when credential-type is shared_payment_token', + cta: { + commands: [ + { + command: 'mpp decode', + description: + 'Decode a WWW-Authenticate challenge to extract network-id', + }, + ], + }, + }); } if (networkId && credentialType !== 'shared_payment_token') { - outputError( - 'network-id can only be used when credential-type is shared_payment_token', - ); + return c.error({ + code: 'INVALID_INPUT', + message: + 'network-id can only be used when credential-type is shared_payment_token', + }); } - if ( - credentialType !== 'shared_payment_token' && - !resolved.merchant_name - ) { - outputError('merchant-name is required when credential-type is card'); + if (credentialType !== 'shared_payment_token' && !opts.merchantName) { + return c.error({ + code: 'INVALID_INPUT', + message: 'merchant-name is required when credential-type is card', + }); } - if (credentialType !== 'shared_payment_token' && !resolved.merchant_url) { - outputError('merchant-url is required when credential-type is card'); + if (credentialType !== 'shared_payment_token' && !opts.merchantUrl) { + return c.error({ + code: 'INVALID_INPUT', + message: 'merchant-url is required when credential-type is card', + }); } + // Parse line items/totals: strings from flags need parsing, objects from MCP pass through + const lineItems = opts.lineItem?.length + ? opts.lineItem.map((item: unknown) => + typeof item === 'string' ? parseLineItemFlag(item) : item, + ) + : undefined; + const totals = opts.total?.length + ? opts.total.map((item: unknown) => + typeof item === 'string' ? parseTotalFlag(item) : item, + ) + : undefined; + const createParams = { - payment_details: resolved.payment_method_id as string, + payment_details: opts.paymentMethodId, credential_type: credentialType, network_id: networkId, - amount: resolved.amount as number | undefined, - currency: resolved.currency as string | undefined, - merchant_name: resolved.merchant_name as string | undefined, - merchant_url: resolved.merchant_url as string | undefined, - context: resolved.context as string, - line_items: resolved.line_items as LineItem[] | undefined, - totals: resolved.totals as Total[] | undefined, + amount: opts.amount, + currency: opts.currency, + merchant_name: opts.merchantName, + merchant_url: opts.merchantUrl, + context: opts.context, + line_items: lineItems as LineItem[] | undefined, + totals: totals as Total[] | undefined, request_approval: requestApproval || undefined, - test: resolved.test ? true : undefined, + test: opts.test ? true : undefined, }; - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - const created = await repository.createSpendRequest(createParams); - if (requestApproval) { - outputJson(created); - return pollUntilApproved(repository, created.id, { - onProgress: (elapsedSeconds) => { - process.stderr.write( - `${JSON.stringify({ - type: 'waiting', - command: 'spend_request_approval', - elapsed_seconds: elapsedSeconds, - approval_url: created.approval_url ?? null, - spend_request_id: created.id, - })}\n`, - ); - }, - }); - } - return created; + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} + />, + ); + waitUntilExit().then(async () => { + const created = await repository.createSpendRequest(createParams); + resolve(created); + }); + }); + } + + // Agent mode: create, return immediately with _next polling hint. + // The agent drives the polling loop via `spend-request retrieve`. + const created = await repository.createSpendRequest(createParams); + if (!requestApproval) { + yield created; + return; + } + yield { + ...created, + instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${created.id} --interval 2 --max-attempts 150\` to poll until approved. Do not wait for the user to reply — start polling immediately.`, + _next: { + command: `spend-request retrieve ${created.id} --interval 2 --max-attempts 150`, + until: 'status changes from pending_approval', }, - renderFn: () => ( - {}} - /> - ), - }); - }); + }; + }, + }); - const updateCmd = spendRequestCommand - .command('update ') - .description('Update a spend request'); + cli.command('update', { + description: 'Update a spend request', + args: z.object({ + id: z.string().describe('Spend request ID'), + }), + options: updateOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + if (!storage.isAuthenticated()) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'Not authenticated. Run "link-cli auth login" first.', + cta: { + commands: [ + { command: 'auth login', description: 'Log in to Link' }, + ], + }, + }); + } - registerSchemaOptions(updateCmd, UPDATE_INPUT_SCHEMA); + const id = c.args.id; + const opts = c.options; - updateCmd - .option( - '--json ', - `JSON input (keys: ${Object.keys(UPDATE_INPUT_SCHEMA).join(', ')})`, - ) - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText( - 'after', - buildInputHelp(UPDATE_INPUT_SCHEMA) + - buildOutputHelp(SPEND_REQUEST_OUTPUT_SCHEMA), - ) - .action(async (id: string, options) => { - requireAuth(); + const params: Record = {}; + if (opts.paymentMethodId !== undefined) + params.payment_details = opts.paymentMethodId; + if (opts.amount !== undefined) params.amount = opts.amount; + if (opts.merchantUrl !== undefined) + params.merchant_url = opts.merchantUrl; + if (opts.profileId !== undefined) params.profile_id = opts.profileId; + if (opts.merchantId !== undefined) params.merchant_id = opts.merchantId; + if (opts.currency !== undefined) params.currency = opts.currency; + if (opts.lineItem?.length) + params.line_items = opts.lineItem.map((item: unknown) => + typeof item === 'string' ? parseLineItemFlag(item) : item, + ); + if (opts.total?.length) + params.totals = opts.total.map((item: unknown) => + typeof item === 'string' ? parseTotalFlag(item) : item, + ); - let resolved: Record = {}; - try { - resolved = resolveInput(options, UPDATE_INPUT_SCHEMA); - } catch (err) { - if (err instanceof ValidationError) - outputErrors(err.errors, !!options.outputJson); - outputError((err as Error).message); + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} + />, + ); + waitUntilExit().then(async () => { + resolve(await repository.updateSpendRequest(id, params)); + }); + }); } - const params: Record = {}; - if (resolved.payment_method_id !== undefined) - params.payment_details = resolved.payment_method_id; - if (resolved.amount !== undefined) params.amount = resolved.amount; - if (resolved.merchant_url !== undefined) - params.merchant_url = resolved.merchant_url; - if (resolved.profile_id !== undefined) - params.profile_id = resolved.profile_id; - if (resolved.merchant_id !== undefined) - params.merchant_id = resolved.merchant_id; - if (resolved.currency !== undefined) params.currency = resolved.currency; - if (resolved.line_items !== undefined) - params.line_items = resolved.line_items; - if (resolved.totals !== undefined) params.totals = resolved.totals; + return repository.updateSpendRequest(id, params); + }, + }); - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - return repository.updateSpendRequest(id, params); - }, - renderFn: () => ( - {}} - /> - ), - }); - }); + cli.command('request-approval', { + description: 'Request approval for a spend request', + args: z.object({ + id: z.string().describe('Spend request ID'), + }), + outputPolicy: 'agent-only' as const, + async *run(c) { + if (!storage.isAuthenticated()) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'Not authenticated. Run "link-cli auth login" first.', + cta: { + commands: [ + { command: 'auth login', description: 'Log in to Link' }, + ], + }, + }); + } - spendRequestCommand - .command('request-approval ') - .description('Request approval for a spend request') - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText('after', buildOutputHelp(SPEND_REQUEST_OUTPUT_SCHEMA)) - .action(async (id: string, options: { outputJson?: boolean }) => { - requireAuth(); + const id = c.args.id; - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - const approval = await repository.requestApproval(id); - outputJson(approval); - return pollUntilApproved(repository, id, { - onProgress: (elapsedSeconds) => { - process.stderr.write( - `${JSON.stringify({ - type: 'waiting', - command: 'spend_request_approval', - elapsed_seconds: elapsedSeconds, - approval_url: approval.approval_link ?? null, - spend_request_id: id, - })}\n`, - ); - }, + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} + />, + ); + waitUntilExit().then(async () => { + const approval = await repository.requestApproval(id); + resolve(approval); }); - }, - renderFn: () => ( - {}} - /> - ), - }); - }); + }); + } - const retrieveCmd = spendRequestCommand - .command('retrieve ') - .description('Retrieve a spend request'); + // Agent mode: request approval, return immediately with _next polling hint. + // The agent drives the polling loop via `spend-request retrieve`. + const approval = await repository.requestApproval(id); + yield { + ...approval, + instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${id} --interval 2 --max-attempts 150\` to poll until approved. Do not wait for the user to reply — start polling immediately.`, + _next: { + command: `spend-request retrieve ${id} --interval 2 --max-attempts 150`, + until: 'status changes from pending_approval', + }, + }; + }, + }); - registerSchemaOptions(retrieveCmd, RETRIEVE_INPUT_SCHEMA); + cli.command('retrieve', { + description: 'Retrieve a spend request', + args: z.object({ + id: z.string().describe('Spend request ID'), + }), + options: retrieveOptions, + outputPolicy: 'agent-only' as const, + async *run(c) { + if (!storage.isAuthenticated()) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'Not authenticated. Run "link-cli auth login" first.', + cta: { + commands: [ + { command: 'auth login', description: 'Log in to Link' }, + ], + }, + }); + } - retrieveCmd - .option( - '--json ', - `JSON input (keys: ${Object.keys(RETRIEVE_INPUT_SCHEMA).join(', ')})`, - ) - .option( - '--output-json', - 'Output result as JSON instead of interactive display', - ) - .addHelpText( - 'after', - buildInputHelp(RETRIEVE_INPUT_SCHEMA) + - buildOutputHelp(SPEND_REQUEST_OUTPUT_SCHEMA), - ) - .action(async (id: string, options) => { - requireAuth(); + const id = c.args.id; + const opts = c.options; + const timeout = opts.timeout; + const interval = opts.interval; + const maxAttempts = opts.maxAttempts; + const includeArr = opts.include; + const include = includeArr?.length ? includeArr : undefined; - let resolved: Record = {}; - try { - resolved = resolveInput(options, RETRIEVE_INPUT_SCHEMA); - } catch (err) { - if (err instanceof ValidationError) - outputErrors(err.errors, !!options.outputJson); - outputError((err as Error).message); + if (!c.agent && !c.formatExplicit) { + return new Promise((resolve) => { + const { waitUntilExit } = render( + {}} + />, + ); + waitUntilExit().then(async () => { + const request = await repository.getSpendRequest(id, { include }); + resolve(request); + }); + }); } - const timeout = resolved.timeout as number; - const includeArr = resolved.include as string[] | undefined; - const include = includeArr?.length ? includeArr : undefined; + const terminalStatuses = new Set([ + 'approved', + 'denied', + 'expired', + 'succeeded', + 'failed', + ]); + const deadline = Date.now() + timeout * 1000; + let attempts = 0; - await executeCommand({ - outputJson: !!options.outputJson, - jsonFn: async () => { - const request = await repository.getSpendRequest(id, { include }); - if (!request) { - throw new Error(`Spend request ${id} not found`); - } - return request; - }, - renderFn: () => ( - {}} - /> - ), - }); - }); + while (true) { + const request = await repository.getSpendRequest(id, { include }); + if (!request) { + return c.error({ + code: 'NOT_FOUND', + message: `Spend request ${id} not found`, + }); + } + + if (terminalStatuses.has(request.status)) { + yield request; + return; + } + + attempts++; + const shouldStop = + interval <= 0 || + (maxAttempts > 0 && attempts >= maxAttempts) || + Date.now() >= deadline; + + if (shouldStop) { + yield request; + return; + } + + yield request; + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + } + }, + }); - return spendRequestCommand; + return cli; } diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index 5ef51b6..87b0ee9 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -1,194 +1,98 @@ -import { z } from 'zod'; -import type { InputSchema, OutputSchema } from '../../utils/json-options'; -import { - LineItemSchema, - TotalSchema, - parseKvString, -} from '../../utils/line-item-parser'; +import { z } from 'incur'; -export const SPEND_REQUEST_OUTPUT_SCHEMA: OutputSchema = { - id: { outputExample: '"..."', description: 'Spend request ID' }, - status: { - outputExample: - '"created|pending_approval|approved|denied|expired|succeeded|failed"', - description: 'Current status', - }, - created_at: { - outputExample: '"2026-04-15T14:17:18Z"', - description: 'Creation timestamp', - }, - updated_at: { - outputExample: '"2026-04-15T14:17:18Z"', - description: 'Last update timestamp', - }, - payment_details: { - outputExample: '"csmrpd_abcde12345"', - description: 'Payment method ID', - }, - amount: { outputExample: '1000', description: 'Amount in cents' }, - merchant_name: { outputExample: '"Powdur"', description: 'Merchant name' }, - line_items: { outputExample: '[...]', description: 'Line items' }, - totals: { outputExample: '[...]', description: 'Totals' }, - card: { - outputExample: - '{"number":"4242424242424242","exp_month":12,"exp_year":2027,"cvc":"123","billing_address":{"name":"Jane Doe","line1":"123 Main St","city":"San Francisco","state":"CA","postal_code":"94111","country":"US"},"valid_until":1750000000}', - description: - 'Card credentials (present when credential_type is card and status is approved). Includes billing_address (name, line1, line2, city, state, postal_code, country) and valid_until (unix timestamp) when available.', - }, - shared_payment_token: { - outputExample: - '{"id":"spt_xxx","billing_address":{"name":"Jane Doe","line1":"123 Main St","city":"San Francisco","state":"CA","postal_code":"94111","country":"US"},"valid_until":"2026-04-21T20:46:58Z"}', - description: - 'Shared payment token object (present when credential_type is shared_payment_token and status is approved). Use the "id" field as the SPT value.', - }, -}; - -export const CREATE_INPUT_SCHEMA: InputSchema = { - payment_method_id: { - schema: z.string().min(1), - flag: '--payment-method-id ', - description: 'Payment method ID', - required: true, - }, - credential_type: { - schema: z.enum(['shared_payment_token', 'card']), - flag: '--credential-type ', - description: 'Payment credential type', - jsonDescription: - '"card" for checkout forms/Stripe Elements; "shared_payment_token" for HTTP 402/machine payment flows — evaluate the merchant site before choosing', - defaultValue: 'card', - required: true, - }, - network_id: { - schema: z.string().min(1), - flag: '--network-id ', - description: 'Network ID (required for shared_payment_token)', - jsonDescription: - 'Required for shared_payment_token — use `link-cli mpp decode --challenge ` to validate the stripe challenge and extract this value', - }, - amount: { - schema: z.coerce.number().int().positive().max(50000), - flag: '--amount ', - description: 'Amount in cents', - jsonDescription: 'Total in cents, max 50000 ($500.00)', - required: true, - }, - currency: { - schema: z.string().length(3), - flag: '--currency ', - description: 'Currency code', - defaultValue: 'usd', - }, - merchant_name: { - schema: z.string().min(3), - flag: '--merchant-name ', - description: 'Merchant name', - jsonDescription: - 'Required for card credential type; forbidden for shared_payment_token', - alias: '-m', - }, - merchant_url: { - schema: z.url(), - flag: '--merchant-url ', - description: 'Merchant URL', - jsonDescription: - 'Required for card credential type; forbidden for shared_payment_token', - }, - context: { - schema: z.string().min(100), - flag: '--context ', - description: 'Description of what is being purchased and why', - jsonDescription: - 'Min 100 chars — write a full sentence describing the purchase and rationale; the user reads this when approving', - required: true, - }, - line_items: { - schema: z.array(LineItemSchema), - flag: '--line-item ', - description: 'Line item (repeatable)', - flagParser: parseKvString, - }, - totals: { - schema: z.array(TotalSchema), - flag: '--total ', - description: 'Total (repeatable)', - flagParser: parseKvString, - }, - request_approval: { - schema: z.boolean(), - flag: '--request-approval', - description: 'Request approval and wait for user to approve/deny', - jsonDescription: - 'Polls until approved/denied/expired; blocks until the user acts', - defaultValue: true, - }, - test: { - schema: z.boolean(), - flag: '--test', - description: +export const createOptions = z.object({ + paymentMethodId: z.string().describe('Payment method ID'), + credentialType: z + .enum(['shared_payment_token', 'card']) + .default('card') + .describe( + '"card" for checkout forms/Stripe Elements; "shared_payment_token" for HTTP 402/machine payment flows', + ), + networkId: z + .string() + .optional() + .describe( + 'Network ID (required for shared_payment_token) — use `link-cli mpp decode` to extract', + ), + amount: z.coerce + .number() + .int() + .positive() + .max(50000) + .describe('Amount in cents, max 50000 ($500.00)'), + currency: z.string().length(3).default('usd').describe('Currency code'), + merchantName: z + .string() + .optional() + .describe( + 'Merchant name (required for card; forbidden for shared_payment_token)', + ), + merchantUrl: z + .string() + .optional() + .describe( + 'Merchant URL (required for card; forbidden for shared_payment_token)', + ), + context: z + .string() + .min(100) + .describe( + 'Min 100 chars — describe the purchase and rationale; the user reads this when approving', + ), + lineItem: z + .array(z.union([z.string(), z.record(z.string(), z.unknown())])) + .default([]) + .describe('Line item (repeatable, key:value format)'), + total: z + .array(z.union([z.string(), z.record(z.string(), z.unknown())])) + .default([]) + .describe('Total (repeatable, key:value format)'), + requestApproval: z + .boolean() + .default(true) + .describe('Request approval and poll until approved/denied/expired'), + test: z + .boolean() + .default(false) + .describe( 'Use test mode (creates testmode credentials from test card data)', - jsonDescription: - 'When true, creates testmode credentials instead of real ones — safe for development and testing', - defaultValue: false, - }, -}; + ), +}); -export const RETRIEVE_INPUT_SCHEMA: InputSchema = { - timeout: { - schema: z.coerce.number(), - flag: '--timeout ', - description: 'Polling timeout in seconds', - defaultValue: 300, - }, - include: { - schema: z.array(z.string()), - flag: '--include ', - description: 'Include extra data (repeatable, e.g. --include card)', - }, -}; +export const retrieveOptions = z.object({ + timeout: z.coerce + .number() + .default(300) + .describe('Polling timeout in seconds'), + interval: z.coerce + .number() + .default(0) + .describe( + 'Poll interval in seconds. When > 0, polls until status is terminal or timeout is reached, yielding status on each attempt.', + ), + maxAttempts: z.coerce + .number() + .default(0) + .describe('Max poll attempts. 0 = unlimited (use timeout instead).'), + include: z + .array(z.string()) + .default([]) + .describe('Include extra data (repeatable, e.g. --include card)'), +}); -export const UPDATE_INPUT_SCHEMA: InputSchema = { - payment_method_id: { - schema: z.string().min(1), - flag: '--payment-method-id ', - description: 'Payment method ID', - required: true, - }, - amount: { - schema: z.coerce.number().int().positive(), - flag: '--amount ', - description: 'Amount in cents', - }, - merchant_url: { - schema: z.string().min(1), - flag: '--merchant-url ', - description: 'Merchant URL', - }, - profile_id: { - schema: z.string().min(1), - flag: '--profile-id ', - description: 'Profile ID', - }, - merchant_id: { - schema: z.string().min(1), - flag: '--merchant-id ', - description: 'Merchant ID', - }, - currency: { - schema: z.string().min(1), - flag: '--currency ', - description: 'Currency code', - }, - line_items: { - schema: z.array(LineItemSchema), - flag: '--line-item ', - description: 'Line item (repeatable)', - flagParser: parseKvString, - }, - totals: { - schema: z.array(TotalSchema), - flag: '--total ', - description: 'Total (repeatable)', - flagParser: parseKvString, - }, -}; +export const updateOptions = z.object({ + paymentMethodId: z.string().optional().describe('Payment method ID'), + amount: z.coerce.number().optional().describe('Amount in cents'), + merchantUrl: z.string().optional().describe('Merchant URL'), + profileId: z.string().optional().describe('Profile ID'), + merchantId: z.string().optional().describe('Merchant ID'), + currency: z.string().optional().describe('Currency code'), + lineItem: z + .array(z.union([z.string(), z.record(z.string(), z.unknown())])) + .default([]) + .describe('Line item (repeatable, key:value format)'), + total: z + .array(z.union([z.string(), z.record(z.string(), z.unknown())])) + .default([]) + .describe('Total (repeatable, key:value format)'), +}); diff --git a/packages/cli/src/utils/__tests__/execute-command.test.ts b/packages/cli/src/utils/__tests__/execute-command.test.ts deleted file mode 100644 index 0af2318..0000000 --- a/packages/cli/src/utils/__tests__/execute-command.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// We need to capture stderr and test the jsonFn path runs on no-TTY. -// executeCommand calls process.stderr.write directly, so spy on that. - -describe('executeCommand — no-TTY fallback', () => { - let stderrOutput: string[]; - let originalIsTTY: boolean | undefined; - - beforeEach(() => { - vi.resetModules(); - stderrOutput = []; - vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { - stderrOutput.push(String(chunk)); - return true; - }); - originalIsTTY = process.stdout.isTTY; - process.stdout.isTTY = false; - }); - - afterEach(() => { - vi.restoreAllMocks(); - // @ts-expect-error — originalIsTTY is boolean | undefined but isTTY is boolean - process.stdout.isTTY = originalIsTTY; - }); - - it('runs jsonFn and emits TTY notices when isTTY is false and outputJson is false', async () => { - const jsonFn = vi.fn().mockResolvedValue({ ok: true }); - const renderFn = vi.fn(); - - // Import after mocking so the module sees the patched isTTY - const { executeCommand } = await import('../execute-command.js'); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - - await executeCommand({ outputJson: false, jsonFn, renderFn }); - - expect(jsonFn).toHaveBeenCalledOnce(); - expect(renderFn).not.toHaveBeenCalled(); - - const stderr = stderrOutput.join(''); - expect(stderr).toContain('No TTY detected'); - expect(stderr).toContain('link-cli skill'); - }); - - it('does NOT emit TTY notices when outputJson is explicitly true', async () => { - const jsonFn = vi.fn().mockResolvedValue({ ok: true }); - const renderFn = vi.fn(); - - const { executeCommand } = await import('../execute-command.js'); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - - await executeCommand({ outputJson: true, jsonFn, renderFn }); - - const stderr = stderrOutput.join(''); - expect(stderr).not.toContain('No TTY detected'); - }); -}); diff --git a/packages/cli/src/utils/__tests__/json-options.test.ts b/packages/cli/src/utils/__tests__/json-options.test.ts deleted file mode 100644 index 1e032c9..0000000 --- a/packages/cli/src/utils/__tests__/json-options.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; -import { type InputSchema, resolveInput } from '../json-options'; - -const SCHEMA: InputSchema = { - amount: { - schema: z.number(), - flag: '--amount ', - description: 'Amount', - }, - merchant_name: { - schema: z.string().min(1), - flag: '--merchant-name ', - description: 'Merchant name', - }, -}; - -describe('resolveInput', () => { - describe('flags path (no --json)', () => { - it('returns snake_case keys from flag values', () => { - const result = resolveInput( - { amount: 49.99, merchantName: 'Adidas' }, - SCHEMA, - ); - expect(result).toEqual({ amount: 49.99, merchant_name: 'Adidas' }); - }); - - it('omits missing flags from the result', () => { - const result = resolveInput({ amount: 10 }, SCHEMA); - expect(result).toEqual({ amount: 10 }); - }); - }); - - describe('JSON path (--json)', () => { - it('returns snake_case keys from JSON input', () => { - const result = resolveInput( - { json: '{"amount": 49.99, "merchant_name": "Adidas"}' }, - SCHEMA, - ); - expect(result).toEqual({ amount: 49.99, merchant_name: 'Adidas' }); - }); - - it('handles partial JSON (subset of fields)', () => { - const result = resolveInput({ json: '{"amount": 25}' }, SCHEMA); - expect(result).toEqual({ amount: 25 }); - }); - }); - - describe('conflict detection', () => { - it('throws when --json is combined with individual flags', () => { - expect(() => - resolveInput({ json: '{"amount": 10}', amount: 10 }, SCHEMA), - ).toThrow('Cannot combine --json'); - }); - }); - - describe('JSON parse errors', () => { - it('throws on invalid JSON syntax', () => { - expect(() => resolveInput({ json: '{bad' }, SCHEMA)).toThrow( - 'Invalid JSON', - ); - }); - - it('throws when JSON is an array', () => { - expect(() => resolveInput({ json: '[1, 2]' }, SCHEMA)).toThrow( - 'expected object', - ); - }); - - it('throws on unrecognized keys', () => { - expect(() => resolveInput({ json: '{"foo": 1}' }, SCHEMA)).toThrow( - 'Unrecognized key', - ); - }); - }); - - describe('type validation', () => { - it('throws when a number field receives a string', () => { - expect(() => - resolveInput({ outputJson: true, json: '{"amount": "fifty"}' }, SCHEMA), - ).toThrow('amount'); - }); - - it('throws when a string field receives a number', () => { - expect(() => - resolveInput( - { outputJson: true, json: '{"merchant_name": 123}' }, - SCHEMA, - ), - ).toThrow('merchant_name'); - }); - - it('throws when a string field receives an empty string', () => { - expect(() => - resolveInput( - { outputJson: true, json: '{"merchant_name": ""}' }, - SCHEMA, - ), - ).toThrow('merchant_name'); - }); - }); - - describe('error label format', () => { - it('uses flag names when --output-json is not set', () => { - expect(() => resolveInput({ amount: 'fifty' }, SCHEMA)).toThrow( - '--amount', - ); - }); - - it('uses flag names for multi-word flags when --output-json is not set', () => { - expect(() => resolveInput({ merchantName: '' }, SCHEMA)).toThrow( - '--merchant-name', - ); - }); - - it('uses field names when --output-json is set', () => { - expect(() => - resolveInput({ outputJson: true, amount: 'fifty' }, SCHEMA), - ).toThrow('amount:'); - }); - - it('does not use snake_case field names in interactive mode', () => { - const fn = () => resolveInput({ amount: 'fifty' }, SCHEMA); - expect(fn).not.toThrow(expect.stringMatching(/^amount:/)); - }); - }); - - describe('boolean field type', () => { - const schemaWithBool: InputSchema = { - enabled: { - schema: z.boolean(), - flag: '--enabled', - description: 'Enable feature', - }, - }; - - it('accepts a true boolean value', () => { - const result = resolveInput( - { json: '{"enabled": true}' }, - schemaWithBool, - ); - expect(result).toEqual({ enabled: true }); - }); - - it('accepts a false boolean value', () => { - const result = resolveInput( - { json: '{"enabled": false}' }, - schemaWithBool, - ); - expect(result).toEqual({ enabled: false }); - }); - - it('throws when a boolean field receives a string', () => { - expect(() => - resolveInput({ json: '{"enabled": "yes"}' }, schemaWithBool), - ).toThrow('enabled'); - }); - - it('throws when a boolean field receives a number', () => { - expect(() => - resolveInput({ json: '{"enabled": 1}' }, schemaWithBool), - ).toThrow('enabled'); - }); - }); - - describe('array field type', () => { - const schemaWithArray: InputSchema = { - items: { - schema: z.array(z.unknown()), - flag: '--item ', - description: 'Items', - }, - }; - - it('accepts an array value', () => { - const result = resolveInput( - { json: '{"items": [1, 2, 3]}' }, - schemaWithArray, - ); - expect(result).toEqual({ items: [1, 2, 3] }); - }); - - it('accepts an empty array', () => { - const result = resolveInput({ json: '{"items": []}' }, schemaWithArray); - expect(result).toEqual({ items: [] }); - }); - - it('throws when an array field receives an object', () => { - expect(() => - resolveInput( - { outputJson: true, json: '{"items": {"a": 1}}' }, - schemaWithArray, - ), - ).toThrow('items'); - }); - - it('throws when an array field receives a string', () => { - expect(() => - resolveInput( - { outputJson: true, json: '{"items": "not-array"}' }, - schemaWithArray, - ), - ).toThrow('items'); - }); - }); -}); diff --git a/packages/cli/src/utils/configure-root-help.ts b/packages/cli/src/utils/configure-root-help.ts deleted file mode 100644 index c1c640c..0000000 --- a/packages/cli/src/utils/configure-root-help.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Command } from 'commander'; - -export function configureRootHelp( - program: Command, - authCommand: Command, - spendIntentCommand: Command, - paymentMethodsCommand: Command, - skillCommand: Command, - mppCommand: Command, -): void { - program.configureHelp({ - formatHelp(cmd, helper) { - const helpWidth = helper.helpWidth || 80; - const itemIndent = 2; - const itemSeparator = 2; - - function formatItem( - term: string, - termWidth: number, - description: string, - ) { - if (description) { - const fullText = `${term.padEnd(termWidth + itemSeparator)}${description}`; - return helper.wrap( - fullText, - helpWidth - itemIndent, - termWidth + itemSeparator, - ); - } - return term; - } - - function formatList(items: string[]) { - return items.join('\n').replace(/^/gm, ' '.repeat(itemIndent)); - } - - const output: string[] = [`Usage: ${helper.commandUsage(cmd)}`, '']; - - const desc = helper.commandDescription(cmd); - if (desc.length > 0) { - output.push(helper.wrap(desc, helpWidth, 0), ''); - } - - output.push( - 'Getting started:', - formatList([ - 'As an agent, you MUST run `link-cli skill` to fully understand how to get setup.', - 'Optional: Run `npx skills add stripe/link-cli` to install the skill for future use.', - ]), - '', - ); - - const optTermWidth = helper.longestOptionTermLength(cmd, helper); - const optionList = helper - .visibleOptions(cmd) - .map((option) => - formatItem( - helper.optionTerm(option), - optTermWidth, - helper.optionDescription(option), - ), - ); - if (optionList.length > 0) { - output.push('Options:', formatList(optionList), ''); - } - - const commandGroups = [ - { heading: 'Auth:', parent: authCommand }, - { heading: 'Spend Requests:', parent: spendIntentCommand }, - { heading: 'Payment Methods:', parent: paymentMethodsCommand }, - { heading: 'MPP:', parent: mppCommand }, - ]; - - const allLeafCmds = commandGroups.flatMap(({ parent }) => - helper.visibleCommands(parent), - ); - const maxTermWidth = allLeafCmds.reduce( - (max, sub) => - Math.max( - max, - // biome-ignore lint/style/noNonNullAssertion: sub is always a subcommand and always has a parent - `${sub.parent!.name()} ${helper.subcommandTerm(sub)}`.length, - ), - skillCommand.name().length, - ); - - for (const { heading, parent } of commandGroups) { - const cmds = helper.visibleCommands(parent); - if (cmds.length === 0) continue; - const list = cmds.map((sub) => - formatItem( - `${parent.name()} ${helper.subcommandTerm(sub)}`, - maxTermWidth, - helper.subcommandDescription(sub), - ), - ); - output.push(heading, formatList(list), ''); - } - - output.push( - 'Other:', - formatList([ - formatItem( - skillCommand.name(), - maxTermWidth, - skillCommand.description(), - ), - ]), - '', - ); - - return output.join('\n'); - }, - }); -} diff --git a/packages/cli/src/utils/execute-command.tsx b/packages/cli/src/utils/execute-command.tsx deleted file mode 100644 index 79573d0..0000000 --- a/packages/cli/src/utils/execute-command.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { LinkAuthenticationError, type LinkSdkError } from '@stripe/link-sdk'; -import { render } from 'ink'; -import type React from 'react'; -import { ValidationError } from './json-options.js'; - -export function outputJson(data: unknown): void { - process.stdout.write(`${JSON.stringify(data, null, 2)}\n\n`); -} - -export function outputError(message: string, code?: string): never { - process.stderr.write( - `${JSON.stringify({ error: message, ...(code && { code }) })}\n`, - ); - process.exit(1); -} - -export function outputErrors(errors: string[], asJson: boolean): never { - if (asJson) { - process.stderr.write(`${JSON.stringify({ errors })}\n`); - } else { - process.stderr.write(`${errors.join('\n')}\n`); - } - process.exit(1); -} - -export async function executeCommand(opts: { - outputJson: boolean; - jsonFn: () => Promise; - renderFn: () => React.ReactElement; -}): Promise { - try { - if (opts.outputJson) { - const data = await opts.jsonFn(); - outputJson(data); - } else if (!process.stdout.isTTY) { - process.stderr.write('No TTY detected — falling back to JSON output.\n'); - process.stderr.write( - "Run 'link-cli skill' to read the full Link CLI skill file.\n", - ); - const data = await opts.jsonFn(); - outputJson(data); - } else { - const { waitUntilExit } = render(opts.renderFn()); - await waitUntilExit(); - } - } catch (err) { - if (err instanceof ValidationError) { - outputErrors(err.errors, opts.outputJson); - } - if (err instanceof LinkAuthenticationError) { - outputError( - 'Not authenticated. Please run `link login` first.', - err.code, - ); - } - const sdkErr = err as LinkSdkError; - outputError((err as Error).message, sdkErr?.code); - } -} diff --git a/packages/cli/src/utils/help-text.ts b/packages/cli/src/utils/help-text.ts deleted file mode 100644 index e793515..0000000 --- a/packages/cli/src/utils/help-text.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { z } from 'zod'; -import type { InputSchema, OutputSchema } from './json-options'; - -export function buildInputHelp(schema: InputSchema): string { - const entries = Object.entries(schema); - const nonArrayEntries = entries.filter( - ([, def]) => !(def.schema instanceof z.ZodArray), - ); - const arrayEntries = entries.filter( - ([, def]) => def.schema instanceof z.ZodArray, - ); - - // Non-array flags: strip from flag string - const nonArrayFlags = nonArrayEntries.map(([, def]) => { - const flag = def.flag.split(' ')[0]; - return def.required ? `${flag} (required)` : flag; - }); - const flagPrefix = ' Flags: '; - const indent = ' '.repeat(flagPrefix.length); - const arrayFlagLines = arrayEntries.map( - ([, def]) => `${indent}${def.flag} (repeatable)`, - ); - - const flagsLine = `${flagPrefix}${nonArrayFlags.join(' ')}`; - const flagsSection = - arrayFlagLines.length > 0 - ? `${flagsLine}\n${arrayFlagLines.join('\n')}` - : flagsLine; - - // JSON section: pretty-printed multi-line - const jsonIndent = ' '; - const jsonFields = entries.map(([key, def], i) => { - const comma = i < entries.length - 1 ? ',' : ''; - const desc = def.jsonDescription ?? def.description; - const requiredNote = def.required ? 'required' : ''; - const descNote = desc ? desc : ''; - const commentParts = [requiredNote, descNote].filter(Boolean); - const comment = - commentParts.length > 0 ? ` // ${commentParts.join(' — ')}` : ''; - if (def.schema instanceof z.ZodArray) { - return `${jsonIndent}"${key}": [...]${comma}${comment}`; - } - const placeholder = - def.defaultValue !== undefined - ? JSON.stringify(def.defaultValue) - : '"..."'; - return `${jsonIndent}"${key}": ${placeholder}${comma}${comment}`; - }); - const jsonLine = ` JSON: --json '{\n${jsonFields.join('\n')}\n }'`; - - // Array detail sections: derive formats from schema - const arrayDetails: string[] = []; - for (const [key, def] of arrayEntries) { - const element = (def.schema as z.ZodArray>) - .element; - if (!(element instanceof z.ZodObject)) continue; - const keys = Object.keys(element.shape); - const flagFormat = `"key:,key:,..."`; - const jsonExample = keys - .slice(0, 3) - .map((k) => `"${k}": "..."`) - .join(', '); - const jsonFormat = `"${key}": [{ ${jsonExample}${keys.length > 3 ? ', ...' : ''} }]`; - arrayDetails.push( - ` ${def.flag}\n Keys: ${keys.join(', ')}\n Flag: ${flagFormat}\n JSON: ${jsonFormat}`, - ); - } - - const parts = ['\nInput formats:', flagsSection, jsonLine]; - if (arrayDetails.length > 0) { - parts.push('', ...arrayDetails); - } - - return `${parts.join('\n')}\n`; -} - -export function buildOutputHelp(schema: OutputSchema, isArray = false): string { - const fields = Object.entries(schema); - - const lines = ['\nOutput (--output-json):']; - const outerIndent = ' '; - const innerIndent = isArray ? ' ' : ' '; - - if (isArray) { - lines.push(`${outerIndent}[`); - lines.push(`${outerIndent} {`); - } else { - lines.push(`${outerIndent}{`); - } - - fields.forEach(([key, def], i) => { - const comma = i < fields.length - 1 ? ',' : ''; - lines.push(`${innerIndent}"${key}": ${def.outputExample}${comma}`); - }); - - if (isArray) { - lines.push(`${outerIndent} }`); - lines.push(`${outerIndent}]`); - } else { - lines.push(`${outerIndent}}`); - } - - return `${lines.join('\n')}\n`; -} diff --git a/packages/cli/src/utils/json-options.ts b/packages/cli/src/utils/json-options.ts deleted file mode 100644 index 17f4979..0000000 --- a/packages/cli/src/utils/json-options.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { Command } from 'commander'; -import { z } from 'zod'; - -export class ValidationError extends Error { - errors: string[]; - constructor(errors: string[]) { - super(errors.join('\n')); - this.errors = errors; - } -} - -export interface InputFieldDef { - schema: z.ZodType; - flag: string; - description: string; - jsonDescription?: string; // richer description for --help JSON block; falls back to description - required?: boolean; - alias?: string; - defaultValue?: unknown; - flagParser?: (raw: string) => unknown; -} -export type InputSchema = Record; - -export interface OutputFieldDef { - outputExample: string; - description: string; -} -export type OutputSchema = Record; - -function flagToCommanderKey(flag: string): string { - const name = flag.split(' ')[0].replace(/^--/, ''); - return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); -} - -export function registerSchemaOptions(cmd: Command, schema: InputSchema): void { - function collect(value: string, previous: string[]): string[] { - return previous.concat([value]); - } - - for (const [, def] of Object.entries(schema)) { - const flags = def.alias ? `${def.alias}, ${def.flag}` : def.flag; - - if (def.schema instanceof z.ZodArray) { - cmd.option(flags, def.description, collect, []); - } else if (def.defaultValue !== undefined) { - cmd.option(flags, def.description, def.defaultValue as string); - } else { - cmd.option(flags, def.description); - } - } -} - -export function resolveInput( - options: Record, - schema: InputSchema, -): Record { - const entries = Object.entries(schema); - - let rawInput: Record; - - if (options.json !== undefined) { - // Error if any schema flags were explicitly set alongside --json - const conflicts = entries.filter(([, def]) => { - const val = options[flagToCommanderKey(def.flag)]; - if (def.schema instanceof z.ZodArray) - return Array.isArray(val) && val.length > 0; - if (def.defaultValue !== undefined) - return val !== undefined && val !== def.defaultValue; - return val !== undefined; - }); - - if (conflicts.length > 0) { - const names = conflicts - .map(([, def]) => def.flag.split(' ')[0]) - .join(', '); - throw new Error(`Cannot combine --json with individual flags (${names})`); - } - - try { - rawInput = JSON.parse(options.json as string) as Record; - } catch { - throw new Error(`Invalid JSON: ${options.json}`); - } - - // Apply schema defaults for fields not present in the JSON input - for (const [key, def] of entries) { - if (rawInput[key] === undefined && def.defaultValue !== undefined) { - rawInput[key] = def.defaultValue; - } - } - } else { - // Build raw object from flags, keyed by schema field name - rawInput = {}; - for (const [key, def] of entries) { - const val = options[flagToCommanderKey(def.flag)]; - if (def.schema instanceof z.ZodArray) { - const arr = (val as string[] | undefined) ?? []; - if (arr.length > 0) { - rawInput[key] = def.flagParser ? arr.map(def.flagParser) : arr; - } - } else if (val !== undefined) { - rawInput[key] = val; - } - } - } - - // Validate through Zod (same path for both --json and flags) - const zodShape = Object.fromEntries( - entries.map(([key, def]) => [ - key, - def.required ? def.schema : def.schema.optional(), - ]), - ); - - const fieldToFlag = Object.fromEntries( - entries.map(([key, def]) => [key, def.flag.split(' ')[0]]), - ); - const useJsonKeys = !!options.outputJson; - - try { - return z.object(zodShape).strict().parse(rawInput) as Record< - string, - unknown - >; - } catch (err) { - if (err instanceof z.ZodError) { - const messages = err.issues.map((issue) => { - const fieldName = issue.path[0] as string | undefined; - const label = - !useJsonKeys && fieldName && fieldToFlag[fieldName] - ? fieldToFlag[fieldName] - : issue.path.join('.'); - return label ? `${label}: ${issue.message}` : issue.message; - }); - throw new ValidationError(messages); - } - throw err; - } -} diff --git a/packages/cli/src/utils/require-auth.ts b/packages/cli/src/utils/require-auth.ts deleted file mode 100644 index 4f5f234..0000000 --- a/packages/cli/src/utils/require-auth.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { storage } from '@stripe/link-sdk'; -import { outputError } from './execute-command'; - -export function requireAuth(): void { - if (!storage.isAuthenticated()) { - outputError('Not authenticated. Run "link-cli auth login" first.'); - } -} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index da0288a..80612dc 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -5,10 +5,6 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const skillContent = readFileSync( - join(__dirname, '../../skills/create-payment-credential/SKILL.md'), - 'utf-8', -); const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')); let buildNumber = '0'; try { @@ -29,7 +25,6 @@ export default defineConfig({ sourcemap: false, banner: { js: '#!/usr/bin/env node' }, define: { - SKILL_CONTENT: JSON.stringify(skillContent), __CLI_VERSION__: JSON.stringify(pkg.version), __BUILD_NUMBER__: JSON.stringify(buildNumber), __CLI_NAME__: JSON.stringify(pkg.name), diff --git a/packages/cli/turbo.json b/packages/cli/turbo.json index c54c296..ee890b2 100644 --- a/packages/cli/turbo.json +++ b/packages/cli/turbo.json @@ -3,10 +3,7 @@ "extends": ["//"], "tasks": { "build": { - "inputs": [ - "$TURBO_DEFAULT$", - "../../skills/create-payment-credential/SKILL.md" - ] + "inputs": ["$TURBO_DEFAULT$"] } } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1fa9def..00696fe 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -8,4 +8,4 @@ export * from './resources/auth'; export * from './resources/spend-request'; export * from './resources/payment-methods'; export { MemoryStorage, storage } from './utils/storage'; -export type { AuthStorage } from './utils/storage'; +export type { AuthStorage, PendingDeviceAuth } from './utils/storage'; diff --git a/packages/sdk/src/utils/storage.ts b/packages/sdk/src/utils/storage.ts index b38e585..6a9f30a 100644 --- a/packages/sdk/src/utils/storage.ts +++ b/packages/sdk/src/utils/storage.ts @@ -2,8 +2,17 @@ import fs from 'node:fs'; import type { AuthTokens } from '@/types/index'; import Conf from 'conf'; +export interface PendingDeviceAuth { + device_code: string; + interval: number; + expires_at: number; + verification_url: string; + passphrase: string; +} + interface StorageSchema { auth: AuthTokens | null; + pendingDeviceAuth: PendingDeviceAuth | null; } export interface AuthStorage { @@ -11,6 +20,9 @@ export interface AuthStorage { setAuth(auth: AuthTokens): void; clearAuth(): void; isAuthenticated(): boolean; + getPendingDeviceAuth(): PendingDeviceAuth | null; + setPendingDeviceAuth(pending: PendingDeviceAuth): void; + clearPendingDeviceAuth(): void; clearAll(): void; getPath(): string; deleteConfig(): void; @@ -32,6 +44,7 @@ class Storage implements AuthStorage { projectName: 'link-cli', defaults: { auth: null, + pendingDeviceAuth: null, }, }); } @@ -55,6 +68,24 @@ class Storage implements AuthStorage { return this.getAuth() !== null; } + getPendingDeviceAuth(): PendingDeviceAuth | null { + const pending = this.getConfig().get('pendingDeviceAuth'); + if (!pending) return null; + if (Date.now() >= pending.expires_at) { + this.clearPendingDeviceAuth(); + return null; + } + return pending; + } + + setPendingDeviceAuth(pending: PendingDeviceAuth): void { + this.getConfig().set('pendingDeviceAuth', pending); + } + + clearPendingDeviceAuth(): void { + this.getConfig().set('pendingDeviceAuth', null); + } + clearAll(): void { this.getConfig().clear(); } @@ -74,6 +105,7 @@ class Storage implements AuthStorage { export class MemoryStorage implements AuthStorage { private auth: AuthTokens | null; + private pendingAuth: PendingDeviceAuth | null = null; constructor(initialAuth: AuthTokens | null = null) { this.auth = initialAuth ? withComputedExpiry(initialAuth) : null; @@ -95,8 +127,26 @@ export class MemoryStorage implements AuthStorage { return this.auth !== null; } + getPendingDeviceAuth(): PendingDeviceAuth | null { + if (!this.pendingAuth) return null; + if (Date.now() >= this.pendingAuth.expires_at) { + this.pendingAuth = null; + return null; + } + return this.pendingAuth; + } + + setPendingDeviceAuth(pending: PendingDeviceAuth): void { + this.pendingAuth = pending; + } + + clearPendingDeviceAuth(): void { + this.pendingAuth = null; + } + clearAll(): void { this.auth = null; + this.pendingAuth = null; } getPath(): string { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80ba14f..4db9782 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,9 @@ importers: packages/cli: dependencies: - commander: - specifier: ^12.1.0 - version: 12.1.0 + incur: + specifier: ^0.4.1 + version: 0.4.1 ink: specifier: ^5.2.1 version: 5.2.1(@types/react@18.3.28)(react@18.3.1) @@ -34,7 +34,7 @@ importers: version: 5.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) mppx: specifier: ^0.5.7 - version: 0.5.7(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(express@5.2.1)(hono@4.12.12)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.10(typescript@5.9.3)(zod@4.3.6)) + version: 0.5.7(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(express@5.2.1)(hono@4.12.12)(typescript@5.9.3)(viem@2.47.10(typescript@5.9.3)(zod@4.3.6)) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -68,7 +68,7 @@ importers: version: 6.0.8 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -93,7 +93,7 @@ importers: version: 22.19.15 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -112,20 +112,6 @@ packages: resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} engines: {node: '>=14.13.1'} - '@apidevtools/json-schema-ref-parser@14.2.1': - resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} - engines: {node: '>= 20'} - peerDependencies: - '@types/json-schema': ^7.0.15 - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -187,6 +173,9 @@ packages: cpu: [x64] os: [win32] + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -413,10 +402,6 @@ packages: peerDependencies: hono: ^4 - '@humanwhocodes/momoa@2.0.4': - resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} - engines: {node: '>=10.10.0'} - '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -455,8 +440,17 @@ packages: '@cfworker/json-schema': optional: true - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + '@modelcontextprotocol/server@2.0.0-alpha.2': + resolution: {integrity: sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==} + engines: {node: '>=20'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -500,22 +494,6 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} - '@readme/better-ajv-errors@2.4.0': - resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==} - engines: {node: '>=18'} - peerDependencies: - ajv: 4.11.8 - 8 - - '@readme/openapi-parser@6.0.1': - resolution: {integrity: sha512-uMtwMPVv86Xr5Y6A+QFrxLwWW1irJp3TIz7R3Zs2WaX383MPYX5kaQemPBsaHCu58ZULd+bosNIGUoFW8KM5Ew==} - engines: {node: '>=20'} - peerDependencies: - openapi-types: '>=7' - - '@readme/openapi-schemas@3.1.0': - resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==} - engines: {node: '>=18'} - '@remix-run/fetch-proxy@0.7.1': resolution: {integrity: sha512-rPLfOpAaCXtm1dLI45uIPKERNbXbrh0P9AJc1sliz8pWd/McaFYjdr5KzB4QrFSfPvEt/Wmy6F2521qB1kK0ug==} @@ -821,9 +799,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -891,14 +866,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv-draft-04@1.0.0: - resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} - peerDependencies: - ajv: ^8.5.0 - peerDependenciesMeta: - ajv: - optional: true - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1060,10 +1027,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1403,8 +1366,13 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - incur@0.3.19: - resolution: {integrity: sha512-LvVwb8ZNYb7FwbHreL9eBqPq4HYS0Q3tF4/kU36YVzw3cZrdqKRKzezRUYIoX4T0JQYUOwWcbmyMAvoUbWuFxA==} + incur@0.3.25: + resolution: {integrity: sha512-jrSkzauM42ilbQJ6THVkAY6dTulkyVW0sZpVHdA8gfiBwrLrLnLUf8U3bAOegAKBIMSOFgk1idchgu9xm9HMng==} + engines: {node: '>=22'} + hasBin: true + + incur@0.4.1: + resolution: {integrity: sha512-+0JwzFYrPsuASGBJp7ya3HRT+ET406L57wmbji52bAlxHWPIRC9Zh4ZWaatsj1nqZGHAbHse5KBs7P3L3ytc8g==} engines: {node: '>=22'} hasBin: true @@ -1537,10 +1505,6 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonpointer@5.0.1: - resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} - engines: {node: '>=0.10.0'} - ky@1.14.3: resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} engines: {node: '>=18'} @@ -1549,10 +1513,6 @@ packages: resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} engines: {node: '>=18'} - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1754,9 +1714,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1869,8 +1826,8 @@ packages: yaml: optional: true - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} prettier@2.8.8: @@ -2148,10 +2105,6 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -2437,19 +2390,6 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 4.0.0 - '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': - dependencies: - '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/runtime@7.29.2': {} '@biomejs/biome@1.9.4': @@ -2487,6 +2427,8 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@cfworker/json-schema@4.1.1': {} + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -2727,8 +2669,7 @@ snapshots: '@hono/node-server@1.19.13(hono@4.12.12)': dependencies: hono: 4.12.12 - - '@humanwhocodes/momoa@2.0.4': {} + optional: true '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': dependencies: @@ -2767,7 +2708,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.13(hono@4.12.12) ajv: 8.18.0 @@ -2786,10 +2727,19 @@ snapshots: raw-body: 3.0.2 zod: 4.3.6 zod-to-json-schema: 3.25.2(zod@4.3.6) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: - supports-color + optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)': + dependencies: + zod: 4.3.6 + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 @@ -2830,28 +2780,6 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@readme/better-ajv-errors@2.4.0(ajv@8.18.0)': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 - '@humanwhocodes/momoa': 2.0.4 - ajv: 8.18.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - picocolors: 1.1.1 - - '@readme/openapi-parser@6.0.1(openapi-types@12.1.3)': - dependencies: - '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) - '@readme/better-ajv-errors': 2.4.0(ajv@8.18.0) - '@readme/openapi-schemas': 3.1.0 - '@types/json-schema': 7.0.15 - ajv: 8.18.0 - ajv-draft-04: 1.0.0(ajv@8.18.0) - openapi-types: 12.1.3 - - '@readme/openapi-schemas@3.1.0': {} - '@remix-run/fetch-proxy@0.7.1': dependencies: '@remix-run/headers': 0.19.0 @@ -2898,7 +2826,7 @@ snapshots: '@rolldown/binding-wasm32-wasi@1.0.0-rc.10(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -3038,8 +2966,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/json-schema@7.0.15': {} - '@types/node@12.20.55': {} '@types/node@22.19.15': @@ -3112,13 +3038,10 @@ snapshots: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + optional: true acorn@8.16.0: {} - ajv-draft-04@1.0.0(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -3186,6 +3109,7 @@ snapshots: type-is: 2.0.1 transitivePeerDependencies: - supports-color + optional: true boxen@7.1.1: dependencies: @@ -3218,7 +3142,8 @@ snapshots: esbuild: 0.27.4 load-tsconfig: 0.2.5 - bytes@3.1.2: {} + bytes@3.1.2: + optional: true cac@6.7.14: {} @@ -3226,11 +3151,13 @@ snapshots: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + optional: true call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + optional: true camelcase@5.3.1: {} @@ -3277,8 +3204,6 @@ snapshots: color-name@1.1.4: {} - commander@12.1.0: {} - commander@4.1.1: {} conf@13.1.0: @@ -3309,22 +3234,27 @@ snapshots: consola@3.4.2: {} - content-disposition@1.0.1: {} + content-disposition@1.0.1: + optional: true - content-type@1.0.5: {} + content-type@1.0.5: + optional: true convert-source-map@2.0.0: {} convert-to-spaces@2.0.1: {} - cookie-signature@1.2.2: {} + cookie-signature@1.2.2: + optional: true - cookie@0.7.2: {} + cookie@0.7.2: + optional: true cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 + optional: true cross-spawn@7.0.6: dependencies: @@ -3346,7 +3276,8 @@ snapshots: deep-extend@0.6.0: {} - depd@2.0.0: {} + depd@2.0.0: + optional: true detect-indent@6.1.0: {} @@ -3367,10 +3298,12 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + optional: true eastasianwidth@0.2.0: {} - ee-first@1.1.1: {} + ee-first@1.1.1: + optional: true emoji-regex@10.6.0: {} @@ -3378,7 +3311,8 @@ snapshots: emoji-regex@9.2.2: {} - encodeurl@2.0.0: {} + encodeurl@2.0.0: + optional: true enquirer@2.4.1: dependencies: @@ -3389,15 +3323,18 @@ snapshots: environment@1.1.0: {} - es-define-property@1.0.1: {} + es-define-property@1.0.1: + optional: true - es-errors@1.3.0: {} + es-errors@1.3.0: + optional: true es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + optional: true es-toolkit@1.45.1: {} @@ -3432,7 +3369,8 @@ snapshots: escape-goat@4.0.0: {} - escape-html@1.0.3: {} + escape-html@1.0.3: + optional: true escape-string-regexp@2.0.0: {} @@ -3442,15 +3380,18 @@ snapshots: dependencies: '@types/estree': 1.0.8 - etag@1.8.1: {} + etag@1.8.1: + optional: true eventemitter3@5.0.1: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.0.6: + optional: true eventsource@3.0.7: dependencies: eventsource-parser: 3.0.6 + optional: true expect-type@1.3.0: {} @@ -3458,6 +3399,7 @@ snapshots: dependencies: express: 5.2.1 ip-address: 10.1.0 + optional: true express@5.2.1: dependencies: @@ -3491,6 +3433,7 @@ snapshots: vary: 1.1.2 transitivePeerDependencies: - supports-color + optional: true extendable-error@0.1.7: {} @@ -3528,6 +3471,7 @@ snapshots: statuses: 2.0.2 transitivePeerDependencies: - supports-color + optional: true find-up@4.1.0: dependencies: @@ -3540,9 +3484,11 @@ snapshots: mlly: 1.8.2 rollup: 4.60.1 - forwarded@0.2.0: {} + forwarded@0.2.0: + optional: true - fresh@2.0.0: {} + fresh@2.0.0: + optional: true fs-extra@7.0.1: dependencies: @@ -3559,7 +3505,8 @@ snapshots: fsevents@2.3.3: optional: true - function-bind@1.1.2: {} + function-bind@1.1.2: + optional: true get-caller-file@2.0.5: {} @@ -3577,11 +3524,13 @@ snapshots: has-symbols: 1.1.0 hasown: 2.0.2 math-intrinsics: 1.1.0 + optional: true get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + optional: true get-tsconfig@4.13.7: dependencies: @@ -3604,19 +3553,23 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - gopd@1.2.0: {} + gopd@1.2.0: + optional: true graceful-fs@4.2.10: {} graceful-fs@4.2.11: {} - has-symbols@1.1.0: {} + has-symbols@1.1.0: + optional: true hasown@2.0.2: dependencies: function-bind: 1.1.2 + optional: true - hono@4.12.12: {} + hono@4.12.12: + optional: true http-errors@2.0.1: dependencies: @@ -3625,6 +3578,7 @@ snapshots: setprototypeof: 1.2.0 statuses: 2.0.2 toidentifier: 1.0.1 + optional: true human-id@4.1.3: {} @@ -3634,22 +3588,28 @@ snapshots: ignore@5.3.2: {} - incur@0.3.19(openapi-types@12.1.3): + incur@0.3.25: dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) - '@readme/openapi-parser': 6.0.1(openapi-types@12.1.3) + '@cfworker/json-schema': 4.1.1 + '@modelcontextprotocol/server': 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) + '@toon-format/toon': 2.1.0 + tokenx: 1.3.0 + yaml: 2.8.3 + zod: 4.3.6 + + incur@0.4.1: + dependencies: + '@cfworker/json-schema': 4.1.1 + '@modelcontextprotocol/server': 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) '@toon-format/toon': 2.1.0 tokenx: 1.3.0 yaml: 2.8.3 zod: 4.3.6 - transitivePeerDependencies: - - '@cfworker/json-schema' - - openapi-types - - supports-color indent-string@5.0.0: {} - inherits@2.0.4: {} + inherits@2.0.4: + optional: true ini@1.3.8: {} @@ -3694,9 +3654,11 @@ snapshots: - bufferutil - utf-8-validate - ip-address@10.1.0: {} + ip-address@10.1.0: + optional: true - ipaddr.js@1.9.1: {} + ipaddr.js@1.9.1: + optional: true is-extglob@2.1.1: {} @@ -3725,7 +3687,8 @@ snapshots: is-path-inside@4.0.0: {} - is-promise@4.0.0: {} + is-promise@4.0.0: + optional: true is-subdir@1.2.0: dependencies: @@ -3739,7 +3702,8 @@ snapshots: dependencies: ws: 8.18.3 - jose@6.2.2: {} + jose@6.2.2: + optional: true joycon@3.1.1: {} @@ -3762,16 +3726,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonpointer@5.0.1: {} - ky@1.14.3: {} latest-version@9.0.0: dependencies: package-json: 10.0.1 - leven@3.1.0: {} - lightningcss-android-arm64@1.32.0: optional: true @@ -3841,11 +3801,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - math-intrinsics@1.1.0: {} + math-intrinsics@1.1.0: + optional: true - media-typer@1.1.0: {} + media-typer@1.1.0: + optional: true - merge-descriptors@2.0.0: {} + merge-descriptors@2.0.0: + optional: true merge2@1.4.1: {} @@ -3854,11 +3817,13 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mime-db@1.54.0: {} + mime-db@1.54.0: + optional: true mime-types@3.0.2: dependencies: mime-db: 1.54.0 + optional: true mimic-fn@2.1.0: {} @@ -3873,22 +3838,19 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 - mppx@0.5.7(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(express@5.2.1)(hono@4.12.12)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.10(typescript@5.9.3)(zod@4.3.6)): + mppx@0.5.7(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(express@5.2.1)(hono@4.12.12)(typescript@5.9.3)(viem@2.47.10(typescript@5.9.3)(zod@4.3.6)): dependencies: '@remix-run/fetch-proxy': 0.7.1 '@remix-run/node-fetch-server': 0.13.0 - incur: 0.3.19(openapi-types@12.1.3) + incur: 0.3.25 ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) viem: 2.47.10(typescript@5.9.3)(zod@4.3.6) zod: 4.3.6 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) express: 5.2.1 hono: 4.12.12 transitivePeerDependencies: - - '@cfworker/json-schema' - - openapi-types - - supports-color - typescript mri@1.2.0: {} @@ -3903,28 +3865,30 @@ snapshots: nanoid@3.3.11: {} - negotiator@1.0.0: {} + negotiator@1.0.0: + optional: true object-assign@4.1.1: {} - object-inspect@1.13.4: {} + object-inspect@1.13.4: + optional: true obug@2.1.1: {} on-finished@2.4.1: dependencies: ee-first: 1.1.1 + optional: true once@1.4.0: dependencies: wrappy: 1.0.2 + optional: true onetime@5.1.2: dependencies: mimic-fn: 2.1.0 - openapi-types@12.1.3: {} - outdent@0.5.0: {} ox@0.14.7(typescript@5.9.3)(zod@4.3.6): @@ -3969,7 +3933,8 @@ snapshots: dependencies: quansync: 0.2.11 - parseurl@1.3.3: {} + parseurl@1.3.3: + optional: true patch-console@2.0.0: {} @@ -3977,7 +3942,8 @@ snapshots: path-key@3.1.1: {} - path-to-regexp@8.4.2: {} + path-to-regexp@8.4.2: + optional: true path-type@4.0.0: {} @@ -3993,7 +3959,8 @@ snapshots: pirates@4.0.7: {} - pkce-challenge@5.0.1: {} + pkce-challenge@5.0.1: + optional: true pkg-types@1.3.1: dependencies: @@ -4003,15 +3970,15 @@ snapshots: pngjs@5.0.0: {} - postcss-load-config@6.0.1(postcss@8.5.10)(tsx@4.21.0)(yaml@2.8.3): + postcss-load-config@6.0.1(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: - postcss: 8.5.10 + postcss: 8.5.8 tsx: 4.21.0 yaml: 2.8.3 - postcss@8.5.10: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -4025,6 +3992,7 @@ snapshots: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + optional: true pupa@3.3.0: dependencies: @@ -4039,12 +4007,14 @@ snapshots: qs@6.15.0: dependencies: side-channel: 1.1.0 + optional: true quansync@0.2.11: {} queue-microtask@1.2.3: {} - range-parser@1.2.1: {} + range-parser@1.2.1: + optional: true raw-body@3.0.2: dependencies: @@ -4052,6 +4022,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 unpipe: 1.0.0 + optional: true rc@1.2.8: dependencies: @@ -4168,6 +4139,7 @@ snapshots: path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color + optional: true run-parallel@1.2.0: dependencies: @@ -4196,6 +4168,7 @@ snapshots: statuses: 2.0.2 transitivePeerDependencies: - supports-color + optional: true serve-static@2.2.1: dependencies: @@ -4205,10 +4178,12 @@ snapshots: send: 1.2.1 transitivePeerDependencies: - supports-color + optional: true set-blocking@2.0.0: {} - setprototypeof@1.2.0: {} + setprototypeof@1.2.0: + optional: true shebang-command@2.0.0: dependencies: @@ -4220,6 +4195,7 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 + optional: true side-channel-map@1.0.1: dependencies: @@ -4227,6 +4203,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 object-inspect: 1.13.4 + optional: true side-channel-weakmap@1.0.2: dependencies: @@ -4235,6 +4212,7 @@ snapshots: get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-map: 1.0.1 + optional: true side-channel@1.1.0: dependencies: @@ -4243,6 +4221,7 @@ snapshots: side-channel-list: 1.0.0 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + optional: true siginfo@2.0.0: {} @@ -4279,7 +4258,8 @@ snapshots: stackback@0.0.2: {} - statuses@2.0.2: {} + statuses@2.0.2: + optional: true std-env@4.0.0: {} @@ -4350,18 +4330,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyrainbow@3.1.0: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - toidentifier@1.0.1: {} + toidentifier@1.0.1: + optional: true tokenx@1.3.0: {} @@ -4372,7 +4348,7 @@ snapshots: tslib@2.8.1: optional: true - tsup@8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -4383,7 +4359,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.10)(tsx@4.21.0)(yaml@2.8.3) + postcss-load-config: 6.0.1(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.60.1 source-map: 0.7.6 @@ -4392,7 +4368,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.10 + postcss: 8.5.8 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -4425,6 +4401,7 @@ snapshots: content-type: 1.0.5 media-typer: 1.1.0 mime-types: 3.0.2 + optional: true typescript@5.9.3: {} @@ -4436,7 +4413,8 @@ snapshots: universalify@0.1.2: {} - unpipe@1.0.0: {} + unpipe@1.0.0: + optional: true update-notifier@7.3.1: dependencies: @@ -4451,7 +4429,8 @@ snapshots: semver: 7.7.4 xdg-basedir: 5.1.0 - vary@1.1.2: {} + vary@1.1.2: + optional: true viem@2.47.10(typescript@5.9.3)(zod@4.3.6): dependencies: @@ -4474,9 +4453,9 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.8 rolldown: 1.0.0-rc.10(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - tinyglobby: 0.2.16 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.19.15 esbuild: 0.27.4 @@ -4553,7 +4532,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - wrappy@1.0.2: {} + wrappy@1.0.2: + optional: true ws@8.18.3: {} @@ -4589,5 +4569,6 @@ snapshots: zod-to-json-schema@3.25.2(zod@4.3.6): dependencies: zod: 4.3.6 + optional: true zod@4.3.6: {} diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 513603c..9b53ce9 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -26,33 +26,28 @@ user-invocable: true # Creating Payment Credentials -Use the Link CLI to get secure, one-time-use payment credentials from a Link wallet to complete purchases. +Use Link to get secure, one-time-use payment credentials from a Link wallet to complete purchases. -## Installation +## Choosing how to call Link -Install the CLI with +Link CLI can run as an **MCP server** or as a **standalone CLI**. Always prefer the MCP server when available — it avoids shell parsing issues and is the intended integration path. -```bash -npm install -g @stripe/link-cli -``` - -Install the skill file with +1. **Check for the MCP server first.** Look for a `link-cli` MCP server in your active MCP connections. If present, call its tools directly (e.g. `auth_status`, `auth_login`, `spend-request_create`, `payment-methods_list`, `mpp_pay`, `mpp_decode`). +2. **Fall back to the CLI** only if the MCP server is not available. Install it with `npm install -g @stripe/link-cli`, then use the shell commands documented below. -```bash -npx skills add stripe/link-cli -``` +The rest of this document shows CLI commands. When using the MCP server, map each command to its corresponding MCP tool — the parameters and behavior are identical. -## Running commands +## Running commands (CLI fallback) -All commands support `--output-json` for machine-readable output. Use `--json` to pass structured input. Always run `link-cli --help` before running the command to see full schema details, including all fields, types, and constraints. +All commands support `--format json` for machine-readable output. Pass input via flags (run `link-cli --help` to see full schema details, including all fields, types, and constraints). IMPORTANT: Run `auth login`, `spend-request create`, and `spend-request request-approval` with `run_in_background=true` (or `TaskOutput(task_id, block: false)`). These commands emit JSON to stdout before they exit, then keep running while they poll for user action. The JSON stream contract for these long-running commands is: -- `auth login --output-json`: first object contains `verification_url` and `passphrase`; final object contains authentication result after approval succeeds -- `spend-request create --request-approval --output-json`: first object is the created spend request; final object is the terminal spend request after polling completes -- `spend-request request-approval --output-json`: first object contains the approval link; final object is the terminal spend request after polling completes +- `auth login --format json`: first object contains `verification_url` and `passphrase`; final object contains authentication result after approval succeeds +- `spend-request create --request-approval --format json`: first object is the created spend request; final object is the terminal spend request after polling completes +- `spend-request request-approval --format json`: first object contains the approval link; final object is the terminal spend request after polling completes Always keep reading stdout until the process exits. Do not assume the first JSON object is the full result. The user MUST visit the verification or approval URL to continue, and you should always show that full URL in clear text. @@ -71,7 +66,7 @@ Copy this checklist and track progress: Check auth status: ```bash -link-cli auth status --output-json +link-cli auth status --format json ``` If the response includes an `update` field, a newer version of `link-cli` is available — run the `update_command` from that field to upgrade before proceeding. @@ -79,7 +74,7 @@ If the response includes an `update` field, a newer version of `link-cli` is ava If not authenticated: ```bash -link-cli auth login --client-name "" --output-json +link-cli auth login --client-name "" --format json ``` Replace `` with the name of your agent or application (e.g. `"Personal Assistant", "Shopping Bot"`). This name appears in the user's Link app when they approve the connection. Use a clear, unique, identifiable name. Display the url and passphrase to the user, with the guidance "Please visit the following URL to approve secure access to Link.” @@ -114,7 +109,7 @@ What you find determines which credential type to use: To derive `network_id`, use Link CLI's challenge decoder: ```bash -link-cli mpp decode --challenge '' --output-json +link-cli mpp decode --challenge '' --format json ``` This validates the Stripe challenge, decodes the `request` payload, and returns both the extracted `network_id` and the decoded request JSON. Pass the full header exactly as received, even if it also contains non-Stripe or multiple `Payment` challenges. @@ -124,29 +119,35 @@ This validates the Stripe challenge, decodes the `request` payload, and returns Use the default payment method, unless the user explicitly asks to select a different one. ```bash -link-cli payment-methods list --output-json +link-cli payment-methods list --format json ``` ### Step 4: Create the spend request with the right credential type ```bash -link-cli spend-request create --json "{request}" --output-json +link-cli spend-request create \ + --payment-method-id \ + --amount \ + --context "" \ + --merchant-name "" \ + --merchant-url "" \ + --format json ``` Wait until the user has approved the spend request. If they deny, ask for clarification what to do next. Recommend the user approves with the [Link app](https://link.com/download). Show the download URL. -**Test mode:** Add `"test": true` to the JSON input (or `--test` flag) to create testmode credentials instead of real ones. Useful for development and integration testing. +**Test mode:** Add `--test` to create testmode credentials instead of real ones. Useful for development and integration testing. ### Step 5: Complete payment -**Card:** Run `link-cli spend-request retrieve --include card --output-json` 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. +**Card:** Run `link-cli spend-request retrieve --include card --format json` 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. **SPT with 402 flow:** The SPT is **one-time use** — if the payment fails, you need a new spend request and new SPT. ```bash -link-cli mpp pay --spend-request-id [--method POST] [--data '{"amount":100}'] [--header 'Name: Value'] --output-json +link-cli mpp pay --spend-request-id [--method POST] [--data '{"amount":100}'] [--header 'Name: Value'] --format json ``` `mpp pay` handles the full 402 flow automatically: probes the URL, parses the `www-authenticate` header, builds the `Authorization: Payment` credential using the SPT, and retries. @@ -161,7 +162,7 @@ link-cli mpp pay --spend-request-id [--method POST] [--data '{"amount ## Errors -All errors go to stderr as `{"error": "..."}` with exit code 1. +All errors are output as JSON with `code` and `message` fields, with exit code 1. ### Common errors and recovery