SLOPSHOPPER

issue

/issue N loads a GitHub issue into the prompt; /issue alone opens a pane of open issues

newpanecommandtoastpromptprocess
v0.1.0no licenseupdated 2026-10-04robertgregorywest/claude-mods/mods/issue
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issue
│ ┃ Open issues ✕ › fix the failing auth test and add an audit log call │ ┃ Loading open issues… │ ┃ [ Refresh ][ Close ] ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /issue │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Open issues
Loading open issues… [ Refresh ][ Close ]
README

claude-mods

Personal Claude Code mods (function-hook plugins).

Mods

issue

Start work on a GitHub issue without leaving the prompt. Needs the gh CLI, logged in, and a session started inside the repo.

  • /issue 33 fetches issue #33 and puts a prompt in the input box: Work on GitHub issue #33: <title>. Commit with "Closes #33" in the message. Press Enter to send it, or edit it first.
  • The issue's text goes to the model along with that prompt: title, state, labels, body and comments, cut at 20k characters. The model doesn't need to run gh issue view itself. This only happens when the next prompt you send still mentions #33 (not #330 or other/repo#33); otherwise the issue is dropped. Messages from peer sessions, schedules or other plugins don't count as your next prompt.
  • /issue with no number opens a pane listing up to 50 open issues. Press 1–9 or select an issue to load it the same way. Refresh (r) reloads the list, and Close closes the pane.

push

/push pushes the current branch to its upstream, the same as git push.

  • On success you get a toast, e.g. Pushed 2 commits to origin/main, named after the branch's real upstream.
  • Otherwise it says why in the command's output: git's last error line, nothing to push, or no upstream branch.

Layout

Each mod lives in mods/<name>/, laid out like template/: a working mod named mod-template that bin/mods new copies and renames, and that bin/mods check checks along with the mods, so it can't fall out of date. Change the layout there, not here.

A mod that keeps $.state adds its contract as types/index.d.ts and points "types" in .claude-plugin/plugin.json at it, as mods/issue does.

Install

bin/mods install            # all mods
bin/mods install issue      # just one (added to what's already installed)
bin/mods uninstall push
bin/mods list

Install doesn't copy anything. It points CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json at the folders in this repo (keeping any unrelated entries, and backing the file up to settings.json.mods-bak). So:

  • Updating = editing files here, or git pull. Interactive sessions watch these folders and hot-reload a mod when its files are saved.
  • Adding a new mod to the list needs a session restart, since the env var is read at startup.
  • Deleting or renaming a mod leaves a dead entry in the setting. bin/mods keeps it and warns about it, and list shows it as missing, until you run bin/mods uninstall <old-name>.

The words used here (installed set, repo entry, foreign entry, dead entry) are defined in CONTEXT.md. tests/bin-mods.sh tests this against a throwaway settings file. The pre-commit hook runs it when bin/ or tests/ changes.

Test loop

  1. bin/mods new <name> "description" to scaffold from template/. Its one test checks the template's placeholder behaviour, so it fails once you replace that behaviour.
  2. Write the module and its tests. Ask Claude to load the plugin-authoring skill first: it has the API types and examples.
  3. bin/mods check <name> runs claude plugin validate, tsc and claude plugin test. tsc is skipped until the engine has loaded the mod once, because that load writes .claude-plugin/types/ (gitignored).
  4. Try it live:
  5. bin/mods try <name> starts a one-off claude --plugin-dir session with only that mod, without installing it.
  6. Or install it. From then on every save reloads it in running sessions, and failures show as a dim line in the transcript (claude --debug gives more).
  7. Commit. The pre-commit hook (.githooks/, enabled with git config core.hooksPath .githooks) runs bin/mods check on every mod the commit touches, and on the template when it changes.

Because installed mods load straight from the working tree, a half-finished edit reaches your real sessions as soon as you save it. For risky changes, use a branch plus bin/mods try, or a worktree.

Source 2 files
hooks/register.tsx 174 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptSubmitInput, Register } from 'claude-code'
3
4import type { IssueRow } from '../types'
5
6// --- Issue handoff -----------------------------------------------------------
7// An issue /issue fetched is staged, and goes to the model as context with the
8// next prompt the person sends, if that prompt still names it. Only stage and
9// claim are used outside this section.
10
11type Issue = {
12  number: number
13  title: string
14  body: string
15  state: string
16  labels: { name: string }[]
17  comments: { author: { login: string }; body: string }[]
18}
19
20const BODY_LIMIT = 20_000
21
22const staged = atom({ plugin: 'issue', key: 'staged' } as const, null)
23
24// What the model reads with the prompt, so it needs no `gh issue view` of its own.
25const describe = (issue: Issue) => {
26  const labels = issue.labels.map(label => label.name).join(', ') || 'none'
27  const comments = issue.comments.map(c => `--- comment by @${c.author.login}\n${c.body}`)
28  const text = [
29    `GitHub issue #${issue.number}, fetched by /issue just now (state ${issue.state}, labels: ${labels}).`,
30    `Title: ${issue.title}`,
31    '',
32    issue.body || '(no description)',
33    ...comments,
34  ].join('\n')
35
36  return text.length > BODY_LIMIT ? `${text.slice(0, BODY_LIMIT)}\n[cut at ${BODY_LIMIT} characters]` : text
37}
38
39// Only the person's own prompts claim the staged issue; a peer's, a schedule's
40// or a plugin's neither gets it nor uses it up.
41const isPersons = (origin: PromptSubmitInput['origin']) => origin.kind === 'composer' || origin.kind === 'bridge'
42
43// `#33` as a whole reference: not `#330`, `abc#33` or `other/repo#33`.
44const mentions = (text: string, number: number) => new RegExp(`(?<![\\w/])#${number}(?!\\d)`).test(text)
45
46// Stage an issue for the next prompt, replacing any staged before it.
47const stage = async ($: EngineInterface, issue: Issue) => {
48  await update($, staged, () => ({ number: issue.number, context: describe(issue) }))
49}
50
51// The context to send with this prompt, or null. The person's next prompt uses
52// the staged issue up whether or not it names it.
53const claim = async ($: EngineInterface, e: Pick<PromptSubmitInput, 'text' | 'origin'>) => {
54  if (!isPersons(e.origin)) return null
55
56  const issue = await read($, staged)
57  if (issue === null) return null
58
59  await update($, staged, () => null)
60  return mentions(e.text, issue.number) ? issue.context : null
61}
62
63// --- /issue command and pane --------------------------------------------------
64
65const PANE = 'issue-list'
66
67const open = atom({ plugin: 'issue', key: 'open' } as const, null)
68const error = atom({ plugin: 'issue', key: 'error' } as const, null)
69
70const gh = async ($: EngineInterface, ...args: string[]) => {
71  const { exitCode, stdout, stderr } = await $.process.run(['gh', ...args], { timeoutMs: 20_000 })
72
73  return { ok: exitCode === 0, out: stdout, err: stderr.trim().split('\n').at(-1) ?? '' }
74}
75
76const load = async ($: EngineInterface, number: number) => {
77  const fields = 'number,title,body,state,labels,comments'
78  const view = await gh($, 'issue', 'view', String(number), '--json', fields)
79  if (!view.ok) return `gh issue view ${number} failed: ${view.err}`
80
81  const issue = JSON.parse(view.out) as Issue
82  await stage($, issue)
83  const closed = issue.state === 'OPEN' ? '' : ` (it is ${issue.state.toLowerCase()})`
84  await $.prompt.fill({
85    text: `Work on GitHub issue #${number}: ${issue.title}${closed}. Commit with "Closes #${number}" in the message.`,
86  })
87
88  return `Loaded #${number} into the prompt. Press Enter to start.`
89}
90
91const refresh = async ($: EngineInterface) => {
92  const list = await gh($, 'issue', 'list', '--state', 'open', '--limit', '50', '--json', 'number,title,labels')
93  if (!list.ok) {
94    await update($, error, () => `gh issue list failed: ${list.err}`)
95    return
96  }
97
98  const rows = (JSON.parse(list.out) as Pick<Issue, 'number' | 'title' | 'labels'>[]).map(issue => ({
99    number: issue.number,
100    title: issue.title,
101    labels: issue.labels.map(label => label.name),
102  }))
103  await update($, error, () => null)
104  await update($, open, () => rows)
105}
106
107export const register: Register = on => {
108  on('session.start', async ($, e, next) => {
109    await $.command.register({
110      name: 'issue',
111      description: 'Load GitHub issue N into the prompt (/issue 33), or pick from open issues (/issue)',
112    })
113
114    return next(e)
115  })
116
117  on('command.run', { command: 'issue' }, async ($, e) => {
118    const arg = e.args.trim().replace(/^#/, '')
119
120    if (arg === '') {
121      await update($, open, () => null)
122      await $.ui.open({ id: PANE, title: 'Open issues', focus: true })
123      await refresh($)
124      return {}
125    }
126
127    if (!/^\d+$/.test(arg)) return { text: `Usage: /issue 33, or /issue for a list (got "${e.args}")` }
128
129    return { text: await load($, Number(arg)) }
130  })
131
132  on('prompt.submit', async ($, e, next) => {
133    const context = await claim($, e)
134
135    return next(context === null ? e : { ...e, context: [...(e.context ?? []), context] })
136  })
137
138  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
139    const { Box, Button, Text } = $.ui.resolve(e)
140    const rows = await read($, open)
141    const failure = await read($, error)
142
143    const pick = async (row: IssueRow) => {
144      const result = await load($, row.number)
145      await $.ui.close({ id: PANE })
146      $.ui.toast(result)
147    }
148
149    return (
150      <Box flexDirection="column">
151        {failure && <Text color="red">{failure}</Text>}
152        {!failure && rows === null && <Text dimColor>Loading open issues…</Text>}
153        {rows?.length === 0 && <Text dimColor>No open issues.</Text>}
154        {rows?.map((row, index) => (
155          <Box>
156            <Button
157              key={`issue-${row.number}`}
158              label={`#${row.number} ${row.title}`}
159              hotkey={index < 9 ? String(index + 1) : undefined}
160              plain
161              onPress={() => pick(row)}
162            />
163            {row.labels.length > 0 && <Text dimColor> {row.labels.join(', ')}</Text>}
164          </Box>
165        ))}
166        <Box>
167          <Button key="refresh" label="Refresh" hotkey="r" dimColor onPress={() => refresh($)} />
168          <Button key="close" label="Close" role="dismiss" dimColor onPress={() => $.ui.close({ id: PANE })} />
169        </Box>
170      </Box>
171    )
172  })
173}
174
types/index.d.ts 14 lines
1export type Staged = { number: number; context: string }
2
3export type IssueRow = { number: number; title: string; labels: string[] }
4
5declare module 'claude-code' {
6  interface PluginState {
7    issue: {
8      open: IssueRow[] | null
9      error: string | null
10      staged: Staged | null
11    }
12  }
13}
14