A pane listing the project root repository's open pull requests with titles; click one to open it in the browser.

ushironoko's dotfiles management system.
# Initial setup
git clone https://github.com/ushironoko/dotfiles.git
cd dotfiles
./init.sh
# After setup, the dotfiles command is available:
dotfiles install # Install symlinks
dotfiles list # List managed files
dotfiles restore # Restore from backup
dotfiles doctor # Check environment
# Future updates with ghq (after initial setup):
ghq get ushironoko/dotfiles
cd $(ghq root)/github.com/ushironoko/dotfiles
dotfiles installCreates symbolic links from repository to system locations.
-d, --dryRun: Preview changes-f, --force: Force overwrite-s, --select: Interactive selection-v, --verbose: Detailed outputdotfiles listShows all managed dotfiles and their status.
-v, --verbose: Detailed informationdotfiles restoreRestores files from backup.
-b, --backup <timestamp>: Specific backup-l, --list: List available backups-v, --verbose: Detailed outputdotfiles doctorDiagnoses environment issues.
-c, --check <categories>: Check specific areas (environment,conflicts,ghq,config,mcp)-v, --verbose: Detailed diagnosticsIf you're having issues with the initial setup, run the diagnostic tool:
./init.sh --check
This will show:
Run dotfiles doctor to diagnose issues. Common fixes:
# If mise commands are not found after installation
# Make sure ~/.local/bin is in your PATH and restart shell
export PATH="$HOME/.local/bin:$PATH"
exec $SHELL
# Remove conflicting Bun installation
rm -rf ~/.bun
# Fix PATH permanently
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(mise activate bash)"' >> ~/.bashrc
exec $SHELL
# Migrate old repos to ghq
./scripts/migrate-to-ghq.sh --dry-run
./scripts/migrate-to-ghq.sh --symlink
Edit dotfiles.config.ts to manage your files:
The versioned Codex harness installs global instructions, native custom agents, lifecycle hooks, command restrictions, and the shared Claude skills alongside ~/.codex/config.toml. Each component is linked selectively so Codex runtime state and bundled skills remain intact. After hook changes, review and trust them with /hooks in Codex.
~/.codex/config.toml)~/.codex/config.toml is symlinked to codex/config.toml, but codex rewrites that file at runtime, filling it with machine-local state — [projects."<path>"] trust levels (absolute paths, including private/client repo names), [mcp_servers.*] tables wired to Codex.app paths, [marketplaces.*] sources, the notify helper path, per-repo [desktop...perPath] prefs, and [hooks.state] approval hashes. A git clean filter (codex-scrub, codex/scrub-config.awk) drops anything carrying a quoted absolute path, every [projects.*]/[mcp_servers.*] table, and the complete [hooks.state] tree at git add time. The path rule remains content-based so unknown machine state is scrubbed by default; URLs and relative paths are kept. The working tree keeps the live file untouched, so codex keeps functioning.
The filter driver lives in .git/config (never committed), so it must be registered once per clone:
bun run setup:git-filters # also run automatically by init.sh and run-all
init.sh runs it on initial setup and bun run run-all runs it before every pre-commit check, so under normal use no manual step is needed. If you commit codex/config.toml on a fresh clone without it, trust state would leak — the filter is set to required = true to fail loudly if the scrubber ever errors.
hooks/register.tsx 362 lines1import type { ElementTable, On } from "claude-code";
2
3const PANE_ID = "pr-view";
4const PANE_TITLE = "Pull Requests";
5const COMMAND = "prs";
6const REFRESH_MS = 60_000;
7const PR_LIMIT = 50;
8const MAX_TITLE_CHARS = 200;
9const GH_FIELDS = "number,title,url,isDraft,author,headRefName";
10
11interface PullRequest {
12 number: number;
13 title: string;
14 url: string;
15 isDraft: boolean;
16 author: string;
17 branch: string;
18}
19
20type Listing =
21 | { kind: "idle" }
22 | { kind: "loading"; previous: readonly PullRequest[] }
23 | { kind: "loaded"; prs: readonly PullRequest[]; root: string }
24 | { kind: "error"; message: string };
25
26interface Host {
27 root: () => Promise<string>;
28 run: (
29 argv: readonly string[],
30 cwd?: string,
31 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
32 open: () => Promise<unknown>;
33 close: () => Promise<unknown>;
34 redraw: () => void;
35}
36
37type Kit = Pick<ElementTable<"terminal">, "Box" | "Text" | "Button">;
38
39const isDrawable = (char: string): boolean => {
40 const code = char.codePointAt(0) ?? 0;
41
42 return code >= 32 && code !== 127;
43};
44
45const lineOf = (text: string): string => {
46 const cleaned = [...text.replaceAll(/\s+/g, " ").trim()]
47 .filter(isDrawable)
48 .join("");
49
50 return cleaned.length > MAX_TITLE_CHARS
51 ? `${cleaned.slice(0, MAX_TITLE_CHARS - 1)}…`
52 : cleaned;
53};
54
55const messageOf = (error: unknown): string =>
56 error instanceof Error ? error.message : String(error);
57
58const isWebUrl = (value: unknown): value is string => {
59 if (typeof value !== "string") {
60 return false;
61 }
62
63 try {
64 return new URL(value).protocol === "https:";
65 } catch {
66 return false;
67 }
68};
69
70const pullRequestOf = (raw: unknown): PullRequest | null => {
71 if (typeof raw !== "object" || raw === null) {
72 return null;
73 }
74
75 const record = raw as Record<string, unknown>;
76 const author = record.author as Record<string, unknown> | null | undefined;
77
78 if (typeof record.number !== "number" || !isWebUrl(record.url)) {
79 return null;
80 }
81
82 return {
83 number: record.number,
84 title: lineOf(typeof record.title === "string" ? record.title : ""),
85 url: record.url,
86 isDraft: record.isDraft === true,
87 author: typeof author?.login === "string" ? lineOf(author.login) : "",
88 branch:
89 typeof record.headRefName === "string" ? lineOf(record.headRefName) : "",
90 };
91};
92
93const pullRequestsOf = (stdout: string): PullRequest[] => {
94 const parsed: unknown = JSON.parse(stdout);
95
96 return Array.isArray(parsed)
97 ? parsed.flatMap((raw) => {
98 const pr = pullRequestOf(raw);
99
100 return pr === null ? [] : [pr];
101 })
102 : [];
103};
104
105const fetchListing = async (host: Host): Promise<Listing> => {
106 try {
107 const root = await host.root();
108 const { exitCode, stdout, stderr } = await host.run(
109 [
110 "gh",
111 "pr",
112 "list",
113 "--state",
114 "open",
115 "--limit",
116 String(PR_LIMIT),
117 "--json",
118 GH_FIELDS,
119 ],
120 root,
121 );
122
123 if (exitCode !== 0) {
124 return {
125 kind: "error",
126 message: lineOf(stderr) || `gh exited with ${exitCode}`,
127 };
128 }
129
130 return { kind: "loaded", prs: pullRequestsOf(stdout), root };
131 } catch (error) {
132 return { kind: "error", message: lineOf(messageOf(error)) };
133 }
134};
135
136const openInBrowser = async (
137 host: Host,
138 url: string,
139): Promise<string | null> => {
140 try {
141 const { exitCode, stderr } = await host.run(["open", url]);
142
143 return exitCode === 0
144 ? null
145 : lineOf(stderr) || `open exited with ${exitCode}`;
146 } catch (error) {
147 return lineOf(messageOf(error));
148 }
149};
150
151const prsOf = (listing: Listing): readonly PullRequest[] => {
152 switch (listing.kind) {
153 case "loaded": {
154 return listing.prs;
155 }
156 case "loading": {
157 return listing.previous;
158 }
159 default: {
160 return [];
161 }
162 }
163};
164
165const statusView = ({ Text }: Kit, listing: Listing) => {
166 switch (listing.kind) {
167 case "idle":
168 case "loading": {
169 return <Text dimColor>Loading…</Text>;
170 }
171 case "error": {
172 return <Text color="red">{listing.message}</Text>;
173 }
174 case "loaded": {
175 return listing.prs.length === 0 ? (
176 <Text dimColor>No open pull requests.</Text>
177 ) : null;
178 }
179 }
180};
181
182const rowLabelOf = (pr: PullRequest): string =>
183 `#${pr.number}${pr.isDraft ? " [draft]" : ""} ${pr.title}${pr.author === "" ? "" : ` @${pr.author}`}`;
184
185const matchesOf = (prs: readonly PullRequest[], query: string) => {
186 const needle = query.trim().toLowerCase();
187
188 return needle === ""
189 ? prs
190 : prs.filter((pr) =>
191 [`#${pr.number}`, pr.title, pr.branch, `@${pr.author}`].some((field) =>
192 field.toLowerCase().includes(needle),
193 ),
194 );
195};
196
197export const register = (on: On) => {
198 let listing: Listing = { kind: "idle" };
199 let notice: string | null = null;
200 let query = "";
201 let host: Host | null = null;
202 let isOpen = false;
203 let generation = 0;
204
205 const refresh = async (current: Host) => {
206 generation += 1;
207 const mine = generation;
208
209 listing = { kind: "loading", previous: prsOf(listing) };
210 current.redraw();
211
212 const next = await fetchListing(current);
213
214 if (mine === generation) {
215 listing = next;
216 current.redraw();
217 }
218 };
219
220 const press = async (current: Host, url: string) => {
221 notice = await openInBrowser(current, url);
222 current.redraw();
223 };
224
225 on("session.start", async ($, e, next) => {
226 const current: Host = {
227 root: () => $.session.root(),
228 run: (argv, cwd) =>
229 $.process.run(argv, cwd === undefined ? undefined : { cwd }),
230 open: () => $.ui.open({ id: PANE_ID, title: PANE_TITLE }),
231 close: () => $.ui.close({ id: PANE_ID }),
232 redraw: () => $.ui.invalidate("ui.render"),
233 };
234 host = current;
235
236 await $.command.register({
237 name: COMMAND,
238 description:
239 "Toggle the pane listing this repository's open pull requests",
240 });
241
242 const loop = () =>
243 $.clock.after(REFRESH_MS, () => {
244 const pending = isOpen ? refresh(current) : Promise.resolve();
245
246 void pending.catch(() => undefined).finally(loop);
247 });
248 loop();
249
250 return next(e);
251 });
252
253 on("command.run", { command: COMMAND }, async () => {
254 if (host === null) {
255 return { text: "pr-view is not ready yet." };
256 }
257
258 if (isOpen) {
259 isOpen = false;
260 await host.close();
261
262 return { text: "Pull requests pane hidden." };
263 }
264
265 isOpen = true;
266 notice = null;
267 await host.open();
268 void refresh(host).catch(() => undefined);
269
270 return { text: "Pull requests pane shown." };
271 });
272
273 on("ui.render", { component: "Pane" }, async ($, e, next) => {
274 if (e.requestId !== PANE_ID || e.surface !== "terminal") {
275 return next(e);
276 }
277
278 const { Box, Text, Button, Input } = $.ui.resolve(e);
279 const kit: Kit = { Box, Text, Button };
280 const prs = prsOf(listing);
281 const shown = matchesOf(prs, query);
282 const current = host;
283
284 return (
285 <Box flexDirection="column" paddingRight={1}>
286 <Box flexDirection="row" justifyContent="space-between">
287 <Text bold>
288 Open PRs{" "}
289 {shown.length === prs.length
290 ? prs.length
291 : `${shown.length}/${prs.length}`}
292 </Text>
293 <Button
294 key="refresh"
295 plain
296 dimColor
297 label="↻ refresh"
298 onPress={() => {
299 if (current !== null) {
300 void refresh(current).catch(() => undefined);
301 }
302 }}
303 />
304 </Box>
305 {listing.kind === "loaded" ? (
306 <Text dimColor wrap="truncate-start">
307 {listing.root}
308 </Text>
309 ) : null}
310 <Input
311 key="filter"
312 label="filter "
313 placeholder="title, #number, branch or @author"
314 value={query}
315 onInput={(value) => {
316 query = value;
317 current?.redraw();
318 }}
319 onSubmit={(value) => {
320 query = value;
321 current?.redraw();
322 }}
323 />
324 {statusView(kit, listing)}
325 {shown.length === 0 && prs.length > 0 ? (
326 <Text dimColor>No pull requests match "{query.trim()}".</Text>
327 ) : null}
328 {notice === null ? null : <Text color="red">{notice}</Text>}
329 <Box flexDirection="column">
330 {shown.map((pr) => (
331 <Box key={`pr:${pr.number}`} flexDirection="column">
332 <Button
333 key={`open:${pr.number}`}
334 plain
335 dimColor={pr.isDraft}
336 label={rowLabelOf(pr)}
337 onPress={() => {
338 if (current !== null) {
339 void press(current, pr.url).catch(() => undefined);
340 }
341 }}
342 />
343 <Text dimColor wrap="truncate-end">
344 {" "}
345 {pr.branch}
346 </Text>
347 </Box>
348 ))}
349 </Box>
350 </Box>
351 );
352 });
353
354 on("ui.close", { id: PANE_ID }, (_, e, next) => {
355 isOpen = false;
356 query = "";
357 generation += 1;
358
359 return next(e);
360 });
361};
362