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..d7c5a47 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -905,6 +905,82 @@ 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, yields unauthenticated status on timeout (exit 0)', 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(0); + const output = parseJson(result.stdout) as Record[]; + const last = output[output.length - 1]; + expect(last.authenticated).toBe(false); + }); + + 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' }); + + 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('denied'); + }); }); describe('auth logout', () => { diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 7dae3f9..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,8 +107,6 @@ export function createAuthCli( ); } - // 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, @@ -55,17 +115,36 @@ export function createAuthCli( verification_url: authRequest.verification_url_complete, phrase: authRequest.user_code, }); - yield { + + const interval = c.options.interval; + + if (interval <= 0) { + 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. 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; + } + + 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. 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.', + }); + + yield* pollAuthStatus(authResource, storage, { + interval, + maxAttempts: c.options.maxAttempts, + timeout: c.options.timeout, + }); }, }); @@ -140,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/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/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; } diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 11f0f2c..da9c784 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -97,6 +97,8 @@ 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. +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. Always check the current authentication status before starting a new login flow — the user might already be logged in.