Flags expired Google Cloud credentials (invalid_rapt) with the login command to run

A Claude Code mod that notices when Google Cloud credentials have expired and says which login to run.
At session start it checks both logins in the background (gcloud auth print-access-token and gcloud auth application-default print-access-token) and sets the status line and a toast for any that need reauthentication. Network and other failures are ignored.
Also, when a BigQuery MCP call, or a Bash call running bq, gcloud, gsutil or dbt, fails with invalid_rapt, invalid_grant, reauth related error or Reauthentication required:
! gcloud auth application-default loginbq, gcloud and gsutil use the gcloud login: ! gcloud auth loginThe status line clears, with a "GCP credentials restored" toast, after the next successful call on that credential or a gcloud auth ... login run through Claude.
! may not pass through the hook, so the status line stays until the next successful BigQuery callbq/dbt command whose output contains one of the phrases counts, even if auth wasn't the causeclaude plugin validate .
npx -p typescript tsc -p .
claude plugin test .hooks/register.ts 52 lines1import type { Register } from 'claude-code'
2
3import type { Credential } from '../types'
4
5import { LOGIN, modelNoteOf, nextExpired, outcomeOf, PROBE, probeOutcomeOf, statusOf } from './detect'
6
7const EXPIRED = { plugin: 'gcp-reauth', key: 'expired' } as const
8
9export const register: Register = on => {
10 on('session.start', async ($, e, next) => {
11 const { value = [] } = await $.state.get(EXPIRED)
12 $.ui.status(statusOf(value))
13 const started = await next(e)
14 void (async () => {
15 const outcomes = await Promise.all(
16 (Object.keys(PROBE) as Credential[]).map(credential =>
17 $.process
18 .run(PROBE[credential], { timeoutMs: 15000 })
19 .then(({ exitCode, stderr }) => probeOutcomeOf(credential, exitCode, stderr))
20 .catch(() => undefined),
21 ),
22 )
23 const { value: expired = [] } = await $.state.get(EXPIRED)
24 const updated = outcomes.reduce((acc, outcome) => (outcome ? nextExpired(acc, outcome) : acc), expired)
25 await $.state.set(EXPIRED, updated)
26 $.ui.status(statusOf(updated))
27 if (updated.length) $.ui.toast(`GCP credentials expired: run ${updated.map(c => `! ${LOGIN[c]}`).join(' and ')}`, { timeoutMs: 10000 })
28 })()
29 return started
30 })
31
32 on('tool.call', async ($, e, next) => {
33 const ran = await next(e)
34 if (ran.deny !== undefined) return ran
35 const command = e.tool === 'Bash' ? e.command : undefined
36 const outcome = outcomeOf(e.tool, command, ran.text ?? '', ran.isError === true)
37 if (!outcome) return ran
38
39 const { value: expired = [] } = await $.state.get(EXPIRED)
40 const updated = nextExpired(expired, outcome)
41 if (updated.length !== expired.length) {
42 await $.state.set(EXPIRED, updated)
43 $.ui.status(statusOf(updated))
44 $.ui.toast(
45 outcome.isExpired ? `GCP credentials expired: run ! ${LOGIN[outcome.credential]}` : 'GCP credentials restored',
46 { timeoutMs: outcome.isExpired ? 10000 : 4000 },
47 )
48 }
49 return outcome.isExpired ? { ...ran, context: [...(ran.context ?? []), modelNoteOf(outcome.credential)] } : ran
50 })
51}
52hooks/detect.ts 46 lines1import type { Credential } from '../types'
2
3export const LOGIN: Record<Credential, string> = {
4 adc: 'gcloud auth application-default login',
5 gcloud: 'gcloud auth login',
6}
7
8const REAUTH = /invalid_rapt|reauth related error|reauthentication (is )?(required|needed|failed)|invalid_grant/i
9const GCLOUD_CLI = /(^|[^\w-])(bq|gcloud|gsutil)\s/
10const ADC_CLI = /(^|[^\w-])dbt\s/
11const LOGIN_COMMAND = /(^|[^\w-])gcloud\s+auth\s+(application-default\s+)?login(\s|$)/
12
13export type Outcome = { credential: Credential; isExpired: boolean }
14
15export const PROBE: Record<Credential, readonly string[]> = {
16 adc: ['gcloud', 'auth', 'application-default', 'print-access-token'],
17 gcloud: ['gcloud', 'auth', 'print-access-token'],
18}
19
20export const probeOutcomeOf = (credential: Credential, exitCode: number, stderr: string): Outcome | undefined =>
21 REAUTH.test(stderr) ? { credential, isExpired: true } : exitCode === 0 ? { credential, isExpired: false } : undefined
22
23const credentialOfText = (text: string, fallback: Credential): Credential =>
24 text.includes(LOGIN.adc) ? 'adc' : text.includes(LOGIN.gcloud) ? 'gcloud' : fallback
25
26export const outcomeOf = (tool: string, command: string | undefined, text: string, isError: boolean): Outcome | undefined => {
27 if (tool.startsWith('mcp__bigquery__')) return { credential: 'adc', isExpired: REAUTH.test(text) }
28 if (tool !== 'Bash' || !command) return undefined
29 const login = LOGIN_COMMAND.exec(command)
30 if (login) return isError ? undefined : { credential: login[2] ? 'adc' : 'gcloud', isExpired: false }
31 const fallback = GCLOUD_CLI.test(command) ? 'gcloud' : ADC_CLI.test(command) ? 'adc' : undefined
32 if (!fallback) return undefined
33 if (REAUTH.test(text)) return { credential: credentialOfText(text, fallback), isExpired: true }
34 return isError ? undefined : { credential: fallback, isExpired: false }
35}
36
37export const nextExpired = (expired: readonly Credential[], { credential, isExpired }: Outcome): Credential[] =>
38 isExpired ? [...new Set([...expired, credential])] : expired.filter(c => c !== credential)
39
40export const statusOf = (expired: readonly Credential[]): string | undefined =>
41 expired.length ? `GCP reauth needed: ${expired.map(c => `! ${LOGIN[c]}`).join(' · ')}` : undefined
42
43export const modelNoteOf = (credential: Credential): string =>
44 `Google Cloud ${credential === 'adc' ? 'application-default' : 'gcloud CLI'} credentials need reauthentication. ` +
45 `Tell the user to run \`! ${LOGIN[credential]}\` before switching to another way of querying.`
46types/index.d.ts 8 lines1export type Credential = 'adc' | 'gcloud'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'gcp-reauth': { expired: Credential[] }
6 }
7}
8