From c3d2d2c1788798994d3f7219e65082dc1be3a865 Mon Sep 17 00:00:00 2001 From: Kyle Reese Date: Wed, 6 May 2026 13:55:17 -0400 Subject: [PATCH 1/3] support polling straight from auth login --- CLAUDE.md | 1 + README.md | 3 + packages/cli/src/__tests__/cli.test.ts | 75 +++++++++++++++++++++ packages/cli/src/commands/auth/index.tsx | 81 ++++++++++++++++++++--- packages/cli/src/commands/auth/schema.ts | 14 ++++ skills/create-payment-credential/SKILL.md | 4 +- 6 files changed, 169 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b01a5d..5690939 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ Input is passed via flags. Define options in the command's zod schema — incur ### 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 `loginOptions` in `packages/cli/src/commands/auth/schema.ts`. +- `auth login --interval [--timeout ] [--max-attempts ]` — when `--interval` is provided, the command yields the verification code immediately then polls inline until authenticated or timed out. Without `--interval`, returns the code with a `_next` hint for separate polling via `auth status`. ### spend-request command diff --git a/README.md b/README.md index 3edbe85..79afcfc 100644 --- a/README.md +++ b/README.md @@ -173,12 +173,15 @@ link-cli mpp pay https://climate.stripe.dev/api/contribute \ ```bash link-cli auth login --client-name "Claude Code" # identify the connecting agent +link-cli auth login --client-name "Claude Code" --interval 5 --timeout 300 # login + poll in one call link-cli auth status # check auth status link-cli auth logout # disconnect ``` When you provide `--client-name`, the Link app displays it when you approve the connection — for example, `Claude Code on my-macbook` instead of `link-cli on my-macbook`. +With `--interval`, the login command yields the verification code immediately and then polls inline until authenticated or timed out — no separate `auth status` call needed. This is recommended for agents that cannot relay the code while a separate polling command blocks their I/O channel. + `auth status` includes an `update` field when a newer version is available: ```json diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 60f70ed..b2f2f09 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -905,6 +905,81 @@ describe('production mode', () => { expect(next.command).toContain('auth status'); expect(next.until).toContain('authenticated'); }); + + it('with --interval, yields code first then polls until authenticated', async () => { + storage.clearAuth(); + setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); + setResponseForUrl('/device/token', 200, TOKEN_RESPONSE); + + const result = await runProdCli( + 'auth', + 'login', + '--client-name', + 'Polling Agent', + '--interval', + '1', + '--timeout', + '10', + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = parseJson(result.stdout) as Record[]; + expect(output.length).toBe(2); + expect(output[0].verification_url).toBe( + 'https://app.link.com/device/setup?code=apple-grape', + ); + expect(output[0].phrase).toBe('apple-grape'); + expect(output[0]._next).toBeUndefined(); + expect(output[1].authenticated).toBe(true); + expect(output[1].token_type).toBe('Bearer'); + }); + + it('with --interval, exits with POLLING_TIMEOUT when approval never comes', async () => { + storage.clearAuth(); + setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); + setResponseForUrl('/device/token', 400, { + error: 'authorization_pending', + }); + + const result = await runProdCli( + 'auth', + 'login', + '--client-name', + 'Timeout Agent', + '--interval', + '1', + '--timeout', + '2', + '--json', + ); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('POLLING_TIMEOUT'); + }); + + it('with --interval, exits with AUTH_FAILED on access_denied', async () => { + storage.clearAuth(); + setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); + setResponseForUrl('/device/token', 400, { error: 'access_denied' }); + + const result = await runProdCli( + 'auth', + 'login', + '--client-name', + 'Denied Agent', + '--interval', + '1', + '--timeout', + '5', + '--json', + ); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('AUTH_FAILED'); + }); }); describe('auth logout', () => { diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 7dae3f9..37fa9b9 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -45,8 +45,7 @@ export function createAuthCli( ); } - // Agent mode: initiate device auth, store pending state, return immediately. - // The agent drives the polling loop via `auth status --interval`. + // Agent mode: initiate device auth, store pending state, yield code immediately. const authRequest = await authResource.initiateDeviceAuth(clientName); storage.setPendingDeviceAuth({ device_code: authRequest.device_code, @@ -55,17 +54,83 @@ export function createAuthCli( verification_url: authRequest.verification_url_complete, phrase: authRequest.user_code, }); + + const interval = c.options.interval; + const maxAttempts = c.options.maxAttempts; + + if (interval <= 0) { + // No polling requested: return code with _next hint (original behavior). + yield { + verification_url: authRequest.verification_url_complete, + phrase: 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', + }, + }; + return; + } + + // Inline polling: emit code to stderr (visible immediately even while + // stdout is buffered), then yield it as structured output for MCP streaming. + process.stderr.write( + `\nVerification URL: ${authRequest.verification_url_complete}\nPhrase: ${authRequest.user_code}\n\nOpen the URL, log in to Link, and enter the phrase to approve.\nPolling for approval...\n\n`, + ); yield { verification_url: authRequest.verification_url_complete, phrase: 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', - }, + 'Present the verification_url to the user and ask them to approve in the Link app. Polling has started automatically — no further action needed.', }; + + const deadline = Date.now() + c.options.timeout * 1000; + let attempts = 0; + + while (true) { + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + + const pending = storage.getPendingDeviceAuth(); + if (!pending) { + return c.error({ + code: 'AUTH_EXPIRED', + message: + 'Device authorization expired. Please run auth login again.', + }); + } + + try { + const tokens = await authResource.pollDeviceAuth(pending.device_code); + if (tokens) { + storage.setAuth(tokens); + storage.clearPendingDeviceAuth(); + yield { + authenticated: true, + token_type: tokens.token_type, + credentials_path: storage.getPath(), + }; + return; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return c.error({ code: 'AUTH_FAILED', message }); + } + + attempts++; + const shouldStop = + (maxAttempts > 0 && attempts >= maxAttempts) || + Date.now() >= deadline; + + if (shouldStop) { + return c.error({ + code: 'POLLING_TIMEOUT', + message: + 'Timed out waiting for user approval. The verification code may have expired — run auth login again to get a new one.', + }); + } + } }, }); diff --git a/packages/cli/src/commands/auth/schema.ts b/packages/cli/src/commands/auth/schema.ts index cb11f37..82b4025 100644 --- a/packages/cli/src/commands/auth/schema.ts +++ b/packages/cli/src/commands/auth/schema.ts @@ -7,6 +7,20 @@ export const loginOptions = z.object({ .describe( 'Agent or app name shown in the Link app when approving the device connection', ), + 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.'), }); export const statusOptions = z.object({ diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 11f0f2c..3e95dfa 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -92,11 +92,13 @@ 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 "" +link-cli auth login --client-name "" --interval 5 --timeout 300 ``` Replace `` with the name of your agent or application (for example, `"Personal Assistant"`, `"Shopping Bot"`). This name appears in the user's Link app when they approve the connection. Use a clear, unique, identifiable name. +With `--interval 5 --timeout 300`, the command yields the verification code immediately (present it to the user right away), then polls inline until authenticated or timed out. No separate `auth status` call is needed. + DO NOT PROCEED until the user is authenticated with Link. Always check the current authentication status before starting a new login flow — the user might already be logged in. From 5b0f38896af7700b86553ea5a7c902bee85a4c1b Mon Sep 17 00:00:00 2001 From: Kyle Reese Date: Tue, 12 May 2026 13:19:12 -0400 Subject: [PATCH 2/3] improvements --- packages/cli/src/commands/auth/index.tsx | 92 +++++++++++++---------- skills/create-payment-credential/SKILL.md | 4 +- 2 files changed, 53 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 37fa9b9..55d16d1 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -74,11 +74,18 @@ export function createAuthCli( return; } - // Inline polling: emit code to stderr (visible immediately even while - // stdout is buffered), then yield it as structured output for MCP streaming. - process.stderr.write( - `\nVerification URL: ${authRequest.verification_url_complete}\nPhrase: ${authRequest.user_code}\n\nOpen the URL, log in to Link, and enter the phrase to approve.\nPolling for approval...\n\n`, - ); + // Inline polling: emit code to stderr immediately (stdout is buffered + // until command exits), then poll using the shared pollUntil utility. + if (c.format === 'json') { + process.stderr.write( + `${JSON.stringify({ verification_url: authRequest.verification_url_complete, phrase: authRequest.user_code })}\n`, + ); + } else { + process.stderr.write( + `\nVerification URL: ${authRequest.verification_url_complete}\nPhrase: ${authRequest.user_code}\n\nOpen the URL, log in to Link, and enter the phrase to approve.\nPolling for approval...\n\n`, + ); + } + yield { verification_url: authRequest.verification_url_complete, phrase: authRequest.user_code, @@ -86,50 +93,53 @@ export function createAuthCli( 'Present the verification_url to the user and ask them to approve in the Link app. Polling has started automatically — no further action needed.', }; - const deadline = Date.now() + c.options.timeout * 1000; - let attempts = 0; - - while (true) { - await new Promise((resolve) => setTimeout(resolve, interval * 1000)); - - const pending = storage.getPendingDeviceAuth(); - if (!pending) { - return c.error({ - code: 'AUTH_EXPIRED', - message: - 'Device authorization expired. Please run auth login again.', - }); - } + try { + for await (const result of pollUntil({ + fn: async () => { + const pending = storage.getPendingDeviceAuth(); + if (!pending) { + throw new Error( + 'Device authorization expired. Please run auth login again.', + ); + } - try { - const tokens = await authResource.pollDeviceAuth(pending.device_code); - if (tokens) { - storage.setAuth(tokens); - storage.clearPendingDeviceAuth(); + const tokens = await authResource.pollDeviceAuth( + pending.device_code, + ); + if (tokens) { + storage.setAuth(tokens); + storage.clearPendingDeviceAuth(); + return { + authenticated: true as const, + token_type: tokens.token_type, + }; + } + return { authenticated: false as const }; + }, + isTerminal: (status) => status.authenticated, + interval, + maxAttempts, + timeout: c.options.timeout, + })) { + if (result.terminal && result.value.authenticated) { yield { authenticated: true, - token_type: tokens.token_type, + token_type: result.value.token_type, credentials_path: storage.getPath(), }; return; } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return c.error({ code: 'AUTH_FAILED', message }); - } - - attempts++; - const shouldStop = - (maxAttempts > 0 && attempts >= maxAttempts) || - Date.now() >= deadline; - - if (shouldStop) { - return c.error({ - code: 'POLLING_TIMEOUT', - message: - 'Timed out waiting for user approval. The verification code may have expired — run auth login again to get a new one.', - }); + if (result.terminal) { + return c.error({ + code: 'POLLING_TIMEOUT', + message: + 'Timed out waiting for user approval. The verification code may have expired — run auth login again to get a new one.', + }); + } } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return c.error({ code: 'AUTH_FAILED', message }); } }, }); diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 3e95dfa..da9c784 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -92,12 +92,12 @@ 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 "" --interval 5 --timeout 300 +link-cli auth login --client-name "" ``` Replace `` with the name of your agent or application (for example, `"Personal Assistant"`, `"Shopping Bot"`). This name appears in the user's Link app when they approve the connection. Use a clear, unique, identifiable name. -With `--interval 5 --timeout 300`, the command yields the verification code immediately (present it to the user right away), then polls inline until authenticated or timed out. No separate `auth status` call is needed. +The response includes a `_next` command — run it to poll until authenticated. If your environment cannot relay the verification code while a separate polling command blocks I/O, use inline polling instead: `auth login --client-name "" --interval 5 --timeout 300`. This yields the code immediately then polls in the same command. DO NOT PROCEED until the user is authenticated with Link. From 589b444acb02be5bc5ac0036ba2cd2460335a523 Mon Sep 17 00:00:00 2001 From: Kyle Reese Date: Thu, 21 May 2026 11:22:03 -0400 Subject: [PATCH 3/3] sanitize output; make the polling into a shared helper and share across the auth login polling and the auth status polling --- packages/cli/src/__tests__/cli.test.ts | 13 +- packages/cli/src/commands/auth/index.tsx | 192 +++++++++------------ packages/cli/src/utils/resource-factory.ts | 10 +- 3 files changed, 93 insertions(+), 122 deletions(-) diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index b2f2f09..d7c5a47 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -935,7 +935,7 @@ describe('production mode', () => { expect(output[1].token_type).toBe('Bearer'); }); - it('with --interval, exits with POLLING_TIMEOUT when approval never comes', async () => { + it('with --interval, yields unauthenticated status on timeout (exit 0)', async () => { storage.clearAuth(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); setResponseForUrl('/device/token', 400, { @@ -954,12 +954,13 @@ describe('production mode', () => { '--json', ); - expect(result.exitCode).toBe(1); - const output = result.stdout + result.stderr; - expect(output).toContain('POLLING_TIMEOUT'); + expect(result.exitCode).toBe(0); + const output = parseJson(result.stdout) as Record[]; + const last = output[output.length - 1]; + expect(last.authenticated).toBe(false); }); - it('with --interval, exits with AUTH_FAILED on access_denied', async () => { + it('with --interval, exits with error on access_denied', async () => { storage.clearAuth(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); setResponseForUrl('/device/token', 400, { error: 'access_denied' }); @@ -978,7 +979,7 @@ describe('production mode', () => { expect(result.exitCode).toBe(1); const output = result.stdout + result.stderr; - expect(output).toContain('AUTH_FAILED'); + expect(output).toContain('denied'); }); }); diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 55d16d1..771f0be 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -4,12 +4,74 @@ import React from 'react'; import type { IAuthResource } from '../../auth/types'; import { pollUntil } from '../../utils/poll-until'; import { renderInteractive } from '../../utils/render-interactive'; +import { sanitizeDeep } from '../../utils/sanitize-text'; import type { UpdateInfoProvider } from '../../utils/update-info'; import { Login } from './login'; import { Logout } from './logout'; import { loginOptions, statusOptions } from './schema'; import { AuthStatus } from './status'; +interface PollAuthOptions { + interval: number; + maxAttempts: number; + timeout: number; +} + +async function* pollAuthStatus( + authResource: IAuthResource, + storage: AuthStorage, + opts: PollAuthOptions, + update?: { + current_version: string; + latest_version: string; + update_command: string; + }, +) { + for await (const result of pollUntil({ + fn: async () => { + const pending = storage.getPendingDeviceAuth(); + if (pending && !storage.isAuthenticated()) { + const tokens = await authResource.pollDeviceAuth(pending.device_code); + if (tokens) { + storage.setAuth(tokens); + storage.clearPendingDeviceAuth(); + } + } + + const auth = storage.getAuth(); + if (auth) { + return { + authenticated: true as const, + access_token: `${auth.access_token.substring(0, 20)}...`, + token_type: auth.token_type, + credentials_path: storage.getPath(), + ...(update && { update }), + }; + } + + const currentPending = storage.getPendingDeviceAuth(); + return { + authenticated: false as const, + credentials_path: storage.getPath(), + ...(update && { update }), + ...(currentPending + ? { + pending: true, + verification_url: currentPending.verification_url, + phrase: currentPending.phrase, + } + : {}), + }; + }, + isTerminal: (status) => status.authenticated, + interval: opts.interval, + maxAttempts: opts.maxAttempts, + timeout: opts.timeout, + })) { + yield result.value; + } +} + export function createAuthCli( authResource: IAuthResource, getUpdateInfo?: UpdateInfoProvider, @@ -45,7 +107,6 @@ export function createAuthCli( ); } - // Agent mode: initiate device auth, store pending state, yield code immediately. const authRequest = await authResource.initiateDeviceAuth(clientName); storage.setPendingDeviceAuth({ device_code: authRequest.device_code, @@ -56,11 +117,9 @@ export function createAuthCli( }); const interval = c.options.interval; - const maxAttempts = c.options.maxAttempts; if (interval <= 0) { - // No polling requested: return code with _next hint (original behavior). - yield { + yield sanitizeDeep({ verification_url: authRequest.verification_url_complete, phrase: authRequest.user_code, instruction: @@ -70,77 +129,22 @@ export function createAuthCli( poll_interval_seconds: authRequest.interval, until: 'authenticated is true', }, - }; + }); return; } - // Inline polling: emit code to stderr immediately (stdout is buffered - // until command exits), then poll using the shared pollUntil utility. - if (c.format === 'json') { - process.stderr.write( - `${JSON.stringify({ verification_url: authRequest.verification_url_complete, phrase: authRequest.user_code })}\n`, - ); - } else { - process.stderr.write( - `\nVerification URL: ${authRequest.verification_url_complete}\nPhrase: ${authRequest.user_code}\n\nOpen the URL, log in to Link, and enter the phrase to approve.\nPolling for approval...\n\n`, - ); - } - - yield { + yield sanitizeDeep({ verification_url: authRequest.verification_url_complete, phrase: authRequest.user_code, instruction: 'Present the verification_url to the user and ask them to approve in the Link app. Polling has started automatically — no further action needed.', - }; - - try { - for await (const result of pollUntil({ - fn: async () => { - const pending = storage.getPendingDeviceAuth(); - if (!pending) { - throw new Error( - 'Device authorization expired. Please run auth login again.', - ); - } + }); - const tokens = await authResource.pollDeviceAuth( - pending.device_code, - ); - if (tokens) { - storage.setAuth(tokens); - storage.clearPendingDeviceAuth(); - return { - authenticated: true as const, - token_type: tokens.token_type, - }; - } - return { authenticated: false as const }; - }, - isTerminal: (status) => status.authenticated, - interval, - maxAttempts, - timeout: c.options.timeout, - })) { - if (result.terminal && result.value.authenticated) { - yield { - authenticated: true, - token_type: result.value.token_type, - credentials_path: storage.getPath(), - }; - return; - } - if (result.terminal) { - return c.error({ - code: 'POLLING_TIMEOUT', - message: - 'Timed out waiting for user approval. The verification code may have expired — run auth login again to get a new one.', - }); - } - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return c.error({ code: 'AUTH_FAILED', message }); - } + yield* pollAuthStatus(authResource, storage, { + interval, + maxAttempts: c.options.maxAttempts, + timeout: c.options.timeout, + }); }, }); @@ -215,52 +219,16 @@ export function createAuthCli( ); } - for await (const result of pollUntil({ - fn: async () => { - // 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(); - } - } - - const auth = storage.getAuth(); - if (auth) { - return { - authenticated: true as const, - access_token: `${auth.access_token.substring(0, 20)}...`, - token_type: auth.token_type, - credentials_path: storage.getPath(), - ...(update && { update }), - }; - } - - const currentPending = storage.getPendingDeviceAuth(); - return { - authenticated: false as const, - credentials_path: storage.getPath(), - ...(update && { update }), - ...(currentPending - ? { - pending: true, - verification_url: currentPending.verification_url, - phrase: currentPending.phrase, - } - : {}), - }; + yield* pollAuthStatus( + authResource, + storage, + { + interval, + maxAttempts, + timeout: opts.timeout, }, - isTerminal: (status) => status.authenticated, - interval, - maxAttempts, - timeout: opts.timeout, - })) { - yield result.value; - } + update, + ); }, }); diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 7b9d299..8ba5b17 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -82,10 +82,12 @@ export class ResourceFactory { return this.authResource; } - this.authResource = new LinkAuthResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - }); + this.authResource = sanitizeResource( + new LinkAuthResource({ + verbose: this.verbose, + defaultHeaders: this.defaultHeaders, + }), + ); return this.authResource; }