SLOPSHOPPER

gcp-reauth

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

newguardtoaststatusprocess
v0.1.0no licenseupdated 2026-10-06michelr/dotclaude/plugins/gcp-reauth
A shopper browsing a rack in a slop shop
README

gcp-reauth

A Claude Code mod that notices when Google Cloud credentials have expired and says which login to run.

What it does

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:

  • A toast and a status line name the login to run:
  • BigQuery MCP and dbt use application-default credentials: ! gcloud auth application-default login
  • bq, gcloud and gsutil use the gcloud login: ! gcloud auth login
  • If the error text names one of the two commands, that one wins
  • The model gets a note to ask you to log in again instead of switching to another way of querying

The 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.

Limitations

  • A login you run yourself with ! may not pass through the hook, so the status line stays until the next successful BigQuery call
  • Any failing bq/dbt command whose output contains one of the phrases counts, even if auth wasn't the cause

Development

claude plugin validate .
npx -p typescript tsc -p .
claude plugin test .
Source 3 files
hooks/register.ts 52 lines
1import 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}
52
hooks/detect.ts 46 lines
1import 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.`
46
types/index.d.ts 8 lines
1export type Credential = 'adc' | 'gcloud'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'gcp-reauth': { expired: Credential[] }
6  }
7}
8