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

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
75 changes: 75 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,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<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, 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', () => {
Expand Down
91 changes: 83 additions & 8 deletions packages/cli/src/commands/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -55,17 +54,93 @@ 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 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`,
);
Comment on lines +79 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think you need this? incur should handle the output format for you, you just need to return the object

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

or yield the object

}

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.',
};

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 });
}
},
});

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
4 changes: 3 additions & 1 deletion skills/create-payment-credential/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
version: 0.4.3
version: 0.5.0
name: create-payment-credential
description: |
Gets secure, one-time-use payment credentials (cards, tokens) from a Link wallet so agents can complete purchases on behalf of users. Use when the user says "get me a card", "buy something", "pay for X", "make a purchase", "I need to pay", "complete checkout", or asks to transact on any merchant site. Use when the user asks to connect or log in to or sign up for their Link account.
Expand Down 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