The checkout's git and folder menus from /git and /open: forge links, copies and desktop apps

Personal NixOS configuration using flakes, home-manager, and agenix for secret management.
All common tasks are managed via just; run just to list every recipe with its description.
just switch # Rebuild and switch this machine (any host class)
just switch desktop # ...or a specific host
just switch host user@remote # ...or push a nixos host to a remote (nixos-rebuild --target-host)
just test # Apply until reboot
just build # Build without switching
just diff # ...and diff the build against the running system
just update # Update flake inputs
just fmt # Format everything
just gc # Collect garbage and delete old generations
This config exposes a custom fireproof.* options namespace. A generated Markdown reference — descriptions, types, defaults, and declaration links — is available at docs/fireproof-options.md. CI regenerates it after each push to main.
just new-host <hostname> <username> # card, SSH key, rekey (YubiKey)
just deploy-remote <hostname> root@<ip> # nixos-anywhere: formats the target's disks
deploy-remote installs onto a target already booted into a Linux with SSH: the generic installer image, any NixOS ISO, or a cloud VM. The target's disks come from the host's hosts/<hostname>/ cards (see the disko templates), and nixos-anywhere wipes whatever is on them.
The full procedure, including disk layouts, TPM2 unlock and installing to a fresh Mac, is in docs/hosts.md.
Secrets are managed with agenix + agenix-rekey, using a YubiKey as the master identity.
Layout, commands and YubiKey requirements are in docs/secrets.md; just lists the secret recipes.
A Nix development shell is available with useful tools for working on this configuration:
# Enter the development shell
nix develop
Code is formatted with treefmt-nix; the formatters are configured in formatter.nix.
just fmt
Heavily inspired by / stolen from Flexoki
The palette is the fireproof.theme.colors options in modules/base/theme.nix; defaults are listed in docs/fireproof-options.md.
hooks/register.tsx 260 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, ProcessRunResult, Register, RenderSurface } from "claude-code";
3
4import type { FolderApp, Menu, MenuKind, Row } from "../types";
5import {
6 chooseRemote,
7 folderMenu,
8 gitMenu,
9 parsePr,
10 parseStatus,
11 type PrAnswer,
12 type Status,
13} from "./menus";
14
15const PANE = "git-menu";
16const TITLES: Record<MenuKind, string> = { git: "Git", folder: "Open folder" };
17const HINTS = "1-9 or Enter picks · Esc closes";
18const GIT_TIMEOUT_MS = 3000;
19const GH_TIMEOUT_MS = 5000;
20// Starts a GUI app without holding $.process.run, which waits for the output to close.
21const DETACH = [
22 "sh",
23 "-c",
24 'exec >/dev/null 2>&1 </dev/null; if command -v setsid >/dev/null; then exec setsid -f "$@"; fi; "$@" &',
25 "sh",
26];
27
28const menu = atom({ plugin: "git-menu", key: "menu" } as const, null);
29
30let isMacAnswer: Promise<boolean> | undefined;
31
32const run = async (
33 $: EngineInterface,
34 argv: string[],
35 timeoutMs = GIT_TIMEOUT_MS,
36): Promise<ProcessRunResult | undefined> => {
37 try {
38 return await $.process.run(argv, { timeoutMs });
39 } catch {
40 return undefined;
41 }
42};
43
44/** One read-only git command; anything it refuses is an empty answer. */
45const git = async ($: EngineInterface, cwd: string, args: string[]) => {
46 const r = await run($, ["git", "--no-optional-locks", "-C", cwd, ...args]);
47 return r?.exitCode === 0 ? r.stdout.trim() : "";
48};
49
50const isMac = ($: EngineInterface) =>
51 (isMacAnswer ??= run($, ["uname", "-s"]).then((r) => r?.stdout.trim() === "Darwin"));
52
53async function lookupPr($: EngineInterface, cwd: string, status: Status): Promise<PrAnswer> {
54 // A branch that was never pushed has no pull request yet, and that is an answer.
55 if (!status.branch || !status.upstream) return null;
56 let r: ProcessRunResult;
57 try {
58 r = await $.process.run(["gh", "pr", "view", "--json", "url,number,state,isDraft"], {
59 cwd,
60 timeoutMs: GH_TIMEOUT_MS,
61 });
62 } catch {
63 return "no-gh";
64 }
65 if (r.exitCode !== 0) {
66 // These two are answers; anything else (offline, auth, timeout) is not.
67 return /no pull requests found|no git remotes found/.test(r.stderr) ? null : "unknown";
68 }
69 return parsePr(r.stdout) ?? "unknown";
70}
71
72/** The remote's default branch, then the local main/master refs, then `main`. */
73async function defaultBranch($: EngineInterface, cwd: string, remote: string) {
74 const head = await git($, cwd, [
75 "symbolic-ref",
76 "--quiet",
77 "--short",
78 `refs/remotes/${remote}/HEAD`,
79 ]);
80 if (head.startsWith(`${remote}/`)) return head.slice(remote.length + 1);
81 for (const candidate of ["main", "master"]) {
82 const ref = `refs/remotes/${remote}/${candidate}`;
83 if (await git($, cwd, ["rev-parse", "--verify", "--quiet", ref])) return candidate;
84 }
85 return "main";
86}
87
88async function buildGit($: EngineInterface, cwd: string): Promise<Menu> {
89 const out = await git($, cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=no"]);
90 if (!out) return { kind: "git", title: TITLES.git, rows: [], error: "Not a git repository." };
91 const status = parseStatus(out);
92 const branch = status.branch;
93 const [config, branchRemote, mergeBase, pr] = await Promise.all([
94 git($, cwd, ["config", "--get-regexp", "^remote\\..*\\.url$"]),
95 branch ? git($, cwd, ["config", "--get", `branch.${branch}.remote`]) : "",
96 branch ? git($, cwd, ["config", "--get", `branch.${branch}.gh-merge-base`]) : "",
97 lookupPr($, cwd, status),
98 ]);
99 const remote = chooseRemote(config, branchRemote);
100 // Mirrors `gh pr create`: the branch's gh-merge-base, else the remote's default branch.
101 const base = mergeBase || (remote ? await defaultBranch($, cwd, remote.name) : "main");
102 return gitMenu({ status, remote, base, pr });
103}
104
105async function buildFolder($: EngineInterface, cwd: string): Promise<Menu> {
106 const mac = await isMac($);
107 const launchers: Partial<Record<FolderApp, string[]>> = {};
108 if (mac) {
109 const bundle = async (app: FolderApp, name: string, args: string[], fresh = false) => {
110 if (await $.fs.exists(`/Applications/${name}.app`))
111 launchers[app] = fresh
112 ? ["open", "-na", `${name}.app`, "--args", ...args]
113 : ["open", "-a", name, ...args];
114 };
115 await Promise.all([
116 bundle("code", "Visual Studio Code", [cwd]),
117 bundle("smerge", "Sublime Merge", [cwd]),
118 // Ghostty takes a directory only as a second instance.
119 bundle("ghostty", "Ghostty", [`--working-directory=${cwd}`], true),
120 ]);
121 launchers.files = ["open", cwd];
122 } else {
123 const probe = await run($, [
124 "sh",
125 "-c",
126 'for c; do command -v "$c" >/dev/null && echo "$c"; done',
127 "sh",
128 "code",
129 "smerge",
130 "nautilus",
131 "xdg-open",
132 "ghostty",
133 ]);
134 const found = new Set((probe?.stdout ?? "").split("\n").filter(Boolean));
135 if (found.has("code")) launchers.code = ["code", cwd];
136 if (found.has("smerge")) launchers.smerge = ["smerge", cwd];
137 if (found.has("nautilus")) launchers.files = ["nautilus", cwd];
138 else if (found.has("xdg-open")) launchers.files = ["xdg-open", cwd];
139 // A fresh shell in the checkout, not this session resumed.
140 if (found.has("ghostty")) launchers.ghostty = ["ghostty", `--working-directory=${cwd}`];
141 }
142 return folderMenu(cwd, launchers, mac);
143}
144
145const isOpen = async ($: EngineInterface) => (await $.ui.panes()).some((p) => p.id === PANE);
146
147async function close($: EngineInterface) {
148 await update($, menu, () => null);
149 await $.ui.close({ id: PANE });
150}
151
152/**
153 * Opens a menu, or closes it when it is already up, as a second click on pi's
154 * footer did. The pane opens before git answers: one opened after the command
155 * hook returned counts as unasked and stays hidden on narrower terminals.
156 */
157async function show($: EngineInterface, kind: MenuKind) {
158 if ((await read($, menu))?.kind === kind && (await isOpen($))) return close($);
159 const cwd = await $.session.cwd();
160 const title = TITLES[kind];
161 await update($, menu, () => ({ kind, title, rows: null }));
162 const opened = await $.ui.open({ id: PANE, title, focus: true, closeOnEscape: true, rows: 10 });
163 if (!opened.isPlaced) $.ui.toast(`The ${title} menu can't show yet: ${opened.reason}`);
164 const built = kind === "git" ? await buildGit($, cwd) : await buildFolder($, cwd);
165 // A close or another menu while git answered wins over this one.
166 await update($, menu, (m) => (m?.kind === kind && m.rows === null ? built : m));
167 const shown = await read($, menu);
168 if (shown?.kind === kind && shown.rows !== null && built.preferred)
169 // Cosmetic: a ring that cannot move leaves Tab and the hotkeys working.
170 await $.ui.focus({ requestId: PANE, key: built.preferred }).catch(() => undefined);
171}
172
173async function press($: EngineInterface, row: Row, surface: RenderSurface) {
174 if (!row.action) {
175 $.ui.toast(`${row.label}: ${row.unavailable ?? "not available"}`);
176 return;
177 }
178 const action = row.action;
179 if (action.kind === "copy") {
180 const r = await $.ui.copy({ text: action.text, surface });
181 $.ui.toast(r.isCopied ? `Copied the ${action.what}` : `Couldn't copy: ${r.reason}`);
182 } else {
183 const argv =
184 action.kind === "url" ? [(await isMac($)) ? "open" : "xdg-open", action.url] : action.argv;
185 const r = await run($, [...DETACH, ...argv]);
186 if (r?.exitCode !== 0) $.ui.toast(`Couldn't start ${argv[0]}`);
187 }
188 await close($);
189}
190
191export const register: Register = (on) => {
192 on("session.start", async ($, e, next) => {
193 for (const [name, description] of [
194 ["git", "Open the git menu: the repository, branch and pull request on the forge"],
195 ["open", "Open the folder menu: this checkout in an editor, file manager or terminal"],
196 ] as const) {
197 await $.command
198 .register({ name, description, immediate: true })
199 .catch((error) =>
200 $.ui.log(`git-menu: /${name} not registered: ${String(error)}`, { to: "debug" }),
201 );
202 }
203 return next(e);
204 });
205
206 on("command.run", { command: "git" }, async ($) => {
207 await show($, "git");
208 return { text: "Opened the git menu." };
209 });
210
211 on("command.run", { command: "open" }, async ($) => {
212 await show($, "folder");
213 return { text: "Opened the folder menu." };
214 });
215
216 on("ui.close", { id: PANE }, async ($, e, next) => {
217 await update($, menu, () => null);
218 return next(e);
219 });
220
221 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
222 const { Box, Text, Button } = $.ui.resolve(e);
223 const m = await read($, menu);
224 if (!m) return <Text dimColor>No menu open.</Text>;
225 const rows = m.rows ?? [];
226 // Notes share one column, so the rows read as a table.
227 const width = Math.max(0, ...rows.map((r) => r.label.length));
228 return (
229 <Box flexDirection="column">
230 <Box flexDirection="row">
231 <Text bold>{m.title}</Text>
232 {m.context ? <Text dimColor>{` ${m.context}`}</Text> : null}
233 </Box>
234 {m.error ? (
235 <Text dimColor>{m.error}</Text>
236 ) : m.rows === null ? (
237 <Text dimColor>Asking git…</Text>
238 ) : (
239 rows.map((row, i) => {
240 const note = row.unavailable ?? row.description;
241 return (
242 <Button
243 key={row.id}
244 plain
245 hotkey={i < 9 ? String(i + 1) : undefined}
246 dimColor={row.action === undefined}
247 onPress={(p) => void press($, row, p.surface)}
248 >
249 {row.label.padEnd(width)}
250 {note ? <Text dimColor>{` ${note}`}</Text> : null}
251 </Button>
252 );
253 })
254 )}
255 <Text dimColor>{HINTS}</Text>
256 </Box>
257 );
258 });
259};
260hooks/menus.ts 307 lines1/**
2 * The menus as data, ported from pi's footer (pi/extensions/parent/chrome):
3 * every git row is a link or a copy, so nothing here pushes, rebases or creates
4 * anything; a row that cannot run keeps its place and says why in a few words.
5 */
6import type { FolderApp, Menu, Row } from "../types";
7
8export type Forge = "github" | "gitlab" | "codeberg";
9
10export type Remote = {
11 name: string;
12 forge: Forge;
13 host: string;
14 /** Group path, which GitLab may nest. */
15 owner: string;
16 repo: string;
17 /** The repository's page, without a trailing slash. */
18 web: string;
19};
20
21export type Status = {
22 branch: string | null;
23 oid: string;
24 upstream: boolean;
25};
26
27export type PrKind = "open" | "draft" | "merged" | "closed";
28
29export type Pr = { url: string; number: number; state: PrKind };
30
31/** The branch's pull request: `null` for none, `unknown` when gh could not say. */
32export type PrAnswer = Pr | null | "unknown" | "no-gh";
33
34export const FOLDER_APPS: readonly FolderApp[] = ["code", "smerge", "files", "ghostty"];
35
36const SHORT_SHA = 7;
37
38// Self-hosted forges may share these layouts, but a guessed link that 404s is worse than none.
39const FORGES: Record<string, Forge> = {
40 "github.com": "github",
41 "gitlab.com": "gitlab",
42 "codeberg.org": "codeberg",
43};
44
45export const parseStatus = (output: string): Status => {
46 let branch: string | null = null;
47 let oid = "";
48 let upstream = false;
49 for (const line of output.split("\n")) {
50 if (line.startsWith("# branch.head ")) {
51 const head = line.slice("# branch.head ".length);
52 branch = head === "(detached)" ? null : head;
53 } else if (line.startsWith("# branch.oid ")) {
54 oid = line.slice("# branch.oid ".length);
55 } else if (line.startsWith("# branch.upstream ")) {
56 upstream = true;
57 }
58 }
59 return { branch, oid, upstream };
60};
61
62/** `gh pr view --json url,number,state,isDraft` as a Pr; undefined when it does not parse. */
63export const parsePr = (stdout: string): Pr | undefined => {
64 try {
65 const data = JSON.parse(stdout) as {
66 url: string;
67 number: number;
68 state: string;
69 isDraft?: boolean;
70 };
71 const state: PrKind = data.isDraft
72 ? "draft"
73 : data.state === "MERGED"
74 ? "merged"
75 : data.state === "CLOSED"
76 ? "closed"
77 : "open";
78 return { url: data.url, number: data.number, state };
79 } catch {
80 return undefined;
81 }
82};
83
84/** A remote URL as a forge link, or undefined for a host this does not know. */
85export const parseRemote = (name: string, raw: string): Remote | undefined => {
86 const trimmed = raw
87 .trim()
88 .replace(/\/+$/, "")
89 .replace(/\.git$/, "");
90 let host: string;
91 let path: string;
92 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
93 let url: URL;
94 try {
95 url = new URL(trimmed);
96 } catch {
97 return undefined;
98 }
99 host = url.hostname;
100 path = url.pathname;
101 } else {
102 // The scp-like form git writes for SSH: [user@]host:path.
103 const match = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(trimmed);
104 if (!match?.[1] || !match[2]) return undefined;
105 host = match[1];
106 path = match[2];
107 }
108 host = host.toLowerCase();
109 const forge = FORGES[host];
110 if (!forge) return undefined;
111 const segments = path.split("/").filter(Boolean);
112 if (segments.length < 2) return undefined;
113 const repo = segments[segments.length - 1] ?? "";
114 const owner = segments.slice(0, -1).join("/");
115 return { name, forge, host, owner, repo, web: `https://${host}/${owner}/${repo}` };
116};
117
118/**
119 * The remote links follow: the branch's upstream remote, then `origin`, then
120 * the only supported one. `config` is `git config --get-regexp '^remote\..*\.url$'`.
121 */
122export const chooseRemote = (config: string, branchRemote: string): Remote | undefined => {
123 const remotes = new Map<string, Remote>();
124 for (const line of config.split("\n")) {
125 const match = /^remote\.(.+)\.url\s+(.+)$/.exec(line.trim());
126 // A remote with separate fetch and push URLs lists both; the fetch URL is the page people read.
127 if (!match?.[1] || !match[2] || remotes.has(match[1])) continue;
128 const remote = parseRemote(match[1], match[2]);
129 if (remote) remotes.set(remote.name, remote);
130 }
131 const supported = [...remotes.values()];
132 return (
133 (branchRemote && branchRemote !== "." ? remotes.get(branchRemote) : undefined) ??
134 remotes.get("origin") ??
135 (supported.length === 1 ? supported[0] : undefined)
136 );
137};
138
139/** A branch name as URL path segments, so a `#` or a space still links. */
140const segment = (text: string) => text.split("/").map(encodeURIComponent).join("/");
141
142const branchUrl = (remote: Remote, branch: string) => {
143 const leaf = segment(branch);
144 switch (remote.forge) {
145 case "github":
146 return `${remote.web}/tree/${leaf}`;
147 case "gitlab":
148 return `${remote.web}/-/tree/${leaf}`;
149 case "codeberg":
150 return `${remote.web}/src/branch/${leaf}`;
151 }
152};
153
154const commitUrl = (remote: Remote, sha: string) =>
155 remote.forge === "gitlab" ? `${remote.web}/-/commit/${sha}` : `${remote.web}/commit/${sha}`;
156
157// The compare form rather than `gh pr create --web`, which may offer to push.
158const compareUrl = (remote: Remote, base: string, branch: string) =>
159 `${remote.web}/compare/${segment(base)}...${segment(branch)}?expand=1`;
160
161export type GitFacts = {
162 status: Status;
163 remote: Remote | undefined;
164 /** The branch a new pull request targets, as `gh pr create` picks it. */
165 base: string;
166 pr: PrAnswer;
167};
168
169export const gitMenu = ({ status, remote, base, pr }: GitFacts): Menu => {
170 const { branch } = status;
171 const found = typeof pr === "object" ? pr : null;
172 const noRemote = "no supported remote";
173 const rows: Row[] = [
174 remote
175 ? {
176 id: "repo",
177 label: "Open repository",
178 description: remote.host,
179 action: { kind: "url", url: remote.web },
180 }
181 : { id: "repo", label: "Open repository", unavailable: noRemote },
182 ];
183
184 if (branch) {
185 rows.push({
186 id: "branch",
187 label: "Open branch",
188 unavailable: remote ? undefined : noRemote,
189 action: remote ? { kind: "url", url: branchUrl(remote, branch) } : undefined,
190 });
191 } else {
192 const sha = status.oid || "HEAD";
193 rows.push({
194 id: "commit",
195 label: `Open commit ${sha.slice(0, SHORT_SHA)}`,
196 unavailable: remote ? undefined : noRemote,
197 action: remote ? { kind: "url", url: commitUrl(remote, sha) } : undefined,
198 });
199 }
200
201 if (found) {
202 rows.push({
203 id: "pr",
204 label: `Open pull request #${found.number}`,
205 description: found.state,
206 action: { kind: "url", url: found.url },
207 });
208 }
209
210 const create =
211 found && (found.state === "merged" || found.state === "closed")
212 ? "Create new pull request"
213 : "Create pull request";
214 const blocked = !remote
215 ? noRemote
216 : remote.forge !== "github"
217 ? "GitHub only"
218 : !branch
219 ? "detached HEAD"
220 : found && (found.state === "open" || found.state === "draft")
221 ? `PR #${found.number} is open`
222 : !status.upstream
223 ? "push the branch first"
224 : pr === "no-gh"
225 ? "gh not installed"
226 : pr === "unknown"
227 ? "gh could not say"
228 : undefined;
229 rows.push(
230 blocked || !remote || !branch
231 ? { id: "create", label: create, unavailable: blocked }
232 : {
233 id: "create",
234 label: create,
235 description: `into ${base}`,
236 action: { kind: "url", url: compareUrl(remote, base, branch) },
237 },
238 );
239
240 rows.push(
241 branch
242 ? {
243 id: "copy",
244 label: "Copy branch name",
245 action: { kind: "copy", text: branch, what: "branch name" },
246 }
247 : {
248 id: "copy",
249 label: "Copy commit SHA",
250 action: { kind: "copy", text: status.oid, what: "commit SHA" },
251 },
252 );
253 if (found) {
254 rows.push({
255 id: "copy-pr",
256 label: "Copy PR URL",
257 action: { kind: "copy", text: found.url, what: "pull request URL" },
258 });
259 }
260
261 // The pull request is what the menu is usually opened for, then the branch, then the repository.
262 const preferred = found ? "pr" : !remote ? "repo" : branch ? "branch" : "commit";
263 return {
264 kind: "git",
265 title: "Git",
266 context: remote ? `${remote.owner}/${remote.repo}` : undefined,
267 rows,
268 preferred,
269 };
270};
271
272export const appLabel = (app: FolderApp, isMac: boolean): string => {
273 switch (app) {
274 case "code":
275 return "VS Code";
276 case "smerge":
277 return "Sublime Merge";
278 case "files":
279 return isMac ? "Finder" : "Files";
280 case "ghostty":
281 return "Ghostty";
282 }
283};
284
285/**
286 * The checkout in the desktop's apps, each started by its argv in `launchers`;
287 * one missing keeps its row, so the order never moves.
288 */
289export const folderMenu = (
290 cwd: string,
291 launchers: Partial<Record<FolderApp, string[]>>,
292 isMac: boolean,
293): Menu => ({
294 kind: "folder",
295 title: "Open folder",
296 rows: [
297 ...FOLDER_APPS.map((app): Row => {
298 const argv = launchers[app];
299 return argv
300 ? { id: app, label: appLabel(app, isMac), action: { kind: "launch", argv } }
301 : { id: app, label: appLabel(app, isMac), unavailable: "not installed" };
302 }),
303 { id: "copy", label: "Copy path", action: { kind: "copy", text: cwd, what: "path" } },
304 ],
305 preferred: launchers.code ? "code" : undefined,
306});
307types/index.d.ts 41 lines1export type MenuKind = "git" | "folder";
2
3export type FolderApp = "code" | "smerge" | "files" | "ghostty";
4
5/** What a row does when pressed; plain data, so the menu survives a reload. */
6export type Action =
7 | { kind: "url"; url: string }
8 | { kind: "copy"; text: string; what: string }
9 | { kind: "launch"; argv: string[] };
10
11export type Row = {
12 id: string;
13 label: string;
14 /** A short note beside the label: a host, a state, a destination. */
15 description?: string;
16 /** Why the row cannot run; it keeps its place and says this when pressed. */
17 unavailable?: string;
18 action?: Action;
19};
20
21export type Menu = {
22 kind: MenuKind;
23 title: string;
24 /** Beside the title, e.g. the repository the rows act on. */
25 context?: string;
26 /** Null while git and gh are still answering. */
27 rows: Row[] | null;
28 /** The row the focus starts on. */
29 preferred?: string;
30 /** Why the menu has no rows, e.g. not a repository. */
31 error?: string;
32};
33
34declare module "claude-code" {
35 interface PluginState {
36 "git-menu": {
37 menu: Menu | null;
38 };
39 }
40}
41