Skip to content
Open
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 @@ -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
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 @@ -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<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
81 changes: 73 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,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(
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.

should be json output/respect the output format

`\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) {
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.

is this code repeated in the other auth polling command?

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

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
Expand Up @@ -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 "<your-agent-name>"
link-cli auth login --client-name "<your-agent-name>" --interval 5 --timeout 300
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 we should make this the default behavior. Agents seem good with the _next output?

```

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.

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.
Expand Down
Loading