diff --git a/CLAUDE.md b/CLAUDE.md index 46faa33..f6d1ba2 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 aa9e053..d43befd 100644 --- a/README.md +++ b/README.md @@ -165,12 +165,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 ac006a6..8f5d30d 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -787,6 +787,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 1e694ee..3b2fc17 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 e5ffcca..ab66412 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -90,11 +90,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.