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.

Personal Claude Code mods.
/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
/team shows or hides the pane.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)./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 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./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).claude --plugin-dir ./plugins/team-board
claude plugin validate ./plugins/team-board
claude plugin test ./plugins/team-boardhooks/register.tsx 320 lines1import { 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}
320hooks/parse.ts 120 lines1import 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}
120types/index.d.ts 27 lines1// 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