นักเขียนประจำ Git: แต่งข้อความ commit จาก diff (ปกติ/Conventional/แบบกวีน่ารัก), เขียน PR จาก commit แล้วสร้างด้วย gh และสรุปงานวันนี้เป็นโพสต์โซเชียล

นักเขียนประจำ Git: อ่าน diff ที่ stage ไว้แล้วเสนอข้อความ commit 3 แบบให้เลือก (Conventional / ไทย / กวีน่ารัก 🎀) กดแล้ว commit ให้, เขียน Pull Request จาก commit ของ branch (title + Summary + Changes + Testing) ถ้ามี GitHub CLI ถามแล้วสร้าง PR ให้ ไม่มีก็คัดลอกให้พร้อมลิงก์หน้าเปิด PR และสรุปงานวันนี้เป็นโพสต์โซเชียล 280 ตัวอักษร
| คำสั่ง | ทำอะไร | ||
|---|---|---|---|
/gitmsg | เสนอข้อความ commit 3 แบบ แล้ว commit ให้ | ||
| `/gitmsg style conventional\ | thai\ | cute` | สไตล์ข้อความ |
/pr [base] | เขียน PR จาก commit ของ branch นี้ | ||
/tweet [en] | สรุปงานวันนี้เป็นโพสต์ |
/plugin install git-writer-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
การสร้าง PR จะ push branch ขึ้น origin จึงถามก่อนทุกครั้ง ข้อความที่ได้มาจาก Haiku อ่านเฉพาะ diff/commit ของจริง ควรอ่านทวนก่อนใช้
hooks/register.tsx 194 lines1/**
2 * ============================================================================
3 * MOD 54: Git Writer TH - นักเขียนประจำ Git (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: การเขียน commit message และ PR description ที่ไม่มีใครอยากเขียน
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. /gitmsg - อ่าน diff ที่ stage ไว้ แล้วเสนอข้อความ commit 3 แบบให้เลือก กดแล้ว commit ให้เลย
9 * สไตล์: conventional (ค่าเริ่มต้น) / ไทย / กวีน่ารัก 🎀
10 * 2. /pr [base] - อ่าน commit ของ branch นี้ แล้วเขียน PR (title + สรุป + วิธีทดสอบ)
11 * มี GitHub CLI (gh) → ถามแล้วสร้าง PR ให้ / ไม่มี → คัดลอกลง clipboard พร้อมลิงก์หน้าเปิด PR
12 * 3. /tweet - สรุปงานวันนี้จาก git log เป็นโพสต์สั้นไม่เกิน 280 ตัวอักษร คัดลอกให้พร้อมโพสต์
13 *
14 * ใช้ Haiku เขียน (ถูกและเร็ว) อ่านเฉพาะ diff/commit ของจริง ไม่แต่งเรื่องเกินข้อมูล
15 */
16
17import type { EngineInterface, Register } from 'claude-code'
18
19type Style = 'conventional' | 'thai' | 'cute'
20const MAX_DIFF = 16_000
21
22let style: Style = 'conventional'
23
24// ==========================================================================
25// บทที่ 1: เรียก git / gh - argv ล้วน ไม่ผ่าน shell
26// ==========================================================================
27async function run($: EngineInterface, argv: string[]): Promise<{ ok: boolean; out: string; err: string }> {
28 const r = await $.process.run(argv, { timeoutMs: 60_000 }).catch(() => undefined)
29 return { ok: r?.exitCode === 0, out: (r?.stdout ?? '').trim(), err: (r?.stderr ?? '').trim() }
30}
31
32async function write($: EngineInterface, system: string, prompt: string, maxTokens: number): Promise<string | undefined> {
33 for (const model of ['haiku', await $.session.model()]) {
34 const r = await $.model.complete({ model, system, prompt, maxTokens }).catch(() => undefined)
35 if (r?.isAnswered) return r.text.trim()
36 }
37 return undefined
38}
39
40async function ask($: EngineInterface, question: string, options: string[], header: string): Promise<string | undefined> {
41 try {
42 return await $.ui.ask(question, { header, options })
43 } catch {
44 return undefined
45 }
46}
47
48async function copy($: EngineInterface, text: string): Promise<boolean> {
49 const r = await $.ui.copy({ text }).catch(() => undefined)
50 return !!r?.isCopied
51}
52
53// ==========================================================================
54// บทที่ 2: ข้อความ commit
55// ==========================================================================
56const STYLE_RULE: Record<Style, string> = {
57 conventional: 'ภาษาอังกฤษ รูปแบบ Conventional Commits: <type>(<scope>): <summary> ตัวพิมพ์เล็ก ไม่เกิน 72 ตัวอักษร',
58 thai: 'ภาษาไทย กระชับ บอกว่าเปลี่ยนอะไรและทำไม ไม่เกิน 72 ตัวอักษร',
59 cute: 'รูปแบบ <type>: <summary> แต่ summary เป็นภาษาไทยแบบกวีน่ารัก คล้องจอง มี emoji 1 ตัว แต่ยังต้องบอกว่าเปลี่ยนอะไรจริง ไม่เกิน 72 ตัวอักษร ใช้คำไทยที่คนพูดกันจริง ถูกไวยากรณ์ ห้ามใช้คำแปลกหรือผิดความหมาย',
60}
61
62async function gitmsg($: EngineInterface): Promise<string> {
63 let diff = await run($, ['git', 'diff', '--staged', '--no-color', '-U2'])
64 if (!diff.ok) return '❌ โฟลเดอร์นี้ไม่ใช่ git repo'
65 let staged = true
66 if (!diff.out) {
67 diff = await run($, ['git', 'diff', '--no-color', '-U2'])
68 staged = false
69 if (!diff.out) return '🤷 ไม่มีอะไรเปลี่ยน (ทั้งที่ stage และยังไม่ stage)'
70 }
71 const stat = await run($, ['git', 'diff', staged ? '--staged' : '', '--stat'].filter(Boolean))
72 const system = `เขียนข้อความ commit 3 แบบจาก diff ที่ให้มา ${STYLE_RULE[style]}
73ตอบเฉพาะ 3 บรรทัด บรรทัดละหนึ่งแบบ ไม่มีเลขนำหน้า ไม่มีเครื่องหมายคำพูด ไม่มีคำอธิบายอื่น`
74 const text = await write($, system, `${stat.out}\n\n${diff.out.slice(0, MAX_DIFF)}`, 300)
75 const options = (text ?? '').split('\n').map(l => l.replace(/^\s*(?:\d+[.)]|[-*•])\s*/, '').replace(/^["'`]|["'`]$/g, '').trim()).filter(Boolean).slice(0, 3)
76 if (options.length === 0) return '❌ เขียนข้อความไม่สำเร็จ'
77 const list = options.map((o, i) => ` ${i + 1}. ${o}`).join('\n')
78 if (!staged) return `✍️ ข้อเสนอ (ยังไม่ได้ stage ไฟล์ จึงยังไม่ commit ให้ ใช้ git add ก่อน):\n${list}`
79 const picked = await ask($, 'ใช้ข้อความ commit ไหน?', [...options, 'ยังไม่ commit'], 'Commit')
80 if (!picked || picked === 'ยังไม่ commit') return `✍️ ข้อเสนอ:\n${list}`
81 const c = await run($, ['git', 'commit', '-m', picked])
82 return c.ok ? `✅ commit แล้ว: ${picked}\n${c.out.split('\n')[0] ?? ''}` : `❌ commit ไม่สำเร็จ\n${c.err || c.out}`
83}
84
85// ==========================================================================
86// บทที่ 3: PR - หา base, รวบรวม commit + diff, เขียน, แล้วสร้างหรือคัดลอก
87// ==========================================================================
88async function baseBranch($: EngineInterface): Promise<string> {
89 const head = await run($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
90 if (head.ok) return head.out.replace(/^origin\//, '')
91 for (const b of ['main', 'master', 'develop']) if ((await run($, ['git', 'rev-parse', '--verify', '--quiet', b])).ok) return b
92 return 'main'
93}
94
95function compareUrl(remote: string, base: string, branch: string): string | undefined {
96 const m = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/.exec(remote)
97 return m ? `https://github.com/${m[1]}/${m[2]}/compare/${base.split('/').map(encodeURIComponent).join('/')}...${branch.split('/').map(encodeURIComponent).join('/')}?expand=1` : undefined
98}
99
100async function pr($: EngineInterface, baseArg: string | undefined): Promise<string> {
101 const branch = await run($, ['git', 'branch', '--show-current'])
102 if (!branch.ok || !branch.out) return '❌ ไม่ได้อยู่บน branch (หรือไม่ใช่ git repo)'
103 const base = baseArg || (await baseBranch($))
104 if (branch.out === base) return `ℹ️ ตอนนี้อยู่บน ${base} เอง สร้าง branch ใหม่ก่อน: /gswitch feature/xxx new (Mod 53)`
105 const log = await run($, ['git', 'log', '--no-color', '--pretty=format:- %s%n%b', `${base}..HEAD`])
106 if (!log.ok) return `❌ หา branch ${base} ไม่เจอ ลอง /pr <base>`
107 if (!log.out) return `ℹ️ ${branch.out} ยังไม่มี commit ใหม่เทียบกับ ${base}`
108 const diff = await run($, ['git', 'diff', '--no-color', '-U1', `${base}...HEAD`])
109 const stat = await run($, ['git', 'diff', '--stat', `${base}...HEAD`])
110 const system = `เขียน Pull Request จาก commit และ diff ที่ให้มา เป็นภาษา${style === 'conventional' ? 'อังกฤษ' : 'ไทย'}
111บรรทัดแรก: TITLE: <หัวข้อสั้น ไม่เกิน 70 ตัวอักษร>
112จากนั้นเป็น markdown มีหัวข้อ: Summary (2-4 bullet ว่าเปลี่ยนอะไรและทำไม), Changes (ไฟล์/ส่วนที่สำคัญ), Testing (วิธีทดสอบที่สมเหตุสมผลจากโค้ด)
113ห้ามแต่งเรื่องที่ไม่มีใน diff`
114 const text = await write($, system, `Commits:\n${log.out}\n\nStat:\n${stat.out}\n\nDiff:\n${diff.out.slice(0, MAX_DIFF)}`, 900)
115 if (!text) return '❌ เขียน PR ไม่สำเร็จ'
116 const title = /^TITLE:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? branch.out
117 const body = text.replace(/^TITLE:.*\n?/m, '').trim()
118 const preview = `📝 PR: ${branch.out} → ${base}\n\n# ${title}\n\n${body}`
119
120 const gh = await run($, ['gh', '--version'])
121 if (gh.ok) {
122 const a = await ask($, `สร้าง PR "${title}" ไปที่ ${base} เลยไหม? (จะ push branch ขึ้น origin ด้วย)`, ['สร้าง PR', 'สร้างแบบ draft', 'ยังไม่สร้าง'], 'Pull Request')
123 if (a === 'สร้าง PR' || a === 'สร้างแบบ draft') {
124 const push = await run($, ['git', 'push', '-u', 'origin', 'HEAD'])
125 if (!push.ok) return `${preview}\n\n❌ push ไม่สำเร็จ\n${push.err}`
126 const created = await run($, ['gh', 'pr', 'create', '--base', base, '--title', title, '--body', body, ...(a === 'สร้างแบบ draft' ? ['--draft'] : [])])
127 return created.ok ? `✅ สร้าง PR แล้ว: ${created.out.split('\n').pop()}` : `${preview}\n\n❌ gh pr create ไม่สำเร็จ\n${created.err}`
128 }
129 }
130 // ไม่มี gh หรือยังไม่สร้าง: คัดลอกให้ไปวางเอง
131 const copied = await copy($, `${title}\n\n${body}`)
132 const remote = await run($, ['git', 'remote', 'get-url', 'origin'])
133 const url = remote.ok ? compareUrl(remote.out, base, branch.out) : undefined
134 return `${preview}\n\n${copied ? '📋 คัดลอก title + body แล้ว' : ''}${url ? `\n🔗 push branch แล้วเปิด: ${url}` : ''}${gh.ok ? '' : '\n💡 ติดตั้ง GitHub CLI (gh) แล้ว /pr จะสร้าง PR ให้เลย'}`
135}
136
137// ==========================================================================
138// บทที่ 4: โพสต์สรุปงานวันนี้
139// ==========================================================================
140async function tweet($: EngineInterface, lang: string): Promise<string> {
141 const log = await run($, ['git', 'log', '--since=midnight', '--no-color', '--pretty=format:- %s'])
142 if (!log.ok) return '❌ โฟลเดอร์นี้ไม่ใช่ git repo'
143 if (!log.out) return '📭 วันนี้ยังไม่มี commit ให้สรุป'
144 const repo = (await $.session.root()).split(/[\\/]/).pop() ?? ''
145 const system = `เขียนโพสต์โซเชียลสั้นๆ อัปเดตความคืบหน้าโปรเจกต์ "${repo}" จากรายการ commit วันนี้ เป็นภาษา${lang === 'en' ? 'อังกฤษ' : 'ไทย'} สนุก เป็นกันเอง ใส่ emoji 2-3 ตัว และ hashtag 1-2 อัน ใช้ภาษาที่เป็นธรรมชาติแบบคนโพสต์จริง ถูกไวยากรณ์ อ่านแล้วเข้าใจทันที
146ยาวรวมไม่เกิน 270 ตัวอักษร ตอบเฉพาะตัวโพสต์ ห้ามแต่งความสำเร็จที่ไม่มีใน commit`
147 const text = await write($, system, log.out, 300)
148 if (!text) return '❌ เขียนโพสต์ไม่สำเร็จ'
149 const post = text.replace(/^["']|["']$/g, '')
150 const copied = await copy($, post)
151 return `🐦 (${[...post].length}/280 ตัวอักษร)${copied ? ' คัดลอกแล้ว พร้อมโพสต์' : ''}\n\n${post}`
152}
153
154const HELP = `✍️ Git Writer TH (Mod 54)
155========================================
156/gitmsg เสนอข้อความ commit 3 แบบจาก diff ที่ stage ไว้ แล้ว commit ให้
157/gitmsg style <แบบ> conventional | thai | cute (ตอนนี้: STYLE)
158/pr [base] เขียน PR จาก commit ของ branch นี้ (มี gh = สร้างให้เลย)
159/tweet [en] สรุปงานวันนี้เป็นโพสต์ 280 ตัวอักษร`
160
161export const register: Register = on => {
162 on('session.start', async ($, e, next) => {
163 style = ((await $.store.get('style')) as Style | undefined) ?? 'conventional'
164 await $.command.register({ name: 'gitmsg', description: 'ให้ Claude แต่งข้อความ commit จาก diff แล้ว commit ให้ (/gitmsg help)', argumentHint: '[style conventional|thai|cute]' })
165 await $.command.register({ name: 'pr', description: 'เขียน Pull Request จาก commit ของ branch นี้ แล้วสร้างด้วย gh', argumentHint: '[base]' })
166 await $.command.register({ name: 'tweet', description: 'สรุปงานวันนี้จาก git log เป็นโพสต์สั้น', argumentHint: '[en]' })
167 return next(e)
168 })
169
170 on('command.run', { command: 'gitmsg' }, async ($, e) => {
171 const [sub = '', value = ''] = e.args.trim().split(/\s+/).filter(Boolean)
172 if (sub === 'help') return { text: HELP.replace('STYLE', style) }
173 if (sub === 'style') {
174 if (!['conventional', 'thai', 'cute'].includes(value)) return { text: `วิธีใช้: /gitmsg style conventional|thai|cute (ตอนนี้: ${style})` }
175 style = value as Style
176 await $.store.set('style', style)
177 return { text: `🎨 สไตล์: ${style}${style === 'cute' ? ' 🎀' : ''}` }
178 }
179 return { text: await gitmsg($) }
180 })
181
182 on('command.run', { command: 'pr' }, async ($, e) => ({ text: await pr($, e.args.trim().split(/\s+/)[0] || undefined) }))
183 on('command.run', { command: 'tweet' }, async ($, e) => ({ text: await tweet($, e.args.trim()) }))
184}
185
186/**
187 * ========================================================================
188 * การบ้าน / ต่อยอด
189 * ========================================================================
190 * 1. เขียน CHANGELOG.md จาก commit ระหว่างสอง tag
191 * 2. ใส่ "Closes #123" เองถ้าชื่อ branch มีเลข issue
192 * 3. /tweet แบบ thread หลายโพสต์สำหรับสรุปรายสัปดาห์
193 */
194