Runs each git push, pull, fetch, clone and gh command as the logged-in GitHub account that owns or belongs to the repository's owner, so Claude stops switching…

Make the $20 Claude Pro plan last longer in Claude Code.
Twenty-two small mods that show you exactly where your usage goes and cut the waste. One mod makes a model call: prompt-polish, once each time you press Improve, and never on its own. The others make none. None adds anything to the system prompt (read-cap adds one tool, which Claude Code lists by name only until Claude first uses it; write-guard adds one line to a tool result at most once per conversation; compact-keeper adds a note of at most 4,000 characters after a compaction): every figure on screen is one Claude Code already reports, or a time the mod measured.
| Mod | What it does | Where |
|---|---|---|
| tool-diet | Loads tools you have not used lately on demand instead of with every request | Everywhere |
| skill-diet | Lists skills you have not used lately in this project by name only, without their descriptions | Everywhere |
| agent-diet | Runs Explore subagents on Haiku instead of your main model | Everywhere |
| context-xray | /xray opens the exact breakdown of what fills your context window | Everywhere |
| pro-hud | Live meters above the prompt for your 5-hour session, your week and the context window, plus a per-turn receipt of tokens in, cached and out | Claude desktop app |
| output-diet | Trims long shell output, search results and subagent reports before Claude reads them, keeping the head, the tail and the error lines; the untrimmed text is saved to a file Claude can open without a permission prompt | Everywhere |
| reread-guard | Skips Claude re-reading a file it already read when the file has not changed, with Read or a plain cat, sed -n, head, tail or Get-Content; a deliberate retry still goes through | Everywhere |
| write-guard | Steers Claude to Edit instead of rewriting an existing file in full with Write; a deliberate rewrite still goes through | Everywhere |
| loop-guard | Holds back a shell command that already failed twice in a row, so Claude changes approach | Everywhere |
| cmd-diet | Adds quiet flags to noisy shell commands before they run, so Claude reads short output from the start; errors, failures and warnings stay in full | Everywhere |
| gh-account | Runs each git push, pull, fetch, clone and gh command as the logged-in GitHub account that can see the repository, without switching the active account | Everywhere |
| cache-clock | Counts down until the prompt cache expires; once it has, shows exactly how many tokens your next message will re-send uncached | Everywhere |
| read-cap | Stops Claude reading a file over 1,000 lines whole (with Read, cat or Get-Content) and gives it an outline tool, so it reads only the lines it needs; a retry still reads the whole file | Everywhere |
| session-receipt | /receipt opens a pane with the exact tokens every turn of the session spent, and the costliest turns | Everywhere |
| peek | Typing status while background tasks run opens a pane with each one's exact elapsed time and last output lines, instead of sending the prompt to Claude | Everywhere |
| budget-guard | Holds a prompt back once your 5-hour or weekly usage reaches your limit (90% by default); sending it again goes through | Everywhere |
| turn-budget | When one turn uses more than +5 session points, writes a handoff and continues in a fresh session by itself; /handoff any time | Everywhere |
| compact-keeper | After a compaction, adds a note of exact facts from before it: your latest prompt in full, the todo list, the last failed command, files edited | Everywhere |
| collision-guard | Asks before Claude edits a file another chat on this machine changed in the last 30 minutes | Everywhere |
| answer-pane | Explain, plan and ELI5 pages drawn natively in a side pane; plans have decision buttons and Respond fills the prompt box | Desktop app (no diagrams in the terminal) |
| prompt-polish | An Improve button beside Send (above the prompt in the terminal) rewrites your draft with Opus at low effort and puts it back in the box; Undo restores it. One model call per press | Everywhere |
| kit-updates | Tells you when an installed mod from this kit has a newer version or a new mod joins the kit; /kit-update installs updates and new mods | Everywhere |

In Claude Code:
/plugin marketplace add VedantAndhale/claude-pro-kit
/plugin install tool-diet@claude-pro-kit
/plugin install skill-diet@claude-pro-kit
/plugin install agent-diet@claude-pro-kit
/plugin install context-xray@claude-pro-kit
/plugin install pro-hud@claude-pro-kit
/plugin install output-diet@claude-pro-kit
/plugin install reread-guard@claude-pro-kit
/plugin install write-guard@claude-pro-kit
/plugin install loop-guard@claude-pro-kit
/plugin install cmd-diet@claude-pro-kit
/plugin install gh-account@claude-pro-kit
/plugin install cache-clock@claude-pro-kit
/plugin install read-cap@claude-pro-kit
/plugin install session-receipt@claude-pro-kit
/plugin install peek@claude-pro-kit
/plugin install budget-guard@claude-pro-kit
/plugin install turn-budget@claude-pro-kit
/plugin install compact-keeper@claude-pro-kit
/plugin install collision-guard@claude-pro-kit
/plugin install answer-pane@claude-pro-kit
/plugin install prompt-polish@claude-pro-kit
/plugin install kit-updates@claude-pro-kit
Updates are off by default for marketplaces you add yourself. With kit-updates installed you are told when a fix ships and /kit-update installs it, from the desktop app or the terminal. Without it, turn on auto-update once: in a terminal, run claude, then /plugin → Marketplaces → claude-pro-kit → Enable auto-update; the desktop app has no toggle for it.
Install any one on its own; they do not depend on each other. Mods are not sandboxed, so read the code before installing: each mod is a single file under plugins/<name>/hooks/.
Every tool listed in front sends its whole description and schema with every request. A deferred tool is listed by name only, and Claude loads it through ToolSearch when it needs it; Claude Code already does this for most MCP tools. tool-diet does it for the rest of the tools you are not using: anything outside the core set (Bash, PowerShell, Read, Edit, Write, Glob, Grep, Agent, Skill, ToolSearch, TodoWrite, AskUserQuestion) that you have not used in your last five sessions.
Measured with one prompt, "Reply with just OK.", in a fresh session, as the API reported each request:
| Prompt tokens per request | |
|---|---|
| Without tool-diet | 43,859 |
| With tool-diet | 27,905 |
| Change | −15,954 (−36%) |
On that setup, the largest tool moved was Artifact, whose description alone is 19,870 characters. What moves depends on your tools and your habits; /xray shows yours.
/tool-diet lists what is on demand this session, grouped by where each tool comes from; /tool-diet keep <tool> always loads one, unkeep undoes it, and /tool-diet off|on switches it. Answers are toasts, so they add nothing to the conversation. The status line keeps the count: 38 tools on demand.
Every request carries the skill listing: each installed skill's name and its whole description. With a few plugins installed that is dozens of skills, most of which a given project never uses (video skills in a backend repo, document skills in a game). skill-diet keeps the skills you used lately in this project listed in full and lists the rest on one line by name only. The Skill tool still loads any of them, and typing /name still works.
Measured with one prompt, "Reply with just OK.", in a fresh session with 45 skills installed and the other mods on, as the API reported the request:
| Prompt tokens per request | |
|---|---|
| Without skill-diet | 27,423 |
| With skill-diet | 19,197 |
| Change | −8,226 (−30%) |
In the same setup, asked to fill in a PDF form, Claude found anthropic-skills:pdf from its name alone and loaded it with the Skill tool. What moves depends on your skills; /xray shows yours.
/name or called by Claude through the Skill tool./skill-diet shows what is listed by name only this session and how many characters left the listing; /skill-diet keep <skill> always lists one in full, unkeep undoes it, and /skill-diet off|on switches it. Answers are toasts, so they add nothing to the conversation. The status line keeps the count, in the form <n> skills by name only, <n> characters off./xray shows the skills line before and after, in tokens.A subagent runs on your main model unless its definition or Claude's Agent call names another. Explore only searches and reads, yet in the author's own 85 subagent transcripts, every one of the 2,043 Explore requests ran on Opus or Sonnet, reading 147,298,992 input tokens. agent-diet starts the agent types you list (Explore by default) on Haiku.
/agent-diet shows the setting and how many subagents it moved this session; /agent-diet model haiku|sonnet|opus picks the model, /agent-diet add|remove <agent type> changes the list, and /agent-diet off|on switches it. Answers are toasts, so they add nothing to the conversation. The status line keeps the count, in the form 2 subagents on haiku.Measured with claude -p --model sonnet --output-format json on a task that sends one Explore subagent to find a file, three runs each in alternating order. Every run found the file. Figures are the cost and tokens Claude Code reported per model:
| Run | Without: cost | Without: Sonnet tokens in | With: cost | With: Sonnet tokens in | With: Haiku tokens in |
|---|---|---|---|---|---|
| 1 | $0.05692 | 68,395 | $0.03724 | 40,213 | 27,848 |
| 2 | $0.05577 | 68,111 | $0.03764 | 39,593 | 27,872 |
| 3 | $0.05441 | 68,123 | $0.03736 | 40,184 | 42,770 |
| Average | $0.05570 | $0.03741 |

/xray opens a pane with the exact breakdown /context computes: what is sent with every request (system prompt, tools, MCP tools, memory files, skills, messages), what is loaded on demand, which MCP tools load every time, and each memory file's size. It measures when the pane opens and when you press Refresh (or r), never in the background, because the exact count sends one token-count request per tool and memory file.
Opens by itself when the context crosses 60% and again at 80%, with a toast pointing at /compact and /handoff. /xray auto off keeps it to /xray only.
The recording at the top of this page is the band. In text:
Session ━━━━━━━━━━━━━━────── 69% resets in 32m
Week ━━━━━━━━━━━━━━━───── 74% resets in 4d 17h
Context ━━━━━━━━──────────── 44% 436,034 tokens
This turn 1m 35s · 1 tool · 0 files edited · 436,034 in · 431,260 cached · 580 out
On a wide window the three meters sit side by side on one row; on a narrow one each figure on the turn line wraps whole rather than being cut off.
/compact when it climbs.· session 20%, and finished tool calls draw as one line: status dot, tool, target, time./hud shows what is on; /hud all on|off, or /hud band|spinner|cards on|off. The answer is a toast, so toggling adds nothing to the conversation. It draws in the desktop app only and leaves the terminal as it is. Rows other mods put above the prompt still draw beneath the band.
Weekly toasts at 50%, 75% and 90% of the week, once each, because the weekly limit drains quietly across many sessions.
When a Bash or PowerShell result runs past 120 lines or 8,000 characters, Claude reads:
[output-diet: 82/500 lines shown; all 500 in ~/.claude/projects/<project>/<session>/tool-results/output-diet-<call>.txt]
<first 30 lines>
… [lines 31–450 omitted; error/warning lines from them:]
250│ ERROR: build failed in src/app.ts
300│ Warning: deprecated API
…
<last 50 lines>
3 trimmed · 41,200 chars saved.tool-results folder, beside the transcript, where Claude Code keeps the outputs it saves itself, so Claude can open it without a permission prompt. When that folder cannot be found, it goes under ~/.claude/output-diet/ (or CLAUDE_CONFIG_DIR).Grep result past the same limits keeps its first 100 lines, with a note to narrow the search: [output-diet: first 100/252 lines shown; narrow the search, or read all in …]. In the author's 1,048 past Grep results, 26 went past the limits, and keeping the first 100 lines would have cut 104,052 characters. Glob is left alone: none of 128 went past.When Claude asks to Read the same range of the same file again in the same conversation, and the file's size and modification time have not changed, the read is skipped and Claude is told to use the copy it has. Any edit, a different range, or a subagent (which has its own context) reads freely. Claude Code can clear old tool results from context, so retrying the identical read straight after a skip always goes through. The record resets on /compact and /clear.
What Claude is told in place of the repeat read, kept to one line because the model reads it:
api.ts unchanged since you read it; use that copy. If it's gone from context, retry the same Read.
Shell reads too. Claude often reads a file with the shell instead of Read: in the author's transcripts, 318 sed -n runs went past the guard. A shell command that only prints one file is now treated the same way: cat FILE, sed -n 'A,Bp' FILE, head -n N / tail -n N, and in PowerShell Get-Content, gc, cat or type (with -TotalCount, -Head, -First, -Tail, -Last or -Raw). The same range of the same unchanged file is skipped once, and retrying the same command goes through. A whole cat after a whole Read that showed every line counts as a repeat. A pipe, a chain, a variable, a glob or any other flag runs untouched. A shell read never counts as a Read, since Edit needs a real one first.
The status line counts them: 2 re-reads skipped.
Write sends the whole file as Claude's output, the most expensive kind of token; Edit sends only the lines that change. Claude sometimes rewrites a file it has already read in full to change a few lines. In the author's own 272 session transcripts, 141 writes rewrote a file Claude had already read or written (999,273 characters), and 112 of them followed an earlier rewrite in the same session. Of the 512,847 characters in the rewrites whose previous version was in the transcript, 171,325 had changed.
A hook runs after Claude has written the content, so write-guard cannot save the rewrite it sees; it stops the ones after it:
You rewrote all of api.ts. For changes to an existing file use Edit: it sends only the changed lines.api.ts exists; change it with Edit, not a full Write. If a full rewrite is intended, retry the same Write. Retrying the same Write goes through./clear starts over.1 full rewrite held back.Each retry of a failing command re-sends the whole conversation, and the same command usually fails the same way. In the author's 272 session transcripts, 10 commands failed 3 or more times, 38 runs between them.
Once the exact same Bash or PowerShell command has failed twice in a row, the next try is held back once, and Claude is told:
This exact command failed 2 times in a row. Change the approach instead of rerunning it. If a rerun is intended, retry the same command.
/clear starts over.1 failing retry held back.output-diet trims long output after a command has run; cmd-diet keeps the noise from being printed at all. Before a Bash or PowerShell command runs, a known noisy command gets its own quiet flags, which drop progress lines and keep errors, test failures and warnings:
| Command | Runs as | Output, measured |
|---|---|---|
git status | git status --short --branch | 415 to 58 characters |
pytest | pytest -q | 1,063 to 495 characters, failure details unchanged |
cargo build / test / check / clippy / run | cargo build -q | 197 to 0 characters on success, warnings unchanged |
npm install / ci | npm install --no-audit --no-fund | 62 to 18 characters |
curl (Bash only) | curl -sS | 1,049 to 577 characters, the progress meter removed |
mvn | mvn -B -ntp | not measured: drops download progress |
wget (Bash only) | wget -nv | not measured: one line per file |
docker pull | docker pull -q | not measured: drops layer progress |
In a live headless run (git status && python -m pytest on 41 test files, one failing), the request cost 41,535 tokens without cmd-diet and 40,571 with it, by the API's usage, and both runs named the failing test and its reason correctly. The shorter output stays in context, so every later request in the session sends less too.
&&, || or ; chain is handled on its own. A step that pipes, redirects or substitutes (| grep, > file, $(...)) is left alone, since something else reads its output; 2>&1 is fine.git status --porcelain, pytest -v, curl -fsSL, npm install --silent) is left alone, and a flag already present is not added twice.3 commands quieted.With two GitHub accounts logged in to gh, a push to a repository the other account owns fails, and Claude spends turns on gh auth status and gh auth switch. Before a Bash or PowerShell command runs, gh-account matches each git push, pull, fetch, clone, ls-remote and gh step to the repository's owner, and the owner to a logged-in account. When that account is not gh's active one, that step alone runs with its token:
GH_TOKEN="$(gh auth token --user ACCOUNT)" git push
The token itself never appears in the command or its output, and the active account is not switched. For git it also asks gh for the credential.
-R owner/repo, a gh api repos/<owner>/... path, or the folder's remotes. With no remote named, every remote must point at the same owner.gh auth status, and organizations from gh api user/orgs. What it learns is kept across sessions.gh auth and other gh commands that reach no repository, a command that already sets GH_TOKEN, a token from the environment, git over ssh, hosts other than github.com, and subshells or substitutions./gh-account shows which account each owner uses, and /gh-account forget clears it. Answers are toasts. The status line counts them: 2 commands sent as <account>.Claude's prompt cache keeps your conversation for a fixed time after each request. Reply within it and the context is read from cache; reply after it and the whole context is sent again at full price. That message pays the cache-write price (1.25 times the input price for a 5-minute cache, 2 times for a 1-hour one) on every token instead of the cache-read price (0.1 times): 12.5 to 20 times more for the same context, and nothing on screen says so.
cache-clock puts the countdown in the status line, restarted by every response from the main conversation:
cache warm · 3m left
cache cold · next message re-sends 61,204 tokens
When the cache runs out, a toast says so once (after the lifetime is known, see below). The token figure is the previous response
hooks/register.ts 498 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Two GitHub accounts logged into gh, one active: a push to a repository the
4// other account owns fails, and Claude spends turns on `gh auth status` and
5// `gh auth switch`. Before a shell command runs, each `git push|pull|fetch|
6// clone|ls-remote` and `gh ...` step is matched to the repository's owner
7// (from the URL in the command, `-R owner/repo`, or the folder's remotes),
8// and the owner to the one logged-in account that is that owner or a member
9// of it. When that account is not gh's active one, the step alone runs with
10// its token: `GH_TOKEN="$(gh auth token --user ACCOUNT)"`, so the token itself
11// never appears in the command or its output. Nothing is guessed: no owner, no
12// single matching account, or anything unclear leaves the command as typed.
13
14const SHELLS = new Set(['Bash', 'PowerShell'])
15
16const GIT_SUBS = new Set(['push', 'pull', 'fetch', 'clone', 'ls-remote'])
17
18// gh subcommands that never reach a repository, or manage the accounts themselves.
19const GH_SKIP = new Set(['auth', 'config', 'alias', 'completion', 'help', 'version', 'extension', 'ext', 'status'])
20
21// Options of git push/pull/fetch/clone/ls-remote that take the next token as their value.
22const GIT_VALUE_OPTIONS = new Set([
23 '-o', '--push-option', '--repo', '--receive-pack', '--exec', '--upload-pack', '-u', '--depth', '-j', '--jobs',
24 '--shallow-since', '--shallow-exclude', '--deepen', '--negotiation-tip', '--server-option', '-s', '--strategy',
25 '-X', '--strategy-option', '-b', '--branch', '--origin', '--reference', '--reference-if-able',
26 '--separate-git-dir', '--template', '-c', '--config', '--filter', '--bundle-uri', '--ref-format', '--sort',
27])
28
29const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/
30
31const CREDENTIAL = "-c credential.helper= -c 'credential.helper=!gh auth git-credential'"
32
33export type Account = { login: string, active: boolean, fromEnv: boolean }
34
35export type Owner = { owner: string, https: boolean }
36
37// What a step needs to find its repository's owner.
38export type Step = {
39 kind: 'git' | 'gh'
40 sub: string
41 // Named in the command itself; `null` when the command names something that is not GitHub.
42 named?: Owner | null
43 // A remote name given to git; undefined means the folder's remotes decide.
44 remote?: string
45 // `git -C dir`.
46 dir?: string
47}
48
49// Splits at top-level `&&`, `||`, `;` and newlines, outside quotes; the
50// separators are kept at odd indexes. Undefined for an unclosed quote.
51export function segments(command: string): string[] | undefined {
52 const out: string[] = []
53 let cur = ''
54 let quote: string | undefined
55 for (let i = 0; i < command.length; i++) {
56 const c = command[i]
57 if (quote !== undefined) {
58 cur += c
59 if (c === quote) quote = undefined
60 continue
61 }
62 if (c === "'" || c === '"') {
63 quote = c
64 cur += c
65 continue
66 }
67 const two = command.slice(i, i + 2)
68 if (two === '&&' || two === '||') {
69 out.push(cur, two)
70 cur = ''
71 i++
72 continue
73 }
74 if (c === ';' || c === '\n') {
75 out.push(cur, c)
76 cur = ''
77 continue
78 }
79 cur += c
80 }
81 if (quote !== undefined) return undefined
82 out.push(cur)
83 return out
84}
85
86function unquoted(text: string): string {
87 return text.replace(/'[^']*'|"[^"]*"/g, '""')
88}
89
90// Words of one step, quotes removed, up to the first pipe or redirect.
91export function words(segment: string): string[] {
92 const out: string[] = []
93 const re = /'([^']*)'|"([^"]*)"|(\S+)/g
94 let cur: string | undefined
95 let last = -1
96 for (let m = re.exec(segment); m !== null; m = re.exec(segment)) {
97 const piece = m[1] ?? m[2] ?? m[3]
98 if (m[3] !== undefined && /^(\d?>|<|\||&)/.test(m[3])) break
99 if (cur !== undefined && m.index === last) cur += piece
100 else {
101 if (cur !== undefined) out.push(cur)
102 cur = piece
103 }
104 last = m.index + m[0].length
105 }
106 if (cur !== undefined) out.push(cur)
107 return out
108}
109
110// The owner in a GitHub remote URL, and whether git would reach it over https
111// (where a credential helper, and so GH_TOKEN, is used at all).
112export function githubOwner(url: string): Owner | undefined {
113 const https = url.match(/^https?:\/\/(?:[^@/]+@)?github\.com\/([A-Za-z0-9-]+)(?:\/|$)/i)
114 if (https !== null) return { owner: https[1], https: true }
115 const ssh = url.match(/^(?:ssh:\/\/)?[^@/\s]+@github\.com[:/]([A-Za-z0-9-]+)\//i)
116 if (ssh !== null) return { owner: ssh[1], https: false }
117 return undefined
118}
119
120// `owner/repo`, `github.com/owner/repo` or a URL, as gh's -R takes it.
121function repoOwner(value: string): Owner | null {
122 const url = githubOwner(value)
123 if (url !== undefined) return url
124 const m = value.match(/^(?:github\.com\/)?([A-Za-z0-9-]+)\/[A-Za-z0-9._-]+$/i)
125 return m !== null ? { owner: m[1], https: true } : null
126}
127
128// The first argument that is not an option or an option's value.
129function positional(args: string[]): string | undefined {
130 for (let i = 0; i < args.length; i++) {
131 const a = args[i]
132 if (a === '--') return args[i + 1]
133 if (a.startsWith('-')) {
134 if (GIT_VALUE_OPTIONS.has(a)) i++
135 continue
136 }
137 return a
138 }
139 return undefined
140}
141
142// What a step needs, or undefined when it is not a step this mod handles.
143export function parseStep(segment: string): Step | undefined {
144 const w = words(segment)
145 if (w[0] === 'git') {
146 let i = 1
147 let dir: string | undefined
148 while (i < w.length && w[i].startsWith('-')) {
149 if (w[i] === '-C') {
150 dir = w[i + 1]
151 i += 2
152 } else if (w[i] === '-c') i += 2
153 else if (/^--(git-dir|work-tree|namespace)/.test(w[i])) return undefined
154 else i++
155 }
156 const sub = w[i]
157 if (sub === undefined || !GIT_SUBS.has(sub)) return undefined
158 if (dir !== undefined && /[$~]/.test(dir)) return undefined
159 const target = positional(w.slice(i + 1))
160 if (sub === 'clone') return { kind: 'git', sub, named: target === undefined ? null : githubOwner(target) ?? null }
161 if (target === undefined) return { kind: 'git', sub, dir }
162 const url = githubOwner(target)
163 if (url !== undefined) return { kind: 'git', sub, named: url, dir }
164 if (/[:/\\$]|^\./.test(target)) return { kind: 'git', sub, named: null, dir }
165 return { kind: 'git', sub, remote: target, dir }
166 }
167 if (w[0] === 'gh') {
168 const sub = w[1]
169 if (sub === undefined || sub.startsWith('-') || GH_SKIP.has(sub)) return undefined
170 for (let i = 2; i < w.length; i++) {
171 const a = w[i]
172 const value = a === '-R' || a === '--repo' ? w[i + 1] : a.startsWith('--repo=') ? a.slice(7) : a.startsWith('-R') && a.length > 2 ? a.slice(2) : undefined
173 if (value !== undefined) return { kind: 'gh', sub, named: repoOwner(value) }
174 }
175 if (sub === 'api') {
176 const path = w.slice(2).find(a => !a.startsWith('-'))
177 const m = path?.match(/^\/?(?:repos|orgs|users)\/([A-Za-z0-9-]+)(?:\/|$)/)
178 if (m) return { kind: 'gh', sub, named: { owner: m[1], https: true } }
179 }
180 if (sub === 'repo' && w[3] !== undefined && !w[3].startsWith('-')) {
181 const named = repoOwner(w[3])
182 if (named !== null) return { kind: 'gh', sub, named }
183 }
184 for (const a of w.slice(2)) {
185 const url = githubOwner(a)
186 if (url !== undefined) return { kind: 'gh', sub, named: url }
187 }
188 return { kind: 'gh', sub }
189 }
190 return undefined
191}
192
193// The directory a `cd` step moves to: a string, `null` when it cannot be
194// known, undefined when the step is not a directory change.
195export function cdTarget(segment: string, base: string): string | null | undefined {
196 const w = words(segment)
197 if (w.length === 0) return undefined
198 const verb = w[0].toLowerCase()
199 if (verb === 'pushd' || verb === 'popd' || verb === 'push-location' || verb === 'pop-location') return null
200 if (verb !== 'cd' && verb !== 'set-location' && verb !== 'sl' && verb !== 'chdir') return undefined
201 const args = w.slice(1).filter(a => a.toLowerCase() !== '-path' && a.toLowerCase() !== '-literalpath')
202 if (args.length !== 1) return null
203 return resolveDir(base, args[0])
204}
205
206export function resolveDir(base: string, path: string): string | null {
207 if (path === '-' || /[$~*?%]/.test(path) || path.startsWith('-')) return null
208 // Git Bash's `/d/repo` is `D:/repo`, on Windows only.
209 const drive = /^[a-zA-Z]:/.test(base) ? path.match(/^\/([a-zA-Z])(\/.*)?$/) : null
210 if (drive !== null) return `${drive[1].toUpperCase()}:${drive[2] ?? '/'}`
211 if (/^([a-zA-Z]:)?[\\/]/.test(path)) return path
212 return `${base.replace(/[\\/]+$/, '')}/${path}`
213}
214
215// `gh auth status --json hosts`, or the plain text older gh versions print.
216export function parseAccounts(output: string): Account[] {
217 try {
218 const parsed = JSON.parse(output) as { hosts?: Record<string, { login?: string, active?: boolean, state?: string, tokenSource?: string }[]> }
219 const list = parsed.hosts?.['github.com']
220 if (Array.isArray(list)) {
221 return list
222 .filter(a => typeof a.login === 'string' && LOGIN.test(a.login) && (a.state === undefined || a.state === 'success'))
223 .map(a => ({ login: a.login as string, active: a.active === true, fromEnv: /TOKEN$/.test(a.tokenSource ?? '') }))
224 }
225 } catch {
226 // Plain text below.
227 }
228 const out: Account[] = []
229 let host = ''
230 for (const line of output.split(/\r?\n/)) {
231 if (/^\S/.test(line)) host = line.trim()
232 const m = line.match(/Logged in to (\S+) account (\S+)(?: \((\S+)\))?/)
233 if (m !== null) {
234 if (m[1] === 'github.com' && LOGIN.test(m[2])) out.push({ login: m[2], active: false, fromEnv: /TOKEN$/.test(m[3] ?? '') })
235 continue
236 }
237 if (host === 'github.com' && /Active account: true/.test(line) && out.length > 0) out[out.length - 1].active = true
238 }
239 return out
240}
241
242// The account for an owner: the one whose login is the owner, else the one
243// account that is a member of it. `orgs` absent checks the login alone; any
244// account's orgs unknown means no answer.
245export function pickAccount(owner: string, accounts: readonly Account[], orgs?: Readonly<Record<string, readonly string[] | undefined>>): string | undefined {
246 const lower = owner.toLowerCase()
247 const self = accounts.find(a => a.login.toLowerCase() === lower)
248 if (self !== undefined) return self.login
249 if (orgs === undefined || accounts.some(a => orgs[a.login] === undefined)) return undefined
250 const members = accounts.filter(a => orgs[a.login]?.some(o => o.toLowerCase() === lower))
251 return members.length === 1 ? members[0].login : undefined
252}
253
254// The step run as `account`; git also asks gh for the credential.
255export function rewriteStep(segment: string, kind: 'git' | 'gh', account: string, shell: string): string {
256 const lead = segment.match(/^\s*/)?.[0] ?? ''
257 const trail = segment.match(/\s*$/)?.[0] ?? ''
258 let body = segment.slice(lead.length, segment.length - trail.length)
259 if (kind === 'git') body = `git ${CREDENTIAL}${body.slice(3)}`
260 if (shell === 'PowerShell') {
261 return `${lead}$ghAccountPrev = $env:GH_TOKEN; try { $env:GH_TOKEN = (gh auth token --user ${account}); ${body} } finally { $env:GH_TOKEN = $ghAccountPrev }${trail}`
262 }
263 return `${lead}GH_TOKEN="$(gh auth token --user ${account})" ${body}${trail}`
264}
265
266// Commands this mod leaves whole: a token already chosen, or shell syntax
267// (subshells, substitutions, blocks) where steps and folders cannot be read exactly.
268export function untouchable(command: string, shell: string): boolean {
269 if (/GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN/.test(command)) return true
270 const bare = unquoted(command)
271 return shell === 'PowerShell' ? /[{}()`]/.test(bare) : /[()`{}]|\$\(/.test(bare)
272}
273
274type Remote = { name: string, fetch?: string, push?: string }
275
276export function parseRemotes(output: string): Remote[] {
277 const byName = new Map<string, Remote>()
278 for (const line of output.split(/\r?\n/)) {
279 const m = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/)
280 if (m === null) continue
281 const remote = byName.get(m[1]) ?? { name: m[1] }
282 remote[m[3] as 'fetch' | 'push'] = m[2]
283 byName.set(m[1], remote)
284 }
285 return [...byName.values()]
286}
287
288// The owner a step reaches from the folder's remotes: the named remote, or
289// every remote when none is named, and then only if they all agree.
290export function remoteOwner(step: Step, remotes: readonly Remote[]): Owner | undefined {
291 const urlOf = (r: Remote) => (step.sub === 'push' ? r.push ?? r.fetch : r.fetch ?? r.push)
292 if (step.remote !== undefined) {
293 const r = remotes.find(x => x.name === step.remote)
294 const url = r === undefined ? undefined : urlOf(r)
295 return url === undefined ? undefined : githubOwner(url)
296 }
297 const owners = remotes.map(r => {
298 const url = urlOf(r)
299 return url === undefined ? undefined : githubOwner(url)
300 })
301 if (owners.length === 0 || owners.some(o => o === undefined)) return undefined
302 const first = owners[0] as Owner
303 const same = owners.every(o => o!.owner.toLowerCase() === first.owner.toLowerCase() && o!.https === first.https)
304 return same ? first : undefined
305}
306
307export function describeMap(map: Readonly<Record<string, string>>): string {
308 const pairs = Object.entries(map).sort(([a], [b]) => a.localeCompare(b))
309 if (pairs.length === 0) return 'gh-account: nothing learned yet'
310 return `gh-account: ${pairs.map(([owner, account]) => `${owner} -> ${account}`).join(', ')}`
311}
312
313export function describeSent(sent: ReadonlyMap<string, number>): string {
314 return [...sent].map(([account, n]) => `${n} ${n === 1 ? 'command' : 'commands'} sent as ${account}`).join(' · ')
315}
316
317const STORE_KEY = 'owners'
318
319// What one session has read: accounts, remotes per folder, orgs per account,
320// owners no account matched, and how many commands went out as whom.
321type State = {
322 accounts?: Promise<Account[]>
323 remotes: Map<string, Promise<Remote[] | undefined>>
324 orgs: Map<string, Promise<string[] | undefined>>
325 misses: Set<string>
326 sent: Map<string, number>
327}
328
329function run($: EngineInterface, argv: string[], init: { cwd?: string, env?: Record<string, string> } = {}) {
330 return $.process.run(argv, { ...init, timeoutMs: 15_000 }).catch(() => ({ exitCode: 1, stdout: '', stderr: '' }))
331}
332
333function listAccounts($: EngineInterface, state: State): Promise<Account[]> {
334 state.accounts ??= (async () => {
335 const json = await run($, ['gh', 'auth', 'status', '--json', 'hosts'])
336 if (json.exitCode === 0) {
337 const found = parseAccounts(json.stdout)
338 if (found.length > 0) return found
339 }
340 const text = await run($, ['gh', 'auth', 'status'])
341 return parseAccounts(`${text.stdout}\n${text.stderr}`)
342 })()
343 return state.accounts
344}
345
346function remotesOf($: EngineInterface, state: State, dir: string): Promise<Remote[] | undefined> {
347 let found = state.remotes.get(dir)
348 if (found === undefined) {
349 found = run($, ['git', 'remote', '-v'], { cwd: dir }).then(r => (r.exitCode === 0 ? parseRemotes(r.stdout) : undefined))
350 state.remotes.set(dir, found)
351 }
352 return found
353}
354
355// The token stays inside this call: handed to `gh api` as its environment only.
356function orgsOf($: EngineInterface, state: State, login: string): Promise<string[] | undefined> {
357 let found = state.orgs.get(login)
358 if (found === undefined) {
359 found = (async () => {
360 const token = await run($, ['gh', 'auth', 'token', '--user', login])
361 const value = token.stdout.trim()
362 if (token.exitCode !== 0 || value === '') return undefined
363 const listed = await run($, ['gh', 'api', 'user/orgs', '--paginate', '--jq', '.[].login'], { env: { GH_TOKEN: value } })
364 return listed.exitCode === 0 ? listed.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean) : undefined
365 })()
366 state.orgs.set(login, found)
367 }
368 return found
369}
370
371async function learned($: EngineInterface): Promise<Record<string, string>> {
372 return ((await $.store.get(STORE_KEY)) ?? {}) as Record<string, string>
373}
374
375// The login match first, then an owner learned in an earlier session (still
376// logged in), then the org lists; the answer is kept in the store.
377async function accountFor($: EngineInterface, state: State, owner: string, list: Account[]): Promise<string | undefined> {
378 const key = owner.toLowerCase()
379 let chosen = pickAccount(owner, list)
380 const map = await learned($)
381 if (chosen === undefined && map[key] !== undefined && list.some(a => a.login === map[key])) chosen = map[key]
382 if (chosen === undefined && !state.misses.has(key)) {
383 const known: Record<string, string[] | undefined> = {}
384 for (const a of list) known[a.login] = await orgsOf($, state, a.login)
385 chosen = pickAccount(owner, list, known)
386 }
387 if (chosen === undefined) {
388 state.misses.add(key)
389 return undefined
390 }
391 if (map[key] !== chosen) await $.store.set(STORE_KEY, { ...map, [key]: chosen })
392 return chosen
393}
394
395async function rewriteCommand($: EngineInterface, state: State, command: string, shell: string): Promise<{ command: string, account: string } | undefined> {
396 if (untouchable(command, shell)) return undefined
397 const parts = segments(command)
398 if (parts === undefined) return undefined
399
400 let cwd: string | undefined
401 let dir: string | null | undefined
402 let list: Account[] | undefined
403 let account: string | undefined
404
405 for (let i = 0; i < parts.length; i += 2) {
406 const part = parts[i]
407 const body = part.trimStart()
408 const base = dir === undefined ? (cwd ??= await $.session.cwd()) : dir
409 if (base !== null) {
410 const moved = cdTarget(body, base)
411 if (moved !== undefined) {
412 dir = moved
413 continue
414 }
415 } else if (cdTarget(body, '.') !== undefined) continue
416
417 const step = parseStep(body)
418 if (step === undefined) continue
419
420 let owner: Owner | undefined
421 if (step.named !== undefined) owner = step.named ?? undefined
422 else if (base !== null) {
423 const where = step.dir === undefined ? base : resolveDir(base, step.dir)
424 const found = where === null ? undefined : await remotesOf($, state, where)
425 owner = found === undefined ? undefined : remoteOwner(step, found)
426 }
427 if (owner === undefined || (step.kind === 'git' && !owner.https)) continue
428
429 list ??= await listAccounts($, state)
430 if (list.length === 0 || list.some(a => a.fromEnv)) return undefined
431 const chosen = await accountFor($, state, owner.owner, list)
432 if (chosen === undefined || list.find(a => a.active)?.login === chosen) continue
433
434 parts[i] = rewriteStep(part, step.kind, chosen, shell)
435 account = chosen
436 }
437
438 return account !== undefined ? { command: parts.join(''), account } : undefined
439}
440
441export const register: Register = on => {
442 const state: State = { remotes: new Map(), orgs: new Map(), misses: new Set(), sent: new Map() }
443
444 on('session.start', async ($, e, next) => {
445 state.accounts = undefined
446 state.remotes.clear()
447 state.orgs.clear()
448 state.misses.clear()
449 state.sent.clear()
450 await $.command.register({
451 name: 'gh-account',
452 description: 'gh-account: /gh-account shows which account each GitHub owner uses · forget clears it',
453 })
454 return next(e)
455 })
456
457 on('tool.call', async ($, e, next) => {
458 if (!SHELLS.has(e.tool) || typeof e.command !== 'string') return next(e)
459 const command = e.command
460
461 const changed = await rewriteCommand($, state, command, e.tool).catch(() => undefined)
462 const ran = await next(changed === undefined ? e : { ...e, command: changed.command })
463
464 // An account switch, login or logout, or a remote added or moved: read them again.
465 if (/\bgh\s+auth\s+(switch|login|logout|refresh)\b/.test(command)) {
466 state.accounts = undefined
467 state.orgs.clear()
468 state.misses.clear()
469 }
470 if (/\bgit\b[^;&|\n]*\b(remote|clone|init)\b/.test(command)) state.remotes.clear()
471
472 if (changed !== undefined && ran.deny === undefined) {
473 state.sent.set(changed.account, (state.sent.get(changed.account) ?? 0) + 1)
474 $.ui.status(describeSent(state.sent))
475 }
476 return ran
477 })
478
479 // Answered as a toast: nothing is added to the conversation.
480 on('command.run', { command: 'gh-account' }, async ($, e) => {
481 const args = (e.args ?? '').trim()
482 if (args === '') {
483 $.ui.toast(describeMap(await learned($)))
484 return {}
485 }
486 if (args === 'forget') {
487 const count = Object.keys(await learned($)).length
488 await $.store.delete(STORE_KEY)
489 state.orgs.clear()
490 state.misses.clear()
491 $.ui.toast(`gh-account: forgot ${count} ${count === 1 ? 'owner' : 'owners'}`)
492 return {}
493 }
494 $.ui.toast('gh-account: use /gh-account to list, /gh-account forget to clear')
495 return {}
496 })
497}
498