From 43ba38325db666af3ce0ef185cba7ed9c7b25452 Mon Sep 17 00:00:00 2001 From: Setup User Date: Wed, 20 May 2026 16:54:38 -0400 Subject: [PATCH 1/4] feat: add spend-request list command Implements `spend-request list` subcommand that calls GET /spend_requests, unwraps the response, and renders active spend requests grouped by status with color coding. Includes SDK interface update, implementation, Ink UI component, subcommand registration, and unit/integration tests. Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- packages/cli/src/__tests__/cli.test.ts | 70 +++++++++++++++++ .../cli/src/commands/spend-request/index.tsx | 17 ++++ .../cli/src/commands/spend-request/list.tsx | 77 +++++++++++++++++++ .../resources/__tests__/spend-request.test.ts | 55 +++++++++++++ packages/sdk/src/resources/interfaces.ts | 2 + packages/sdk/src/resources/spend-request.ts | 22 ++++++ 6 files changed, 243 insertions(+) create mode 100644 packages/cli/src/commands/spend-request/list.tsx diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 60f70ed..debe88f 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -599,6 +599,76 @@ describe('production mode', () => { }); }); + describe('spend-request list', () => { + it('sends GET to /spend_requests and returns the API response as JSON output', async () => { + const requests_list = [ + { ...BASE_REQUEST, id: 'lsrq_001', status: 'approved' }, + { ...BASE_REQUEST, id: 'lsrq_002', status: 'pending_approval' }, + { ...BASE_REQUEST, id: 'lsrq_003', status: 'created' }, + ]; + setNextResponse(200, { data: requests_list }); + + const result = await runProdCli('spend-request', 'list', '--json'); + + expect(result.exitCode).toBe(0); + expect(lastRequest.method).toBe('GET'); + expect(lastRequest.url).toBe('/spend_requests'); + expect(lastRequest.headers.authorization).toBe( + 'Bearer prod_test_access_token', + ); + const output = parseJson(result.stdout) as unknown[]; + expect(output).toHaveLength(3); + expect((output[0] as Record).id).toBe('lsrq_001'); + expect((output[0] as Record).status).toBe('approved'); + }); + + it('returns an empty array when there are no active spend requests', async () => { + setNextResponse(200, { data: [] }); + + const result = await runProdCli('spend-request', 'list', '--json'); + + expect(result.exitCode).toBe(0); + const output = parseJson(result.stdout) as unknown[]; + expect(output).toHaveLength(0); + }); + + it('surfaces API errors for list', async () => { + setNextResponse(403, { error: { message: 'Forbidden' } }); + + const result = await runProdCli('spend-request', 'list', '--json'); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('Forbidden'); + }); + + it('exits non-zero with auth error when not logged in', async () => { + storage.clearAll(); + + const result = await runProdCli('spend-request', 'list', '--json'); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not logged in|auth|login|token/i); + }); + + it('unwraps data envelope so output is a flat array, not an object with a data key', async () => { + const requests_list = [ + { ...BASE_REQUEST, id: 'lsrq_flat_001', status: 'approved' }, + ]; + setNextResponse(200, { data: requests_list }); + + const result = await runProdCli('spend-request', 'list', '--json'); + + expect(result.exitCode).toBe(0); + const output = parseJson(result.stdout); + expect(Array.isArray(output)).toBe(true); + const arr = output as Record[]; + expect(arr[0].id).toBe('lsrq_flat_001'); + expect((output as Record).data).toBeUndefined(); + }); + }); + describe('spend-request retrieve', () => { it('sends GET to /spend-requests/:id', async () => { const result = await runProdCli( diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 1ba14dc..02d3430 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -18,6 +18,7 @@ import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth, requireAuthGuard } from '../../utils/require-auth'; import { CancelSpendRequest } from './cancel'; import { CreateSpendRequest } from './create'; +import { SpendRequestList } from './list'; import { RequestApproval } from './request-approval'; import { RetrieveSpendRequest } from './retrieve'; import { createOptions, retrieveOptions, updateOptions } from './schema'; @@ -54,6 +55,22 @@ export function createSpendRequestCli( description: 'Spend request management commands', }); + cli.command('list', { + description: 'List active spend requests (created, pending_approval, approved)', + outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage)], + async run(c) { + if (!c.agent && !c.formatExplicit) { + return renderInteractive( + {}} />, + () => repository.listSpendRequests(), + ); + } + + return repository.listSpendRequests(); + }, + }); + cli.command('create', { description: 'Create a new spend request', options: createOptions, diff --git a/packages/cli/src/commands/spend-request/list.tsx b/packages/cli/src/commands/spend-request/list.tsx new file mode 100644 index 0000000..5433054 --- /dev/null +++ b/packages/cli/src/commands/spend-request/list.tsx @@ -0,0 +1,77 @@ +import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback } from 'react'; +import { useAsyncAction } from '../../hooks/use-async-action'; + +interface SpendRequestListProps { + repository: ISpendRequestResource; + onComplete: (result: SpendRequest[]) => void; +} + +export const SpendRequestList: React.FC = ({ + repository, + onComplete, +}) => { + const action = useCallback(() => repository.listSpendRequests(), [repository]); + const { status, data: requests, error } = useAsyncAction(action, onComplete); + + if (status === 'loading') { + return ( + + + Loading spend requests... + + + ); + } + + if (status === 'error') { + return ( + + ✗ Failed to load spend requests + {error} + + ); + } + + if (!requests || requests.length === 0) { + return ( + + No active spend requests found + + ); + } + + return ( + + Active Spend Requests + + {requests.map((sr) => { + const statusColor = + sr.status === 'approved' + ? 'green' + : sr.status === 'pending_approval' + ? 'yellow' + : 'white'; + const amount = + sr.amount != null + ? `$${(sr.amount / 100).toFixed(2)} ${(sr.currency ?? 'usd').toUpperCase()}` + : ''; + return ( + + + {sr.id} + {' '} + {sr.status} + {sr.merchant_name ? ` ${sr.merchant_name}` : ''} + {amount ? ` ${amount}` : ''} + + + ); + })} + + + ); +}; diff --git a/packages/sdk/src/resources/__tests__/spend-request.test.ts b/packages/sdk/src/resources/__tests__/spend-request.test.ts index cc82490..33f3fad 100644 --- a/packages/sdk/src/resources/__tests__/spend-request.test.ts +++ b/packages/sdk/src/resources/__tests__/spend-request.test.ts @@ -415,4 +415,59 @@ describe('SpendRequestResource', () => { ); }); }); + + describe('listSpendRequests', () => { + it('sends GET to correct endpoint with Bearer auth header', async () => { + mockFetchResponse(200, { data: [spendRequestResponse] }); + + await repo.listSpendRequests(); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.link.com/spend_requests'); + expect(opts.method).toBe('GET'); + expect(opts.headers.Authorization).toBe('Bearer test_token'); + expect(opts.body).toBeUndefined(); + }); + + it('returns array of SpendRequests unwrapped from data envelope', async () => { + const requests = [ + { ...spendRequestResponse, id: 'si_001', status: 'approved' }, + { ...spendRequestResponse, id: 'si_002', status: 'pending_approval' }, + { ...spendRequestResponse, id: 'si_003', status: 'created' }, + ]; + mockFetchResponse(200, { data: requests }); + + const result = await repo.listSpendRequests(); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('si_001'); + expect(result[1].id).toBe('si_002'); + expect(result[2].id).toBe('si_003'); + }); + + it('returns empty array when no active spend requests', async () => { + mockFetchResponse(200, { data: [] }); + + const result = await repo.listSpendRequests(); + + expect(result).toEqual([]); + }); + + it('throws on HTTP error with message from body', async () => { + mockFetchResponse(403, { error: { message: 'Forbidden' } }); + + await expect(repo.listSpendRequests()).rejects.toThrow( + 'Failed to list spend requests (403): Forbidden', + ); + }); + + it('throws when no access token is available', async () => { + getAccessToken.mockRejectedValueOnce(new Error('Missing access token')); + + await expect(repo.listSpendRequests()).rejects.toThrow( + 'Missing access token', + ); + }); + }); }); diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 48c0f96..be026db 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -53,6 +53,8 @@ export interface UpdateSpendRequestParams { } export interface ISpendRequestResource { + list(): Promise; + listSpendRequests(): Promise; create(params: CreateSpendRequestParams): Promise; createSpendRequest(params: CreateSpendRequestParams): Promise; update(id: string, params: UpdateSpendRequestParams): Promise; diff --git a/packages/sdk/src/resources/spend-request.ts b/packages/sdk/src/resources/spend-request.ts index 3a754ec..eb43044 100644 --- a/packages/sdk/src/resources/spend-request.ts +++ b/packages/sdk/src/resources/spend-request.ts @@ -143,6 +143,28 @@ export class SpendRequestResource implements ISpendRequestResource { return res; } + list(): Promise { + return this.listSpendRequests(); + } + + async listSpendRequests(): Promise { + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: this.spendRequestsEndpoint, + }); + + if (status < 200 || status >= 300) { + throw new LinkApiError( + `Failed to list spend requests (${status}): ${extractApiError(data, rawBody)}`, + { status, rawBody, details: data }, + ); + } + + const body = data as Record | null; + const items = (body?.data as unknown[] | undefined) ?? []; + return items.map(normalizeSpendRequest); + } + create(params: CreateSpendRequestParams): Promise { return this.createSpendRequest(params); } From 3c70c0052e83bc71ee8438e93012418d7ad05239 Mon Sep 17 00:00:00 2001 From: Setup User Date: Wed, 20 May 2026 17:23:28 -0400 Subject: [PATCH 2/4] fix: correct onComplete type in SpendRequestList to accept null Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- packages/cli/src/commands/spend-request/list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/spend-request/list.tsx b/packages/cli/src/commands/spend-request/list.tsx index 5433054..29dc46b 100644 --- a/packages/cli/src/commands/spend-request/list.tsx +++ b/packages/cli/src/commands/spend-request/list.tsx @@ -7,7 +7,7 @@ import { useAsyncAction } from '../../hooks/use-async-action'; interface SpendRequestListProps { repository: ISpendRequestResource; - onComplete: (result: SpendRequest[]) => void; + onComplete: (result: SpendRequest[] | null) => void; } export const SpendRequestList: React.FC = ({ From 9c2b2e9f8920b5b8e27fb60ad925f773901b9f5b Mon Sep 17 00:00:00 2001 From: Setup User Date: Wed, 20 May 2026 17:44:37 -0400 Subject: [PATCH 3/4] fix: apply biome formatting to list command files Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- packages/cli/src/commands/spend-request/index.tsx | 3 ++- packages/cli/src/commands/spend-request/list.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 02d3430..adfc779 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -56,7 +56,8 @@ export function createSpendRequestCli( }); cli.command('list', { - description: 'List active spend requests (created, pending_approval, approved)', + description: + 'List active spend requests (created, pending_approval, approved)', outputPolicy: 'agent-only' as const, middleware: [requireAuth(authStorage)], async run(c) { diff --git a/packages/cli/src/commands/spend-request/list.tsx b/packages/cli/src/commands/spend-request/list.tsx index 29dc46b..5333497 100644 --- a/packages/cli/src/commands/spend-request/list.tsx +++ b/packages/cli/src/commands/spend-request/list.tsx @@ -14,7 +14,10 @@ export const SpendRequestList: React.FC = ({ repository, onComplete, }) => { - const action = useCallback(() => repository.listSpendRequests(), [repository]); + const action = useCallback( + () => repository.listSpendRequests(), + [repository], + ); const { status, data: requests, error } = useAsyncAction(action, onComplete); if (status === 'loading') { From e7a2653a36816b7023d546e1ca31552a9e7ac9c6 Mon Sep 17 00:00:00 2001 From: Setup User Date: Thu, 21 May 2026 20:52:54 -0400 Subject: [PATCH 4/4] fix: call exit() after list completes so interactive mode terminates Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- packages/cli/src/commands/spend-request/list.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/spend-request/list.tsx b/packages/cli/src/commands/spend-request/list.tsx index 5333497..74b3b5b 100644 --- a/packages/cli/src/commands/spend-request/list.tsx +++ b/packages/cli/src/commands/spend-request/list.tsx @@ -1,5 +1,5 @@ import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; -import { Box, Text } from 'ink'; +import { Box, Text, useApp } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback } from 'react'; @@ -14,11 +14,22 @@ export const SpendRequestList: React.FC = ({ repository, onComplete, }) => { + const { exit } = useApp(); const action = useCallback( () => repository.listSpendRequests(), [repository], ); - const { status, data: requests, error } = useAsyncAction(action, onComplete); + const wrappedOnComplete = useCallback( + (result: SpendRequest[] | null) => { + onComplete(result); + exit(); + }, + [onComplete, exit], + ); + const { status, data: requests, error } = useAsyncAction( + action, + wrappedOnComplete, + ); if (status === 'loading') { return (