SLOPSHOPPER

cc-commit-command

/commit [instructions]: drafts a commit plan, confirms it in a pane and a confirm dialog, then commits; the transcript keeps only the command and the result

newpanecommandstatusmodelprocess
v0.1.0no licenseupdated 2026-10-15HuliLulu46/cc-commit-command
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-commit-command
│ ┃ cc-commit-command ✕ › fix the failing auth test and add an audit log call │ ┃ To commit (0) │ ┃ Not committed (1) ⏺ Read(src/auth.ts) │ ┃ M src/auth.ts ?? src/auth.test.ts ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ (no commit message yet) ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Generation failed: the model did not return ⎿ 3 pass, 1 fail │ ┃ the expected format │ ┃ OK ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /commit │ ⎿ cc-commit-command: Cancelled, nothing committed │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cc-commit-command
To commit (0) Not committed (1) M src/auth.ts ?? src/auth.test.ts (no commit message yet) Generation failed: the model did not return the expected format OK
README

cc-commit-command

A Claude Code mod that adds /commit: Claude drafts a commit plan, you confirm it in a pane and a dialog, then it commits. The transcript keeps only the command and the result line, such as Committed 3f2a1c9 feat(api): add retry (2 files).

Usage

/commit
/commit only the docs, and mention the issue number
  1. The mod collects git status, the diff and the last 10 commits.
  2. Claude picks the files and writes a Conventional Commits message in the language and style of the recent commits.
  3. With staged changes, it commits exactly the index.
  4. Otherwise it picks the files this session changed, from the edits earlier in the conversation, and leaves out files other sessions or manual edits touched.
  5. With no conversation yet, it selects every change except likely sensitive files (.env, *.pem, *.key, *.p12, names with credential or secret) and says so.
  6. A pane shows the files to commit, the files left out and the message; likely sensitive files are marked.
  7. A dialog offers Commit, Regenerate, Edit in editor and Cancel. Typing in its Other field revises the plan with your instructions.

Edit in editor opens the message in $VISUAL, else $EDITOR, else vi; save and close to return to the dialog. Use a waiting editor command such as zed --wait or code --wait.

The commit runs git commit through a shell, so the repository's hooks (such as pre-commit) run. If the commit fails, the files the mod staged are unstaged again and the error shows in the pane.

Install

At the prompt of a Claude Code terminal session:

/plugin install cc-commit-command --marketplace HuliLulu46/cc-commit-command

Answer y to add the marketplace, then pick a scope (user is first).

From a shell:

claude plugin install cc-commit-command --marketplace HuliLulu46/cc-commit-command

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with HuliLulu46/cc-commit-command, then Add plugin.

Known limits

  • Diffs longer than 40,000 characters are cut, and at most 20 untracked files are included in the diff.
  • No attribution lines (such as Co-Authored-By) are added.

Built against Claude Code 2.1.300. A mod is an early-access API that may change between releases.

Develop

claude plugin validate .
tsc -p .
Source 2 files
hooks/register.tsx 301 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileRow, View } from '../types'
5
6const COMMAND = 'commit'
7const PANE = 'cc-commit-command'
8const view = atom({ plugin: 'cc-commit-command', key: 'view' } as const, { files: [], message: '', note: '' } as View)
9
10const SUBMIT = 'Commit'
11const REGENERATE = 'Regenerate'
12const EDIT = 'Edit in editor'
13const CANCEL = 'Cancel'
14
15const SENSITIVE = /(^|\/)\.env($|\.)|\.(pem|key|p12)$|credential|secret/i
16const DIFF_LIMIT = 40_000
17
18const RULES = `- message uses Conventional Commits: <type>(<scope>): <subject>; for larger changes add a body after a blank line; type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore
19- Match the language and style of the recent commits
20- Describe only the changes in files; no attribution lines (such as Co-Authored-By)
21- Output only one JSON object {"files": [...], "message": "..."}, with no code fence or explanation`
22
23type Change = { status: string; path: string }
24type Plan = { files: string[]; message: string }
25type Repo = {
26  all: Change[]
27  // Only the staged files when the index is non-empty, otherwise all changes
28  candidates: Change[]
29  isStaged: boolean
30  branch: string
31  log: string
32  diff: string
33}
34// adjust: new instructions given, so revise the previous version; otherwise produce a clearly different one
35type Request = Repo & { guidance: string; previous?: string; adjust?: boolean }
36type Reply = { isAnswered: boolean; text?: string; reason?: string }
37
38// Set on session.start
39let home = ''
40let cwd = ''
41
42function git($: EngineInterface, root: string, args: string[]) {
43  return $.process.run(['git', ...args], { cwd: root })
44}
45
46const tail = (text: string, lines: number) => text.trim().split('\n').slice(-lines).join('\n')
47
48// -z format: each entry is "XY path"; renames and copies are followed by an extra entry with the original path
49function parseStatus(output: string): Change[] {
50  const parts = output.split('\0').filter(Boolean)
51  const changes: Change[] = []
52  for (let i = 0; i < parts.length; i++) {
53    const entry = parts[i] ?? ''
54    const status = entry.slice(0, 2)
55    changes.push({ status, path: entry.slice(3) })
56    if (status[0] === 'R' || status[0] === 'C') i++
57  }
58  return changes
59}
60
61async function collect($: EngineInterface, root: string): Promise<Repo> {
62  const all = parseStatus(
63    (await git($, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])).stdout,
64  )
65  const staged = all.filter(change => change.status[0] !== ' ' && change.status[0] !== '?')
66  const isStaged = staged.length > 0
67  const branch = (await git($, root, ['branch', '--show-current'])).stdout.trim() || 'HEAD'
68  const log = (await git($, root, ['log', '--oneline', '-10'])).stdout.trim()
69  let diff = (await git($, root, isStaged ? ['diff', '--cached'] : ['diff'])).stdout
70  if (!isStaged)
71    for (const change of all.filter(change => change.status === '??').slice(0, 20))
72      diff += (await git($, root, ['diff', '--no-index', '--', '/dev/null', change.path])).stdout
73  if (diff.length > DIFF_LIMIT) diff = `${diff.slice(0, DIFF_LIMIT)}\n… (diff too long, truncated)`
74  return { all, candidates: isStaged ? staged : all, isStaged, branch, log, diff }
75}
76
77const contextOf = (r: Request) => `<repo>
78Branch: ${r.branch}
79Recent commits:
80${r.log || '(no commits yet)'}
81</repo>
82
83<candidates>
84${r.candidates.map(change => `${change.status} ${change.path}`).join('\n')}
85</candidates>
86
87<diff>
88${r.diff}
89</diff>${
90  r.guidance
91    ? `\n\nUser instructions for this commit (may add or remove files or rewrite the message):\n<guidance>\n${r.guidance}\n</guidance>`
92    : ''
93}${
94  r.previous
95    ? `\n\n${r.adjust ? 'This is the previous commit message; revise it according to <guidance>' : 'The user was not satisfied with the previous commit message; produce a clearly different version'}:\n<previous>\n${r.previous}\n</previous>`
96    : ''
97}`
98
99const forkPromptOf = (r: Request) => `This turn is not a new task: do not continue earlier work; only prepare one commit for the git changes below and output JSON as required.
100${
101  r.isStaged
102    ? '- files: the index is already chosen; return every file in <candidates> as is'
103    : '- files: pick from <candidates> the files this session changed, based on the edits and writes earlier in the conversation; skip files with no change record in the conversation, since they may come from other sessions or manual edits; files that <guidance> explicitly adds or removes follow <guidance>'
104}
105- files excludes likely sensitive files (.env, keys, credentials) unless <guidance> explicitly asks for them
106${RULES}
107
108${contextOf(r)}`
109
110const SYSTEM = `You write commit messages for git changes.
111- files returns every file in <candidates> as is
112${RULES}`
113
114function parsePlan(text: string, paths: string[]): Plan | undefined {
115  try {
116    const raw = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) as {
117      files?: unknown
118      message?: unknown
119    }
120    if (typeof raw.message !== 'string' || !Array.isArray(raw.files)) return undefined
121    const files = raw.files.filter((file): file is string => typeof file === 'string' && paths.includes(file))
122    return { files, message: raw.message.trim() }
123  } catch {
124    return undefined
125  }
126}
127
128// The fork carries session context, so it can tell which files this session changed; with no conversation yet, fall back to sonnet, which sees only the diff
129async function generate($: EngineInterface, r: Request): Promise<{ plan: Plan; note: string } | string> {
130  const paths = r.candidates.map(change => change.path)
131  const forked = (await $.model.fork({ prompt: forkPromptOf(r) })) as Reply
132  const hasContext = forked.reason !== 'nothing-to-fork'
133  const reply = hasContext
134    ? forked
135    : ((await $.model.complete({ model: 'sonnet', system: SYSTEM, prompt: contextOf(r) })) as Reply)
136  if (!reply.isAnswered || !reply.text) return `Generation failed: ${reply.reason ?? 'the model returned nothing'}`
137  const plan = parsePlan(reply.text, paths)
138  if (!plan) return `Generation failed: the model did not return the expected format\n${tail(reply.text, 3)}`
139
140  const notes: string[] = []
141  if (r.isStaged) {
142    plan.files = paths
143    notes.push('Index already has staged changes: committing only the index')
144  } else if (!hasContext) {
145    plan.files = paths.filter(path => !SENSITIVE.test(path))
146    notes.push('No session context: selected all changes (except likely sensitive files); please review')
147  }
148  if (plan.files.some(path => SENSITIVE.test(path))) notes.push('⚠ Selected files include likely sensitive files')
149  return { plan, note: notes.join('\n') }
150}
151
152async function editMessage($: EngineInterface, message: string): Promise<{ message: string; error?: string }> {
153  const dir = `${home}/.claude/cache/cc-commit-command`
154  const file = `${dir}/COMMIT_EDITMSG`
155  await $.process.run(['mkdir', '-p', dir])
156  await $.fs.write(file, `${message}\n\n# Save and close to return to the confirm dialog; lines starting with # are ignored; an empty message keeps the original\n`)
157  // When EDITOR waits for close (e.g. zed --wait), this step blocks until the editor closes
158  const edited = await $.process.run(['/bin/sh', '-c', '${VISUAL:-${EDITOR:-vi}} "$0"', file], {
159    timeoutMs: 600_000,
160  })
161  if (edited.exitCode !== 0) return { message, error: `Editor exited with code ${edited.exitCode}: ${tail(edited.stderr, 2)}` }
162  const text = (await $.fs.read(file))
163    .split('\n')
164    .filter(line => !line.startsWith('#'))
165    .join('\n')
166    .trim()
167  return { message: text || message }
168}
169
170async function commit($: EngineInterface, root: string, isStaged: boolean, plan: Plan) {
171  if (!isStaged) {
172    const added = await git($, root, ['add', '--', ...plan.files])
173    if (added.exitCode !== 0) return { error: `Staging failed: ${tail(added.stderr, 3)}` }
174  }
175  // $.process.run disables repo hooks when running git directly; going through sh runs pre-commit and other hooks
176  const done = await $.process.run(['/bin/sh', '-c', 'git commit -F -'], {
177    cwd: root,
178    stdin: plan.message,
179    timeoutMs: 600_000,
180  })
181  if (done.exitCode === 0) return { hash: (await git($, root, ['rev-parse', '--short', 'HEAD'])).stdout.trim() }
182
183  // Unstage only the files added here; leave the user's existing staged content alone
184  if (!isStaged) {
185    const hasHead = (await git($, root, ['rev-parse', '--verify', '-q', 'HEAD'])).exitCode === 0
186    await git($, root, hasHead ? ['reset', '-q', '--', ...plan.files] : ['rm', '--cached', '-r', '-q', '--', ...plan.files])
187  }
188  return { error: `Commit failed:\n${tail(done.stderr || done.stdout, 5)}` }
189}
190
191const rowsOf = (repo: Repo, plan: Plan | undefined): FileRow[] =>
192  repo.all.map(change => ({
193    ...change,
194    isSelected: plan?.files.includes(change.path) ?? false,
195    isSensitive: SENSITIVE.test(change.path),
196  }))
197
198export const register: Register = on => {
199  on('session.start', async ($, e, next) => {
200    home = (await $.env.get('HOME')) ?? ''
201    cwd = e.cwd
202    // No immediate: mid-turn the model may still be editing files, so wait for the turn to end before committing
203    await $.command.register({
204      name: COMMAND,
205      description: 'Draft a commit plan, confirm it in a dialog, then commit',
206      argumentHint: '[instructions]',
207    })
208    return next(e)
209  })
210
211  // Intermediate steps go only to the pane, confirm dialog and status line; the returned text is the only result line in the transcript
212  on('command.run', { command: COMMAND }, async ($, e) => {
213    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
214    if (top.exitCode !== 0) return { text: 'Not committed: current directory is not a git repository' }
215    const root = top.stdout.trim()
216    const repo = await collect($, root)
217    if (repo.all.length === 0) return { text: 'Not committed: no changes' }
218
219    let request: Request = { ...repo, guidance: e.args.trim() }
220    let plan: Plan | undefined
221    let note = ''
222    let needsPlan = true
223    try {
224      for (;;) {
225        if (needsPlan) {
226          needsPlan = false
227          $.ui.status('Drafting commit plan…')
228          const generated = await generate($, request)
229          $.ui.status(undefined)
230          if (typeof generated === 'string') note = generated
231          else ({ plan, note } = generated)
232        }
233        await update($, view, () => ({ files: rowsOf(repo, plan), message: plan?.message ?? '', note }))
234        await $.ui.open({ id: PANE, title: `commit · ${repo.branch}` })
235
236        const answer = await $.ui
237          .ask('What would you like to do with this commit?', { header: 'commit', options: [SUBMIT, REGENERATE, EDIT, CANCEL] })
238          .catch(() => CANCEL)
239        if (answer === CANCEL) return { text: 'Cancelled, nothing committed' }
240        if (answer === REGENERATE) {
241          request = { ...request, previous: plan?.message, adjust: false }
242          needsPlan = true
243        } else if (answer === EDIT) {
244          if (!plan) continue
245          const edited = await editMessage($, plan.message)
246          plan = { ...plan, message: edited.message }
247          note = edited.error ?? note
248        } else if (answer === SUBMIT) {
249          if (!plan || plan.files.length === 0 || !plan.message) {
250            note = 'No files or commit message to commit'
251            continue
252          }
253          const result = await commit($, root, repo.isStaged, plan)
254          if ('hash' in result) {
255            const subject = plan.message.split('\n')[0]
256            return { text: `Committed ${result.hash} ${subject} (${plan.files.length} ${plan.files.length === 1 ? 'file' : 'files'})` }
257          }
258          note = result.error
259        } else {
260          // The dialog's Other option: revision instructions typed by the user
261          const guidance = [request.guidance, answer].filter(Boolean).join('; ')
262          request = { ...request, guidance, previous: plan?.message, adjust: true }
263          needsPlan = true
264        }
265      }
266    } finally {
267      $.ui.status(undefined)
268      await $.ui.close({ id: PANE })
269    }
270  })
271
272  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
273    const { Box, Text } = $.ui.resolve(e)
274    const { files, message, note } = await read($, view)
275    const selected = files.filter(file => file.isSelected)
276    const skipped = files.filter(file => !file.isSelected)
277    const rowOf = (file: FileRow) => `  ${file.status} ${file.path}${file.isSensitive ? '  ⚠ likely sensitive' : ''}`
278    return (
279      <Box flexDirection="column">
280        <Text bold>{`To commit (${selected.length})`}</Text>
281        {selected.map(file => (
282          <Text>{rowOf(file)}</Text>
283        ))}
284        {skipped.length > 0 && <Text bold>{`Not committed (${skipped.length})`}</Text>}
285        {skipped.map(file => (
286          <Text dimColor>{rowOf(file)}</Text>
287        ))}
288        <Text> </Text>
289        {(message || '(no commit message yet)').split('\n').map(line => (
290          <Text>{line || ' '}</Text>
291        ))}
292        {note && <Text> </Text>}
293        {note &&
294          note.split('\n').map(line => (
295            <Text color="yellow">{line}</Text>
296          ))}
297      </Box>
298    )
299  })
300}
301
types/index.d.ts 15 lines
1export type FileRow = { status: string; path: string; isSelected: boolean; isSensitive: boolean }
2
3export type View = {
4  files: FileRow[]
5  message: string
6  // Hint at the bottom of the pane: no session context, commit failure reason, etc.
7  note: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'cc-commit-command': { view: View }
13  }
14}
15