A band above the prompt, in the workstation checkout only: whether this host, its dotfiles and (from WSL) the Windows checkout are at origin/main, and the…

Dev-environment provisioning for Linux hosts (native and WSL), plus Warp and Windows Terminal on Windows. One command per host installs the tools, system packages, /etc files, services and dotfiles.
One entry script per OS (bootstrap.sh, bootstrap.ps1) installs a pinned mise (mise.jdx.dev) and hands off to it. mise owns every tool and every piece of host state. This checkout is mise's global config directory: ~/.config/mise on Linux, %USERPROFILE%\.config\mise on Windows.
| Piece | Role |
|---|---|
bootstrap.sh / bootstrap.ps1 | Install pinned mise, decide the system steps, run mise. Nothing is installed by hand in the scripts beyond mise (and, on Windows, SSHFS-Win). |
| mise tools | Every tool is a pin in config.toml / config.linux.toml. mise ls is the tool list. |
mise bootstrap | Host state from [bootstrap.*] tables: dnf packages, /etc files, services, repos, dotfiles, then the tools and the bootstrap task. |
mise dot ([dotfiles]) | Personal config under $HOME, templated per machine. Every deployed file is an independent copy, never a symlink into the checkout. |
| starship, zsh plugins, fzf, zoxide | Prompt, completion, fuzzy find and directory jumping. |
| zellij | Persistent sessions; runs on the remote host so a dropped SSH tab loses nothing. |
| helix | Modal editor and $EDITOR, unless the shell inherits one, as a ws open workspace's panes do (dotfiles/config/helix). It uses Catppuccin Mocha with the mauve accent and the same clangd flags as Neovim. clang-format runs on save only where the project has a .clang-format. Files auto-save 3 s after an edit, and yanks copy over OSC 52 on Linux (the Windows clipboard on Windows). |
| Warp, Windows Terminal, WezTerm | The Windows terminals (see Terminals). |
Nothing is pushed anywhere and there is no host list: each host keeps itself current with wsu.
Every host gets the same setup. Three tracked config files hold it, plus the git-ignored config.local.toml for per-host values, and miserc.toml (git-ignored, written by scripts/lib/mise-env.sh on Linux and bootstrap.ps1 on Windows) picks the OS file:
| File | Loads on | Holds |
|---|---|---|
config.toml | every host | tools for both OSes (Windows-only and Linux-only entries carry os = [...]), [vars] pins, cross-platform dotfiles |
config.linux.toml | Linux | the Linux toolbelt, dnf packages, /etc/wsl.conf, hooks, Linux dotfiles |
config.windows.toml | Windows | Windows dotfiles, winget GUI apps |
config.local.toml | every host, git-ignored | name, email, sudo, per-host overrides |
System steps and sudo. dnf packages, /etc files and zsh as the login shell need sudo. bootstrap.sh checks once: cached sudo or a password prompt that succeeds means they run; a failed prompt saves sudo = "no" in config.local.toml, and from then on bootstrap.sh, mise run update and wsu skip them (mise bootstrap --skip packages,files) while everything user-level still installs. An unattended run with no terminal skips them for that run only. To apply them later, get sudo and re-run ./bootstrap.sh. mise run health shows the state in its "system steps" row.
A compiler without sudo (Linux). Every Linux host also gets mise's zig, which adds only zig to PATH, so a host's own gcc always wins. Neovim builds its parsers with it when there is no cc. CC=zcc CXX=zc++ uses it for your own builds: clang-based, targeting glibc 2.28, with LLVM's libc++ and no AddressSanitizer. cmake, ninja and make stay dnf-only, because mise copies would shadow dnf's. A no-sudo host that lacks them adds e.g. cmake = "4.4.4" to its config.local.toml, then runs cd ~ && mise lock --global --platform linux-x64 cmake && mise install, which writes the git-ignored mise.local.lock.
bootstrap.sh, bootstrap.ps1 entry points (Linux; Windows, no admin)
config.toml, config.linux.toml, config.windows.toml
mise tool pins, [vars] pins, [dotfiles] entries,
host state as [bootstrap.*] tables (Linux)
config.local.toml git-ignored: this host's name, email, sudo
mise.lock, mise.linux.lock, locks/
generated lockfiles; never hand-edit
tasks/ file tasks with real logic (bootstrap, health, update, ...);
one-line wrappers are [tasks] in config.toml / config.linux.toml
scripts/, scripts/lib/ checks (check-invariants.sh, check-templates.sh), helpers, tests
dotfiles/ every deployed source under its real name
*.tera templates (zshrc, bashrc, zshenv, gitconfig, ssh/config, ...)
config/ ~/.config/*: starship, helix, nvim, zellij, zsh plugins, ...
claude/ ~/.claude/*: CLAUDE.md, hooks, skills, settings seed and enforced keys
local/bin/ ~/.local/bin: batpipe, winterop, zcc, zc++
windows/ the Windows-only sources
configs/wsl/wsl.conf source of /etc/wsl.conf ([bootstrap.files], not [dotfiles])
docs/claude/, .claude/ notes, hooks and settings for Claude Code
docs/windows/application_list.md apps to hand-install on a fresh Windows machine
Dotfile modes: template for the .tera sources, copy for everything else, on every platform. A directory source carries exclude = [".vendor", ".gitkeep"]. ~/.claude/settings.json is not a dotfile entry: a jq merge (scripts/lib/claude-settings-merge.sh; on Windows scripts/claude-settings-merge.ps1, with the same filter) combines dotfiles/claude/settings.seed.json, the live file and settings.enforced.json, the last winning. wsu runs it on both OSes.
Prerequisites: curl, git, tar (a single preflight lists every missing one at once), and free space in $HOME for the tools: the linux figure in disk-budget.toml plus 1 GB, less what is already installed (about 6.9 GB fresh; none when every tool is installed). Without it the tools step stops before installing anything and lists the largest folders in your home; WORKSTATION_SKIP_DISK_CHECK=1 overrides. RHEL-family EL8 and EL9 are supported (tested on AlmaLinux 9.8 and RHEL 8.10). On EL8, bear isn't packaged and is skipped; plain gdb is pwndbg's bundled GDB 17 on every host, so GEF gets a modern gdb there too. Then, on the host:
curl -fsSL https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.sh | bash && \
exec zsh # or: source ~/.bashrc, if you can't chsh on this host
The repo is public, so no token or SSH key is needed. GITHUB_TOKEN is optional: mise uses it to lift GitHub's 60-requests/hour anonymous API limit. To use a (public) fork, change DOTFILES_REPO at the top of bootstrap.sh ($DotfilesRepo in bootstrap.ps1). Your git name and email are asked once and stored in config.local.toml.
What bootstrap.sh does:
curl, git, tar.~/.config/mise (or pull it), then carry on in the checkout's own bootstrap.sh, so a pull's changes apply in the same run.~/.local/bin.config.local.toml) and decide the system steps (sudo; see above).miserc.toml, then run mise bootstrap --yes (packages, /etc files, services, repos, dotfiles, the tools, the bootstrap task, then the Linux-only final hook in config.linux.toml: vcpkg, claude, fonts, which skips itself under WSL, and WezTerm's terminfo entry). The tools come from its pre-tools hook (scripts/lib/mise-install.sh: the disk check, then mise install), after the dnf batch, because node needs dnf's libatomic to start. The first run passes --force-dotfiles (why).sudo usermod -s for a local account, or, for a directory (AD/LDAP) account served by SSSD, a per-host sss_override (installing sssd-tools) and an sssd restart.It is idempotent; re-run any time. Copy your SSH key from a client with ssh-copy-id <user>@<host>.
| Flag | Meaning |
|---|---|
--reinstall | Wipe the cloned repo (including config.local.toml, so name and email are asked again), then re-bootstrap. Prompts first. Installed tools, deployed dotfiles, SSH keys and system packages are kept. |
--yes, -y | Skip the --reinstall confirmation. |
--help, -h | Usage. |
Running --reinstall from inside the repo is refused (the script would delete itself); run it from outside, with the script in memory:
curl -fsSL https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.sh | bash -s -- --reinstall
mise run check-updates runs mise outdated --bump for every pinned tool, dnf check-update on Linux hosts, and git ls-remote for the [vars] pins. Tools tracking latest (the pypi: tools) are reported as rolling. It only reports; the weekly bump workflow (see Adding things) does the bumping.
Extras. A host's first bootstrap ends by offering the Claude Code status line (ccstatusline), also under curl | bash: use the tracked config, define one for this machine only (persisted as a per-host opt-out in config.local.toml), set a new global one (committed back to dotfiles/config/ccstatusline/settings.json), or skip. Re-run any time with mise run statusline. On native (non-WSL) Linux hosts the fonts task installs JetBrainsMono Nerd Font Mono to ~/.local/share/fonts/JetBrainsMonoNerdFontMono/ (needed for glyphs in starship, eza, lazygit, yazi, helix); WSL hosts skip it because Windows Terminal reads Windows-registered fonts. The Claude Code installer, plugins, settings merge and herdr plugin run from tasks/claude, a final hook in config.linux.toml, so they run on Linux hosts only and only on a full mise bootstrap (not wsa). Re-run with mise run fonts or mise run claude.
The Windows host is a client. No admin is needed: everything installs under your user profile (%LOCALAPPDATA%\workstation, mise's %LOCALAPPDATA%\mise, the User PATH, CurrentUser PSGallery, HKCU fonts). One best-effort exception: SSHFS-Win depends on WinFsp, a kernel driver, so its first install raises UAC (two prompts on a host without WinFsp: one for WinFsp, one for SSHFS-Win); decline them or pass -SkipElevated and everything else still completes.
Prerequisites: Git (the script hard-fails with a link if it is missing; winget install Git.Git), a working curl.exe (curl.exe --version), and room on the drive holding %LOCALAPPDATA% for the tools: disk-budget.toml's windows figure plus 1 GB, less what is already installed (about 4 GB fresh; none when every tool is installed; $env:WORKSTATION_SKIP_DISK_CHECK = '1' overrides). PowerShell 5.1 and 7 are supported. Windows HTTP downloads use curl.exe with redirects, retries and checked exit codes.
$f = "$env:TEMP\bootstrap.ps1"; curl.exe -fsSL --retry 3 -o $f https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.ps1
if ($?) { powershell -NoProfile -ExecutionPolicy Bypass -File $f }
Paste both lines into PowerShell. The script runs in its own Windows PowerShell process with the execution policy bypassed for that process only, so it works on a machine still at the default Restricted policy, and a failure leaves your window open with the error. Flags go at the end of the second line (... -File $f -SkipKeyGen). Re-run the same two lines any time, or .\bootstrap.ps1 from the clone if your execution policy allows scripts.
What bootstrap.ps1 does:
curl.exe and git (it never installs Git).%USERPROFILE%\.config\mise (or pull it), then carry on in the checkout's own bootstrap.ps1, so a pull's changes apply in the same run.%LOCALAPPDATA%\workstation\mise. mise is the only version bootstrap.ps1 pins; every CLI tool is a mise pin in config*.toml.miserc.toml (windows), ask once for your git name and email, check that mise loads config.windows.toml (it stops otherwise: the Windows dotfiles and apps would be skipped silently), and run mise bootstrap --only dotfiles,tools: the dotfiles plus every CLI tool (gh, Starship, Helix, Nushell, jq, OpenCode, omp, jcode, DevToys CLI, dnGrep, LogExpert, Node, Go, uv, gopls, language servers, ccstatusline, oh-my-mermaid, archify). The first run passes --force-dotfiles.mise prune and mise reshim. A changed .wslconfig prints the wsl --shutdown reminder. mise bootstrap's post-tools hook (config.windows.toml) runs mise run nu-init, which regenerates Nushell's init files (starship, mise, zoxide, atuin) in %APPDATA%\nushell\vendor\autoload, so wsu refreshes them too. The same hook merges ~/.claude/settings.json (scripts/claude-settings-merge.ps1), so a change to settings.enforced.json, such as the theme or a plugin, reaches Windows with wsu. Its mise.nu builds on each session's own PATH rather than the PATH mise activate nu saw, so Nushell finds what its terminal passes down, as PowerShell does.mise bootstrap --only packages installs config.windows.toml's [bootstrap.packages] winget list (Windows Terminal, Warp, Obsidian, DevToys, DBeaver, WinSCP, Beyond Compare, Zed; latest, checked against the winget manifest's sha256, each self-updating). winget's own settings.json, a tracked dotfile, prefers per-user installers; Zed's only installer is machine scope but installs per-user, without admin. An app counts as installed when winget list --id <Id> --exact finds it. DevToys is the Microsoft Store build (9NBN8W1DS547). Then SSHFS-Win (UAC). Without winget (App Installer) the step warns and skips.~\.local\bin goes on the User PATH), merge ~/.claude/settings.json again (for -SkipToolInstall, which runs no hook) and seed settings.local.json.wpy Python env (a uv venv on mise's python with the libraries in scripts/python-env.txt) and install the Nerd Font (mise's github:ryanoasis/nerd-fonts, registered per-user by scripts/install-nerd-fonts.ps1 with a logon task that re-activates it).Restart the shell afterwards so the new profile loads. Nushell is the default local shell (in Windows Terminal and Zed, both through mise's shim, %LOCALAPPDATA%\mise\shims\nu.exe); PowerShell stays for .NET/COM/registry tasks. Zed's SSH remote terminals use the remote's login shell instead: Zed sends its settings to the remote, so the Nushell setting sits under its "windows" key. Nushell's command-line colours match zsh's on the Linux hosts (commands green, unknown commands red, quoted strings yellow, flags peach, paths underlined): autoload/syntax-highlight.nu overrides the vendored Catppuccin theme's, and lint keeps the two in step. One gap: Nushell gives every argument of an external command (ssh, git) one colour, so its flags and quoted strings stay plain where zsh colours them. atuin's own background jobs don't light starship's jobs gear (scripts/nu-init.nu labels them). Both shells carry the zsh toolbelt (wsh lists it):
z/zi (zoxide), l/la/ll/lt/lta/ltl (eza), y (yazi), fssh (pick an SSH host from ~/.ssh/config and the files it Includes, so config.local's too; the same on Linux) and hidebootstrap.ps1's own flags), as wide as the pane allows, so a long path shows the part that differs; an alias completes as its expansion, so gco <Tab> offers branches, and wse/wsd complete the tracked targets; Ctrl-O edits the command line in Helix; a command that isn't found but mise installs is named, with the way out; ls shows timestamps as ll doesnu-init writes both shells' tool init after every tools phase, and mise's prompt hook runs only when a config file, an installed tool or a MISE_* variable changes (WORKSTATION_MISE_GATE=0 turns that check off), so a prompt costs milliseconds rather than a mise.exe startgc/gp/gl run git (PowerShell's built-in aliases are removed first), and the prompt shows a failed command's exit coderm goes to the Recycle Bin (rm --permanent skips it). The theme is Catppuccin Mocha, vendored into %APPDATA%\nushell\autoload\; ls, eza and the completion menu use the Linux hosts' LS_COLORS (from dotfiles/dircolors), and PowerShell's command line has zsh's colours.
| Flag | Meaning |
|---|---|
-RepoPath <dir> | Clone somewhere other than %USERPROFILE%\.config\mise. |
-SkipKeyGen | Skip the SSH-key prompt. |
-SkipToolInstall | Skip mise, the mise tools phase, the winget GUI apps, the Python env and Claude Code (assume present; the post-tools hook, which runs nu-init, the Neovim plugins, the archify skill copy and the WezTerm nightly install, doesn't run either). |
-SkipDotfiles | Clone and install tools but do not apply dotfiles. On a host's first run this also skips winget's settings.json (the per-user preference), so the GUI apps install with winget's default scope. |
-SkipBurntToast | Skip the BurntToast PowerShell module install. |
-SkipNerdFonts | Skip the Nerd Font install. |
-SkipElevated | Skip SSHFS-Win/WinFsp, the one UAC prompt. Warp's VC++ runtime dependency can also raise UAC on a host without that runtime; this flag does not cover it. |
-Reinstall | Wipe the cloned repo, then re-bootstrap. Prompts unless -Yes. |
-Yes | Skip confirmation prompts (-Reinstall). |
-Reinstall from inside the repo is refused, since the script would delete itself; the download above runs a copy from %TEMP%, so put -Reinstall at the end of its second line.
Health and updates. There is no report mode in bootstrap.ps1. Use mise doctor, mise bootstrap status and mise dot status for the tools, host state and dotfiles. wsu updates the dotfiles and every CLI tool. The GUI apps are config.windows.toml's [bootstrap.packages]: mise bootstrap packages status lists them as installed or missing, mise bootstrap packages apply --manager winget installs the missing ones, and mise bootstrap packages upgrade --manager winget updates them. To add one, add a "winget:<Id>" = "latest" line (the Id from winget search).
A mise version bump reaches Windows through wsu's pull, but the installed mise is then older than config.toml's min_version and refuses the new config, and so does every mise shim, including Windows Terminal's Nushell. Re-run .\bootstrap.ps1 from a Windows PowerShell window to install the new mise.
Windows Terminal's settings.json (Catppuccin Mocha, Nushell default profile) is a tracked copy dotfile deployed straight to the Store package's LocalState. Warp's settings.toml and keybindings.yaml (%LOCALAPPDATA%\warp\Warp\config\) and theme (%APPDATA%\warp\Warp\data\themes\) are tracked too. Both apps rewrite their own files, so record changes made in their UIs with wsr. The generated parts are separate: Warp workstation-*.toml Tab Configs in %APPDATA%\warp\Warp\data\tab_configs\, and the Windows Terminal fragment under %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\workstation\. Your own Tab Configs and profiles are never touched.
SSH host launchers. Every concrete Host alias in the git-ignored ~/.ssh/config.local becomes an SSH: <alias> Warp Tab Config and Windows Terminal profile that runs ssh -t <alias> zellij attach --create main. Patterns and ! negations are skipped. WezTerm lists the same hosts in its launcher, and CTRL+ALT+S there adds, edits or deletes one without opening the file (see Terminals). Re-run .\bootstrap.ps1 after a change, by hand or from WezTerm, then restart Warp or Windows Terminal.
Example: Host build1 with HostName build1.example.com in %USERPROFILE%\.ssh\config.local.
WSL distros are ordinary managed Linux hosts, reached through the AlmaLinux-9 profile in Windows Terminal or Warp (or wsl.exe). bootstrap.sh detects WSL ($WSL_DISTRO_NAME, /proc/version) and the rest of the flow is unchanged. Inside the distro:
sudo dnf install -y curl git tar python3 python3-pip # RHEL / AlmaLinux / Fedora
sudo apt install -y curl git tar python3 python3-pip # Debian / Ubuntu
curl -fsSL https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.sh | bash
Run it where you have sudo (the usual case); without it the system steps, /etc/wsl.conf included, are skipped. Two files configure WSL and the repo owns both:
/etc/wsl.conf (per-distro: systemd, automount, interop, default user, appendWindowsPath=false): deployed on Linux hosts by config.linux.toml from configs/wsl/wsl.conf (on a non-WSL host nothing reads it).%USERPROFILE%\.wslconfig (VM memory, CPU, networking, all distros): deployed on the Windows host by a copy dotfile from dotfiles/wslconfig. It enables autoMemoryReclaim=gradual and pre-opts into sparseVhd=true.Both need wsl --shutdown from a Windows terminal to take effect. bootstrap.ps1 prints that reminder when .wslconfig changes; on Linux, remember it yourself after a /etc/wsl.conf change. WSL tabs open in ~ because the WSL Terminal fragment launches wsl.exe -d AlmaLinux-9 --cd ~. Every tracked shell exports COLORTERM=truecolor, so 24-bit color works in Windows Terminal. Keep long-lived work in a zellij session.
Warp is the day-to-day Windows terminal and opens into AlmaLinux-9 (WSL zsh). Its "+" menu (CTRL+ALT+L) lists the generated Tab Configs. Windows Terminal stays fully configured because Warp cannot run Nushell (its supported shells are PowerShell, WSL2 and Git Bash) and cannot take Windows' default-terminal role. Warp skips the shell-side fzf, atuin, starship, shift-select and fzf-tab wiring (TERM_PROGRAM=WarpTerminal), since its own editor handles those. The pane mnemonics follow zellij's pane mode (d = down, r = right) so muscle memory transfers between local panes and SSH plus zellij.
| Warp | Action |
|---|---|
| ALT+SHIFT+D / R | Split pane down / right |
| ALT+SHIFT+arrows | Move between panes |
| CTRL+SHIFT+Z | Toggle pane maximize |
| CTRL+= / - / 0 | Font size bigger / smaller / reset |
| CTRL+R | Command history search |
| CTRL+↑/↓ | Select previous / next block |
| ALT+↑/↓ | Jump between bookmarked blocks |
| CTRL+SHIFT+P | Command palette |
| CTRL+ALT+L | Tab Config palette |
| Windows Terminal | Action |
|---|
| ALT+SHIFT+D / R | Spl
hooks/register.tsx 203 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register } from 'claude-code'
3
4import type { Sync, SyncChecks, SyncPr } from '../types'
5
6// A band above the prompt in the workstation checkout (~/.config/mise, or
7// %USERPROFILE%\.config\mise), and nowhere else: is this host's checkout at origin/main
8// (else `wsu`), are its dotfiles applied, is the Windows checkout at origin/main (from
9// WSL, read from /mnt/c), and how do the branch's PR checks stand. It looks when the
10// session starts, every minute after, and when a turn or a slash command ends. A probe that fails shows a ?
11// and the rest still draws.
12
13const sync = atom({ plugin: 'workstation-sync', key: 'sync' } as const, null)
14const EVERY_MS = 60_000
15const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
16const MERGEABLE = new Set(['CLEAN', 'HAS_HOOKS'])
17
18type $ = EngineInterface
19type Rollup = { name?: string; context?: string; status?: string; conclusion?: string | null; state?: string }
20type Part = { text: string; color: Color }
21
22const short = (sha: string) => sha.trim().slice(0, 7)
23// One spelling for a path: forward slashes, no trailing one, a drive path in lower case.
24const norm = (path: string) => {
25 const p = path.trim().replace(/\\/g, '/').replace(/\/+$/, '')
26 return /^[a-z]:\//i.test(p) ? p.toLowerCase() : p
27}
28const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
29
30async function run($: $, argv: string[]) {
31 const ran = await $.process.run(argv, { timeoutMs: 20_000 })
32 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `${argv[0]} exited ${ran.exitCode}`)
33 return ran.stdout
34}
35
36// A probe's answer, or null when it failed.
37async function probe<T>(look: () => Promise<T>): Promise<T | null> {
38 try {
39 return await look()
40 } catch {
41 return null
42 }
43}
44
45// The commit a checkout's HEAD names, read from its .git (a loose ref or packed-refs).
46async function headOf($: $, repo: string) {
47 const head = (await $.fs.read(`${repo}/.git/HEAD`)).trim()
48 if (!head.startsWith('ref: ')) return short(head)
49 const ref = head.slice(5)
50 try {
51 return short(await $.fs.read(`${repo}/.git/${ref}`))
52 } catch {
53 const packed = (await $.fs.read(`${repo}/.git/packed-refs`)).split('\n').find(line => line.endsWith(` ${ref}`))
54 if (!packed) throw new Error(`no ${ref}`)
55 return short(packed)
56 }
57}
58
59function checksOf(rollup: Rollup[]): SyncChecks {
60 const checks: SyncChecks = { passed: 0, running: 0, failed: [] }
61 for (const c of rollup) {
62 const name = c.name ?? c.context ?? '?'
63 if (c.state !== undefined) {
64 if (c.state === 'SUCCESS') checks.passed++
65 else if (c.state === 'PENDING' || c.state === 'EXPECTED') checks.running++
66 else checks.failed.push(name)
67 } else if (c.status !== 'COMPLETED') checks.running++
68 else if (PASSED.has(c.conclusion ?? '')) checks.passed++
69 else checks.failed.push(name)
70 }
71 return checks
72}
73
74async function prOf($: $): Promise<SyncPr | 'none' | null> {
75 const ran = await $.process.run(['gh', 'pr', 'view', '--json', 'number,mergeStateStatus,statusCheckRollup'], { timeoutMs: 20_000 })
76 if (ran.exitCode !== 0) return ran.stderr.includes('no pull requests found') ? 'none' : null
77 const pr = JSON.parse(ran.stdout) as { number: number; mergeStateStatus: string; statusCheckRollup: Rollup[] | null }
78 return { number: pr.number, mergeState: pr.mergeStateStatus, checks: checksOf(pr.statusCheckRollup ?? []) }
79}
80
81function prPart(pr: SyncPr | 'none' | null): Part {
82 if (pr === 'none') return { text: 'no PR yet', color: 'inactive' }
83 if (pr === null) return { text: '? PR', color: 'inactive' }
84 const { number: n, checks, mergeState } = pr
85 const total = checks.passed + checks.running + checks.failed.length
86 if (checks.failed.length > 0) return { text: `✗ PR #${n}: ${checks.failed.join(', ')} failed`, color: 'error' }
87 if (checks.running > 0) return { text: `… PR #${n}: ${checks.running} of ${total} checks running`, color: 'warning' }
88 if (total === 0) return { text: `… PR #${n}: no checks yet`, color: 'warning' }
89 const passed = `${plural(checks.passed, 'check')} passed`
90 if (MERGEABLE.has(mergeState)) return { text: `✓ PR #${n}: ${passed}, /ship`, color: 'success' }
91 return { text: `! PR #${n}: ${passed}, ${mergeState}`, color: 'warning' }
92}
93
94function partsOf(s: Sync): Part[] {
95 const parts: Part[] = []
96 if (s.branch === null || s.head === null) parts.push({ text: '? checkout', color: 'inactive' })
97 else if (s.branch !== 'main') parts.push({ text: `${s.branch} ${s.head}`, color: 'claude' }, prPart(s.pr))
98 else if (s.origin === null) parts.push({ text: `main ${s.head} (origin ?)`, color: 'inactive' })
99 else if (s.head === s.origin) parts.push({ text: `✓ main ${s.head}`, color: 'success' })
100 else parts.push({ text: `! main ${s.head}, origin ${s.origin}: run wsu`, color: 'warning' })
101
102 if (s.drift === null) parts.push({ text: '? dotfiles', color: 'inactive' })
103 else if (s.drift === 0) parts.push({ text: '✓ dotfiles', color: 'success' })
104 else parts.push({ text: `! ${plural(s.drift, 'dotfile')} drifted (mise dot status)`, color: 'warning' })
105
106 if (s.windows === undefined) return parts
107 if (s.windows === null) parts.push({ text: '? Windows', color: 'inactive' })
108 else if (s.origin === null) parts.push({ text: `Windows ${s.windows}`, color: 'inactive' })
109 else if (s.windows === s.origin) parts.push({ text: '✓ Windows', color: 'success' })
110 else parts.push({ text: `! Windows ${s.windows}: run wsu there`, color: 'warning' })
111 return parts
112}
113
114// Module state, which a reload starts over (session.start then fires again): the home
115// directory once the session turns out to run in the workstation checkout, and the
116// Windows checkout as WSL sees it (undefined off WSL, null when it can't be found).
117let home: string | undefined
118let windowsRepo: string | null | undefined
119let isLooking = false
120
121async function look($: $) {
122 if (home === undefined || isLooking) return
123 isLooking = true
124 try {
125 const at = home
126 const repo = windowsRepo
127 const [branch, head, origin, drift, windows] = await Promise.all([
128 probe(async () => (await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])).trim()),
129 probe(async () => short(await run($, ['git', 'rev-parse', 'HEAD']))),
130 probe(async () => short(await run($, ['git', 'ls-remote', 'origin', 'refs/heads/main'])) || null),
131 probe(async () => {
132 const status = JSON.parse(await run($, ['mise', '-C', at, 'dot', 'status', '--json'])) as { files: { state: string }[] }
133 return status.files.filter(f => f.state !== 'applied').length
134 }),
135 repo === undefined ? undefined : repo === null ? null : probe(() => headOf($, repo)),
136 ])
137 const pr = branch !== null && branch !== 'main' ? await probe(() => prOf($)) : null
138 const snapshot: Sync = { branch, head, origin, drift, pr }
139 if (windows !== undefined) snapshot.windows = windows
140 await update($, sync, () => snapshot)
141 } catch {
142 // A call refused because a pull just reloaded this mod: the reloaded copy looks again.
143 } finally {
144 isLooking = false
145 }
146}
147
148export const register: Register = on => {
149 on('session.start', async ($, e, next) => {
150 const started = await next(e)
151 home = undefined
152 windowsRepo = undefined
153 const userHome = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
154 if (userHome === undefined) return started
155 const top = await probe(() => run($, ['git', 'rev-parse', '--show-toplevel']))
156 if (top === null || norm(top) !== norm(`${userHome}/.config/mise`)) return started
157
158 home = userHome
159 if (await $.env.get('WSL_DISTRO_NAME')) {
160 const winterop = `${userHome}/.local/bin/winterop`
161 windowsRepo = await probe(async () => {
162 const profile = (await run($, [winterop, 'env', 'USERPROFILE'])).trim()
163 return `${(await run($, [winterop, 'path', '-u', profile])).trim()}/.config/mise`
164 })
165 }
166 void look($)
167 $.clock.every(EVERY_MS, () => void look($))
168 return started
169 })
170
171 on('turn.complete', async ($, e, next) => {
172 const done = await next(e)
173 void look($)
174 return done
175 })
176
177 // Any slash command (a /ship that merged and pulled, say): look once it has finished,
178 // passing its answer on as it came. This sits in every command's chain, so a failure here
179 // hands the command's own answer on (a called next replays, never runs it twice).
180 on('command.run', async ($, e, next) => {
181 const ran = await next(e)
182 void look($)
183 return ran
184 }).catch(($, e, next) => next(e))
185
186 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
187 if (home === undefined || e.props.hasSurvey) return next(e)
188 const snapshot = await read($, sync)
189 if (snapshot === null) return next(e)
190 const { Box, Text } = $.ui.resolve(e)
191 return (
192 <Box key="sync" flexDirection="row" flexWrap="wrap">
193 <Text color="inactive">workstation </Text>
194 {partsOf(snapshot).map((part, i) => (
195 <Text key={`part-${i}`} color={part.color}>
196 {(i === 0 ? ' ' : ' · ') + part.text}
197 </Text>
198 ))}
199 </Box>
200 )
201 })
202}
203types/index.d.ts 21 lines1// What the band draws from: one snapshot of this host's checkout, its dotfiles, the
2// Windows checkout (from WSL) and the branch's PR, each null when its probe failed.
3export type SyncChecks = { passed: number; running: number; failed: string[] }
4export type SyncPr = { number: number; mergeState: string; checks: SyncChecks }
5export type Sync = {
6 branch: string | null
7 head: string | null
8 origin: string | null
9 drift: number | null
10 // undefined off WSL, where there is no Windows checkout to look at
11 windows?: string | null
12 // 'none': the branch has no PR yet; null: gh couldn't say
13 pr: SyncPr | 'none' | null
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'workstation-sync': { sync: Sync | null }
19 }
20}
21