Spots an expired CLI login in tool output and offers a Login button that signs in and retries.

When a Bash call fails on an expired CLI login, auth-guard asks before Claude sees the error:
AWS SSO session expired (profile prod). Log in and retry? [Log in and retry] [Return the error]
It fires only when the command itself runs that provider's CLI and the message is in an errored result or the last 15 lines of output. Reading a file that mentions these errors doesn't trip it.
The login opens your browser (5 minute limit). What happens after depends on the command:
aws s3 ls, aws ec2 describe-*, gcloud ... list, gh pr view, gh api with GET): it re-runs once and Claude gets the new result.If the login fails, Claude gets the original error with the failure appended. Two expired calls at once share one login.
It tells you what to run instead of running it when:
infisical login, an interactive TUI;gh got a 401 while GH_TOKEN or GITHUB_TOKEN is set, since that token wins over any login.With no local terminal or desktop attached (-p, the SDK, mobile), nobody is asked. Claude gets a one-line hint to ask you to run the login.
| Provider | Spotted by | Login |
|---|---|---|
| AWS SSO | Token has expired and refresh failed, Error loading SSO Token, The SSO session associated with this profile has expired | aws sso login [--profile p] |
| AWS console login | Please reauthenticate using 'aws login' | aws login [--profile p] |
gcloud (gcloud, gsutil, bq) | Reauthentication required, There was a problem refreshing your current auth tokens | gcloud auth login |
| Infisical | run [infisical login] | you run infisical login |
| gh | HTTP 401: Bad credentials, Try authenticating with: gh auth login | gh auth login --web --clipboard |
The profile comes from --profile or AWS_PROFILE= in the failed command.
Set preflight to a list of providers (aws, gcloud, gh, infisical) to check them when a session starts and every 10 minutes after. Rows show only for providers with a problem:
auth ✗ aws expired 3m ago [Log in] ? gh check failed just now [Why] ? gcloud not installed just nowhooks/register.tsx 314 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, ToolCallResult } from 'claude-code'
3
4import type { Problem } from '../types'
5
6type Signature = {
7 provider: string
8 // The command words that count as invoking this provider.
9 clis: readonly string[]
10 label: string
11 pattern: RegExp
12 login: readonly string[]
13 // infisical login is an interactive TUI; process.run has no terminal to give it.
14 isManual?: true
15}
16
17// "Confirmed" means seen in a real Bash result in ~/.claude/projects transcripts.
18// The rest come from the CLI binaries' own strings and have not been seen firing.
19const SIGNATURES: readonly Signature[] = [
20 // Confirmed: the first and third. Unconfirmed: "The SSO session associated...".
21 { provider: 'aws', clis: ['aws'], label: 'AWS SSO session expired', login: ['aws', 'sso', 'login'],
22 pattern: /Token has expired and refresh failed|The SSO session associated with this profile has expired|Error loading SSO Token/ },
23 // Confirmed, and the most common AWS one: console-credential sessions from `aws login`.
24 { provider: 'aws', clis: ['aws'], label: 'AWS login session expired', login: ['aws', 'login'],
25 pattern: /Please reauthenticate using 'aws login'/ },
26 // Confirmed, both.
27 { provider: 'gcloud', clis: ['gcloud', 'gsutil', 'bq'], label: 'gcloud auth expired', login: ['gcloud', 'auth', 'login'],
28 pattern: /Reauthentication required|There was a problem refreshing your current auth tokens/ },
29 // Unconfirmed: "Your login session has expired. Please run [infisical login]" and
30 // "To login, run [infisical login]" are from the infisical binary.
31 { provider: 'infisical', clis: ['infisical'], label: 'Infisical login expired', login: ['infisical', 'login'], isManual: true,
32 pattern: /run(?:ning)? \[infisical login\]/ },
33 // Unconfirmed: from the gh binary. --clipboard because process.run captures
34 // the device code gh prints, so the user would never see it.
35 { provider: 'gh', clis: ['gh'], label: 'GitHub CLI login invalid', login: ['gh', 'auth', 'login', '--web', '--clipboard'],
36 pattern: /HTTP 401: Bad credentials|(?:authenticating with|please run|log in, run|re-authenticate, run|Re-authenticate with):?\s+gh auth login/ },
37]
38
39const CHECKS: Record<string, readonly string[]> = {
40 aws: ['aws', 'sts', 'get-caller-identity'],
41 gcloud: ['gcloud', 'auth', 'print-access-token'],
42 gh: ['gh', 'auth', 'status'],
43 // A real subcommand (its --help says so); its exit code on an expired session is unconfirmed.
44 infisical: ['infisical', 'login', 'status'],
45}
46
47const READ_ONLY: Record<string, (words: string[], args: string[]) => boolean> = {
48 aws: ([service, op = '']) =>
49 /^(describe|list|get)-/.test(op) || (service === 's3' && op === 'ls') || (service === 'sts' && op === 'get-caller-identity'),
50 gcloud: words => words.some(word => word === 'list' || word === 'describe' || word === 'read'),
51 gh: (words, args) =>
52 words[0] === 'api' ? isGetApi(args) : words[0] === 'status' || ['list', 'view', 'status'].includes(words[1] ?? ''),
53 infisical: ([first, second]) => first === 'export' || (first === 'secrets' && second === 'get'),
54}
55
56// Logins open a browser and wait for the person.
57const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
58const RECHECK_MS = 10 * 60 * 1000
59const RETRY = 'Log in and retry'
60const LOGIN = 'Log in'
61const PREFIXES = new Set(['sudo', 'env', 'time', 'command', 'exec', 'nohup'])
62
63const problems = atom({ plugin: 'auth-guard', key: 'problems' } as const, [] as readonly Problem[])
64const inflight = new Map<string, Promise<ProcessRunResult>>()
65
66type Parsed = { segments: string[][]; isSimple: boolean }
67
68// Words per simple command, quotes removed. Not a shell parser: it only has to
69// tell a real `aws ...` from one quoted inside a grep pattern.
70export function parse(command: string): Parsed {
71 const segments: string[][] = [[]]
72 for (const [token] of command.matchAll(/(?:[^\s'";&|]|'[^']*'|"[^"]*")+|[;&|\n]/g)) {
73 if (/^[;&|\n]$/.test(token)) segments.push([])
74 else segments[segments.length - 1]!.push(token.replace(/'([^']*)'|"([^"]*)"/g, '$1$2'))
75 }
76 // Any operator, substitution or redirect makes it more than one simple command.
77 const isSimple = segments.length === 1 && !/`|\$\(|[<>]/.test(command.replace(/'[^']*'/g, ''))
78 return { segments: segments.filter(words => words.length > 0), isSimple }
79}
80
81function argsOf(words: string[]): string[] {
82 let i = 0
83 while (i < words.length && (PREFIXES.has(words[i]!) || /^\w+=/.test(words[i]!))) i++
84 return words.slice(i)
85}
86
87// Arguments after the CLI that aren't flags. `--flag value` drops its value, so a
88// boolean long flag drops the next word too: that can only hide a read-only verb.
89function positionals(args: string[]): string[] {
90 const out: string[] = []
91 for (let i = 1; i < args.length; i++) {
92 const word = args[i]!
93 if (!word.startsWith('-')) out.push(word)
94 else if (word.startsWith('--') && !word.includes('=')) i++
95 }
96 return out
97}
98
99function isGetApi(args: string[]): boolean {
100 return args.every((word, i) => {
101 // Fields switch `gh api` to POST unless a method says otherwise; treat them as writes.
102 if (/^(-f|-F|--field|--raw-field|--input)/.test(word)) return false
103 const method = word === '-X' || word === '--method' ? args[i + 1] : /^(?:-X|--method=)(.+)$/.exec(word)?.[1]
104 return method === undefined || method.toUpperCase() === 'GET'
105 })
106}
107
108function profileOf(parsed: Parsed): { profile?: string; unsure?: string } {
109 const found = new Set<string>()
110 for (const words of parsed.segments) {
111 words.forEach((word, i) => {
112 const value =
113 /^AWS_PROFILE=(.*)$/.exec(word)?.[1] ?? /^--profile=(.*)$/.exec(word)?.[1] ?? (word === '--profile' ? words[i + 1] : undefined)
114 if (value !== undefined) found.add(value)
115 })
116 }
117 const [profile, ...rest] = [...found]
118 if (profile === undefined) return {}
119 if (rest.length > 0) return { unsure: `the command names several profiles (${[...found].join(', ')})` }
120 if (!/^\w[\w.@+-]*$/.test(profile)) return { unsure: `the profile is ${profile}, not a name` }
121 return { profile }
122}
123
124export function detect(command: string, output: string, isError: boolean) {
125 const parsed = parse(command)
126 // A clean exit only counts when the message is near the end, where a CLI's own error lands.
127 const text = isError ? output : output.split('\n').slice(-15).join('\n')
128 for (const sig of SIGNATURES) {
129 const calls = parsed.segments.map(argsOf).filter(args => sig.clis.includes(args[0]?.split('/').pop() ?? ''))
130 if (calls.length === 0 || !sig.pattern.test(text)) continue
131 const { profile, unsure } = sig.provider === 'aws' ? profileOf(parsed) : {}
132 const call = calls[0]!
133 return {
134 sig,
135 profile,
136 unsure,
137 label: profile ? `${sig.label} (profile ${profile})` : sig.label,
138 argv: profile ? [...sig.login, '--profile', profile] : [...sig.login],
139 isReadOnly: parsed.isSimple && calls.length === 1 && READ_ONLY[sig.provider]!(positionals(call), call),
140 }
141 }
142 return undefined
143}
144
145type Found = NonNullable<ReturnType<typeof detect>>
146type Answered = Exclude<ToolCallResult, { deny: string }>
147
148function withContext(result: Answered, line: string): Answered {
149 return { ...result, context: [...(result.context ?? []), line] }
150}
151
152function tail(text: string): string {
153 return text.trim().split('\n').slice(-3).join(' | ').slice(0, 300)
154}
155
156function ago(ms: number): string {
157 const minutes = Math.floor(ms / 60_000)
158 return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes}m ago` : `${Math.floor(minutes / 60)}h ago`
159}
160
161// Logins open a browser on this machine; only a local terminal or desktop has one.
162async function canOffer($: EngineInterface): Promise<boolean> {
163 return (await $.session.surfaces()).some(surface => surface === 'terminal' || surface === 'desktop')
164}
165
166async function run($: EngineInterface, argv: readonly string[], timeoutMs: number): Promise<ProcessRunResult> {
167 // A missing binary or a timeout rejects; report it as exit -1 with the reason.
168 return $.process.run(argv, { timeoutMs }).catch((err: unknown) => {
169 const stderr = err instanceof Error ? err.message : String(err)
170 return { exitCode: -1, stdout: '', stderr, isStdoutTruncated: false, isStderrTruncated: false }
171 })
172}
173
174// One login per provider at a time; a second expired call waits on the first's.
175function login($: EngineInterface, provider: string, argv: readonly string[]): Promise<ProcessRunResult> {
176 const pending = inflight.get(provider)
177 if (pending) return pending
178 $.ui.toast(provider === 'gh' ? 'auth-guard: opening browser — gh code copied to clipboard' : 'auth-guard: waiting for browser login (up to 5 min)')
179 const started = run($, argv, LOGIN_TIMEOUT_MS).finally(() => inflight.delete(provider))
180 inflight.set(provider, started)
181 return started
182}
183
184// Why auth-guard won't run the login itself, as a sentence for the user and Claude.
185async function manualReason($: EngineInterface, found: Found): Promise<string | undefined> {
186 if (found.unsure) return `${found.unsure}, so it won't guess: run \`${found.sig.login.join(' ')} --profile <name>\` in a terminal`
187 if (found.sig.isManual) return `Run \`${found.argv.join(' ')}\` in a terminal`
188 if (found.sig.provider !== 'gh') return undefined
189 const name = (await $.env.get('GH_TOKEN')) ? 'GH_TOKEN' : (await $.env.get('GITHUB_TOKEN')) ? 'GITHUB_TOKEN' : undefined
190 return name && `${name} is set and wins over any gh login, so logging in won't help: fix or unset it`
191}
192
193async function check($: EngineInterface, provider: string): Promise<Problem | null> {
194 const argv = CHECKS[provider]
195 const checkedAt = await $.clock.now()
196 if (!argv) return { provider, state: 'unknown provider', checkedAt }
197 const result = await run($, argv, 20_000)
198 if (result.exitCode === 0) return null
199 if (result.exitCode === -1 && /ENOENT|not found|no such file/i.test(result.stderr)) return { provider, state: 'not installed', checkedAt }
200 const found = detect(argv.join(' '), `${result.stdout}\n${result.stderr}`, true)
201 if (found?.sig.provider !== provider) {
202 return { provider, state: 'check failed', checkedAt, detail: `exit ${result.exitCode}: ${tail(result.stderr || result.stdout)}` }
203 }
204 if (found.sig.isManual) return { provider, state: 'expired', checkedAt, detail: `run \`${found.argv.join(' ')}\` in a terminal` }
205 return { provider, state: 'expired', checkedAt, login: found.argv }
206}
207
208async function refresh($: EngineInterface, providers: readonly string[]) {
209 if (providers.length === 0 || !(await canOffer($))) return
210 const rows = await Promise.all(providers.map(provider => check($, provider)))
211 await update($, problems, old => [
212 ...old.filter(row => !providers.includes(row.provider)),
213 ...rows.filter(row => row !== null),
214 ])
215}
216
217async function loginFromBand($: EngineInterface, problem: Problem) {
218 if (!problem.login) return
219 const setState = (state: Problem['state']) =>
220 update($, problems, rows => rows.map(row => (row.provider === problem.provider ? { ...row, state } : row)))
221 await setState('logging in')
222 const result = await login($, problem.provider, problem.login)
223 if (result.exitCode !== 0) {
224 $.ui.toast(`auth-guard: ${problem.login.join(' ')} failed: ${tail(result.stderr || result.stdout)}`)
225 await setState('login failed')
226 return
227 }
228 await refresh($, [problem.provider])
229}
230
231export const register: Register = (on, options) => {
232 const providers = (options.preflight ?? []) as readonly string[]
233
234 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
235 // The vendored types declare no built-in tool inputs, so `e.command` is unknown.
236 const command = typeof e.command === 'string' ? e.command : ''
237 const first = await next(e)
238 if (first.deny !== undefined) return first
239 const found = detect(command, first.text ?? '', first.isError === true)
240 if (!found) return first
241
242 const cmd = found.argv.join(' ')
243 if (!(await canOffer($))) {
244 return withContext(first, `auth-guard: ${found.label}; ask the user to run \`${cmd}\` (it is interactive, don't run it yourself)`)
245 }
246 const manual = await manualReason($, found)
247 if (manual) {
248 await $.ui.ask(`${found.label}. ${manual}.`, { header: 'auth', options: ['OK', 'Dismiss'] }).catch(() => undefined)
249 return withContext(first, `auth-guard: ${found.label}. The user was told: ${manual}`)
250 }
251
252 const retry = first.isReadOnly === true || found.isReadOnly
253 if (!inflight.has(found.sig.provider)) {
254 const offer = retry ? RETRY : LOGIN
255 // Rejects when dismissed; that is a decline.
256 const answer = await $.ui
257 .ask(`${found.label}. ${offer}?`, { header: 'auth', options: [offer, 'Return the error'] })
258 .catch(() => undefined)
259 if (answer !== offer) return first
260 }
261 const result = await login($, found.sig.provider, found.argv)
262 if (result.exitCode !== 0) {
263 return withContext(first, `auth-guard: ${found.label}; \`${cmd}\` failed (exit ${result.exitCode}): ${tail(result.stderr || result.stdout)}`)
264 }
265 if (providers.includes(found.sig.provider)) await refresh($, [found.sig.provider])
266 if (!retry) {
267 return withContext(first, `auth-guard: logged in via \`${cmd}\`; command NOT re-run because it may repeat side effects — re-run it if safe`)
268 }
269 const retried = await next(e)
270 if (retried.deny !== undefined) return retried
271 return withContext(retried, `auth-guard: the first run failed (${found.label}); logged in via \`${cmd}\` and ran it again`)
272 })
273
274 on('session.start', async ($, e, next) => {
275 const result = await next(e)
276 if (providers.length === 0) return result
277 // Checks take seconds; timers keep them off the session's start.
278 $.clock.after(0, () => void refresh($, providers))
279 $.clock.every(RECHECK_MS, () => void refresh($, providers))
280 return result
281 })
282
283 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
284 const below = await next(e)
285 const rows = await read($, problems)
286 if (rows.length === 0 || e.props.hasSurvey) return below
287
288 const now = await $.clock.now()
289 const { Box, Text, Button } = $.ui.resolve(e)
290 return (
291 <Box flexDirection="column">
292 <Box>
293 <Text dimColor>auth </Text>
294 {rows.map(row => (
295 <Box key={row.provider}>
296 <Text color={row.state === 'logging in' ? undefined : 'red'}>
297 {' '}
298 {row.state === 'logging in' ? '…' : row.state === 'expired' ? '✗' : '?'} {row.provider} {row.state}{' '}
299 </Text>
300 <Text dimColor>{ago(now - row.checkedAt)} </Text>
301 {row.login && row.state !== 'logging in' ? (
302 <Button key={`login-${row.provider}`} label="Log in" onPress={() => loginFromBand($, row)} />
303 ) : row.detail ? (
304 <Button key={`why-${row.provider}`} label="Why" onPress={() => $.ui.toast(`auth-guard: ${row.provider} ${row.detail}`)} />
305 ) : null}
306 </Box>
307 ))}
308 </Box>
309 {below}
310 </Box>
311 )
312 })
313}
314types/index.d.ts 16 lines1export type Problem = {
2 provider: string
3 state: 'expired' | 'check failed' | 'not installed' | 'logging in' | 'login failed' | 'unknown provider'
4 checkedAt: number
5 // A login the band may run; absent when the user has to run it in a terminal.
6 login?: readonly string[]
7 // Shown on press: why the check failed, or what to run.
8 detail?: string
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'auth-guard': { problems: readonly Problem[] }
14 }
15}
16