Band above the prompt showing the current branch's PR: CI, Copilot review, open threads

My personal macOS dotfiles. Originally based on https://jooooel.com/sync-dotfiles-using-git/
All the dotfiles start with an underscore (_zshrc, _gitconfig, ...) so they aren't hidden when browsing the repo. This makes ls behave nicely. The install script maps each one to its real dotted location.
Do these in order. Steps 1-6 get a working machine; step 7 covers the things no script can do (permissions, sign-ins) and must be done by hand.
Gives you git so you can clone this repo. (Homebrew's installer in step 2 also triggers this, so you can skip straight to step 2 if you prefer.)
xcode-select --install
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
The installer prints a "Next steps" note telling you to add Homebrew to your PATH. On Apple Silicon that is:
eval "$(/opt/homebrew/bin/brew shellenv)"
Run that now so brew works in the current shell. (Once step 5 links ~/.zshrc and you open a new terminal, Homebrew is on PATH for good.)
It must live at ~/dotfiles (some configs reference that absolute path).
git clone https://github.com/jquintus/dotfiles ~/dotfiles
cd ~/dotfiles
Reads the Brewfile in the repo root: CLI tools, casks (GUI apps), and taps.
brew bundle install
Note: the Brewfile has no Mac App Store (mas) entries, so App-Store-only apps are not installed here. Install those by hand from the App Store (see step 7).
Links every config into place. No dependencies beyond bash, so it is safe to run even before brew bundle if you like.
./scripts/install-mac.sh
The one-to-one links are declared in scripts/links.manifest (a source | target | description table) that the script loops over. To add a config later, add a line to the manifest rather than editing the script. bin/* is linked by a glob, so new scripts there are picked up automatically.
The script also installs Claude Code, which is deliberately not in the Brewfile: the cask lags upstream and never self-updates. The native installer drops a self-updating launcher in ~/.local/bin/claude (already on PATH), and the step is skipped when that launcher already exists. It only needs curl, and warns rather than fails if the download does not work. Confirm with claude doctor: it should report native and Auto-updates: enabled.
It installs Codex the same way and for a related reason: the codex cask is current with upstream, but it is not the standalone install the background app-server daemon starts from, so codex agents refuses to run under it. The official installer lands a self-updating build in ~/.codex/packages/standalone and links it into ~/.local/bin/codex. Confirm with codex doctor: the runtime line should say standalone, not brew.
It installs the Cursor CLI the same way, for the first reason rather than the second: the cursor-cli cask pins a build inside the Caskroom, which fights the binary's own auto-update. The official installer lands a self-updating build in ~/.local/share/cursor-agent/versions and links it into ~/.local/bin twice, as cursor-agent and as the much more collidable agent. Confirm with cursor-agent about.
Copilot's CLI is the exception that stays in the Brewfile. Its cask is marked auto_updates, so the binary updates itself with copilot update and brew never has to catch up.
Last, it runs bin/codex-sync, which hands Codex the same subagents, skills and global instructions Claude Code runs under. See "Codex" below for what that generates and when to rerun it.
Open a new terminal (or source ~/.zshrc) after this so the new shell config takes effect.
Sets system preferences via defaults write (key repeat, menu-bar clock seconds, muted startup chime, disabled F11 Show Desktop, and more). Kept separate from linking so symlinking never mutates OS state. Idempotent, safe to re-run. Prompts for sudo once (to mute the startup chime, which lives in firmware).
./scripts/macos-defaults.sh
Some changes need a logout or restart to fully take effect.
These require clicking through UI, signing in, or granting OS permissions. There is no way to automate them, so run through the checklist by hand:
gh auth login. The token lives in gh/hosts.yml, which is gitignored and machine-local by design, so it is never in this repo.claude/settings.json lists the marketplaces and enables the plugins, but declaring one does not fetch it. Claude Code prompts on first run; to skip the prompt: `` claude plugin install search-sessions@search-sessions ` The search-sessions` binary itself comes from the Brewfile. The plugin is the separate piece that lets Claude search session history on your behalf.codex login. install-mac.sh installs and configures the CLI, but the credentials land in ~/.codex/auth.json, which is machine-local and never in this repo. Confirm with codex doctor.cursor-agent login, which opens a browser. Check it took with cursor-agent status.copilot login. It can also read a GITHUB_TOKEN from the environment instead, but the interactive login is the simpler path.hammerspoon/.hammerspoon/meet.lua. (The matching Chrome-side setting, "Allow JavaScript from Apple Events," is set by macos-defaults.sh and takes effect on Chrome's next launch.)karabiner_grabber and Karabiner-Elements. Without these, Caps Lock silently stays Caps Lock and every Hyper binding in hammerspoon/launcher.lua is dead. See karabiner/README.md.brew install --cask logi-options+, deliberately not in the Brewfile — it was installed by hand, so a brew bundle dump --force would drop the line again). The MX Anywhere 3 needs it for two things, both of which must be clicked in by hand because Options+ keeps its settings in its own sqlite database:Ctrl+- and Ctrl+Shift+-, so they navigate back and forward there. VS Code ignores mouse thumb buttons natively.Both of these have to live in Options+ rather than Karabiner, and the agent has to keep running for them to work. karabiner/README.md explains why.
~/.pgpass and any real DB connection details (see zsh/_zshrc-aliases).zsh/_zshrc-<LocalHostName> if it exists. Find this machine's name with scutil --get LocalHostName, then create that file for host-specific tweaks.The Brewfile is a snapshot of installed Homebrew taps, formulae, and casks.
# Install everything from the Brewfile (e.g. on a new machine)
brew bundle install
# Re-snapshot after installing/removing packages
brew bundle dump --force
# Check whether all Brewfile entries are installed
brew bundle check
# Show installed packages NOT in the Brewfile (add --force to uninstall them)
brew bundle cleanup
scripts/macos-defaults.sh holds every defaults write (and a few related nvram / duti calls). Each setting is annotated with where it lives in System Settings. Run it with ./scripts/macos-defaults.sh; it is idempotent.
Some apps keep their user config outside $HOME (e.g. under Application Support). Those live in their own repo dirs and are symlinked into place by install-mac.sh:
| Repo dir | Symlinked to |
|---|---|
vscode/settings.json | ~/Library/Application Support/Code/User/settings.json |
claude/settings.json | ~/.claude/settings.json |
Because they're symlinks, editing the app's settings (via its UI or by hand) writes straight back to this repo. So after changing a setting, remember to git commit && git push here. The diff shows up under vscode/ or claude/.
Note: the Claude source dir is claude/, not .claude/, on purpose. A .claude/settings.json would be read as project settings whenever Claude runs in this repo (double-registering hooks). Keep it as claude/.
Codex reads the same ideas as Claude Code out of a different shape of config, so bin/codex-sync translates rather than symlinks. Rerun it after editing claude/CLAUDE.md or anything in claude/agents/:
codex-sync
| What | ~/.codex/... | How |
|---|---|---|
| Global instructions | AGENTS.md | generated from claude/CLAUDE.md |
| Subagents | agents/*.toml | generated from claude/agents/*.md |
| Skills | skills/<name> | symlinked to ~/.claude/skills/<name> |
CLAUDE.md as fallback | config.toml | one key, written once and never rewritten |
Only the skills are symlinks, because SKILL.md is the same format in both tools, so edits flow both ways. The other two have to be generated: a Claude subagent is YAML frontmatter plus markdown, and a Codex custom agent is a TOML file with name, description and developer_instructions. Anything the script generates carries a marker line in its first line, and it only ever deletes files carrying that marker or symlinks pointing into ~/.claude.
Two translations worth knowing about. Codex has no @-import, so the machine-local ~/.claude/CLAUDE.work.md is inlined into the generated AGENTS.md (it stays out of this public repo either way).
And Codex has no per-agent tool allowlist. sandbox_mode = "read-only" looks like the equivalent and is not: it also cuts network access and makes every command ask for approval, which breaks an agent like archer that is supposed to read code and run scanners. So the Claude tools: line is copied in as a comment and nothing enforces it but the agent's own instructions — the same thing holding the line in the Claude prompt already.
Skills are read from ~/.claude/skills rather than from this repo, so Codex also sees skills installed on this machine that the repo does not track.
The install script symlinks both configs (~/.vimrc and ~/.config/nvim/init.lua), but plugins are managed separately. Each editor uses a different plugin manager, and both auto-bootstrap the manager on first launch.
# Installs vim-plug automatically if missing, then installs all plugins
vim +PlugInstall +qall
Or open vim and run :PlugInstall.
Packer auto-clones itself on the first launch, so this is a three-step dance:
nvim # 1. prints "Installing packer.nvim..." and clones it, then quit
nvim # 2. reopen so packer is on the runtime path
# 3. run :PackerSync inside nvim to install/sync all plugins
Note:
neo-vim/int.luahardcodes the repo path (/Users/jq/dotfiles/neo-vim/lua), so the Neovim config will break if the repo is moved. This is why step 3 clones to~/dotfiles.
A snapshot of /Applications (regenerate with ls /Applications):
hooks/register.tsx 93 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pr } from '../types'
5import { parseRemote, QUERY, summarize, transitions } from './summarize'
6
7const pr = atom({ plugin: 'pr-band', key: 'pr' } as const, null)
8
9const POLL_MS = 60_000
10// Bash commands after which the PR state has likely changed.
11const TRIGGERS = /\b(git (push|checkout|switch)|gh (pr|api))\b/
12
13let isRefreshing = false
14
15async function refresh($: EngineInterface) {
16 if (isRefreshing) return
17 isRefreshing = true
18 try {
19 const branch = (await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
20 const remote = parseRemote((await $.process.run(['git', 'remote', 'get-url', 'origin'])).stdout)
21 let next: Pr | null = null
22 if (branch && branch !== 'HEAD' && remote) {
23 const res = await $.process.run([
24 'gh', 'api', 'graphql',
25 '-f', `query=${QUERY}`,
26 '-F', `owner=${remote.owner}`,
27 '-F', `name=${remote.name}`,
28 '-F', `branch=${branch}`,
29 ])
30 if (res.exitCode !== 0) return
31 next = summarize(JSON.parse(res.stdout))
32 }
33 const prev = await read($, pr)
34 for (const msg of transitions(prev, next)) $.ui.toast(msg)
35 await update($, pr, () => next)
36 } catch {
37 // Not a repo, gh missing or offline: leave the band as it was.
38 } finally {
39 isRefreshing = false
40 }
41}
42
43export const register: Register = on => {
44 on('session.start', async ($, e, next) => {
45 void refresh($)
46 $.clock.every(POLL_MS, () => void refresh($))
47
48 return next(e)
49 })
50
51 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
52 const ran = await next(e)
53 if (TRIGGERS.test(e.command)) void refresh($)
54
55 return ran
56 }).catch(($, e, next) => next(e))
57
58 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
59 const p = await read($, pr)
60 if (e.props.hasSurvey || !p) return next(e)
61
62 const { Box, Link, Text } = $.ui.resolve(e)
63 const ci =
64 p.ci === 'pass' ? <Text color="success">CI ✓ {p.passed}</Text>
65 : p.ci === 'fail' ? <Text color="error">CI ✗ {p.failed} failed</Text>
66 : p.ci === 'pending' ? <Text color="warning">CI … {p.pending} running</Text>
67 : <Text dimColor>CI none</Text>
68 const copilot =
69 p.copilot === 'requested' ? <Text color="warning">Copilot requested</Text>
70 : p.copilot === 'reviewed' ? <Text color="success">Copilot reviewed</Text>
71 : p.copilot === 'stale' ? <Text color="warning">Copilot review stale</Text>
72 : <Text dimColor>no Copilot review</Text>
73 const sep = <Text dimColor> · </Text>
74
75 const below = await next(e)
76
77 return (
78 <Box flexDirection="column">
79 <Box>
80 <Link href={p.url} label={`PR #${p.number}`} />
81 {p.isDraft && <Text dimColor> (draft)</Text>}
82 {sep}{ci}{sep}{copilot}{sep}
83 <Text color={p.openThreads ? 'warning' : 'success'}>
84 {p.openThreads} open thread{p.openThreads === 1 ? '' : 's'}
85 </Text>
86 {p.approved && <>{sep}<Text color="success">approved</Text></>}
87 </Box>
88 {below}
89 </Box>
90 )
91 })
92}
93hooks/summarize.ts 95 lines1import type { Ci, Copilot, Pr } from '../types'
2
3export const QUERY = `query($owner: String!, $name: String!, $branch: String!) {
4 repository(owner: $owner, name: $name) {
5 pullRequests(headRefName: $branch, states: OPEN, first: 1) {
6 nodes {
7 number url isDraft reviewDecision
8 commits(last: 1) { nodes { commit {
9 committedDate
10 statusCheckRollup { contexts(first: 100) { nodes {
11 __typename
12 ... on CheckRun { status conclusion }
13 ... on StatusContext { state }
14 } } }
15 } } }
16 reviewRequests(first: 20) { nodes { requestedReviewer {
17 ... on Bot { login } ... on User { login }
18 } } }
19 latestReviews(first: 20) { nodes { author { login } submittedAt } }
20 reviewThreads(first: 100) { nodes { isResolved } }
21 }
22 }
23 }
24}`
25
26const IS_COPILOT = /copilot/i
27const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
28
29// owner/name from an ssh or https GitHub remote, null for anything else.
30export function parseRemote(url: string): { owner: string; name: string } | null {
31 const m = url.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(\.git)?$/)
32 return m ? { owner: m[1]!, name: m[2]! } : null
33}
34
35export function summarize(json: any): Pr | null {
36 const pr = json?.data?.repository?.pullRequests?.nodes?.[0]
37 if (!pr) return null
38
39 const commit = pr.commits?.nodes?.[0]?.commit
40 let passed = 0
41 let failed = 0
42 let pending = 0
43 for (const c of commit?.statusCheckRollup?.contexts?.nodes ?? []) {
44 if (c.__typename === 'CheckRun') {
45 if (c.status !== 'COMPLETED') pending++
46 else if (PASSING.has(c.conclusion)) passed++
47 else failed++
48 } else {
49 if (c.state === 'SUCCESS') passed++
50 else if (c.state === 'PENDING' || c.state === 'EXPECTED') pending++
51 else failed++
52 }
53 }
54 const ci: Ci = failed ? 'fail' : pending ? 'pending' : passed ? 'pass' : 'none'
55
56 const requested = (pr.reviewRequests?.nodes ?? []).some((r: any) =>
57 IS_COPILOT.test(r.requestedReviewer?.login ?? ''),
58 )
59 const review = (pr.latestReviews?.nodes ?? []).find((r: any) =>
60 IS_COPILOT.test(r.author?.login ?? ''),
61 )
62 const copilot: Copilot = requested
63 ? 'requested'
64 : !review
65 ? 'none'
66 : commit && review.submittedAt < commit.committedDate
67 ? 'stale'
68 : 'reviewed'
69
70 return {
71 number: pr.number,
72 url: pr.url,
73 isDraft: pr.isDraft,
74 ci,
75 passed,
76 failed,
77 pending,
78 copilot,
79 openThreads: (pr.reviewThreads?.nodes ?? []).filter((t: any) => !t.isResolved).length,
80 approved: pr.reviewDecision === 'APPROVED',
81 }
82}
83
84// Toasts worth raising when the same PR moves from `prev` to `next`.
85export function transitions(prev: Pr | null, next: Pr | null): string[] {
86 if (!prev || !next || prev.number !== next.number) return []
87 const out: string[] = []
88 if (prev.ci === 'pending' && next.ci === 'pass') out.push(`PR #${next.number}: CI passed`)
89 if (prev.ci !== 'fail' && next.ci === 'fail') out.push(`PR #${next.number}: CI failed (${next.failed})`)
90 if (prev.copilot === 'requested' && next.copilot === 'reviewed') {
91 out.push(`PR #${next.number}: Copilot reviewed, ${next.openThreads} open threads`)
92 }
93 return out
94}
95types/index.d.ts 22 lines1export type Ci = 'pass' | 'fail' | 'pending' | 'none'
2export type Copilot = 'none' | 'requested' | 'reviewed' | 'stale'
3
4export type Pr = {
5 number: number
6 url: string
7 isDraft: boolean
8 ci: Ci
9 passed: number
10 failed: number
11 pending: number
12 copilot: Copilot
13 openThreads: number
14 approved: boolean
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'pr-band': { pr: Pr | null }
20 }
21}
22