SLOPSHOPPER

pr-view

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

newpanecommandprocesstimer
★ 12v0.1.0no licenseupdated 2026-10-07ushironoko/dotfiles/claude/.claude/skills/pr-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-view
│ ┃ Pull Requests ✕ › fix the failing auth test and add an audit log call │ ┃ Open PRs 0 ↻ refresh │ ┃ filter : title, #number, branch or @author ⏺ Read(src/auth.ts) │ ┃ JSON Parse error: Unexpected EOF ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prs │ ⎿ pr-view: Pull requests pane shown. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pull Requests
Open PRs 0 ↻ refresh filter : title, #number, branch or @author JSON Parse error: Unexpected EOF
README

Dotfiles

ushironoko's dotfiles management system.

Setup

# 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

Commands

dotfiles install

Creates symbolic links from repository to system locations.

  • -d, --dryRun: Preview changes
  • -f, --force: Force overwrite
  • -s, --select: Interactive selection
  • -v, --verbose: Detailed output

dotfiles list

Shows all managed dotfiles and their status.

  • -v, --verbose: Detailed information

dotfiles restore

Restores files from backup.

  • -b, --backup <timestamp>: Specific backup
  • -l, --list: List available backups
  • -v, --verbose: Detailed output

dotfiles doctor

Diagnoses environment issues.

  • -c, --check <categories>: Check specific areas (environment,conflicts,ghq,config,mcp)
  • -v, --verbose: Detailed diagnostics

Troubleshooting

Quick Diagnostics

If you're having issues with the initial setup, run the diagnostic tool:

./init.sh --check

This will show:

  • Binary locations (mise, bun, node)
  • Command availability in PATH
  • mise installation status
  • Installed tools
  • Recommendations for fixing issues

Common Issues

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

Configuration

Edit dotfiles.config.ts to manage your files:

  • file: Single file symlink
  • directory: Entire directory symlink
  • selective: Specific files with permissions

Codex harness

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.

Live config (~/.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.

Source 1 files
hooks/register.tsx 362 lines
1import 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