SLOPSHOPPER

issue-view

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

newpanecommandprocesstimer
★ 12v0.1.0no licenseupdated 2026-10-23ushironoko/dotfiles/claude/.claude/skills/issue-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issue-view
│ ┃ Issues ✕ › fix the failing auth test and add an audit log call │ ┃ Open issues 0 ↻ refresh │ ┃ filter : title, #number, label 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 │ │ › /issues │ ⎿ issue-view: Issues pane shown. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Issues
Open issues 0 ↻ refresh filter : title, #number, label 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 456 lines
1import 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