A band above the prompt in any GitHub repo: the branch, the PR and its checks, the changed files, and per repo, set in ~/.claude/work-band.json with /wb…

Claude Code plugins I use across projects. This repo is a plugin marketplace.
claude plugin marketplace add adrianj98/skills
adversarial-review · v0.1.0A reviewer that assumes your code is wrong, in a context window that never saw you write it. Modelled on the split-context review Bun used to port a million lines from Zig to Rust — "1 implementer, 2 or more adversarial reviewers per implementer. The implementer doesn't review. The reviewer doesn't implement."
claude plugin install adversarial-review@adrianj98-skills
Or without the plugin system — see Installing without the plugin system.
Ships four levels of the same idea, escalating by how hard it is to skip:
| invoke | ||
|---|---|---|
| reviewer subagent | "have the adversary review my changes" | one pass, cheap |
| review workflow | /adversarial-review:review | 4 lenses in parallel, then refutation voting |
| Stop-hook nudge | automatic, once per diff over ~25 code lines | adversary off to silence |
| PR gate | examples/github-pr-review.yml | not skippable from your laptop |
standup · v0.1.0/standup — a very short list of what you worked on since your last working day. It reads the commits reachable from every worktree of the current repo, so work on a branch checked out somewhere else still counts. It also reads the GitHub PRs you opened or reviewed (through gh), the Jira issues you touched (through an Atlassian MCP server or a jira/acli CLI), and whatever each worktree's aj-log.md gained. Related items become one bullet each. A source that isn't available is skipped.
claude plugin install standup@adrianj98-skills
/standup since friday widens the window; /standup everyone includes every author.
progress · v0.1.0/progress — where a branch and its pull request stand, in two sections. What it's about is a paragraph from the PR description, the commit bodies and aj-log.md. State is a short list: none yet / draft / open / merged, who approved or is still being waited on and how many review threads are open, which checks fail, conflicts or commits behind the base, what's uncommitted or unpushed, any unanswered question in the conversation, and the one thing left before it merges. /progress 189 takes a PR number, /progress feature/x a branch.
claude plugin install progress@adrianj98-skills
deslop · v0.1.0/deslop — what in this change reads like a machine wrote it, as a report you can act on; /deslop run cuts it in the same pass. Comments that restate the line below them, try/catch around calls that can't throw, wrappers with one caller, the as any that made the error go away, README paragraphs about leveraging a robust and comprehensive toolkit.
claude plugin install deslop@adrianj98-skills
A deterministic scan over the lines your branch added produces candidates; the skill then reads files the diff never touched to learn what this repo's authors actually write, and throws out everything that only looks like slop next to some other codebase. /deslop all audits the whole repo instead.
showmethedata · v0.1.0/showmethedata UserSession — how that data is stored and used by the code. Where it lives at rest, what shape it has in each layer and where those shapes disagree, who writes it, who reads it and what for, one write and one read traced end to end, and what eventually deletes it. Point it at a symbol, a table, a field, a file or a directory; with no target it maps the repo one paragraph per store.
claude plugin install showmethedata@adrianj98-skills
Just a prompt, no script: it orients on the repo's stores and schema files, greps every spelling of the target, follows each writer to where the data lands, reads the migration rather than the model, and reports the drift between layers with a file:line on both sides. It never edits anything.
doubt · v0.1.0/doubt — question the analysis or diagnosis the AI just gave, before you act on it. It splits the analysis into claims, checks the load-bearing ones in a fresh context that never saw the reasoning, and names the explanations it skipped. The first line of the report is Holds, Shaky or Wrong, and anything after it is only what failed or went unchecked.
claude plugin install doubt@adrianj98-skills
caveman · v0.1.0/caveman — short, plain replies for the rest of the session, with no pleasantries, hedging or recap and the answer first. Code, errors and technical names stay exact, and warnings stay in full sentences. /caveman off turns it back.
claude plugin install caveman@adrianj98-skills
greenlight · v0.5.0/greenlight: a skill with no script behind it. It first says what it will do and lists the workflows it will watch, each with its checks and which are required. Then it pulls the default branch (asking GitHub which one that is), merges it into your branch, and pushes. If the merge conflicts, Claude resolves it. If the pull request has a required integration check that came out skipping, Claude reruns that run once. When a check fails, Claude fixes the code and pushes again, up to three times. It then reports each check's result from gh pr checks.
claude plugin install greenlight@adrianj98-skills
walkthrough · v0.1.0/walkthrough — a document that walks a reader through a pull request's code in the order they should read it: an overview, then one stop per idea with the code, a file:line, what it does and why, then the tests and the leftovers. /walkthrough 189 takes a PR number, /walkthrough feature/x a branch.
claude plugin install walkthrough@adrianj98-skills
work-band · v0.2.0A mod, not a skill: a hooks module that draws a band above the prompt in any GitHub repo. It shows the checked-out branch, its PR and checks, and the lines added and removed against the PR's base (or the repo's default branch), and at the end the model and subscription plan (Opus 5.5 · Max). Clicking opens a pane beside the transcript: the checks (with a re-run button), the changed files as a tree, or the dev server's logs. /wb hides and shows the band. Claude tells the band what it is working on through the mod's set_topic tool, drawn in cyan; every prompt you send carries a hidden line with that topic, so Claude re-checks it.
Per repo, set in ~/.claude/work-band.json (nothing is written to the repo), it also shows the Jira issue the branch names, only once you set the Jira site, and a button that starts or restarts the dev server: a turbo serve task or any command, with its port variable, ports and extra env. /wb settings lists this repo's settings in a pane, where a click changes one; /wb setup asks each in turn and offers the servers it finds in turbo.json and package.json.
claude plugin install work-band@adrianj98-skills
Or ./install.sh work-band --global, which copies the folder to ~/.claude/mods/work-band and adds it to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.
install.sh copies a plugin's files into ~/.claude/ or a repo's .claude/, rewrites its plugin-relative paths to real ones, and merges any hooks into your existing settings.json rather than overwriting it. It runs from a clone or straight from a curl, and takes the plugin you want:
# every repo, just you
curl -fsSL https://raw.githubusercontent.com/adrianj98/Skills/main/install.sh \
| bash -s -- adversarial-review --global
# this repo, committed for teammates
./install.sh adversarial-review --local
./install.sh all --global every plugin at once
./install.sh --list what's installable
./install.sh adversarial-review --uninstall --global reverse it
Not everything installable is a plugin. claude-local is just a starter CLAUDE.local.md — personal notes for Claude, uncommitted:
./install.sh claude-local --local # <repo root>/CLAUDE.local.md, kept out of git
./install.sh claude-local --global # ~/.claude/CLAUDE.local.md, applies everywhere
It lands beside the repo rather than in .claude/, adds itself to .git/info/exclude rather than the tracked .gitignore, and is never overwritten or deleted once it's yours.
A manifest can also pull skills straight from someone else's GitHub repo with a remote line. superpowers installs obra/superpowers that way — all of it, or just the skills you name:
./install.sh superpowers --global # every skill
./install.sh superpowers --skill brainstorming,systematic-debugging --global
./install.sh superpowers --list # what there is to pick
./install.sh superpowers --uninstall --skill brainstorming --global
It downloads one tarball per repo (no git, no API token), copies each skill directory into skills/, and rewrites superpowers:<skill> references to the bare /<skill> names. What it installed is recorded in .claude/.install-receipts/<plugin>, so --uninstall removes exactly that even after upstream adds or drops skills.
Devin reads these plugins as they are, so --devin hands each one to Devin's own plugin system instead of copying files:
./install.sh all --devin # or one plugin by name
./install.sh standup --devin --uninstall
That records them in your personal plugins, which Devin Cloud sessions and every machine you sign in to load. Skills arrive namespaced (/standup:standup). The hooks and the adversary subagent only run in local Devin sessions, and the review workflow is Claude Code only. SKILLS_DEVIN_SOURCE=you/Skills installs from a fork.
--global | into ~/.claude/ (or $CLAUDE_CONFIG_DIR) |
--local [PATH] | into ./.claude/, or PATH/.claude/ — --repo is an alias |
--devin | into Devin, through devin plugins install |
--private | register hooks in settings.local.json (gitignored) instead |
--no-hook | skip any hooks; install everything else |
--dry-run | print what would happen, change nothing |
--skill NAME | only that skill from a manifest's remote lines (a,b for several) |
--uninstall | remove the files and unregister the hooks |
Re-running is idempotent. SKILLS_RAW=… points the standalone install at a fork or branch.
plugins/<name>/ with a .claude-plugin/plugin.jsonskills/<name>/SKILL.md, agents/*.md, commands/*.md, workflows/*.js, hooks/hooks.json, bin/, .mcp.json.claude-plugin/marketplace.jsonclaude plugin validate .plugins/<name>/install.manifest, plus the name in plugins/index, so install.sh <name> works too. install.sh knows nothing about any particular plugin — the manifest lists what to copy where, which hooks to register, and which plugin-relative paths to rewrite: name Some Plugin
about One line, shown by install.sh --list.
file skills/x/SKILL.md skills/x/SKILL.md /x
bin bin/x bin/x the helper
hook Stop scripts/x.sh hooks/x.sh the nudge
personal NOTES.md NOTES.md beside the repo, never clobbered
remote owner/repo@v1 skills/* skills every skill dir in that GitHub repo
remote owner/repo skills/x skills/x or just one
rewrite ${CLAUDE_PLUGIN_ROOT}/bin/x @ROOT@/bin/x
tip /x — try this first
@ROOT@ is the .claude/ directory, @BASE@ the repo root (or the config dir when installing globally).
MIT
hooks/register.tsx 1770 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Account, Check, CheckState, DiffStat, FilesMode, Jira, Pr, RepoSettings, Serve, Task } from '../types'
5import {
6 SETTINGS_FILE,
7 describe,
8 detectServers,
9 formatSettingsFile,
10 isEnabled,
11 issueKey,
12 jiraSite,
13 parseEnv,
14 parsePorts,
15 parseSettingsFile,
16 planArgv,
17 portEnv,
18 ports,
19 repoOf,
20 serverArgv,
21 serverEnv,
22 serverKind,
23 serverMatch,
24} from './settings'
25import { ancestorsOf, buildRows, marksOf, parseNameStatus, parseStatus, relativeTo } from './tree'
26import type { Change } from './tree'
27
28const pr = atom({ plugin: 'work-band', key: 'pr' } as const, null)
29const jira = atom({ plugin: 'work-band', key: 'jira' } as const, null)
30const serve = atom({ plugin: 'work-band', key: 'serve' } as const, { state: 'stopped', tasks: [] })
31const canServe = atom({ plugin: 'work-band', key: 'canServe' } as const, false)
32const isHidden = atom({ plugin: 'work-band', key: 'isHidden' } as const, false)
33// The port the server runs on: the repo's first configured port, or the one
34// last chosen for this repo (remembered across sessions in $.store).
35const port = atom({ plugin: 'work-band', key: 'port' } as const, 3000)
36// `owner/repo`, this repo's settings and its default branch.
37const repo = atom({ plugin: 'work-band', key: 'repo' } as const, '')
38const settings = atom({ plugin: 'work-band', key: 'settings' } as const, {} as RepoSettings)
39const defaultBranch = atom({ plugin: 'work-band', key: 'defaultBranch' } as const, '')
40const portKey = (r: string) => `port:${r}`
41// What Claude says it is working on now: one to three short labels it sets
42// through the set_topic tool; empty until it does.
43const topic = atom({ plugin: 'work-band', key: 'topic' } as const, [] as string[])
44export const TOPIC_TOOL = 'mcp__work-band__set_topic'
45export const TOPIC_MAX = 3
46export const TOPIC_PROMPT = `The work band above the prompt shows what you are working on. Call ${TOPIC_TOOL} with one to three short labels (two to four words each) when you start on something and whenever the topic changes: a new feature, a different bug, another area of the code. Call it again as one piece of work moves on: from designing to building, from building to testing, from one bug to the next. Do not call it when the topic is unchanged.`
47// The line attached, unseen, to every prompt the person sends: the topic the
48// band shows now, so the model re-checks it at the moment intent most often
49// changes, instead of hearing about the tool once at session start.
50export const topicNudge = (topics: readonly string[]): string =>
51 topics.length === 0
52 ? `The work band shows no topic yet. Call ${TOPIC_TOOL} with what this prompt is about before anything else.`
53 : `The work band shows: ${topicText(topics)}. If this prompt moves the work to something else, call ${TOPIC_TOOL} before anything else; if it is the same work, leave it.`
54// The Files pane: which view, which folders the person opened (All) or
55// closed (PR, Uncommitted), a tick when the file lists change, and the
56// spinner frame of the file being edited.
57const filesMode = atom({ plugin: 'work-band', key: 'filesMode' } as const, 'pr' as FilesMode)
58const openDirs = atom({ plugin: 'work-band', key: 'openDirs' } as const, [] as string[])
59const closedDirs = atom({ plugin: 'work-band', key: 'closedDirs' } as const, [] as string[])
60const filesTick = atom({ plugin: 'work-band', key: 'filesTick' } as const, 0)
61const editFrame = atom({ plugin: 'work-band', key: 'editFrame' } as const, 0)
62// Lines this branch adds and removes against its PR's base, or develop.
63const diffStat = atom({ plugin: 'work-band', key: 'diffStat' } as const, null as DiffStat | null)
64// Runs whose re-run was asked for, until a refresh shows them going again.
65const rerunAsked = atom({ plugin: 'work-band', key: 'rerunAsked' } as const, [] as string[])
66// Which Claude Code account the session runs as, from `claude auth status`.
67const account = atom({ plugin: 'work-band', key: 'account' } as const, null as Account | null)
68// PRs whose merge this session already told the model about.
69const mergedTold = atom({ plugin: 'work-band', key: 'mergedTold' } as const, [] as number[])
70// Which task's logs the pane shows; `tick` changes to redraw it.
71// The branch checked out in the session's repo, shown first on the band.
72const branch = atom({ plugin: 'work-band', key: 'branch' } as const, '')
73const BRANCH_MAX = 40
74const logView = atom({ plugin: 'work-band', key: 'logView' } as const, { task: '', tick: 0 })
75
76// `t` is the shell alias `npx turbo run serve --ui=tui`. The TUI needs a
77// terminal, which a spawned child has none of, so it streams instead.
78// The band runs in any GitHub repo whose settings do not turn it off; in any
79// other folder the mod registers nothing and draws nothing. Whether it is on
80// is kept in `canServe`, state that outlives a hot reload of this module.
81// Whichever Jira MCP server is connected: claude.ai's "Atlassian Rovo", its
82// older "Atlassian", a local `atlassian` server. Every one serves getJiraIssue.
83export const pickJiraTool = (names: readonly string[], op = 'getJiraIssue'): string | undefined =>
84 names.find(n => new RegExp(`^mcp__.+__${op}$`).test(n))
85
86// The labels the model handed set_topic, trimmed, blanks dropped, at most
87// TOPIC_MAX; undefined when the input is not a list of strings.
88export const parseTopics = (input: unknown): string[] | undefined => {
89 const list = (input as { topics?: unknown } | null)?.topics
90 if (!Array.isArray(list) || !list.every(t => typeof t === 'string')) return undefined
91 return (list as string[]).map(t => t.trim()).filter(t => t !== '').slice(0, TOPIC_MAX)
92}
93export const topicText = (topics: readonly string[]): string => topics.join(' · ')
94
95// `text` cut to `width` cells with an ellipsis, whole when it fits.
96export const fitWidth = (text: string, width: number): string =>
97 text.length <= width ? text : width <= 1 ? '…' : `${text.slice(0, width - 1)}…`
98
99// The issue's summary from getJiraIssue's JSON, JSON escapes undone.
100export const parseJiraTitle = (text: string): string | undefined => {
101 const raw = text.match(/"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/)?.[1]
102 if (!raw) return undefined
103 try {
104 return JSON.parse(`"${raw}"`) as string
105 } catch {
106 return raw
107 }
108}
109// The issue's status name from getJiraIssue's JSON: `"status":{...,"name":"To Do",...}`.
110export const parseJiraStatus = (text: string): string | undefined => {
111 const raw = text.match(/"status"\s*:\s*\{[^{}]*?"name"\s*:\s*"((?:[^"\\]|\\.)*)"/)?.[1]
112 if (!raw) return undefined
113 try {
114 return JSON.parse(`"${raw}"`) as string
115 } catch {
116 return raw
117 }
118}
119
120// The transitions getTransitionsForJiraIssue offers, unavailable ones left out.
121export const parseTransitions = (text: string): { id: string; name: string; to: string }[] => {
122 try {
123 const raw = (JSON.parse(text) as { transitions?: unknown }).transitions
124 if (!Array.isArray(raw)) return []
125 return raw.flatMap((t: { id?: unknown; name?: unknown; isAvailable?: unknown; to?: { name?: unknown } }) =>
126 typeof t.id === 'string' && typeof t.name === 'string' && t.isAvailable !== false
127 ? [{ id: t.id, name: t.name, to: typeof t.to?.name === 'string' ? t.to.name : t.name }]
128 : [],
129 )
130 } catch {
131 return []
132 }
133}
134
135// The picker holds at most four options: a longer list is shown three at a
136// time with `More…` last, and the final page up to four.
137export const MORE = 'More…'
138export const transitionPage = (names: readonly string[], page: number): string[] => {
139 const start = page * 3
140 const rest = names.slice(start)
141 return rest.length <= 4 ? [...rest] : [...rest.slice(0, 3), MORE]
142}
143
144// The common Jira statuses by name; any other is its words' initials, or
145// its first four letters when it is one word.
146const STATUS_ABBREVIATIONS: Record<string, string> = {
147 'to do': 'TODO',
148 'in progress': 'PROG',
149 'in review': 'REV',
150 'code review': 'REV',
151 'in testing': 'TEST',
152 'ready for review': 'RFR',
153 'ready for test': 'RFT',
154 'ready for dev': 'RFD',
155 'selected for development': 'SEL',
156 backlog: 'BKLG',
157 blocked: 'BLKD',
158 'on hold': 'HOLD',
159 done: 'DONE',
160 closed: 'CLSD',
161 resolved: 'RSLV',
162 reopened: 'REOP',
163 open: 'OPEN',
164 cancelled: 'CANC',
165 canceled: 'CANC',
166}
167
168export const abbreviateStatus = (name: string): string => {
169 const words = name.trim().split(/\s+/).filter(Boolean)
170 if (words.length === 0) return ''
171 const known = STATUS_ABBREVIATIONS[words.join(' ').toLowerCase()]
172 if (known) return known
173 if (words.length > 1) return words.map(w => w.charAt(0)).join('').toUpperCase()
174 return words[0]!.slice(0, 4).toUpperCase()
175}
176
177export const jiraUrl = (site: string, key: string) => `https://${site}/browse/${key}`
178const POLL_MS = 120_000
179const MIN_PR_GAP_MS = 30_000
180
181const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
182
183// One entry of `gh pr view --json statusCheckRollup`: a CheckRun (name,
184// status, conclusion, detailsUrl) or a StatusContext (context, state, targetUrl).
185type RawCheck = {
186 name?: string
187 context?: string
188 workflowName?: string
189 status?: string | null
190 conclusion?: string | null
191 state?: string | null
192 startedAt?: string | null
193 completedAt?: string | null
194 detailsUrl?: string | null
195 targetUrl?: string | null
196}
197
198export const verdictOf = (c: RawCheck): CheckState => {
199 const verdict = (c.conclusion || c.state || '').toUpperCase()
200 if (c.status && c.status !== 'COMPLETED') return 'pending'
201 if (verdict === 'SUCCESS') return 'pass'
202 if (['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ERROR', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(verdict)) return 'fail'
203 if (verdict === 'PENDING' || verdict === 'EXPECTED' || verdict === '') return 'pending'
204 return 'skipped' // SKIPPED, NEUTRAL, STALE
205}
206
207const CHECK_ORDER: Record<CheckState, number> = { fail: 0, pending: 1, pass: 2, skipped: 3 }
208
209// The checks as the pane lists them: failures first, then running, passed, skipped.
210export const toChecks = (raw: readonly RawCheck[]): Check[] =>
211 raw
212 .map(
213 (c): Check => ({
214 name: c.name ?? c.context ?? '?',
215 workflow: c.workflowName || undefined,
216 state: verdictOf(c),
217 startedAt: c.startedAt && !c.startedAt.startsWith('0001') ? c.startedAt : undefined,
218 completedAt: c.completedAt && !c.completedAt.startsWith('0001') ? c.completedAt : undefined,
219 url: c.detailsUrl ?? c.targetUrl ?? undefined,
220 }),
221 )
222 .sort((a, b) => CHECK_ORDER[a.state] - CHECK_ORDER[b.state] || a.name.localeCompare(b.name))
223
224export const countChecks = (raw: readonly RawCheck[]) => {
225 const states = raw.map(verdictOf)
226 return {
227 passed: states.filter(s => s === 'pass').length,
228 failed: states.filter(s => s === 'fail').length,
229 pending: states.filter(s => s === 'pending').length,
230 }
231}
232
233// ` 31 files changed, 455 insertions(+), 444 deletions(-)`; either count is
234// left out when it is zero, and an empty diff prints nothing.
235export const parseShortstat = (text: string): { added: number; removed: number } => ({
236 added: Number(text.match(/(\d+) insertions?\(\+\)/)?.[1] ?? 0),
237 removed: Number(text.match(/(\d+) deletions?\(-\)/)?.[1] ?? 0),
238})
239
240// A check's GitHub Actions run and job, from its details link:
241// https://github.com/<owner>/<repo>/actions/runs/<run>/job/<job>. The job
242// number there is the databaseId `gh run rerun --job` takes.
243export const runOf = (url: string | undefined): { run: string; job?: string } | undefined => {
244 const m = url?.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/)
245 return m ? { run: m[1]!, job: m[2] } : undefined
246}
247
248// The gh command that re-runs one check: a failed job alone; a skipped one
249// (never ran) only runs again with its whole run, so the run.
250export const rerunOne = (c: Check): string[] | undefined => {
251 const at = runOf(c.url)
252 if (!at) return undefined
253 if (c.state === 'fail') return at.job ? ['gh', 'run', 'rerun', at.run, '--job', at.job] : ['gh', 'run', 'rerun', at.run, '--failed']
254 if (c.state === 'skipped') return ['gh', 'run', 'rerun', at.run]
255 return undefined
256}
257
258// Every failed or skipped check, one gh command per run: the whole run when
259// any of its jobs was skipped (that also re-runs its failures), else only its
260// failed jobs. A run still going is left alone; gh refuses it.
261export const rerunAll = (checks: readonly Check[]): string[][] => {
262 const runs = new Map<string, CheckState[]>()
263 for (const c of checks) {
264 const at = runOf(c.url)
265 if (at) runs.set(at.run, [...(runs.get(at.run) ?? []), c.state])
266 }
267 return [...runs]
268 .filter(([, states]) => !states.includes('pending'))
269 .flatMap(([run, states]) =>
270 states.includes('skipped')
271 ? [['gh', 'run', 'rerun', run]]
272 : states.includes('fail')
273 ? [['gh', 'run', 'rerun', run, '--failed']]
274 : [],
275 )
276}
277
278// `claude auth status` prints JSON: { loggedIn, authMethod, orgName,
279// subscriptionType: "team" | "max" | "pro" | ..., email }.
280export const parseAuthStatus = (text: string): Account | null => {
281 let raw: { loggedIn?: boolean; authMethod?: string; subscriptionType?: string; orgName?: string; email?: string }
282 try {
283 raw = JSON.parse(text)
284 } catch {
285 return null
286 }
287 if (!raw.loggedIn) return { plan: 'signed out' }
288 if (raw.authMethod !== 'claude.ai') return { plan: 'API key', org: raw.orgName || undefined }
289 const plan = raw.subscriptionType ? raw.subscriptionType[0]!.toUpperCase() + raw.subscriptionType.slice(1) : 'claude.ai'
290 return { plan, org: raw.orgName || undefined, email: raw.email || undefined }
291}
292
293// The session's model as the band names it: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
294// `claude-sonnet-4-5-20250929` → `Sonnet 4.5`; anything else as given.
295export const modelLabel = (raw: string): string => {
296 const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(raw.trim())
297 if (!m) return raw.trim()
298 const [, family, major, minor, wide] = m
299 return `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}${wide ? ' 1M' : ''}`
300}
301
302// 1h 02m · 4m 31s · 12s
303export const formatDuration = (ms: number): string => {
304 const sec = Math.max(0, Math.round(ms / 1000))
305 const h = Math.floor(sec / 3600)
306 const m = Math.floor((sec % 3600) / 60)
307 const s = sec % 60
308 if (h) return `${h}h ${String(m).padStart(2, '0')}m`
309 if (m) return `${m}m ${String(s).padStart(2, '0')}s`
310 return `${s}s`
311}
312
313// Turbo has no live per-task status, so it is read off the stream, whose
314// lines turbo 2.11 prefixes with the task:
315// //:serve: cache bypass, force executing <hash> the task started
316// //#bad: ERROR command (...) npm run bad exited (3) it failed
317// A one-shot task (kill-port) prints nothing when it succeeds; it is done
318// once a task depending on it starts, or the whole run exits 0.
319const STARTED = /^[^:\s]+:(\S+?): cache (?:bypass|miss|hit)/
320const FAILED = /^[^#\s]+#(\S+?):\s+ERROR\s+command .*exited \((\d+)\)/
321
322const OUTPUT = /^[^:\s]+:(\S+?): /
323
324// A port typed or picked in the menu: a whole number from 1 to 65535.
325export const parsePort = (text: string): number | undefined => {
326 const n = Number(text.trim())
327 return text.trim() !== '' && Number.isInteger(n) && n >= 1 && n <= 65535 ? n : undefined
328}
329
330// The ports tried, in order, when the remembered one is taken: the remembered
331// one first, then 1920-1990 in a random order (`random` is Math.random; a
332// test passes its own).
333export const RANDOM_PORTS = { from: 1920, to: 1990 } as const
334export const candidatePorts = (preferred: number, random: () => number = Math.random): number[] => {
335 const pool = Array.from({ length: RANDOM_PORTS.to - RANDOM_PORTS.from + 1 }, (_, i) => RANDOM_PORTS.from + i)
336 for (let i = pool.length - 1; i > 0; i--) {
337 const j = Math.floor(random() * (i + 1))
338 ;[pool[i], pool[j]] = [pool[j]!, pool[i]!]
339 }
340 return [preferred, ...pool.filter(at => at !== preferred)]
341}
342
343const taskName = (taskId: string) => taskId.replace(/^.*#/, '')
344
345// The task a stream line was printed by, if it carries turbo's prefix.
346export const taskOfLine = (line: string): string | undefined => line.replace(ANSI, '').match(OUTPUT)?.[1]
347
348// The tasks `turbo run serve --dry=json` plans, dependencies first.
349export const parseDry = (json: string): Task[] => {
350 const raw = JSON.parse(json) as {
351 tasks: { taskId: string; dependencies?: string[]; resolvedTaskDefinition?: { persistent?: boolean } }[]
352 }
353 return raw.tasks
354 .map(
355 (t): Task => ({
356 name: taskName(t.taskId),
357 state: 'pending',
358 persistent: Boolean(t.resolvedTaskDefinition?.persistent),
359 deps: (t.dependencies ?? []).map(taskName),
360 }),
361 )
362 .sort((a, b) => a.deps.length - b.deps.length || a.name.localeCompare(b.name))
363}
364
365const upsert = (tasks: readonly Task[], name: string, change: (t: Task) => Task): Task[] =>
366 tasks.some(t => t.name === name)
367 ? tasks.map(t => (t.name === name ? change(t) : t))
368 : [...tasks, change({ name, state: 'pending', persistent: false, deps: [] })]
369
370export const applyLine = (tasks: readonly Task[], rawLine: string): readonly Task[] => {
371 const line = rawLine.replace(ANSI, '')
372 const failed = line.match(FAILED)
373 if (failed) return upsert(tasks, failed[1]!, t => ({ ...t, state: 'failed', code: Number(failed[2]) }))
374 const started = line.match(STARTED)
375 if (!started) return tasks
376 const name = started[1]!
377 const next = upsert(tasks, name, t => ({ ...t, state: 'running' }))
378 const deps = next.find(t => t.name === name)!.deps
379 return next.map(t => (deps.includes(t.name) && t.state !== 'failed' ? { ...t, state: 'done' } : t))
380}
381
382// The run is over: a clean exit finished the one-shot tasks; anything else
383// still marked running was stopped with it.
384export const finishTasks = (tasks: readonly Task[], code: number | null): Task[] =>
385 tasks.map(t =>
386 t.state === 'running' || t.state === 'pending'
387 ? { ...t, state: code === 0 && !t.persistent && t.state === 'running' ? 'done' : 'stopped' }
388 : t,
389 )
390
391// Planned tasks first, in plan order, with what the stream already showed.
392export const mergePlan = (plan: readonly Task[], seen: readonly Task[]): Task[] => [
393 ...plan.map(p => {
394 const s = seen.find(t => t.name === p.name)
395 return s ? { ...p, state: s.state, code: s.code } : p
396 }),
397 ...seen.filter(t => !plan.some(p => p.name === t.name)),
398]
399
400const TASK_MARK: Record<Task['state'], string> = {
401 pending: '○',
402 running: '●',
403 done: '✓',
404 failed: '✗',
405 stopped: '■',
406}
407const TASK_COLOR: Record<Task['state'], 'success' | 'error' | 'subtle' | undefined> = {
408 pending: undefined,
409 running: 'success',
410 done: 'subtle',
411 failed: 'error',
412 stopped: undefined,
413}
414
415// $.state outlives a reload: a Serve saved by an older version of this mod
416// has no `tasks`, and reading it as an array would blank the whole band.
417const tasksOf = (s: Serve): readonly Task[] => s.tasks ?? []
418
419// Each task's recent output, for the logs pane. Kept in the module, not in
420// $.state: it changes on every line and only the pane reads it.
421const LOGS_PANE = 'server-logs'
422// The one task a plain (not turbo) server shows, its lines its logs.
423const PLAIN_TASK = 'server'
424const LOG_LINES = 500
425const LOG_REDRAW_MS = 250
426const logs = new Map<string, string[]>()
427let logRedrawAt = -Infinity
428
429export const keepLines = (kept: readonly string[], lines: readonly string[], max: number): string[] =>
430 [...kept, ...lines].slice(-max)
431
432// `//:serve: listening on 1776` → `listening on 1776`
433export const withoutPrefix = (line: string): string => line.replace(ANSI, '').replace(OUTPUT, '')
434
435async function recordLogs($: EngineInterface, lines: readonly string[]) {
436 const isPlain = serverKind(await read($, settings)) === 'plain'
437 const byTask = new Map<string, string[]>()
438 for (const line of lines) {
439 const task = isPlain ? PLAIN_TASK : taskOfLine(line)
440 if (task) byTask.set(task, [...(byTask.get(task) ?? []), isPlain ? line : withoutPrefix(line)])
441 }
442 for (const [task, added] of byTask) logs.set(task, keepLines(logs.get(task) ?? [], added, LOG_LINES))
443
444 const view = await read($, logView)
445 const now = await $.clock.now()
446 if (byTask.has(view.task) && now - logRedrawAt >= LOG_REDRAW_MS) {
447 logRedrawAt = now
448 await update($, logView, v => ({ ...v, tick: v.tick + 1 }))
449 }
450}
451
452async function toggleLogs($: EngineInterface, task: string) {
453 const view = await read($, logView)
454 const isOpen = (await $.ui.panes()).some(pane => pane.id === LOGS_PANE)
455 if (isOpen && view.task === task) return $.ui.close({ id: LOGS_PANE })
456 await update($, logView, v => ({ task, tick: v.tick + 1 }))
457 await $.ui.open({ id: LOGS_PANE, title: `${task} logs`, closeOnEscape: true })
458}
459
460const FILES_PANE = 'files'
461const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
462const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
463const EDIT_LINGER_MS = 1500
464
465// Kept in the module, not $.state: the repo's 12k paths would be copied on
466// every write. `filesTick` redraws the pane when they change.
467let repoTop = ''
468let allFiles: string[] = []
469let prChanges: Change[] = []
470let uncommitted: Change[] = []
471const editing = new Map<string, number>() // path → how many edits are in flight
472let spinTimer: { cancel: () => void } | null = null
473
474async function refreshFiles($: EngineInterface) {
475 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
476 if (top.exitCode !== 0) return
477 repoTop = top.stdout.trim()
478 const base = (await read($, pr))?.base ?? ((await read($, defaultBranch)) || 'main')
479 const [ls, status, diff] = await Promise.all([
480 $.process.run(['git', '-C', repoTop, 'ls-files']),
481 $.process.run(['git', '-C', repoTop, 'status', '--porcelain=v1', '-uall']),
482 $.process.run(['git', '-C', repoTop, 'diff', '--name-status', `origin/${base}...HEAD`]),
483 ])
484 allFiles = ls.exitCode === 0 ? ls.stdout.split('\n').filter(Boolean) : []
485 uncommitted = status.exitCode === 0 ? parseStatus(status.stdout) : []
486 prChanges = diff.exitCode === 0 ? parseNameStatus(diff.stdout) : []
487 await update($, filesTick, n => n + 1)
488}
489
490async function isFilesOpen($: EngineInterface) {
491 return (await $.ui.panes()).some(pane => pane.id === FILES_PANE)
492}
493
494// The filetree mod (data-goblin/claude-code-filetree), when it is installed:
495// its /filetree command opens its own pane, which this mod can neither list
496// nor close, so the Files button opens or refocuses it rather than toggling.
497async function hasFiletree($: EngineInterface) {
498 try {
499 return (await $.command.list()).some(c => c.name === 'filetree')
500 } catch {
501 return false
502 }
503}
504
505async function toggleFiles($: EngineInterface) {
506 if (await hasFiletree($)) {
507 const { text } = await $.command.run({ command: 'filetree' })
508 // filetree answers a refusal (no fullscreen layout, a narrow terminal) as text.
509 if (text && !/^File tree on /.test(text)) $.ui.toast(text)
510 return
511 }
512 if (await isFilesOpen($)) return $.ui.close({ id: FILES_PANE })
513 await $.ui.open({ id: FILES_PANE, title: 'Files', closeOnEscape: true })
514 await refreshFiles($)
515}
516
517async function toggleDir($: EngineInterface, dir: string) {
518 const flip = (dirs: string[]) => (dirs.includes(dir) ? dirs.filter(d => d !== dir) : [...dirs, dir])
519 if ((await read($, filesMode)) === 'all') await update($, openDirs, flip)
520 else await update($, closedDirs, flip)
521}
522
523async function startEdit($: EngineInterface, path: string) {
524 editing.set(path, (editing.get(path) ?? 0) + 1)
525 spinTimer ??= $.clock.every(120, () => void update($, editFrame, f => f + 1).catch(() => {}))
526 await update($, editFrame, f => f + 1) // show it now, not at the first tick
527}
528
529async function endEdit($: EngineInterface, path: string) {
530 const left = (editing.get(path) ?? 1) - 1
531 if (left > 0) editing.set(path, left)
532 else editing.delete(path)
533 if (editing.size === 0 && spinTimer) {
534 spinTimer.cancel()
535 spinTimer = null
536 await update($, editFrame, f => f + 1)
537 }
538 if (await isFilesOpen($)) await refreshFiles($)
539}
540
541const CHECKS_PANE = 'pr-checks'
542
543async function toggleChecks($: EngineInterface) {
544 if ((await $.ui.panes()).some(pane => pane.id === CHECKS_PANE)) return $.ui.close({ id: CHECKS_PANE })
545 const current = await read($, pr)
546 await $.ui.open({ id: CHECKS_PANE, title: current ? `PR #${current.number} checks` : 'PR checks', closeOnEscape: true })
547 void refreshAll($) // the pane shows what gh says now; a merge it sees is told
548}
549
550const MARK_COLOR = { pr: 'suggestion', uncommitted: 'warning' } as const
551
552async function rerun($: EngineInterface, commands: readonly string[][], what: string) {
553 const runs = commands.map(argv => argv[3]!)
554 await update($, rerunAsked, asked => [...new Set([...asked, ...runs])])
555 const results = await Promise.all(commands.map(argv => $.process.run(argv)))
556 const failed = results.find(r => r.exitCode !== 0)
557 if (failed) {
558 $.ui.toast(`Re-run of ${what} failed: ${(failed.stderr || failed.stdout).trim().split('\n')[0]}`)
559 await update($, rerunAsked, asked => asked.filter(r => !runs.includes(r)))
560 return
561 }
562 $.ui.toast(`Re-run started: ${what}`)
563 $.clock.after(5_000, () => void refreshAll($)) // pick up the new attempt
564}
565
566const CHECK_MARK: Record<CheckState, string> = { fail: '✗', pending: '…', pass: '✓', skipped: '–' }
567const CHECK_COLOR: Record<CheckState, 'error' | 'warning' | 'success' | 'subtle'> = {
568 fail: 'error',
569 pending: 'warning',
570 pass: 'success',
571 skipped: 'subtle',
572}
573
574// A running task's dot lights up while it prints, and dims FLASH_MS after.
575const FLASH_MS = 400
576const flashTimers = new Map<string, { cancel: () => void }>()
577
578const setFlash = (tasks: readonly Task[], names: ReadonlySet<string>, isOn: boolean): Task[] =>
579 tasks.map(t => (names.has(t.name) ? { ...t, flash: isOn } : t))
580
581async function flash($: EngineInterface, names: ReadonlySet<string>, mine: number) {
582 const fresh = new Set([...names].filter(n => !flashTimers.has(n)))
583 if (fresh.size) await update($, serve, (s): Serve => ({ ...s, tasks: setFlash(tasksOf(s), fresh, true) }))
584 for (const name of names) {
585 flashTimers.get(name)?.cancel()
586 flashTimers.set(
587 name,
588 $.clock.after(FLASH_MS, () => {
589 flashTimers.delete(name)
590 if (mine !== generation) return
591 void update($, serve, (s): Serve => ({ ...s, tasks: setFlash(tasksOf(s), new Set([name]), false) })).catch(() => {})
592 }),
593 )
594 }
595}
596
597let child: AsyncGenerator<unknown, unknown> | null = null
598let generation = 0
599let lastPrFetch = 0
600let plan: Task[] | null = null
601
602async function refreshPr($: EngineInterface): Promise<Pr | null> {
603 lastPrFetch = await $.clock.now()
604 const { exitCode, stdout } = await $.process.run([
605 'gh', 'pr', 'view', '--json',
606 'number,state,isDraft,statusCheckRollup,title,baseRefName,url',
607 ])
608 if (exitCode !== 0) {
609 await update($, pr, () => null)
610 return null
611 }
612 const raw = JSON.parse(stdout)
613 const next: Pr = {
614 number: raw.number,
615 state: String(raw.state).toLowerCase(),
616 isDraft: Boolean(raw.isDraft),
617 title: raw.title ?? '',
618 ...countChecks(raw.statusCheckRollup ?? []),
619 checks: toChecks(raw.statusCheckRollup ?? []),
620 base: raw.baseRefName || undefined,
621 url: raw.url || undefined,
622 }
623 await update($, pr, () => next)
624 const going = new Set(next.checks?.filter(c => c.state === 'pending').map(c => runOf(c.url)?.run))
625 await update($, rerunAsked, asked => asked.filter(run => !going.has(run)))
626 return next
627}
628
629// Jira runs only where the repo's settings name its site.
630async function refreshJira($: EngineInterface, current: Pr | null) {
631 const cfg = await read($, settings)
632 const site = jiraSite(cfg)
633 if (!site) {
634 await update($, jira, () => null)
635 return
636 }
637 const branch = (await $.process.run(['git', 'branch', '--show-current'])).stdout.trim()
638 const key = issueKey(cfg, current?.title ?? '', branch)
639 if (!key) {
640 await update($, jira, () => null)
641 return
642 }
643 const tool = pickJiraTool((await $.tool.list()).map(t => t.name))
644 if (!tool) {
645 await update($, jira, (): Jira => ({ key, error: 'no Jira MCP connected' }))
646 return
647 }
648 const input = { tool, cloudId: site, issueIdOrKey: key, fields: ['summary', 'status'] }
649 // A refresh every two minutes must never open a permission dialog.
650 const { decision } = await $.tool.check({ tool, input })
651 if (decision !== 'allow') {
652 await update($, jira, (): Jira => ({ key, error: `allow ${tool} to show the title` }))
653 return
654 }
655 const result = await $.tool.call(input as never)
656 const text = 'text' in result && typeof result.text === 'string' ? result.text : ''
657 const title = parseJiraTitle(text)
658 const statusName = parseJiraStatus(text)
659 const status = statusName ? abbreviateStatus(statusName) : undefined
660 await update($, jira, (): Jira => (title ? (status ? { key, title, status } : { key, title }) : { key, error: 'no title' }))
661}
662
663// Clicking the status: the issue's transitions in a picker, the chosen one
664// applied, the band refreshed. A click may open the tools' permission dialog.
665async function changeJiraStatus($: EngineInterface) {
666 const j = await read($, jira)
667 const site = jiraSite(await read($, settings))
668 if (!j || !('title' in j) || !site) return
669 const names = (await $.tool.list()).map(t => t.name)
670 const list = pickJiraTool(names, 'getTransitionsForJiraIssue')
671 const move = pickJiraTool(names, 'transitionJiraIssue')
672 if (!list || !move) {
673 $.ui.toast('The Jira MCP cannot change statuses')
674 return
675 }
676 const got = await $.tool.call({ tool: list, cloudId: site, issueIdOrKey: j.key } as never)
677 if ('deny' in got) {
678 $.ui.toast(`Could not list ${j.key}'s statuses: ${got.deny}`)
679 return
680 }
681 const all = parseTransitions('text' in got && typeof got.text === 'string' ? got.text : '')
682 const options = all.filter(t => abbreviateStatus(t.to) !== j.status)
683 if (options.length === 0) {
684 $.ui.toast(`No status changes offered for ${j.key}`)
685 return
686 }
687 let choice: string = MORE
688 for (let page = 0; choice === MORE; page++) {
689 try {
690 choice = await $.ui.ask(`Move ${j.key} to which status?`, {
691 header: 'Jira',
692 options: transitionPage(
693 options.map(t => t.name),
694 page,
695 ),
696 })
697 } catch {
698 return // dismissed
699 }
700 }
701 const picked = options.find(t => t.name.toLowerCase() === choice.trim().toLowerCase())
702 if (!picked) {
703 $.ui.toast(`"${choice}" is not a status ${j.key} can move to`)
704 return
705 }
706 const done = await $.tool.call({ tool: move, cloudId: site, issueIdOrKey: j.key, transition: { id: picked.id } } as never)
707 if ('deny' in done || done.isError) {
708 $.ui.toast(`${j.key} was not moved: ${('deny' in done ? done.deny : done.text) ?? ''}`.trim())
709 return
710 }
711 $.ui.toast(`${j.key} → ${picked.to}`)
712 await refreshJira($, await read($, pr))
713}
714
715// A check that could not run (git failed) leaves the flag as it was, so it
716// never turns off a band that is on; only a remote that is not on GitHub, or
717// settings that turn the band off, turn it off.
718async function checkRepo($: EngineInterface): Promise<boolean> {
719 const { exitCode, stdout } = await $.process.run(['git', 'remote', 'get-url', 'origin'])
720 if (exitCode !== 0) return read($, canServe)
721 answered = true
722 const name = repoOf(stdout) ?? ''
723 await update($, repo, () => name)
724 const cfg = name ? await loadSettings($, name) : {}
725 const ok = name !== '' && isEnabled(cfg)
726 await update($, canServe, () => ok)
727 return ok
728}
729
730// ~/.claude/work-band.json (or under CLAUDE_CONFIG_DIR).
731async function settingsPath($: EngineInterface): Promise<string | undefined> {
732 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? ((await $.env.get('HOME')) ? `${await $.env.get('HOME')}/.claude` : undefined)
733 return dir ? `${dir}/${SETTINGS_FILE}` : undefined
734}
735
736async function readSettingsFile($: EngineInterface): Promise<Record<string, RepoSettings>> {
737 const path = await settingsPath($)
738 if (!path) return {}
739 const text = await $.fs.read(path).catch(() => '')
740 return parseSettingsFile(typeof text === 'string' ? text : '')
741}
742
743async function loadSettings($: EngineInterface, name: string): Promise<RepoSettings> {
744 const cfg = (await readSettingsFile($))[name] ?? {}
745 await update($, settings, () => cfg)
746 return cfg
747}
748
749// Writes this repo's entry and applies it at once.
750async function saveSettings($: EngineInterface, cfg: RepoSettings) {
751 const name = await read($, repo)
752 const path = await settingsPath($)
753 if (!name || !path) {
754 $.ui.toast('No GitHub repo here, so there is nothing to save settings for')
755 return
756 }
757 const all = await readSettingsFile($)
758 all[name] = cfg
759 await $.fs.write(path, formatSettingsFile(all))
760 const wasOn = await read($, canServe)
761 await update($, settings, () => cfg)
762 await update($, canServe, () => isEnabled(cfg))
763 if (isEnabled(cfg) && !wasOn) await activate($)
764 void refreshAll($)
765}
766
767// Whether the band is on in this session. Once git has named the remote,
768// that answer stands: another repo is never checked again. Only while git
769// has not answered (the check failed) does a hook try again, at most every
770// RECHECK_MS and MAX_RETRIES times in all, and a match switches the band on
771// then and there. A hot reload
772// forgets `answered`, so another repo is checked once more after one.
773const RECHECK_MS = 30_000
774const MAX_RETRIES = 5
775let answered = false
776let lastCheck = Number.NEGATIVE_INFINITY
777let checking: Promise<boolean> | null = null
778let failedChecks = 0
779async function isOn($: EngineInterface): Promise<boolean> {
780 if (await read($, canServe)) return true
781 if (answered || failedChecks >= MAX_RETRIES) return false
782 const now = await $.clock.now()
783 if (now - lastCheck < RECHECK_MS) return false
784 lastCheck = now
785 checking ??= checkRepo($)
786 .then(async ok => {
787 if (!answered) failedChecks += 1
788 if (ok) await activate($)
789 return ok
790 })
791 .finally(() => {
792 checking = null
793 })
794 return checking
795}
796
797// The model is told only of a merge this session saw happen (open, then
798// merged), never of a PR that was already merged when the session started.
799// It is told what happened, not what to do about it.
800export type MergeFacts = { by?: string; at?: string; commit?: string }
801// `jiraKey` null: Jira is off for this repo, so nothing is said of it.
802export const mergePrompt = (pr: { number: number; title: string; base?: string; url?: string }, jiraKey: string | null | undefined, facts: MergeFacts = {}): string =>
803 [
804 `PR #${pr.number} ("${pr.title}") was merged${pr.base ? ` into ${pr.base}` : ''}${facts.by ? ` by ${facts.by}` : ''}${facts.at ? ` at ${facts.at}` : ''}${facts.commit ? `, merge commit ${facts.commit.slice(0, 7)}` : ''}.`,
805 pr.url ?? '',
806 jiraKey === null ? '' : jiraKey ? `Jira issue: ${jiraKey}.` : 'No Jira issue key in the PR title or the branch name.',
807 ]
808 .filter(Boolean)
809 .join(' ')
810
811// Who merged it, when, and the merge commit, read once when the merge is seen.
812export const parseMergeFacts = (json: string): MergeFacts => {
813 try {
814 const j = JSON.parse(json) as { mergedBy?: { login?: string }; mergedAt?: string; mergeCommit?: { oid?: string } }
815 return { by: j.mergedBy?.login, at: j.mergedAt, commit: j.mergeCommit?.oid }
816 } catch {
817 return {}
818 }
819}
820
821async function tellIfMerged($: EngineInterface, before: Pr | null, after: Pr | null) {
822 if (!before || !after || before.number !== after.number) return
823 if (before.state !== 'open' || after.state !== 'merged') return
824 let isNew = false
825 await update($, mergedTold, told => (told.includes(after.number) ? told : ((isNew = true), [...told, after.number])))
826 if (!isNew) return
827 const branch = (await $.process.run(['git', 'branch', '--show-current'])).stdout.trim()
828 const cfg = await read($, settings)
829 const key = jiraSite(cfg) ? (issueKey(cfg, after.title, branch) ?? undefined) : null
830 $.ui.toast(`PR #${after.number} merged`)
831 const got = await $.process.run(['gh', 'pr', 'view', String(after.number), '--json', 'mergedBy,mergedAt,mergeCommit'])
832 const facts = got.exitCode === 0 ? parseMergeFacts(got.stdout) : {}
833 void $.prompt.submit({ text: mergePrompt(after, key, facts) })
834}
835
836async function refreshDiff($: EngineInterface, current: Pr | null) {
837 const base = current?.base ?? ((await read($, defaultBranch)) || 'main')
838 const { exitCode, stdout } = await $.process.run(['git', 'diff', '--shortstat', `origin/${base}...HEAD`])
839 await update($, diffStat, (): DiffStat | null => (exitCode === 0 ? { ...parseShortstat(stdout), base } : null))
840}
841
842async function refreshAccount($: EngineInterface) {
843 const { exitCode, stdout } = await $.process.run(['claude', 'auth', 'status'], { timeoutMs: 15_000 })
844 await update($, account, () => (exitCode === 0 ? parseAuthStatus(stdout) : null))
845}
846
847// The PR poll, which is how a merge is seen while the session sits idle. A
848// hot reload cancels the timer without running session.start again, so the
849// band re-arms it whenever it draws.
850let pollTimer: { cancel(): void } | null = null
851function ensurePolling($: EngineInterface) {
852 pollTimer ??= $.clock.every(POLL_MS, () => void refreshAll($))
853}
854
855async function refreshAll($: EngineInterface) {
856 try {
857 const before = await read($, pr)
858 const head = await $.process.run(['git', 'branch', '--show-current'])
859 if (head.exitCode === 0) await update($, branch, () => head.stdout.trim())
860 const current = await refreshPr($)
861 await refreshDiff($, current)
862 await tellIfMerged($, before, current)
863 await refreshJira($, current)
864 } catch (err) {
865 $.ui.log(`work-band refresh failed: ${String(err)}`, { to: 'debug' })
866 }
867}
868
869// Until the server answers on its port, `server` flashes blue; once it
870// answers it turns green and the port is not checked again. The first check
871// is a couple of seconds in, after kill-port has cleared the port; the band
872// cannot wait on kill-port's own state, which turbo leaves running.
873const BLINK_MS = 500
874const BLINKS_PER_CHECK = 4
875let upTimer: { cancel(): void } | null = null
876
877function watchUp($: EngineInterface, mine: number) {
878 upTimer?.cancel()
879 let ticks = 0
880 const timer = $.clock.every(BLINK_MS, () =>
881 void (async () => {
882 const s = await read($, serve)
883 if (mine !== generation || s.state !== 'running' || s.up) {
884 timer.cancel()
885 if (upTimer === timer) upTimer = null
886 return
887 }
888 await update($, serve, (s): Serve => ({ ...s, blink: !s.blink }))
889 if (++ticks % BLINKS_PER_CHECK) return
890 const r = await $.process.run(['curl', '-s', '-o', '/dev/null', '-m', '2', serverUrl(await read($, port))])
891 if (r.exitCode !== 0 || mine !== generation) return
892 timer.cancel()
893 if (upTimer === timer) upTimer = null
894 await update($, serve, (s): Serve => (s.state === 'running' ? { ...s, up: true, blink: false } : s))
895 })().catch(() => {}),
896 )
897 upTimer = timer
898}
899
900// The pids of every server this Claude process started (a process whose
901// command holds `match`, such as `turbo run serve`), and of everything under
902// them. `ps` rows are `pid ppid command`.
903export const serveTrees = (ps: string, me: number, match = 'turbo run serve'): number[] => {
904 const rows = ps
905 .split('\n')
906 .map(l => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(l))
907 .filter((m): m is RegExpExecArray => m !== null)
908 .map(m => ({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3]! }))
909 const out = match ? rows.filter(r => r.ppid === me && r.command.includes(match)).map(r => r.pid) : []
910 for (let i = 0; i < out.length; i++) for (const r of rows) if (r.ppid === out[i] && !out.includes(r.pid)) out.push(r.pid)
911 return out
912}
913
914// Leaving the stream kills only npm, not the turbo, yarn, server and vite
915// under it, and a hot reload of this module loses `child` altogether, so a
916// restart would start a second server beside the first. Every server tree
917// this Claude process started is found with `ps` and killed instead.
918async function killServers($: EngineInterface) {
919 const me = Number((await $.process.run(['sh', '-c', 'echo $PPID'])).stdout.trim())
920 if (!me) return
921 const match = serverMatch(await read($, settings))
922 if (!match) return
923 const pids = serveTrees((await $.process.run(['ps', '-axo', 'pid=,ppid=,command='])).stdout, me, match)
924 if (pids.length) await $.process.run(['kill', '-TERM', ...pids.map(String)])
925}
926
927async function stopServe($: EngineInterface) {
928 ++generation
929 if (child) {
930 const old = child
931 child = null
932 await old.return(undefined) // leaving the stream kills the child
933 }
934 await killServers($).catch(() => {})
935 await update($, serve, (s): Serve => ({ state: 'stopped', tasks: finishTasks(tasksOf(s), null) }))
936}
937
938// Chrome's tab already on this server comes to the front, else a new tab
939// opens. Matching the origin plus `/` keeps :1776 from matching :17760.
940export const CHROME_FOCUS_TAB = `on run argv
941 set origin to item 1 of argv
942 tell application "Google Chrome"
943 repeat with w in windows
944 set i to 0
945 repeat with t in tabs of w
946 set i to i + 1
947 set u to URL of t
948 if u is origin or u starts with (origin & "/") then
949 set active tab index of w to i
950 set index of w to 1
951 activate
952 return "reused"
953 end if
954 end repeat
955 end repeat
956 open location origin
957 activate
958 return "opened"
959 end tell
960end run`
961
962export const serverUrl = (at: number) => `http://localhost:${at}`
963
964// A Button that opens a URL rather than a Link: a terminal without OSC 8
965// draws a Link's URL beside its text, and the band has no room for that.
966async function openUrl($: EngineInterface, url: string) {
967 await $.process.run(['open', url])
968}
969
970async function openServer($: EngineInterface) {
971 const url = serverUrl(await read($, port))
972 const chrome = await $.process.run(['osascript', '-e', CHROME_FOCUS_TAB, url]).catch(() => null)
973 if (chrome?.exitCode === 0) return
974 // Not Chrome, or macOS refused the terminal control of it: a plain open.
975 await $.process.run(['open', url])
976}
977
978// Something is listening on the port (lsof answers its pids, exit 0).
979async function isPortTaken($: EngineInterface, at: number) {
980 const { exitCode, stdout } = await $.process.run(['lsof', '-nP', `-iTCP:${at}`, '-sTCP:LISTEN', '-t'])
981 return exitCode === 0 && stdout.trim() !== ''
982}
983
984// At session start: keep the remembered port if it is free, else take a
985// random free one in 1920-1990 for this session (the remembered one stays stored).
986async function pickFreePort($: EngineInterface) {
987 const preferred = await read($, port)
988 for (const at of candidatePorts(preferred)) {
989 if (await isPortTaken($, at)) continue
990 if (at !== preferred) {
991 await update($, port, () => at)
992 $.ui.toast(`Port ${preferred} is in use; the server will use ${at}`)
993 }
994 return
995 }
996}
997
998async function choosePort($: EngineInterface) {
999 const current = await read($, port)
1000 let answer: string
1001 try {
1002 answer = await $.ui.ask(`Which port should the server use? It is on ${current} now.`, {
1003 header: 'port',
1004 options: ports(await read($, settings)).slice(0, 4).map(String),
1005 })
1006 } catch {
1007 return // dismissed
1008 }
1009 const chosen = parsePort(answer)
1010 if (chosen === undefined) {
1011 $.ui.toast(`"${answer}" is not a port (1-65535)`)
1012 return
1013 }
1014 await update($, port, () => chosen)
1015 await $.store.set(portKey(await read($, repo)), chosen)
1016 if ((await read($, serve)).state === 'running') await restartServe($)
1017}
1018
1019async function openServeMenu($: EngineInterface) {
1020 const isRunning = (await read($, serve)).state === 'running'
1021 const at = await read($, port)
1022 let choice: string
1023 try {
1024 choice = isRunning
1025 ? await $.ui.ask(`The server is running on ${at}. What should happen to it?`, {
1026 header: 'server',
1027 options: ['Restart', 'Kill', 'Change port', 'Cancel'],
1028 })
1029 : await $.ui.ask(`The server is stopped. Start it on ${at}?`, {
1030 header: 'server',
1031 options: ['Start', 'Change port', 'Cancel'],
1032 })
1033 } catch {
1034 return // dismissed
1035 }
1036 if (choice === 'Restart' || choice === 'Start') return restartServe($)
1037 if (choice === 'Kill') return stopServe($)
1038 if (choice === 'Change port') return choosePort($)
1039}
1040
1041async function loadPlan($: EngineInterface, mine: number) {
1042 const dry = planArgv(await read($, settings))
1043 if (!dry) return
1044 if (!plan) {
1045 const { exitCode, stdout } = await $.process.run(dry, { timeoutMs: 120_000 })
1046 if (exitCode !== 0) return
1047 plan = parseDry(stdout)
1048 }
1049 const planned = plan
1050 if (mine === generation) await update($, serve, (s): Serve => ({ ...s, tasks: mergePlan(planned, tasksOf(s)) }))
1051}
1052
1053async function restartServe($: EngineInterface) {
1054 const mine = ++generation
1055 if (child) {
1056 const old = child
1057 child = null
1058 await old.return(undefined) // leaving the stream kills the child
1059 }
1060 await killServers($).catch(() => {})
1061 logs.clear()
1062 // The command, its port variable and any extra env come from the repo's
1063 // settings (alertd-core's sets ORCHESTRATOR_SINGLETON_GATE=false there).
1064 const cfg = await read($, settings)
1065 const argv = serverArgv(cfg)
1066 if (!argv.length) return
1067 const stream = $.process.spawn({ argv, env: serverEnv(cfg, await read($, port)) })
1068 child = stream
1069 // A plain server is one task, `server`, whose lines are its logs.
1070 const first: readonly Task[] = serverKind(cfg) === 'plain' ? [{ name: PLAIN_TASK, state: 'running', persistent: true, deps: [] }] : (plan ?? [])
1071 await update($, serve, (): Serve => ({ state: 'running', tasks: first }))
1072 watchUp($, mine)
1073 void loadPlan($, mine).catch(() => {})
1074
1075 void (async () => {
1076 let rest = ''
1077 try {
1078 let step = await stream.next()
1079 while (!step.done) {
1080 const lines = (rest + step.value.text).split(/\r?\n|\r/)
1081 rest = lines.pop() ?? ''
1082 if (mine === generation && lines.some(l => STARTED.test(l.replace(ANSI, '')) || FAILED.test(l.replace(ANSI, '')))) {
1083 await update($, serve, (s): Serve => ({ ...s, tasks: lines.reduce(applyLine, tasksOf(s)) }))
1084 }
1085 if (mine === generation) await recordLogs($, lines)
1086 const printing = new Set(lines.map(taskOfLine).filter((n): n is string => n !== undefined))
1087 if (mine === generation && printing.size) await flash($, printing, mine)
1088 step = await stream.next()
1089 }
1090 if (mine === generation) {
1091 child = null
1092 const { code, signal } = step.value
1093 await update($, serve, (s): Serve => ({
1094 state: 'exited',
1095 code: code ?? null,
1096 signal: signal ?? undefined,
1097 tasks: finishTasks(applyLine(tasksOf(s), rest), code),
1098 }))
1099 }
1100 } catch (err) {
1101 if (mine === generation) {
1102 child = null
1103 await update($, serve, (s): Serve => ({ ...s, state: 'exited', code: null, tasks: finishTasks(tasksOf(s), null) }))
1104 $.ui.log(`work-band: server stream failed: ${String(err)}`, { to: 'debug' })
1105 }
1106 }
1107 })().catch(() => {
1108 // the module unloaded while the child ran: nothing left to update
1109 })
1110}
1111
1112// ---------- settings: the pane, one question per setting, the wizard ----------
1113const SETTINGS_PANE = 'wb-settings'
1114const SETTING_KEYS = ['enabled', 'jira.site', 'jira.projects', 'server.kind', 'server.portEnv', 'server.ports', 'server.env'] as const
1115type SettingKey = (typeof SETTING_KEYS)[number]
1116
1117const KEEP = 'Keep as is'
1118const copy = (cfg: RepoSettings): RepoSettings => JSON.parse(JSON.stringify(cfg)) as RepoSettings
1119
1120// The servers the repo looks like it has, from its turbo.json and package.json.
1121async function detectedServers($: EngineInterface) {
1122 const top = repoTop || '.'
1123 const [turbo, pkg] = await Promise.all([
1124 $.fs.read(`${top}/turbo.json`).catch(() => undefined),
1125 $.fs.read(`${top}/package.json`).catch(() => undefined),
1126 ])
1127 return detectServers(typeof turbo === 'string' ? turbo : undefined, typeof pkg === 'string' ? pkg : undefined)
1128}
1129
1130// Asks for one setting and returns the settings with it changed; undefined
1131// when the question was dismissed or the answer did not parse (a toast says
1132// why). Each question takes free text through the dialog's Other.
1133async function askSetting($: EngineInterface, key: SettingKey, cfg: RepoSettings): Promise<RepoSettings | undefined> {
1134 const next = copy(cfg)
1135 // The dialog pads one option with Yes/No, so a lone option gets Keep as is.
1136 const ask = (question: string, header: string, options: string[]) => {
1137 const list = [...new Set(options.filter(Boolean))].slice(0, 4)
1138 return $.ui.ask(question, { header, options: list.length < 2 ? [...list, KEEP] : list }).catch(() => undefined)
1139 }
1140 if (key === 'enabled') {
1141 const a = await ask('Show the work band in this repo?', 'band', ['On', 'Off'])
1142 if (a === undefined) return undefined
1143 if (a === KEEP) return next
1144 if (a === 'Off') next.enabled = false
1145 else delete next.enabled
1146 return next
1147 }
1148 if (key === 'jira.site') {
1149 const current = jiraSite(cfg)
1150 const a = await ask('Which Jira site does this repo use? Type it under Other (yourco.atlassian.net), or None to turn Jira off.', 'Jira', ['None', ...(current ? [current] : [])])
1151 if (a === undefined) return undefined
1152 if (a === KEEP) return next
1153 const site = a.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '')
1154 if (a === 'None' || !site) {
1155 if (next.jira) delete next.jira.site
1156 } else next.jira = { ...next.jira, site }
1157 return next
1158 }
1159 if (key === 'jira.projects') {
1160 const current = cfg.jira?.projects?.join(', ')
1161 const a = await ask('Which Jira projects count? Type their keys under Other (AL, OPS), or Any.', 'projects', ['Any', ...(current ? [current] : [])])
1162 if (a === undefined) return undefined
1163 if (a === KEEP) return next
1164 const list = a === 'Any' ? [] : a.split(/[\s,]+/).map(k => k.trim().toUpperCase()).filter(Boolean)
1165 next.jira = { ...next.jira, projects: list.length ? list : undefined }
1166 if (!list.length) delete next.jira.projects
1167 return next
1168 }
1169 if (key === 'server.kind') {
1170 const found = await detectedServers($)
1171 const a = await ask('Which dev server should the band run? Or type a command under Other.', 'server', ['None', ...found.map(f => f.label)])
1172 if (a === undefined) return undefined
1173 if (a === KEEP) return next
1174 if (a === 'None') {
1175 next.server = { ...next.server, kind: 'none' }
1176 return next
1177 }
1178 const hit = found.find(f => f.label === a)
1179 const command = hit ? hit.command : a.trim().split(/\s+/).filter(Boolean)
1180 if (!command.length) return undefined
1181 next.server = { ...next.server, kind: hit ? hit.kind : command.includes('turbo') ? 'turbo' : 'plain', command }
1182 return next
1183 }
1184 if (key === 'server.portEnv') {
1185 const a = await ask('Which environment variable does the server read its port from?', 'port var', [portEnv(cfg), 'PORT'])
1186 if (a === undefined) return undefined
1187 if (a === KEEP) return next
1188 const name = a.trim()
1189 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1190 $.ui.toast(`"${a}" is not a variable name`)
1191 return undefined
1192 }
1193 next.server = { ...next.server, portEnv: name }
1194 return next
1195 }
1196 if (key === 'server.ports') {
1197 const a = await ask('Which ports may the server use, first choice first? Type them under Other (3000, 3001).', 'ports', [ports(cfg).join(', '), '3000'])
1198 if (a === undefined) return undefined
1199 if (a === KEEP) return next
1200 const list = parsePorts(a)hooks/settings.ts 144 lines1// The band's per-repo settings, kept in one file in the person's home folder
2// (`~/.claude/work-band.json`), keyed by `owner/repo`; nothing is stored in
3// the repo. A repo with no entry gets the defaults: the band on, no Jira, no
4// server.
5
6import type { RepoSettings, ServerKind } from '../types'
7
8export const SETTINGS_FILE = 'work-band.json'
9
10// `git@github.com:alert-d/alertd-core.git`, `https://github.com/o/r` → `o/r`;
11// undefined for a remote that is not on GitHub.
12export const repoOf = (remote: string): string | undefined =>
13 /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(remote.trim())?.[1]
14
15// The whole file, one entry per repo; anything that is not an object reads
16// as no settings.
17export const parseSettingsFile = (text: string): Record<string, RepoSettings> => {
18 try {
19 const all = JSON.parse(text) as unknown
20 return all && typeof all === 'object' && !Array.isArray(all) ? (all as Record<string, RepoSettings>) : {}
21 } catch {
22 return {}
23 }
24}
25
26export const formatSettingsFile = (all: Record<string, RepoSettings>): string => `${JSON.stringify(all, null, 2)}\n`
27
28export const isEnabled = (s: RepoSettings): boolean => s.enabled !== false
29
30// Jira runs only where its site is set.
31export const jiraSite = (s: RepoSettings): string | undefined => s.jira?.site?.trim() || undefined
32
33export const DEFAULT_KEY_PATTERN = '[A-Z][A-Z0-9]+-\\d+'
34export const keyPattern = (s: RepoSettings): RegExp => {
35 try {
36 return new RegExp(`\\b(?:${s.jira?.keyPattern || DEFAULT_KEY_PATTERN})\\b`)
37 } catch {
38 return new RegExp(`\\b(?:${DEFAULT_KEY_PATTERN})\\b`)
39 }
40}
41
42// The issue key in the PR title, else the branch, from an allowed project.
43export const issueKey = (s: RepoSettings, ...texts: readonly string[]): string | undefined => {
44 const pattern = keyPattern(s)
45 const projects = s.jira?.projects?.length ? s.jira.projects : undefined
46 for (const text of texts) {
47 const key = text.match(pattern)?.[0]
48 if (key && (!projects || projects.includes(key.split('-')[0]!))) return key
49 }
50 return undefined
51}
52
53export const TURBO_SERVE = ['npx', 'turbo', 'run', 'serve', '--ui=stream']
54
55export const serverKind = (s: RepoSettings): ServerKind => {
56 const k = s.server?.kind
57 if (k === 'turbo' || k === 'plain') return s.server?.command?.length || k === 'turbo' ? k : 'none'
58 return 'none'
59}
60
61// What starts the server; empty when there is none.
62export const serverArgv = (s: RepoSettings): readonly string[] => {
63 const kind = serverKind(s)
64 if (kind === 'none') return []
65 return s.server?.command?.length ? s.server.command : TURBO_SERVE
66}
67
68// Turbo's plan of the tasks the server runs: the same command, dry.
69export const planArgv = (s: RepoSettings): readonly string[] | undefined => {
70 if (serverKind(s) !== 'turbo') return undefined
71 const argv = serverArgv(s).filter(a => !a.startsWith('--ui'))
72 return [...argv, '--dry=json']
73}
74
75export const portEnv = (s: RepoSettings): string => s.server?.portEnv?.trim() || 'PORT'
76export const ports = (s: RepoSettings): readonly number[] => {
77 const list = (s.server?.ports ?? []).filter(p => Number.isInteger(p) && p > 0 && p < 65536)
78 return list.length ? list : [3000]
79}
80
81// What a process of this server's command looks like in `ps`, to find one
82// this session started and lost track of.
83export const serverMatch = (s: RepoSettings): string => serverArgv(s).filter(a => !a.startsWith('-')).slice(1).join(' ')
84
85export const serverEnv = (s: RepoSettings, at: number): Record<string, string> => ({
86 FORCE_COLOR: '0',
87 ...(s.server?.env ?? {}),
88 [portEnv(s)]: String(at),
89})
90
91// A server the repo looks like it has, offered by the wizard, never started
92// unasked.
93export type ServerChoice = { label: string; kind: ServerKind; command: string[] }
94export const detectServers = (turboJson: string | undefined, packageJson: string | undefined): ServerChoice[] => {
95 const out: ServerChoice[] = []
96 try {
97 const tasks = Object.keys((JSON.parse(turboJson ?? '') as { tasks?: object; pipeline?: object }).tasks ?? {})
98 if (tasks.some(t => t === 'serve' || t.endsWith('#serve'))) out.push({ label: 'turbo run serve', kind: 'turbo', command: TURBO_SERVE })
99 } catch {
100 // no turbo.json, or not JSON
101 }
102 try {
103 const scripts = (JSON.parse(packageJson ?? '') as { scripts?: Record<string, string> }).scripts ?? {}
104 for (const name of ['dev', 'serve', 'start']) {
105 if (scripts[name]) out.push({ label: `npm run ${name}`, kind: 'plain', command: ['npm', 'run', name] })
106 }
107 } catch {
108 // no package.json, or not JSON
109 }
110 return out
111}
112
113// `1776, 1791 1792` → [1776, 1791, 1792]; undefined when any part is not a port.
114export const parsePorts = (text: string): number[] | undefined => {
115 const parts = text.split(/[\s,]+/).filter(Boolean)
116 const list = parts.map(Number)
117 return parts.length && list.every(p => Number.isInteger(p) && p > 0 && p < 65536) ? list : undefined
118}
119
120// `A=1 B=two` → { A: '1', B: 'two' }; undefined when a part has no `=`.
121export const parseEnv = (text: string): Record<string, string> | undefined => {
122 const parts = text.split(/\s+/).filter(Boolean)
123 if (!parts.every(p => /^[A-Za-z_][A-Za-z0-9_]*=/.test(p))) return undefined
124 return Object.fromEntries(parts.map(p => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)]))
125}
126
127// One line per setting, as the settings pane lists them.
128export const describe = (s: RepoSettings): { key: string; label: string; value: string }[] => {
129 const kind = serverKind(s)
130 return [
131 { key: 'enabled', label: 'Band', value: isEnabled(s) ? 'on' : 'off' },
132 { key: 'jira.site', label: 'Jira site', value: jiraSite(s) ?? 'none (Jira off)' },
133 { key: 'jira.projects', label: 'Jira projects', value: s.jira?.projects?.join(', ') || 'any' },
134 { key: 'server.kind', label: 'Server', value: kind === 'none' ? 'none' : `${kind}: ${serverArgv(s).join(' ')}` },
135 ...(kind === 'none'
136 ? []
137 : [
138 { key: 'server.portEnv', label: 'Port variable', value: portEnv(s) },
139 { key: 'server.ports', label: 'Ports', value: ports(s).join(', ') },
140 { key: 'server.env', label: 'Extra env', value: Object.entries(s.server?.env ?? {}).map(([k, v]) => `${k}=${v}`).join(' ') || 'none' },
141 ]),
142 ]
143}
144hooks/tree.ts 113 lines1// The Files pane's tree: pure functions over repo-relative paths.
2
3export type Mark = 'uncommitted' | 'pr'
4
5export type Change = { path: string; status: string }
6
7export type Row = {
8 path: string
9 name: string
10 depth: number
11 isDir: boolean
12 isOpen: boolean
13 mark?: Mark
14 status?: string
15}
16
17// `git status --porcelain=v1 -uall`: `XY path`, or `XY old -> new` for a rename.
18export const parseStatus = (porcelain: string): Change[] =>
19 porcelain
20 .split('\n')
21 .filter(line => line.length > 3)
22 .map(line => ({
23 status: line.slice(0, 2).trim() || '?',
24 path: line.slice(3).split(' -> ').pop()!.replace(/^"|"$/g, ''),
25 }))
26
27// `git diff --name-status base...HEAD`: `M\tpath`, or `R100\told\tnew`.
28export const parseNameStatus = (diff: string): Change[] =>
29 diff
30 .split('\n')
31 .filter(Boolean)
32 .map(line => {
33 const [status = '', ...paths] = line.split('\t')
34 return { status: status.charAt(0), path: paths[paths.length - 1] ?? '' }
35 })
36 .filter(c => c.path)
37
38// Every folder above a path: `a/b/c.ts` → `a`, `a/b`.
39export const ancestorsOf = (path: string): string[] => {
40 const parts = path.split('/').slice(0, -1)
41 return parts.map((_, i) => parts.slice(0, i + 1).join('/'))
42}
43
44type Dir = { dirs: Map<string, Dir>; files: Set<string> }
45
46const newDir = (): Dir => ({ dirs: new Map(), files: new Set() })
47
48const treeOf = (paths: Iterable<string>): Dir => {
49 const root = newDir()
50 for (const path of paths) {
51 const parts = path.split('/')
52 let dir = root
53 for (const part of parts.slice(0, -1)) {
54 let next = dir.dirs.get(part)
55 if (!next) dir.dirs.set(part, (next = newDir()))
56 dir = next
57 }
58 dir.files.add(parts[parts.length - 1]!)
59 }
60 return root
61}
62
63// The mark a folder shows: uncommitted if any file under it is, else pr.
64export const folderMarks = (marks: ReadonlyMap<string, Mark>): Map<string, Mark> => {
65 const out = new Map<string, Mark>()
66 for (const [path, mark] of marks) {
67 for (const dir of ancestorsOf(path)) {
68 if (out.get(dir) !== 'uncommitted') out.set(dir, mark)
69 }
70 }
71 return out
72}
73
74// The rows a pane shows, folders first then files, each level by name.
75// `isOpen(dir)` says which folders are expanded.
76export const buildRows = (
77 paths: Iterable<string>,
78 isOpen: (dir: string) => boolean,
79 marks: ReadonlyMap<string, Mark>,
80 statuses: ReadonlyMap<string, string>,
81): Row[] => {
82 const dirMarks = folderMarks(marks)
83 const rows: Row[] = []
84 const walk = (dir: Dir, prefix: string, depth: number) => {
85 for (const name of [...dir.dirs.keys()].sort()) {
86 const path = prefix + name
87 const open = isOpen(path)
88 rows.push({ path, name, depth, isDir: true, isOpen: open, mark: dirMarks.get(path) })
89 if (open) walk(dir.dirs.get(name)!, `${path}/`, depth + 1)
90 }
91 for (const name of [...dir.files].sort()) {
92 const path = prefix + name
93 rows.push({ path, name, depth, isDir: false, isOpen: false, mark: marks.get(path), status: statuses.get(path) })
94 }
95 }
96 walk(treeOf(paths), '', 0)
97 return rows
98}
99
100// Each file's mark: uncommitted wins over pr when a file is both.
101export const marksOf = (pr: readonly Change[], uncommitted: readonly Change[]): Map<string, Mark> => {
102 const marks = new Map<string, Mark>()
103 for (const c of pr) marks.set(c.path, 'pr')
104 for (const c of uncommitted) marks.set(c.path, 'uncommitted')
105 return marks
106}
107
108// `/repo/src/a.ts` under `/repo` → `src/a.ts`; a path outside → undefined.
109export const relativeTo = (top: string, file: string): string | undefined => {
110 const root = top.endsWith('/') ? top : `${top}/`
111 return file.startsWith(root) ? file.slice(root.length) : undefined
112}
113types/index.d.ts 80 lines1export type CheckState = 'pass' | 'fail' | 'pending' | 'skipped'
2export type Check = {
3 name: string
4 workflow?: string
5 state: CheckState
6 startedAt?: string
7 completedAt?: string
8 url?: string
9}
10export type Pr = {
11 number: number
12 state: string
13 isDraft: boolean
14 passed: number
15 failed: number
16 pending: number
17 title: string
18 checks?: Check[]
19 base?: string
20 url?: string
21}
22export type Account = { plan: string; org?: string; email?: string }
23export type DiffStat = { added: number; removed: number; base: string }
24export type FilesMode = 'all' | 'pr' | 'uncommitted'
25// `status` is the issue's status abbreviated (To Do → TODO), absent when Jira gave none.
26export type Jira = { key: string; title: string; status?: string } | { key: string; error: string }
27export type Task = {
28 name: string
29 state: 'pending' | 'running' | 'done' | 'failed' | 'stopped'
30 code?: number
31 persistent: boolean
32 deps: string[]
33 // lit while the task prints
34 flash?: boolean
35}
36export type Serve = {
37 state: 'stopped' | 'running' | 'exited'
38 code?: number | null
39 signal?: string
40 tasks: readonly Task[]
41 // the port answered: the server is up, and the port is not checked again
42 up?: boolean
43 // on/off while it starts, so `server` flashes
44 blink?: boolean
45}
46
47export type ServerKind = 'turbo' | 'plain' | 'none'
48// One repo's entry in ~/.claude/work-band.json; every field optional.
49export type RepoSettings = {
50 enabled?: boolean
51 jira?: { site?: string; keyPattern?: string; projects?: string[] }
52 server?: { kind?: ServerKind; command?: string[]; portEnv?: string; ports?: number[]; env?: Record<string, string> }
53}
54
55declare module 'claude-code' {
56 interface PluginState {
57 'work-band': { pr: Pr | null; jira: Jira | null; serve: Serve; canServe: boolean; isHidden: boolean; logView: { task: string; tick: number }; mergedTold: number[]
58 port: number
59 filesMode: FilesMode
60 openDirs: string[]
61 closedDirs: string[]
62 filesTick: number
63 editFrame: number
64 diffStat: DiffStat | null
65 rerunAsked: string[]
66 account: Account | null
67 // What Claude says it is working on: the labels set_topic was last called with.
68 topic: string[]
69 // the checked-out branch; '' when HEAD is detached
70 branch: string
71 // `owner/repo` of the origin remote, '' until known or off GitHub
72 repo: string
73 // this repo's entry in ~/.claude/work-band.json
74 settings: RepoSettings
75 // the repo's default branch, from gh; the diff's base with no PR
76 defaultBranch: string
77 }
78 }
79}
80