A pane listing the project root repository's open issues 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 456 lines1import type { ElementTable, On } from "claude-code";
2
3const PANE_ID = "issue-view";
4const PANE_TITLE = "Issues";
5const COMMAND = "issues";
6const REFRESH_MS = 60_000;
7const ISSUE_LIMIT = 500;
8const MAX_TITLE_CHARS = 200;
9const CHROME_ROWS = 6;
10const ROW_LINES = 2;
11const MIN_PAGE_SIZE = 5;
12const GH_FIELDS = "number,title,url,author,labels";
13
14interface Issue {
15 number: number;
16 title: string;
17 url: string;
18 author: string;
19 labels: readonly string[];
20}
21
22type Listing =
23 | { kind: "idle" }
24 | { kind: "loading"; previous: readonly Issue[] }
25 | { kind: "loaded"; issues: readonly Issue[]; root: string }
26 | { kind: "error"; message: string };
27
28interface Host {
29 root: () => Promise<string>;
30 run: (
31 argv: readonly string[],
32 cwd?: string,
33 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
34 open: () => Promise<unknown>;
35 close: () => Promise<unknown>;
36 redraw: () => void;
37}
38
39type Kit = Pick<ElementTable<"terminal">, "Box" | "Text" | "Button">;
40
41interface Paged<T> {
42 items: readonly T[];
43 page: number;
44 pages: number;
45 first: number;
46 last: number;
47}
48
49const isDrawable = (char: string): boolean => {
50 const code = char.codePointAt(0) ?? 0;
51
52 return code >= 32 && code !== 127;
53};
54
55const lineOf = (text: string): string => {
56 const cleaned = [...text.replaceAll(/\s+/g, " ").trim()]
57 .filter(isDrawable)
58 .join("");
59
60 return cleaned.length > MAX_TITLE_CHARS
61 ? `${cleaned.slice(0, MAX_TITLE_CHARS - 1)}…`
62 : cleaned;
63};
64
65const messageOf = (error: unknown): string =>
66 error instanceof Error ? error.message : String(error);
67
68const isWebUrl = (value: unknown): value is string => {
69 if (typeof value !== "string") {
70 return false;
71 }
72
73 try {
74 return new URL(value).protocol === "https:";
75 } catch {
76 return false;
77 }
78};
79
80const labelsOf = (raw: unknown): string[] =>
81 Array.isArray(raw)
82 ? raw.flatMap((label: unknown) => {
83 const name =
84 typeof label === "object" && label !== null
85 ? (label as Record<string, unknown>).name
86 : undefined;
87
88 return typeof name === "string" && name !== "" ? [lineOf(name)] : [];
89 })
90 : [];
91
92const issueOf = (raw: unknown): Issue | null => {
93 if (typeof raw !== "object" || raw === null) {
94 return null;
95 }
96
97 const record = raw as Record<string, unknown>;
98 const author = record.author as Record<string, unknown> | null | undefined;
99
100 if (typeof record.number !== "number" || !isWebUrl(record.url)) {
101 return null;
102 }
103
104 return {
105 number: record.number,
106 title: lineOf(typeof record.title === "string" ? record.title : ""),
107 url: record.url,
108 author: typeof author?.login === "string" ? lineOf(author.login) : "",
109 labels: labelsOf(record.labels),
110 };
111};
112
113const issuesOf = (stdout: string): Issue[] => {
114 const parsed: unknown = JSON.parse(stdout);
115
116 return Array.isArray(parsed)
117 ? parsed.flatMap((raw) => {
118 const issue = issueOf(raw);
119
120 return issue === null ? [] : [issue];
121 })
122 : [];
123};
124
125const fetchListing = async (host: Host): Promise<Listing> => {
126 try {
127 const root = await host.root();
128 const { exitCode, stdout, stderr } = await host.run(
129 [
130 "zsh",
131 "-c",
132 '"$@"',
133 "zsh",
134 "gh",
135 "issue",
136 "list",
137 "--state",
138 "open",
139 "--limit",
140 String(ISSUE_LIMIT),
141 "--json",
142 GH_FIELDS,
143 ],
144 root,
145 );
146
147 if (exitCode !== 0) {
148 return {
149 kind: "error",
150 message: lineOf(stderr) || `gh exited with ${exitCode}`,
151 };
152 }
153
154 return { kind: "loaded", issues: issuesOf(stdout), root };
155 } catch (error) {
156 return { kind: "error", message: lineOf(messageOf(error)) };
157 }
158};
159
160const openInBrowser = async (
161 host: Host,
162 url: string,
163): Promise<string | null> => {
164 try {
165 const { exitCode, stderr } = await host.run(["open", url]);
166
167 return exitCode === 0
168 ? null
169 : lineOf(stderr) || `open exited with ${exitCode}`;
170 } catch (error) {
171 return lineOf(messageOf(error));
172 }
173};
174
175const issuesOfListing = (listing: Listing): readonly Issue[] => {
176 switch (listing.kind) {
177 case "loaded": {
178 return listing.issues;
179 }
180 case "loading": {
181 return listing.previous;
182 }
183 default: {
184 return [];
185 }
186 }
187};
188
189const statusView = ({ Text }: Kit, listing: Listing) => {
190 switch (listing.kind) {
191 case "idle":
192 case "loading": {
193 return <Text dimColor>Loading…</Text>;
194 }
195 case "error": {
196 return <Text color="red">{listing.message}</Text>;
197 }
198 case "loaded": {
199 return listing.issues.length === 0 ? (
200 <Text dimColor>No open issues.</Text>
201 ) : null;
202 }
203 }
204};
205
206const rowLabelOf = (issue: Issue): string =>
207 `#${issue.number} ${issue.title}${issue.author === "" ? "" : ` @${issue.author}`}`;
208
209const matchesOf = (issues: readonly Issue[], query: string) => {
210 const needle = query.trim().toLowerCase();
211
212 return needle === ""
213 ? issues
214 : issues.filter((issue) =>
215 [
216 `#${issue.number}`,
217 issue.title,
218 `@${issue.author}`,
219 ...issue.labels,
220 ].some((field) => field.toLowerCase().includes(needle)),
221 );
222};
223
224const pageSizeOf = (bodyRows: number): number =>
225 Math.max(MIN_PAGE_SIZE, Math.floor((bodyRows - CHROME_ROWS) / ROW_LINES));
226
227const pagedOf = <T,>(
228 items: readonly T[],
229 page: number,
230 size: number,
231): Paged<T> => {
232 const pages = Math.max(1, Math.ceil(items.length / size));
233 const current = Math.min(Math.max(page, 0), pages - 1);
234 const first = current * size;
235
236 return {
237 items: items.slice(first, first + size),
238 page: current,
239 pages,
240 first,
241 last: Math.min(first + size, items.length),
242 };
243};
244
245const pagerView = <T,>(
246 { Box, Text, Button }: Kit,
247 paged: Paged<T>,
248 total: number,
249 onPage: (page: number) => void,
250) =>
251 paged.pages <= 1 ? null : (
252 <Box flexDirection="row" columnGap={2}>
253 {paged.page > 0 ? (
254 <Button
255 key="page:prev"
256 plain
257 hotkey="p"
258 label="‹ prev"
259 onPress={() => onPage(paged.page - 1)}
260 />
261 ) : (
262 <Text dimColor>‹ prev</Text>
263 )}
264 <Text dimColor>
265 page {paged.page + 1}/{paged.pages} · {paged.first + 1}–{paged.last} of{" "}
266 {total}
267 </Text>
268 {paged.page < paged.pages - 1 ? (
269 <Button
270 key="page:next"
271 plain
272 hotkey="n"
273 label="next ›"
274 onPress={() => onPage(paged.page + 1)}
275 />
276 ) : (
277 <Text dimColor>next ›</Text>
278 )}
279 </Box>
280 );
281
282export const register = (on: On) => {
283 let listing: Listing = { kind: "idle" };
284 let notice: string | null = null;
285 let query = "";
286 let page = 0;
287 let host: Host | null = null;
288 let isOpen = false;
289 let generation = 0;
290
291 const refresh = async (current: Host) => {
292 generation += 1;
293 const mine = generation;
294
295 listing = { kind: "loading", previous: issuesOfListing(listing) };
296 current.redraw();
297
298 const next = await fetchListing(current);
299
300 if (mine === generation) {
301 listing = next;
302 current.redraw();
303 }
304 };
305
306 const press = async (current: Host, url: string) => {
307 notice = await openInBrowser(current, url);
308 current.redraw();
309 };
310
311 on("session.start", async ($, e, next) => {
312 const current: Host = {
313 root: () => $.session.root(),
314 run: (argv, cwd) =>
315 $.process.run(argv, cwd === undefined ? undefined : { cwd }),
316 open: () => $.ui.open({ id: PANE_ID, title: PANE_TITLE }),
317 close: () => $.ui.close({ id: PANE_ID }),
318 redraw: () => $.ui.invalidate("ui.render"),
319 };
320 host = current;
321
322 await $.command.register({
323 name: COMMAND,
324 description: "Toggle the pane listing this repository's open issues",
325 });
326
327 const loop = () =>
328 $.clock.after(REFRESH_MS, () => {
329 const pending = isOpen ? refresh(current) : Promise.resolve();
330
331 void pending.catch(() => undefined).finally(loop);
332 });
333 loop();
334
335 return next(e);
336 });
337
338 on("command.run", { command: COMMAND }, async () => {
339 if (host === null) {
340 return { text: "issue-view is not ready yet." };
341 }
342
343 if (isOpen) {
344 isOpen = false;
345 await host.close();
346
347 return { text: "Issues pane hidden." };
348 }
349
350 isOpen = true;
351 notice = null;
352 await host.open();
353 void refresh(host).catch(() => undefined);
354
355 return { text: "Issues pane shown." };
356 });
357
358 on("ui.render", { component: "Pane" }, async ($, e, next) => {
359 if (e.requestId !== PANE_ID || e.surface !== "terminal") {
360 return next(e);
361 }
362
363 const { Box, Text, Button, Input } = $.ui.resolve(e);
364 const kit: Kit = { Box, Text, Button };
365 const issues = issuesOfListing(listing);
366 const shown = matchesOf(issues, query);
367 const paged = pagedOf(shown, page, pageSizeOf(e.props.scroll.bodyRows));
368 const current = host;
369
370 return (
371 <Box flexDirection="column" paddingRight={1}>
372 <Box flexDirection="row" justifyContent="space-between">
373 <Text bold>
374 Open issues{" "}
375 {shown.length === issues.length
376 ? issues.length
377 : `${shown.length}/${issues.length}`}
378 </Text>
379 <Button
380 key="refresh"
381 plain
382 dimColor
383 label="↻ refresh"
384 onPress={() => {
385 if (current !== null) {
386 void refresh(current).catch(() => undefined);
387 }
388 }}
389 />
390 </Box>
391 {listing.kind === "loaded" ? (
392 <Text dimColor wrap="truncate-start">
393 {listing.root}
394 </Text>
395 ) : null}
396 <Input
397 key="filter"
398 label="filter "
399 placeholder="title, #number, label or @author"
400 value={query}
401 onInput={(value) => {
402 query = value;
403 page = 0;
404 current?.redraw();
405 }}
406 onSubmit={(value) => {
407 query = value;
408 page = 0;
409 current?.redraw();
410 }}
411 />
412 {pagerView(kit, paged, shown.length, (target) => {
413 page = target;
414 current?.redraw();
415 })}
416 {statusView(kit, listing)}
417 {shown.length === 0 && issues.length > 0 ? (
418 <Text dimColor>No issues match "{query.trim()}".</Text>
419 ) : null}
420 {notice === null ? null : <Text color="red">{notice}</Text>}
421 <Box flexDirection="column">
422 {paged.items.map((issue) => (
423 <Box key={`issue:${issue.number}`} flexDirection="column">
424 <Button
425 key={`open:${issue.number}`}
426 plain
427 label={rowLabelOf(issue)}
428 onPress={() => {
429 if (current !== null) {
430 void press(current, issue.url).catch(() => undefined);
431 }
432 }}
433 />
434 {issue.labels.length === 0 ? null : (
435 <Text dimColor wrap="truncate-end">
436 {" "}
437 {issue.labels.join(", ")}
438 </Text>
439 )}
440 </Box>
441 ))}
442 </Box>
443 </Box>
444 );
445 });
446
447 on("ui.close", { id: PANE_ID }, (_, e, next) => {
448 isOpen = false;
449 query = "";
450 page = 0;
451 generation += 1;
452
453 return next(e);
454 });
455};
456