SLOPSHOPPER

pr-merge

Band above the prompt with the current branch's GitHub PR and a Merge button (via gh)

newbandtoastprocess
★ 1v0.1.2no licenseupdated 2026-10-11nerevar48/nerevar-mods/plugins/pr-merge
A shopper browsing a rack in a slop shop
README

nerevar-mods

Marketplace модов Claude Code.

МодЧто делает
turn-statsПолоса над полем ввода: время работы сессии, токены (включая субагентов), квота
pr-mergeПолоса с PR текущей ветки и кнопкой Merge через gh (настройки: mergeMethod, deleteBranch)
session-progressПрогресс-бар задачи сессии по todo-списку и плану модели (настройка: language)
copy-session-linkКнопка «🔗 Ссылка» в футере поля ввода Claude Code Desktop: копирует ссылку claude:// на текущую сессию
visual-answer-improveИтоговые секции ответа в цветных рамках с вкладкой-меткой: «Требуется решение» (оранжевая), «Сделано» (зелёная), «Не сделано» (мягкая красная). Правило про секции прикладывается к промптам пользователя (настройки: decisionColor, doneColor, notDoneColor)
image-libraryПанель «Библиотека» — галерея изображений (png/jpg/webp/gif/svg) из папки проекта (без папок сборки и зависимостей: node_modules, .git, Library, bin, dist, build и т. п.), папок сессий Claude Code этого проекта и Codex (~/.codex/generated_images): миниатюры, фильтр по источнику и сессии, сортировка по дате, постраничная навигация; «🔍» или имя — просмотр на всю панель с листанием, «📋 путь» — полный путь к файлу в буфер обмена, «↗» — открыть в системном приложении. Открывается кнопкой «🖼 Библиотека» в футере поля ввода или командой /library. Только Windows: сканирование и миниатюры через PowerShell и System.Drawing, кэш в %LOCALAPPDATA%\claude-image-library (настройки: includeCodex, scanLimit)

Установка

/plugin marketplace add nerevar48/nerevar-mods
/plugin install turn-stats@nerevar-mods
/plugin install pr-merge@nerevar-mods
/plugin install session-progress@nerevar-mods
/plugin install copy-session-link@nerevar-mods
/plugin install visual-answer-improve@nerevar-mods
/plugin install image-library@nerevar-mods

Или в ~/.claude/settings.json:

"extraKnownMarketplaces": {
  "nerevar-mods": { "source": { "source": "github", "repo": "nerevar48/nerevar-mods" } }
},
"enabledPlugins": {
  "turn-stats@nerevar-mods": true,
  "pr-merge@nerevar-mods": true,
  "session-progress@nerevar-mods": true,
  "copy-session-link@nerevar-mods": true,
  "visual-answer-improve@nerevar-mods": true,
  "image-library@nerevar-mods": true
}
Source 2 files
hooks/register.tsx 277 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Checks, Pr } from '../types'
5
6const PLUGIN = 'pr-merge'
7const FIELDS = 'number,title,state,isDraft,mergeable,headRefOid,headRefName,baseRefName,statusCheckRollup'
8const MERGE_TIMEOUT_MS = 120_000
9const PASSED_CONCLUSIONS = ['SUCCESS', 'NEUTRAL', 'SKIPPED']
10
11const pr = atom({ plugin: 'pr-merge', key: 'pr' } as const, null)
12const phase = atom({ plugin: 'pr-merge', key: 'phase' } as const, 'idle')
13
14// The band is shared, and the engine chains mods in no fixed order: each mod
15// files its section under a rank (time 10, PR 20, progress 30), the sections
16// beneath come up as one column, and the column is kept sorted by rank, the
17// rest (core's own drawing) last.
18const COLUMN = 'band-sections'
19const SECTION = 'band-section:'
20const RANK = 20
21
22function rankOf(node: RenderNode): number {
23  const key = typeof node === 'object' && node.type === 'Box' ? node.props?.key : undefined
24  const rank = typeof key === 'string' && key.startsWith(SECTION) ? Number(key.slice(SECTION.length)) : NaN
25  return Number.isFinite(rank) ? rank : Number.MAX_SAFE_INTEGER
26}
27
28function stacked(below: RenderElement, section: RenderElement): RenderElement {
29  const rest = below.type === 'Box' && below.props?.key === COLUMN ? (below.children ?? []) : [below]
30  const children = [section, ...rest].sort((a, b) => rankOf(a) - rankOf(b))
31  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children }
32}
33
34type Check = { __typename?: string; status?: string; conclusion?: string; state?: string }
35
36function checkResult(check: Check): keyof Checks {
37  if (check.__typename === 'CheckRun') {
38    if (check.status !== 'COMPLETED') return 'pending'
39    return PASSED_CONCLUSIONS.includes(check.conclusion ?? '') ? 'passed' : 'failed'
40  }
41  if (check.state === 'SUCCESS') return 'passed'
42  if (check.state === 'PENDING' || check.state === 'EXPECTED') return 'pending'
43  return 'failed'
44}
45
46function summarize(list: readonly Check[]): Checks {
47  const checks: Checks = { passed: 0, failed: 0, pending: 0 }
48  for (const check of list) checks[checkResult(check)] += 1
49  return checks
50}
51
52// One styled run of the PR line.
53type Part = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
54
55function checkParts({ passed, failed, pending }: Checks): Part[] {
56  if (passed + failed + pending === 0) return [{ text: 'нет проверок', isDim: true }]
57  const counts: Part[] = [{ text: `✓${passed}`, color: 'green' }]
58  if (failed > 0) counts.push({ text: `✗${failed}`, color: 'red' })
59  if (pending > 0) counts.push({ text: `…${pending}`, color: 'yellow' })
60  return counts.flatMap((part, i) => (i === 0 ? [part] : [{ text: ' ' }, part]))
61}
62
63function prParts(current: Pr): Part[] {
64  const dot: Part = { text: ' · ', isDim: true }
65  return [
66    { text: `PR #${current.number}`, color: 'magentaBright', isBold: true },
67    { text: ` ${current.title}` },
68    { text: ` → ${current.base}`, isDim: true },
69    dot,
70    ...checkParts(current.checks),
71    ...(current.hasConflicts ? [dot, { text: 'конфликт', color: 'red' }] : []),
72    ...(current.isDraft ? [dot, { text: 'черновик', color: 'yellow' }] : []),
73  ]
74}
75
76function firstLine(text: string): string {
77  return text.trim().split('\n')[0] ?? ''
78}
79
80function errorText(error: unknown): string {
81  return error instanceof Error ? error.message : String(error)
82}
83
84async function refresh($: EngineInterface): Promise<Pr | null> {
85  let next: Pr | null = null
86  try {
87    const { exitCode, stdout } = await $.process.run(['gh', 'pr', 'view', '--json', FIELDS])
88    const view = exitCode === 0 ? JSON.parse(stdout) : null
89    if (view?.state === 'OPEN') {
90      next = {
91        number: view.number,
92        title: view.title,
93        base: view.baseRefName,
94        head: view.headRefName,
95        headSha: view.headRefOid,
96        isDraft: view.isDraft,
97        hasConflicts: view.mergeable === 'CONFLICTING',
98        checks: summarize(view.statusCheckRollup ?? []),
99      }
100    }
101  } catch {
102    // No gh, no git repo or no PR for this branch: the band stays hidden.
103  }
104  await update($, pr, () => next)
105  return next
106}
107
108// One git step of the sync after the merge; a failure is reported and stops the sync.
109async function git($: EngineInterface, args: readonly string[]): Promise<boolean> {
110  try {
111    const { exitCode, stderr } = await $.process.run(['git', ...args], { timeoutMs: MERGE_TIMEOUT_MS })
112    if (exitCode === 0) return true
113    $.ui.toast(`git ${args[0]} не удался: ${firstLine(stderr)}`)
114  } catch (error) {
115    $.ui.toast(`git ${args[0]} не удался: ${errorText(error)}`)
116  }
117  return false
118}
119
120// Tracked changes only: untracked files do not block a switch of branch.
121async function hasLocalChanges($: EngineInterface): Promise<boolean> {
122  const { stdout } = await $.process.run(['git', 'status', '--porcelain', '--untracked-files=no'])
123  return stdout.trim() !== ''
124}
125
126function resolvePrompt(target: Pr): string {
127  return [
128    `PR #${target.number} (${target.head} → ${target.base}) конфликтует с ${target.base}, подготовь его к merge.`,
129    `Сделай git fetch, влей origin/${target.base} в ${target.head} и разреши конфликты, сохранив смысл изменений обеих сторон.`,
130    'Проверь, что затронутый код собирается, закоммить merge и запушь ветку.',
131    'Сам PR не мержь: после этого хода его смержит плагин pr-merge.',
132  ].join(' ')
133}
134
135type MergeOptions = { method: string; deleteBranch: boolean }
136
137// Merges on GitHub, then switches the checkout to the base branch and brings it up to date.
138async function merge($: EngineInterface, target: Pr, { method, deleteBranch }: MergeOptions): Promise<void> {
139  await update($, phase, () => 'merging')
140  try {
141    const argv = ['gh', 'pr', 'merge', String(target.number), `--${method}`, '--match-head-commit', target.headSha]
142    if (deleteBranch) argv.push('--delete-branch')
143    const { exitCode, stderr } = await $.process.run(argv, { timeoutMs: MERGE_TIMEOUT_MS })
144    if (exitCode !== 0) {
145      $.ui.toast(`Merge не удался: ${firstLine(stderr)}`)
146      return
147    }
148    const isSynced =
149      (await git($, ['switch', target.base])) && (await git($, ['fetch'])) && (await git($, ['pull', '--ff-only']))
150    $.ui.toast(
151      isSynced
152        ? `PR #${target.number} смержен, ${target.base} обновлён`
153        : `PR #${target.number} смержен, но ${target.base} не обновлён`,
154    )
155  } catch (error) {
156    $.ui.toast(`Merge не удался: ${errorText(error)}`)
157  } finally {
158    await update($, phase, () => 'idle')
159    await refresh($)
160  }
161}
162
163// The confirmed press: a conflicting PR goes to Claude first, the merge follows its turn.
164async function start($: EngineInterface, options: MergeOptions): Promise<void> {
165  if (await hasLocalChanges($)) {
166    $.ui.toast('Есть незакоммиченные изменения: закоммить или убери их перед merge')
167    await update($, phase, () => 'idle')
168    return
169  }
170  const target = await refresh($)
171  if (target === null) {
172    await update($, phase, () => 'idle')
173    return
174  }
175  if (target.hasConflicts) {
176    await update($, phase, () => 'resolving')
177    void $.prompt.submit({ text: resolvePrompt(target) })
178    return
179  }
180  await merge($, target, options)
181}
182
183// After Claude's resolving turn: merge once the PR no longer conflicts, else stop.
184async function resume($: EngineInterface, isAborted: boolean, options: MergeOptions): Promise<void> {
185  const target = await refresh($)
186  if (isAborted || target === null || target.hasConflicts || (await hasLocalChanges($))) {
187    $.ui.toast('Конфликты не разрешены до конца, merge отменён')
188    await update($, phase, () => 'idle')
189    return
190  }
191  await merge($, target, options)
192}
193
194export const register: Register = (on, options) => {
195  const mergeOptions: MergeOptions = {
196    method: String(options.mergeMethod),
197    deleteBranch: options.deleteBranch === true,
198  }
199  const { method } = mergeOptions
200
201  on('session.start', async ($, e, next) => {
202    // A reload ends a merge that was running; a resolving turn carries on.
203    if ((await read($, phase)) === 'merging') await update($, phase, () => 'idle')
204    await refresh($)
205    return next(e)
206  })
207
208  on('turn.complete', async ($, e, next) => {
209    const result = await next(e)
210    if (e.agentId === undefined && (await read($, phase)) === 'resolving') {
211      await resume($, e.isAborted, mergeOptions)
212    } else {
213      await refresh($)
214    }
215    return result
216  })
217
218  on('ui.press', { plugin: PLUGIN }, async ($, e) => {
219    if (e.element === 'refresh') await refresh($)
220    if (e.element === 'merge') await update($, phase, () => 'confirm')
221    if (e.element === 'cancel') await update($, phase, () => 'idle')
222    if (e.element === 'confirm') await start($, mergeOptions)
223    return { element: e.element }
224  })
225
226  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227    const current = await read($, pr)
228    if (e.props.hasSurvey || current === null) {
229      return next(e)
230    }
231
232    const below = await next(e)
233    const { Box, Button, Text } = $.ui.resolve(e)
234    const step = await read($, phase)
235    // During a running turn the resolving prompt would queue behind it, so no merge starts then.
236    const canMerge = !current.isDraft && !e.props.isWorking
237    const pressed = () => undefined
238
239    // The PR line gives way to the controls: it truncates, they keep their width.
240    return stacked(
241      below,
242      <Box key={`${SECTION}${RANK}`} flexDirection="column">
243        <Box columnGap={1}>
244          <Box flexShrink={1}>
245            <Text wrap="truncate-end">
246              {prParts(current).map(part => (
247                <Text color={part.color} dimColor={part.isDim} bold={part.isBold}>
248                  {part.text}
249                </Text>
250              ))}
251            </Text>
252          </Box>
253          <Box flexShrink={0} columnGap={1}>
254            {step === 'merging' && <Text color="yellow">мержу…</Text>}
255            {step === 'resolving' && <Text color="yellow">Claude разрешает конфликты…</Text>}
256            {step === 'resolving' && <Button key="cancel" label="Отмена" onPress={pressed} />}
257            {step === 'confirm' && canMerge && (
258              <Text>
259                {current.hasConflicts ? 'Разрешить конфликты и смержить' : 'Смержить'} ({method}), затем на{' '}
260                {current.base}?
261              </Text>
262            )}
263            {step === 'confirm' && canMerge && (
264              <Button key="confirm" label="Подтвердить" variant="primary" onPress={pressed} />
265            )}
266            {step === 'confirm' && canMerge && <Button key="cancel" label="Отмена" onPress={pressed} />}
267            {step === 'idle' && canMerge && (
268              <Button key="merge" label={`Merge PR #${current.number} (${method})`} variant="primary" onPress={pressed} />
269            )}
270            {step === 'idle' && <Button key="refresh" label="Обновить" onPress={pressed} />}
271          </Box>
272        </Box>
273      </Box>,
274    )
275  })
276}
277
types/index.d.ts 22 lines
1export type Checks = { passed: number; failed: number; pending: number }
2
3export type Pr = {
4  number: number
5  title: string
6  base: string
7  head: string
8  headSha: string
9  isDraft: boolean
10  hasConflicts: boolean
11  checks: Checks
12}
13
14// resolving: Claude merges the base into the branch and fixes conflicts in its own turn.
15export type Phase = 'idle' | 'confirm' | 'resolving' | 'merging'
16
17declare module 'claude-code' {
18  interface PluginState {
19    'pr-merge': { pr: Pr | null; phase: Phase }
20  }
21}
22