/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

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).
/commit
/commit only the docs, and mention the issue number
git status, the diff and the last 10 commits..env, *.pem, *.key, *.p12, names with credential or secret) and says so.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.
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.
Co-Authored-By) are added.Built against Claude Code 2.1.300. A mod is an early-access API that may change between releases.
claude plugin validate .
tsc -p .hooks/register.tsx 301 lines1import { 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}
301types/index.d.ts 15 lines1export 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