SLOPSHOPPER

account-guard

A line above the prompt with the GitHub account a push would use, and a red warning when that account cannot push to the repo.

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · account-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gh-switch ⎿ account-guard: Nothing to switch: no other signed-in gh account would make this push work ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Personal Claude Code mods.

Install

/plugin marketplace add ColoMiranda/claude-mods
/plugin install team-board@claude-mods
/plugin install open-last@claude-mods
/plugin install copy-draft@claude-mods
/plugin install account-guard@claude-mods
/plugin install recent-chats@claude-mods
/reload-plugins

Mods

  • team-board: a Team pane for agent teams. One row per agent: working, done, or blocked (red, with the reason). Teammate messages fold to one line in the chat. /team shows or hides the pane.
  • open-last: a band above the prompt with the last file Claude wrote and any local dev server it started. open opens it in cmux (else the default app), diff shows what Claude changed in the last turn (in any folder, git or not; shell edits too: sed -i, perl -i, tee, > and >>), and stop kills the server. /open, /last-diff and /stop-servers do the same from the prompt (/diff is Claude Code's own).
  • account-guard: silent while the GitHub account a push from this repo would use can push. Otherwise a line above the prompt names that account (gh's active account or GH_TOKEN for https; for ssh, the key's account once you run /gh-check). Red when that account cannot push to the repo, with a switch button and /gh-switch when another signed-in gh account can; a toast when Claude pushes or opens a PR anyway.
  • copy-draft: a copy button under each code block of Claude's answers (copy 1, copy 2 when there are several; terminal), and /copy-block [n] to copy a block of the last answer from the prompt. The built-in /copy copies the whole answer. A reply button under each table puts one line per row in the prompt box (R22 - , R23 - ...: the row's first cell, or its number), under anything you typed, so you write what to do with each row; /reply-table [n] does the same from the prompt.
  • recent-chats: when Claude opens fresh, a band above the prompt lists this folder's last 5 chats: the /rename name (else Claude's title, else the last prompt), how long ago, and under it a one-line summary of where that chat stopped. Haiku writes each summary from the end of the chat (a few hundred tokens) and it is kept until that chat changes; only sessions with a person at the prompt call the model, never -p runs. Press 1 to 5 in the empty prompt box, or click a row, to resume that chat. When the www mod shows tasks, the chats take the next numbers. Type anything else to start a new chat; the band goes away at the first prompt. Not shown on claude -c or claude -r. Not listed: chats open in another terminal (two sessions would write one chat), and headless runs (claude -p, SDK).

Develop

claude --plugin-dir ./plugins/team-board
claude plugin validate ./plugins/team-board
claude plugin test ./plugins/team-board
Source 3 files
hooks/register.tsx 320 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AccountGuardEnvToken, AccountGuardStatus } from '../types'
5import {
6  bandLine,
7  ghAccounts,
8  type GithubRemote,
9  githubRemote,
10  isAccountCommand,
11  isLogin,
12  isPushCommand,
13  pushAccess,
14  pushRemoteName,
15  sshConfig,
16  sshUser,
17  switchTarget,
18} from './parse'
19
20const status = atom({ plugin: 'account-guard', key: 'status' } as const, null as AccountGuardStatus | null)
21const access = atom({ plugin: 'account-guard', key: 'access' } as const, {} as Record<string, boolean>)
22const NETWORK_MS = 15_000
23// Local reads (git config, gh's own config, ssh's config) never wait longer.
24const LOCAL_MS = 2_000
25const SSH_DAY_MS = 24 * 60 * 60 * 1000
26
27// Whether `account` can push to `repo` ("owner/repo"), asked once per pair as
28// that account: with its gh token, or, for the env token's account, with the
29// env as it is (the token never passes through here). Undefined when GitHub
30// does not tell, or the account is not signed in to gh. Owning the repo is
31// not the test: an org repo is pushed by its members.
32async function canPush($: EngineInterface, repo: string, account: string, isEnvToken = false): Promise<boolean | undefined> {
33  const key = `${repo}@${account}`.toLowerCase()
34  const known = (await read($, access))[key]
35  if (known !== undefined) return known
36  let env: Record<string, string> | undefined
37  if (!isEnvToken) {
38    const token = await $.process.run(['gh', 'auth', 'token', '--user', account])
39    if (token.exitCode !== 0 || token.stdout.trim() === '') return undefined
40    env = { GH_TOKEN: token.stdout.trim() }
41  }
42  const ran = await $.process.run(['gh', 'api', `repos/${repo}`, '--jq', '.permissions.push'], { env, timeoutMs: NETWORK_MS })
43  const answer = pushAccess(ran)
44  if (answer !== undefined) await update($, access, all => ({ ...all, [key]: answer }))
45
46  return answer
47}
48
49// The env variable gh takes its token from (GH_TOKEN first), and whose it is:
50// asked once per process, since the host's env does not change under it.
51const envLogins = new Map<AccountGuardEnvToken, string>()
52
53async function envToken($: EngineInterface): Promise<AccountGuardEnvToken | undefined> {
54  if (await $.env.get('GH_TOKEN').catch(() => undefined)) return 'GH_TOKEN'
55  if (await $.env.get('GITHUB_TOKEN').catch(() => undefined)) return 'GITHUB_TOKEN'
56  return undefined
57}
58
59async function envLogin($: EngineInterface, name: AccountGuardEnvToken): Promise<string | undefined> {
60  if (!envLogins.has(name)) {
61    const login = (await $.process.run(['gh', 'api', 'user', '--jq', '.login'], { timeoutMs: NETWORK_MS })).stdout.trim()
62    if (isLogin(login)) envLogins.set(name, login)
63  }
64  return envLogins.get(name)
65}
66
67// The signed-in gh accounts: `gh auth status` checks each token online, so it
68// runs once, and again only after an account command, a switch or /gh-check.
69let signedIn: { active?: string; all: string[] } | undefined
70
71async function accountsOf($: EngineInterface): Promise<{ active?: string; all: string[] }> {
72  signedIn ??= ghAccounts((await $.process.run(['gh', 'auth', 'status', '--json', 'hosts'], { timeoutMs: NETWORK_MS })).stdout)
73  return signedIn
74}
75
76// gh's active account from its own config file: local, no token check.
77async function activeAccount($: EngineInterface): Promise<string | undefined> {
78  const user = (await $.process.run(['gh', 'config', 'get', '-h', 'github.com', 'user'], { timeoutMs: LOCAL_MS })).stdout.trim()
79  return isLogin(user) ? user : (await accountsOf($)).active
80}
81
82type Ask = { repo: string; account: string; via: 'https' | 'ssh'; isGhActive: boolean; envToken?: AccountGuardEnvToken; logins: string[] }
83type Local = { status?: AccountGuardStatus | null; ask?: Ask; ssh?: { host: string; key: string } }
84
85// ponytail: what a push from `cwd` would sign in as, read locally (git config,
86// gh's config file, ssh's config; the network only for the signed-in list and
87// the env token's login, each once). https:
88// a user in the URL only when it is a signed-in gh login (anything else, or a
89// password, is a credential of its own: never kept); else the env token's
90// account; else gh's active account when gh is git's credential helper. ssh:
91// whom the key signed in as when /gh-check last asked (24 h, in $.store), never
92// asked unprompted: the agent may pop up an approval. The remote is the one
93// `git push` picks on this branch; a push that names another is not seen.
94// Upgrade path: read the push's own arguments at push time.
95async function localTarget($: EngineInterface, cwd: string): Promise<Local> {
96  const git = (...args: string[]) => $.process.run(['git', ...args], { cwd, timeoutMs: LOCAL_MS })
97  if ((await git('rev-parse', '--is-inside-work-tree')).stdout.trim() !== 'true') return { status: null }
98  const branch = (await git('symbolic-ref', '--quiet', '--short', 'HEAD')).stdout.trim() || undefined
99  const config = (await git('config', '--get-regexp', '^(remote\\.pushdefault|branch\\..*\\.(pushremote|remote))$')).stdout
100  const remotes = (await git('remote')).stdout.split('\n').map(one => one.trim()).filter(Boolean)
101  const name = pushRemoteName(branch, config, remotes)
102  if (name === undefined) return { status: null }
103  const remote = githubRemote((await git('remote', 'get-url', '--push', name)).stdout)
104  if (remote === undefined) return { status: null }
105  const repo = `${remote.owner}/${remote.repo}`
106  let ssh: Local['ssh']
107  if (remote.via === 'ssh') {
108    const resolved = sshConfig((await $.process.run(['ssh', '-G', remote.host], { timeoutMs: LOCAL_MS })).stdout)
109    if (resolved.hostname?.toLowerCase() !== 'github.com') return { status: null }
110    ssh = { host: remote.host, key: resolved.key }
111  }
112  try {
113    return await signIn($, cwd, repo, remote, ssh)
114  } catch {
115    return { status: { state: 'unknown', repo, reason: 'gh failed' }, ssh }
116  }
117}
118
119// Whom the remote signs in as, from gh, the env, git's helper and $.store.
120async function signIn($: EngineInterface, cwd: string, repo: string, remote: GithubRemote, ssh: Local['ssh']): Promise<Local> {
121  if (ssh !== undefined) {
122    const seen = (await $.store.get(`ssh:${ssh.key}`)) as { account?: unknown; at?: unknown } | undefined
123    const isFresh = typeof seen?.account === 'string' && isLogin(seen.account) && typeof seen.at === 'number' && (await $.clock.now()) - seen.at < SSH_DAY_MS
124    if (!isFresh) return { status: { state: 'ssh-unchecked', repo }, ssh }
125    const { all } = await accountsOf($)
126    return { ask: { repo, account: seen.account as string, via: 'ssh', isGhActive: false, logins: all }, ssh }
127  }
128  const accounts = await accountsOf($)
129  const named = remote.user === undefined ? undefined : accounts.all.find(login => login.toLowerCase() === remote.user?.toLowerCase())
130  if (remote.hasPassword || (remote.user !== undefined && named === undefined)) return { status: { state: 'url-credential', repo } }
131  const helper = (await $.process.run(['git', 'config', '--get-urlmatch', 'credential.helper', 'https://github.com'], { cwd, timeoutMs: LOCAL_MS })).stdout
132  const usesGh = /\bgh(?:\.exe)? auth git-credential\b/.test(helper)
133  const fromEnv = usesGh ? await envToken($) : undefined
134  if (fromEnv !== undefined) {
135    const account = await envLogin($, fromEnv)
136    if (account === undefined) return { status: { state: 'unknown', repo, reason: `GitHub did not say whose ${fromEnv} this is` } }
137    return { ask: { repo, account, via: 'https', isGhActive: false, envToken: fromEnv, logins: accounts.all } }
138  }
139  if (named !== undefined) return { ask: { repo, account: named, via: 'https', isGhActive: false, logins: accounts.all } }
140  const active = usesGh ? await activeAccount($) : undefined
141  if (active !== undefined) return { ask: { repo, account: active, via: 'https', isGhActive: true, logins: accounts.all } }
142
143  return { status: { state: 'unknown', repo, reason: 'git does not sign in through gh here' } }
144}
145
146// The band's status: the local target, then GitHub's answer for it (cached
147// per repo and account, so only a new pair goes to the network).
148async function resolve($: EngineInterface, cwd: string): Promise<AccountGuardStatus | null> {
149  const local = await localTarget($, cwd)
150  if (local.ask === undefined) return local.status ?? null
151  const { repo, account, via, isGhActive, envToken: fromEnv, logins } = local.ask
152  const env = fromEnv === undefined ? {} : { envToken: fromEnv }
153  try {
154    const can = await canPush($, repo, account, fromEnv !== undefined)
155    if (can === undefined) return { state: 'unknown', repo, reason: `GitHub did not answer for ${account}` }
156    if (can) return { state: 'ok', repo, account, ...env }
157    // The env token wins over every signed-in account: no other one helps.
158    const others: string[] = []
159    for (const other of fromEnv === undefined ? logins : []) {
160      if (other.toLowerCase() !== account.toLowerCase() && (await canPush($, repo, other))) others.push(other)
161    }
162
163    return { state: 'no-access', repo, account, via, isGhActive, canPush: others, ...env }
164  } catch {
165    return { state: 'unknown', repo, reason: 'gh failed' }
166  }
167}
168
169// The newest check wins: one that ends after a later one started is dropped.
170// An unchanged answer is not written again, so the band does not redraw.
171let latest = 0
172
173async function refresh($: EngineInterface, cwd: string) {
174  const mine = ++latest
175  const found = await resolve($, cwd).catch(() => null)
176  if (mine !== latest) return
177  if (JSON.stringify(await read($, status)) !== JSON.stringify(found)) await update($, status, () => found)
178}
179
180// After a push started: the target read again, and a toast if it turns out
181// not to have access where the toast at push time did not say so.
182async function recheckPush($: EngineInterface, known: AccountGuardStatus | null) {
183  await refresh($, await $.session.cwd())
184  const now = await read($, status)
185  if (now?.state === 'no-access' && JSON.stringify(now) !== JSON.stringify(known)) $.ui.toast(bandLine(now).text, { timeoutMs: 10_000 })
186}
187
188// Checks run on a timer, outside the hook that asked: a slow network never
189// holds the session's start or a prompt.
190function later($: EngineInterface, cwd: string) {
191  $.clock.after(0, () => void refresh($, cwd))
192}
193
194// Moves gh's active account (for every terminal, not this session alone),
195// then checks again from scratch so the band follows.
196async function switchTo($: EngineInterface, account: string): Promise<string> {
197  if (!isLogin(account)) return `Not switching: ${JSON.stringify(account)} is not a GitHub login`
198  const ran = await $.process.run(['gh', 'auth', 'switch', '--user', account]).catch((error: unknown) => ({
199    exitCode: 1,
200    stdout: '',
201    stderr: error instanceof Error ? error.message : String(error),
202  }))
203  if (ran.exitCode !== 0) return `Could not switch gh to ${account}: ${(ran.stderr.trim() || ran.stdout.trim()).split('\n')[0]}`
204  signedIn = undefined
205  await update($, access, () => ({}))
206  later($, await $.session.cwd())
207
208  return `Switched gh to ${account}. This applies to all terminals.`
209}
210
211// The person's own check: for an ssh remote, asks GitHub whom the key signs
212// in as (the agent may ask them to approve) and keeps it a day; for https,
213// asks GitHub again from scratch.
214async function checkNow($: EngineInterface): Promise<string> {
215  const cwd = await $.session.cwd()
216  const local = await localTarget($, cwd).catch(() => ({}) as Local)
217  if (local.ssh !== undefined) {
218    const greeted = await $.process.run(['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', `git@${local.ssh.host}`], { timeoutMs: NETWORK_MS })
219    const account = sshUser(`${greeted.stdout}\n${greeted.stderr}`)
220    if (account === undefined) return 'ssh did not say which account the key signs in as'
221    await $.store.set(`ssh:${local.ssh.key}`, { account, at: await $.clock.now() })
222  } else {
223    await update($, access, () => ({}))
224  }
225  signedIn = undefined
226  await refresh($, cwd)
227  const now = await read($, status)
228
229  return now === null ? 'No GitHub remote here' : bandLine(now).text
230}
231
232export const register: Register = on => {
233  on('session.start', async ($, e, next) => {
234    later($, e.cwd)
235    await $.command.register({ name: 'gh-switch', description: 'Switch gh to the signed-in account that can push here', immediate: true })
236    await $.command.register({ name: 'gh-check', description: 'Check which GitHub account a push from here uses (asks the SSH key too)', immediate: true })
237
238    return next(e)
239  })
240
241  on('classic.CwdChanged', async ($, e, next) => {
242    later($, e.new_cwd)
243
244    return next(e)
245  })
246
247  // A switch made in another terminal shows by the next prompt: the re-read is
248  // local (git config, gh's config file, 2 s at most each), and GitHub is
249  // asked only for a repo and account pair not seen yet.
250  on('prompt.submit', async ($, e, next) => {
251    later($, await $.session.cwd())
252
253    return next(e)
254  })
255
256  // A push from an account that cannot push: a toast, and the command runs at
257  // once, never held by a check. The toast comes from what is known; a check
258  // starts beside the push and toasts a moment later when it finds a new "no"
259  // (a switch made in another terminal). An account switch, a login or a
260  // remote change: check again, fresh.
261  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
262    if (isPushCommand(e.command)) {
263      const known = await read($, status)
264      if (known?.state === 'no-access') $.ui.toast(bandLine(known).text, { timeoutMs: 10_000 })
265      $.clock.after(0, () => void recheckPush($, known))
266    }
267    const ran = await next(e)
268    if (ran.deny === undefined && isAccountCommand(e.command)) {
269      signedIn = undefined
270      await update($, access, () => ({}))
271      later($, await $.session.cwd())
272    }
273
274    return ran
275  })
276
277  on('command.run', { command: 'gh-switch' }, async $ => {
278    const target = switchTarget(await read($, status))
279
280    return { text: target === undefined ? 'Nothing to switch: no other signed-in gh account would make this push work' : await switchTo($, target) }
281  })
282
283  on('command.run', { command: 'gh-check' }, async $ => ({ text: await checkNow($) }))
284
285  // One line over the rest of the band (other plugins' rows), never instead of it.
286  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
287    const now = await read($, status)
288    // All good: nothing to show, the band is the other mods' alone.
289    if (e.props.hasSurvey || now === null || now.state === 'ok') return next(e)
290    const { Box, Button, Text } = $.ui.resolve(e)
291    const line = bandLine(now)
292    const target = switchTarget(now)
293    // Nothing beneath (a test's bare engine): this line alone.
294    const rest = await next(e).catch(() => undefined)
295
296    return (
297      <Box flexDirection="column">
298        {line.isWarning ? (
299          <Box gap={1}>
300            <Text color="red" wrap="wrap">
301              {line.text}
302            </Text>
303            {target !== undefined && (
304              <Button key="switch" label={`switch to ${target}`} hotkey="w" onPress={async () => $.ui.toast(await switchTo($, target))} />
305            )}
306          </Box>
307        ) : (
308          <Box gap={1}>
309            <Text dimColor wrap="truncate-end">
310              {line.text}
311            </Text>
312            {now.state === 'ssh-unchecked' && <Button key="check" label="check" hotkey="k" onPress={async () => $.ui.toast(await checkNow($))} />}
313          </Box>
314        )}
315        {rest}
316      </Box>
317    )
318  })
319}
320
hooks/parse.ts 120 lines
1import type { AccountGuardStatus } from '../types'
2
3// `user` and `hasPassword` are what an https URL carries before the host: the
4// user may be a token, so a caller keeps it only once it matches a signed-in
5// gh login, and never shows it.
6export type GithubRemote = { owner: string; repo: string; via: 'https' | 'ssh'; host: string; user?: string; hasPassword?: boolean }
7
8// The GitHub repo a push URL names: https://[user[:password]@]github.com/o/r[.git],
9// git@HOST:o/r[.git] or ssh://git@HOST[:port]/o/r[.git]. An ssh HOST other
10// than github.com may be an alias in ~/.ssh/config; the caller resolves it.
11export function githubRemote(url: string): GithubRemote | undefined {
12  const https = /^https:\/\/(?:([^@/:]+)(:[^@/]*)?@)?github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(url.trim())
13  if (https) return { owner: https[3]!, repo: https[4]!, via: 'https', host: 'github.com', user: https[1], hasPassword: https[2] !== undefined }
14  const ssh = /^(?:ssh:\/\/)?[\w.-]+@([\w.-]+)(?::\d+)?[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url.trim())
15  if (ssh) return { owner: ssh[2]!, repo: ssh[3]!, via: 'ssh', host: ssh[1]! }
16
17  return undefined
18}
19
20// The remote `git push` uses on this branch, as git picks it: the branch's
21// pushRemote, remote.pushDefault, the branch's remote, then origin, then the
22// only one. `config` is `git config --get-regexp` output ("key value" lines).
23export function pushRemoteName(branch: string | undefined, config: string, remotes: readonly string[]): string | undefined {
24  const values = new Map(config.split('\n').map(line => [line.slice(0, line.indexOf(' ')), line.slice(line.indexOf(' ') + 1).trim()]))
25  const picks = [
26    branch && values.get(`branch.${branch}.pushremote`),
27    values.get('remote.pushdefault'),
28    branch && values.get(`branch.${branch}.remote`),
29    'origin',
30    remotes.length === 1 ? remotes[0] : undefined,
31  ]
32
33  return picks.find((name): name is string => typeof name === 'string' && remotes.includes(name))
34}
35
36// What `ssh -G <host>` resolves (no network): the real hostname, and a key
37// naming the identity it offers (files and agent), for a cache per identity.
38export function sshConfig(text: string): { hostname?: string; key: string } {
39  const hostname = /^hostname (\S+)$/im.exec(text)?.[1]
40  const identities = [...text.matchAll(/^(?:identityfile|identityagent) (.+)$/gim)].map(([line]) => line.trim())
41
42  return { hostname, key: [hostname ?? '', ...identities].join('|') }
43}
44
45// "Hi ColoMiranda! You've successfully authenticated..." from `ssh -T`.
46export function sshUser(text: string): string | undefined {
47  return /\bHi ([A-Za-z0-9-]+)!/.exec(text)?.[1]
48}
49
50// The github.com accounts `gh auth status --json hosts` reports signed in,
51// and the active one.
52export function ghAccounts(json: string): { active?: string; all: string[] } {
53  try {
54    const hosts: unknown = JSON.parse(json)?.hosts?.['github.com']
55    const signedIn = (Array.isArray(hosts) ? hosts : []).filter(
56      (one): one is { login: string; active?: boolean } => one?.state === 'success' && typeof one.login === 'string',
57    )
58    return { active: signedIn.find(one => one.active === true)?.login, all: signedIn.map(one => one.login) }
59  } catch {
60    return { all: [] }
61  }
62}
63
64// `gh api repos/<o>/<r> --jq .permissions.push`: true or false, or a 404 (no
65// access to a private repo, or none there); anything else (offline, a rate
66// limit, a bad token) is undefined, never a false "no".
67export function pushAccess(ran: { exitCode: number; stdout: string; stderr: string }): boolean | undefined {
68  if (ran.exitCode === 0 && ran.stdout.trim() === 'true') return true
69  if (ran.exitCode === 0 && ran.stdout.trim() === 'false') return false
70  return /\(HTTP 404\)/.test(ran.stderr) ? false : undefined
71}
72
73// Each command of a line, its leading VAR=value assignments dropped.
74function commands(line: string): string[] {
75  return line.split(/&&|\|\||[;|\n]/).map(one => one.trim().replace(/^(?:\w+=\S*\s+)+/, ''))
76}
77
78export function isPushCommand(line: string): boolean {
79  return commands(line).some(one => /^git\s+push\b/.test(one) || /^gh\s+pr\s+(?:create|merge)\b/.test(one))
80}
81
82// A command that can change which account a push uses, or the remote.
83export function isAccountCommand(line: string): boolean {
84  return commands(line).some(one => /^gh\s+auth\s+(?:switch|login|logout)\b/.test(one) || /^git\s+remote\b/.test(one))
85}
86
87// A GitHub login: letters, digits and hyphens, never a leading hyphen, so it
88// can never reach a command as a flag.
89export function isLogin(name: string): boolean {
90  return /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(name)
91}
92
93// The account `gh auth switch` should move to, when switching gh changes
94// what a push signs in as and another signed-in account can push.
95export function switchTarget(status: AccountGuardStatus | null): string | undefined {
96  return status?.state === 'no-access' && status.isGhActive ? status.canPush[0] : undefined
97}
98
99export function bandLine(status: AccountGuardStatus): { text: string; isWarning: boolean } {
100  if (status.state === 'url-credential') return { text: `GH push uses a credential in the remote URL: ${status.repo}`, isWarning: false }
101  if (status.state === 'ssh-unchecked') return { text: 'GH ssh remote: account not checked. /gh-check', isWarning: false }
102  if (status.state === 'unknown') return { text: `GH status unknown for ${status.repo}: ${status.reason}`, isWarning: false }
103  const from = status.envToken === undefined ? '' : ` (from ${status.envToken})`
104  if (status.state === 'ok') return { text: `GH ${status.account}${from} -> ${status.repo}`, isWarning: false }
105  const who = status.via === 'ssh' ? `${status.account} (SSH key)` : `${status.account}${from}`
106  const [first, ...rest] = status.canPush
107  const fix =
108    status.envToken !== undefined
109      ? `${status.envToken} wins over gh auth switch.`
110      : first === undefined
111      ? 'No signed-in gh account can.'
112      : !status.isGhActive
113        ? `Can push: ${status.canPush.join(', ')}.`
114        : rest.length > 0
115          ? `Also can push: ${rest.join(', ')}.`
116          : '' // the switch button names the account
117
118  return { text: `GH ${who} cannot push to ${status.repo}.${fix === '' ? '' : ` ${fix}`}`, isWarning: true }
119}
120
types/index.d.ts 27 lines
1// The variable gh takes its token from, over every signed-in account.
2export type AccountGuardEnvToken = 'GH_TOKEN' | 'GITHUB_TOKEN'
3
4// What the band shows for the session's working directory.
5// ok: `account` can push to `repo`. no-access: it cannot; `canPush` lists the
6// other signed-in gh accounts that can; `isGhActive` when the push signs in as
7// gh's active account (https through gh's credential helper, no user in the
8// URL, no token in the env), so `gh auth switch` changes it. `envToken`: the
9// account is that variable's token. unknown: the check could not tell.
10// url-credential: the remote URL carries its own credential (never kept).
11// ssh-unchecked: an ssh remote whose key was not asked yet (/gh-check).
12export type AccountGuardStatus =
13  | { state: 'ok'; repo: string; account: string; envToken?: AccountGuardEnvToken }
14  | { state: 'no-access'; repo: string; account: string; via: 'https' | 'ssh'; isGhActive: boolean; canPush: string[]; envToken?: AccountGuardEnvToken }
15  | { state: 'unknown'; repo: string; reason: string }
16  | { state: 'url-credential'; repo: string }
17  | { state: 'ssh-unchecked'; repo: string }
18
19declare module 'claude-code' {
20  interface PluginState {
21    // status: null outside a git repo with a GitHub remote.
22    // access: whether an account can push to a repo, by "owner/repo@account"
23    // (lower case), so a check runs once per pair.
24    'account-guard': { status: AccountGuardStatus | null; access: Record<string, boolean> }
25  }
26}
27