Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Input is passed via flags. Define options in the command's zod schema — incur
### auth login

- `auth login --client-name <name>` — optional flag to identify the agent or app; shown in the user's Link app as `<name> on <hostname>`. Defined in `loginOptions` in `packages/cli/src/commands/auth/schema.ts`.
- `auth login --interval <seconds> [--timeout <seconds>] [--max-attempts <n>]` — 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

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[];
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<string, unknown>[];
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', () => {
Expand Down
153 changes: 98 additions & 55 deletions packages/cli/src/commands/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the same code that was used for auth status as well, just abstracting it into a shared function so the login polling can do the same

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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
},
});

Expand Down Expand Up @@ -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,
);
},
});

Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/auth/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/utils/resource-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions skills/create-payment-credential/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ link-cli auth login --client-name "<your-agent-name>"

Replace `<your-agent-name>` 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 "<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.
Expand Down
Loading