ตัวจัดการไฟล์ภาษาไทยด้วยคำสั่ง /fm: ดู สร้าง ย้าย ก็อป ค้นหา ถังขยะกู้คืนได้ สำรองไฟล์ ไฟล์โปรด

จัดการไฟล์ด้วยคำสั่ง /fm ครบชุด: ดู อ่าน สร้าง ย้าย ก็อป ค้นหา rm จะย้ายเข้าถังขยะ .fm-trash/ ก่อนเสมอ กู้คืนได้ ไฟล์สำคัญ (.env, package.json, .git, CLAUDE.md) ลบด้วย rm ธรรมดาไม่ได้ ใช้ได้ทั้ง Windows และ macOS/Linux
| คำสั่ง | ทำอะไร |
|---|---|
/fm help | ดูคำสั่งทั้งหมด |
/fm ls [path] | ดูไฟล์ |
/fm cat <ไฟล์> | อ่านไฟล์ |
/fm touch / mkdir | สร้างไฟล์ / โฟลเดอร์ |
/fm mv / cp | ย้าย / ก็อป |
/fm rm <ไฟล์> | ลบเข้าถังขยะ |
/fm rm! <ไฟล์> | ลบถาวร |
/fm trash / restore <ชื่อ> / empty-trash | ถังขยะ |
/fm find / grep / ext | ค้นหาตามชื่อ / เนื้อหา / นามสกุล |
/fm backup <ไฟล์> | สำรองเป็น .bak-วันที่ |
/fm fav [ไฟล์] | ไฟล์โปรด |
/fm history / stats | ประวัติ / สถิติ |
/plugin install file-manager-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
$.fs$.process.run โดยส่ง path ผ่าน env (FM_SRC, FM_DST) กัน command injection และชื่อไฟล์ที่มีช่องว่างsh)node_modules, .git)ชื่อที่มีช่องว่างให้ใส่ในเครื่องหมายคำพูด เช่น /fm mv "old name.txt" new.txt
hooks/register.tsx 389 lines1/**
2 * ============================================================================
3 * MOD 10: File Manager TH - ตัวจัดการไฟล์เต็มรูปแบบ (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: Mod ของเราเอง ต่อยอดจาก Mod 01-09
6 *
7 * หน้าที่ของ Mod นี้ (ทุกอย่างผ่านคำสั่ง /fm):
8 * 1. CRUD ไฟล์ครบ: ls, cat, touch, mkdir, mv, cp
9 * 2. ถังขยะกู้คืนได้: rm ย้ายไป .fm-trash/ ก่อน, rm! ลบถาวร, restore กู้คืน
10 * 3. ค้นหา: find (ชื่อ), grep (เนื้อหา), ext (นามสกุล)
11 * 4. backup, ไฟล์โปรด, ประวัติ, สถิติ
12 * 5. ปลอดภัย: ไฟล์สำคัญ (.env, package.json, .git) ลบด้วย rm ธรรมดาไม่ได้
13 *
14 * เทคนิคสำคัญ:
15 * - อ่าน/เขียน/list ไฟล์ใช้ $.fs (ข้ามระบบได้เลย)
16 * - ย้าย/ก็อป/ลบ/สร้างโฟลเดอร์ $.fs ไม่มีให้ จึงใช้ $.process.run
17 * โดยส่ง path ผ่าน env (FM_SRC, FM_DST) ไม่ต่อสตริงเข้าคำสั่ง
18 * -> ชื่อไฟล์มีช่องว่างหรืออักขระพิเศษก็ไม่พัง และกัน command injection
19 */
20
21import type { EngineInterface, FsEntry, Register } from 'claude-code'
22
23type Op = 'mkdir' | 'move' | 'copy' | 'remove'
24type TrashItem = { name: string; original: string; deletedAt: number }
25type HistoryItem = { time: number; action: string }
26
27const TRASH_DIR = '.fm-trash'
28const SKIP_DIRS = new Set(['node_modules', '.git', TRASH_DIR, 'dist', 'build', '.next'])
29const PROTECTED = [/(^|[\\/])\.env(\.|$)/, /(^|[\\/])package(-lock)?\.json$/, /(^|[\\/])\.git([\\/]|$)/, /(^|[\\/])CLAUDE\.md$/]
30const MAX_WALK = 3000 // เดินดูไฟล์ไม่เกินกี่รายการ (กันค้างในโปรเจกต์ใหญ่)
31
32let cwd = ''
33let isWindows = false
34let history: HistoryItem[] = []
35const stats = new Map<string, number>()
36
37// ==========================================================================
38// บทที่ 1: จัดการ path ให้ใช้ได้ทั้ง Windows และ macOS/Linux
39// ==========================================================================
40function sep(): string {
41 return isWindows ? '\\' : '/'
42}
43
44function isAbsolute(p: string): boolean {
45 return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
46}
47
48function resolve(p: string): string {
49 const clean = p.replace(/^["']|["']$/g, '')
50 return isAbsolute(clean) ? clean : `${cwd}${sep()}${clean.replace(/[\\/]/g, sep())}`
51}
52
53function baseName(p: string): string {
54 return p.split(/[\\/]/).pop() ?? p
55}
56
57function relative(p: string): string {
58 return p.toLowerCase().startsWith(cwd.toLowerCase() + sep()) ? p.slice(cwd.length + 1) : p
59}
60
61function isProtected(p: string): boolean {
62 return PROTECTED.some(rx => rx.test(p))
63}
64
65// แยก argument โดยรองรับ "ชื่อ ที่มี ช่องว่าง"
66function splitArgs(text: string): string[] {
67 return [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
68}
69
70// เวลาท้องถิ่นแบบ 20261023-153000 (ใช้ตั้งชื่อไฟล์ ไม่ให้ซ้ำกัน)
71function stamp(ms: number): string {
72 const d = new Date(ms)
73 const pad = (n: number) => String(n).padStart(2, '0')
74 return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
75}
76
77function formatSize(bytes: number): string {
78 if (bytes < 1024) return `${bytes} B`
79 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
80 return `${(bytes / 1024 / 1024).toFixed(1)} MB`
81}
82
83function icon(entry: FsEntry): string {
84 if (entry.kind === 'dir') return '📁'
85 if (/\.(tsx|jsx)$/.test(entry.name)) return '⚛️'
86 if (/\.ts$/.test(entry.name)) return '📘'
87 if (/\.py$/.test(entry.name)) return '🐍'
88 if (/\.(md|txt)$/.test(entry.name)) return '📝'
89 if (/\.(json|ya?ml)$/.test(entry.name)) return '📋'
90 if (/^\.env/.test(entry.name)) return '🔐'
91 if (/\.(png|jpe?g|gif|svg|webp)$/.test(entry.name)) return '🖼️'
92 return '📄'
93}
94
95// ==========================================================================
96// บทที่ 2: ย้าย/ก็อป/ลบ/สร้างโฟลเดอร์ ผ่าน $.process.run แบบปลอดภัย
97// ==========================================================================
98const POWERSHELL: Record<Op, string> = {
99 mkdir: 'New-Item -ItemType Directory -Force -Path $env:FM_SRC | Out-Null',
100 move: 'Move-Item -LiteralPath $env:FM_SRC -Destination $env:FM_DST -Force',
101 copy: 'Copy-Item -LiteralPath $env:FM_SRC -Destination $env:FM_DST -Recurse -Force',
102 remove: 'Remove-Item -LiteralPath $env:FM_SRC -Recurse -Force',
103}
104const SH: Record<Op, string> = {
105 mkdir: 'mkdir -p -- "$FM_SRC"',
106 move: 'mv -f -- "$FM_SRC" "$FM_DST"',
107 copy: 'cp -R -- "$FM_SRC" "$FM_DST"',
108 remove: 'rm -rf -- "$FM_SRC"',
109}
110
111async function runOp($: EngineInterface, op: Op, src: string, dst = ''): Promise<void> {
112 const argv = isWindows
113 ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `$ErrorActionPreference='Stop'; ${POWERSHELL[op]}`]
114 : ['sh', '-c', SH[op]]
115 const result = await $.process.run(argv, { env: { FM_SRC: src, FM_DST: dst }, timeoutMs: 60_000 })
116 if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `exit ${result.exitCode}`)
117}
118
119// ==========================================================================
120// บทที่ 3: เดินดูไฟล์ทั้งโปรเจกต์ (ใช้กับ find / grep / ext)
121// ==========================================================================
122async function walk($: EngineInterface, root: string, visit: (path: string, entry: FsEntry) => Promise<boolean | void>): Promise<void> {
123 const queue = [root]
124 let seen = 0
125 while (queue.length > 0 && seen < MAX_WALK) {
126 const dir = queue.shift() ?? root
127 let entries: FsEntry[]
128 try {
129 entries = await $.fs.list(dir)
130 } catch {
131 continue // อ่านโฟลเดอร์ไม่ได้ ข้ามไป
132 }
133 for (const entry of entries) {
134 seen++
135 const path = `${dir}${sep()}${entry.name}`
136 if (entry.kind === 'dir') {
137 if (!SKIP_DIRS.has(entry.name)) queue.push(path)
138 continue
139 }
140 if ((await visit(path, entry)) === false) return // visit คืน false = พอแล้ว หยุดเดิน
141 }
142 }
143}
144
145// ==========================================================================
146// บทที่ 4: ถังขยะ - จำว่าไฟล์ไหนมาจากไหน (เก็บใน $.store แยกตามโปรเจกต์)
147// ==========================================================================
148const trashKey = () => `trash:${cwd}`
149
150async function readTrash($: EngineInterface): Promise<TrashItem[]> {
151 const saved = await $.store.get(trashKey())
152 return Array.isArray(saved) ? (saved as TrashItem[]) : []
153}
154
155async function readFavs($: EngineInterface): Promise<string[]> {
156 const saved = await $.store.get('favs')
157 return Array.isArray(saved) ? (saved as string[]) : []
158}
159
160async function record($: EngineInterface, action: string, op: string): Promise<void> {
161 history.push({ time: await $.clock.now(), action })
162 if (history.length > 100) history.shift()
163 stats.set(op, (stats.get(op) ?? 0) + 1)
164}
165
166const HELP = `🗂️ File Manager TH (Mod 10)
167----------------------------------------
168/fm ls [path] ดูไฟล์ในโฟลเดอร์
169/fm cat <ไฟล์> อ่านไฟล์ (200 บรรทัดแรก)
170/fm touch <ไฟล์> สร้างไฟล์เปล่า
171/fm mkdir <โฟลเดอร์> สร้างโฟลเดอร์
172/fm mv <เก่า> <ใหม่> ย้าย/เปลี่ยนชื่อ
173/fm cp <ต้นฉบับ> <ปลายทาง> ก็อปปี้
174/fm rm <ไฟล์> ลบเข้าถังขยะ (กู้คืนได้)
175/fm rm! <ไฟล์> ลบถาวร
176/fm trash ดูถังขยะ
177/fm restore <ชื่อ> กู้คืนจากถังขยะ
178/fm empty-trash เทถังขยะ (ลบถาวรทั้งหมด)
179/fm find <ชื่อ> ค้นหาตามชื่อไฟล์
180/fm grep <คำ> ค้นหาตามเนื้อหา
181/fm ext <นามสกุล> ค้นหาตามนามสกุล เช่น /fm ext tsx
182/fm backup <ไฟล์> สำรองเป็น <ไฟล์>.bak-วันที่
183/fm fav [ไฟล์] ดู/เพิ่มไฟล์โปรด
184/fm history | stats ประวัติ และสถิติ
185ชื่อที่มีช่องว่างให้ใส่ "เครื่องหมายคำพูด"`
186
187export const register: Register = on => {
188 on('session.start', async ($, e, next) => {
189 cwd = e.cwd.replace(/[\\/]$/, '')
190 isWindows = /^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')
191 history = []
192 stats.clear()
193 await $.command.register({ name: 'fm', description: 'ตัวจัดการไฟล์ภาษาไทย (/fm help)', argumentHint: '<คำสั่ง> [ไฟล์]' })
194 return next(e)
195 })
196
197 // ==========================================================================
198 // บทที่ 5: คำสั่งย่อยทั้งหมดของ /fm
199 // ==========================================================================
200 on('command.run', { command: 'fm' }, async ($, e) => {
201 const [sub = 'help', ...args] = splitArgs(e.args.trim())
202 const a0 = args[0] ?? ''
203 const a1 = args[1] ?? ''
204 const now = await $.clock.now()
205
206 try {
207 switch (sub) {
208 case 'help':
209 return { text: HELP }
210
211 case 'ls': {
212 const dir = a0 ? resolve(a0) : cwd
213 const entries = (await $.fs.list(dir)).sort((x, y) => (x.kind === y.kind ? x.name.localeCompare(y.name) : x.kind === 'dir' ? -1 : 1))
214 await record($, `ls ${relative(dir) || '.'}`, 'ls')
215 const rows = entries.map(en => `${icon(en)} ${en.name.padEnd(36)} ${en.kind === 'dir' ? '' : formatSize(en.size)}`)
216 return { text: [`📂 ${dir} (${entries.length} รายการ)`, ...rows].join('\n') }
217 }
218
219 case 'cat': {
220 if (!a0) return { text: 'วิธีใช้: /fm cat <ไฟล์>' }
221 const path = resolve(a0)
222 const st = await $.fs.stat(path)
223 if (st.size > 2 * 1024 * 1024) return { text: `❌ ไฟล์ใหญ่เกินไป (${formatSize(st.size)})` }
224 const lines = (await $.fs.read(path)).split('\n')
225 await record($, `cat ${relative(path)}`, 'cat')
226 const shown = lines.slice(0, 200).map((l, i) => `${String(i + 1).padStart(4)} ${l}`)
227 return { text: [`📄 ${relative(path)} (${lines.length} บรรทัด)`, ...shown, lines.length > 200 ? `... อีก ${lines.length - 200} บรรทัด` : ''].join('\n') }
228 }
229
230 case 'touch': {
231 if (!a0) return { text: 'วิธีใช้: /fm touch <ไฟล์>' }
232 const path = resolve(a0)
233 if (await $.fs.exists(path)) return { text: `ℹ️ มีไฟล์ ${relative(path)} อยู่แล้ว` }
234 await $.fs.write(path, '')
235 await record($, `touch ${relative(path)}`, 'touch')
236 return { text: `✨ สร้างไฟล์ ${relative(path)} แล้ว` }
237 }
238
239 case 'mkdir': {
240 if (!a0) return { text: 'วิธีใช้: /fm mkdir <โฟลเดอร์>' }
241 const path = resolve(a0)
242 await runOp($, 'mkdir', path)
243 await record($, `mkdir ${relative(path)}`, 'mkdir')
244 return { text: `📁 สร้างโฟลเดอร์ ${relative(path)} แล้ว` }
245 }
246
247 case 'mv':
248 case 'cp': {
249 if (!a0 || !a1) return { text: `วิธีใช้: /fm ${sub} <ต้นทาง> <ปลายทาง>` }
250 const [src, dst] = [resolve(a0), resolve(a1)]
251 if (!(await $.fs.exists(src))) return { text: `❌ ไม่พบ ${a0}` }
252 if (sub === 'mv' && isProtected(src)) return { text: `🔒 ${a0} เป็นไฟล์สำคัญ ย้ายด้วย /fm ไม่ได้` }
253 await runOp($, sub === 'mv' ? 'move' : 'copy', src, dst)
254 await record($, `${sub} ${relative(src)} -> ${relative(dst)}`, sub)
255 return { text: `${sub === 'mv' ? '🚚 ย้าย' : '📑 ก็อป'} ${relative(src)} -> ${relative(dst)} แล้ว` }
256 }
257
258 case 'rm': {
259 if (!a0) return { text: 'วิธีใช้: /fm rm <ไฟล์>' }
260 const path = resolve(a0)
261 if (!(await $.fs.exists(path))) return { text: `❌ ไม่พบ ${a0}` }
262 if (isProtected(path)) return { text: `🔒 ${a0} เป็นไฟล์สำคัญ ถ้าแน่ใจจริงๆ ใช้ /fm rm! ${a0}` }
263
264 // ย้ายเข้าถังขยะ ตั้งชื่อใหม่กันชื่อซ้ำ: 20261023-153000__ชื่อเดิม
265 const trashName = `${stamp(now)}__${baseName(path)}`
266 await runOp($, 'mkdir', `${cwd}${sep()}${TRASH_DIR}`)
267 await runOp($, 'move', path, `${cwd}${sep()}${TRASH_DIR}${sep()}${trashName}`)
268 await $.store.set(trashKey(), [...(await readTrash($)), { name: trashName, original: path, deletedAt: now }])
269 await record($, `rm ${relative(path)}`, 'rm')
270 return { text: `🗑️ ย้าย ${relative(path)} เข้าถังขยะแล้ว (/fm restore ${baseName(path)} เพื่อกู้คืน)` }
271 }
272
273 case 'rm!': {
274 if (!a0) return { text: 'วิธีใช้: /fm rm! <ไฟล์>' }
275 const path = resolve(a0)
276 if (!(await $.fs.exists(path))) return { text: `❌ ไม่พบ ${a0}` }
277 if (path.replace(/[\\/]$/, '') === cwd) return { text: '🔒 ไม่อนุญาตให้ลบโฟลเดอร์โปรเจกต์ทั้งก้อน' }
278 await runOp($, 'remove', path)
279 await record($, `rm! ${relative(path)}`, 'rm!')
280 return { text: `💥 ลบ ${relative(path)} ถาวรแล้ว${isProtected(path) ? ' (เป็นไฟล์สำคัญ!)' : ''}` }
281 }
282
283 case 'trash': {
284 const items = await readTrash($)
285 if (items.length === 0) return { text: '🗑️ ถังขยะว่าง' }
286 const rows = items.map(t => `🗑️ ${t.name.split('__').slice(1).join('__').padEnd(30)} จาก ${relative(t.original)} (${new Date(t.deletedAt).toLocaleString('th-TH')})`)
287 return { text: [`🗑️ ถังขยะ (${items.length} รายการ)`, ...rows].join('\n') }
288 }
289
290 case 'restore': {
291 if (!a0) return { text: 'วิธีใช้: /fm restore <ชื่อไฟล์>' }
292 const items = await readTrash($)
293 // ถ้ามีชื่อซ้ำหลายอัน เอาอันที่ลบล่าสุด
294 const item = [...items].reverse().find(t => t.name === a0 || t.name.endsWith(`__${a0}`))
295 if (!item) return { text: `❌ ไม่พบ ${a0} ในถังขยะ` }
296 if (await $.fs.exists(item.original)) return { text: `❌ มีไฟล์ ${relative(item.original)} อยู่แล้ว ย้ายออกก่อนแล้วค่อยกู้คืน` }
297 await runOp($, 'move', `${cwd}${sep()}${TRASH_DIR}${sep()}${item.name}`, item.original)
298 await $.store.set(trashKey(), items.filter(t => t !== item))
299 await record($, `restore ${relative(item.original)}`, 'restore')
300 return { text: `♻️ กู้คืน ${relative(item.original)} แล้ว` }
301 }
302
303 case 'empty-trash': {
304 const trashPath = `${cwd}${sep()}${TRASH_DIR}`
305 if (await $.fs.exists(trashPath)) await runOp($, 'remove', trashPath)
306 await $.store.delete(trashKey())
307 await record($, 'empty-trash', 'empty-trash')
308 return { text: '🧹 เทถังขยะแล้ว' }
309 }
310
311 case 'find':
312 case 'ext': {
313 if (!a0) return { text: `วิธีใช้: /fm ${sub} <${sub === 'find' ? 'ชื่อ' : 'นามสกุล'}>` }
314 const want = a0.toLowerCase().replace(/^\./, '')
315 const found: string[] = []
316 await walk($, cwd, async path => {
317 const name = baseName(path).toLowerCase()
318 if (sub === 'find' ? name.includes(want) : name.endsWith(`.${want}`)) found.push(relative(path))
319 return found.length < 100
320 })
321 await record($, `${sub} ${a0}`, sub)
322 return { text: found.length ? [`🔎 เจอ ${found.length} ไฟล์${found.length >= 100 ? ' (แสดงแค่ 100)' : ''}`, ...found.map(f => ` ${f}`)].join('\n') : `🔎 ไม่เจอไฟล์ที่ตรงกับ "${a0}"` }
323 }
324
325 case 'grep': {
326 if (!a0) return { text: 'วิธีใช้: /fm grep <คำ>' }
327 const word = args.join(' ')
328 const hits: string[] = []
329 await walk($, cwd, async (path, entry) => {
330 if (entry.size > 1024 * 1024 || /\.(png|jpe?g|gif|webp|zip|pdf|exe|dll|woff2?)$/i.test(path)) return
331 const text = await $.fs.read(path).catch(() => '')
332 text.split('\n').forEach((line, i) => {
333 if (hits.length < 50 && line.includes(word)) hits.push(` ${relative(path)}:${i + 1} ${line.trim().slice(0, 100)}`)
334 })
335 return hits.length < 50
336 })
337 await record($, `grep ${word}`, 'grep')
338 return { text: hits.length ? [`🔎 เจอ "${word}" ${hits.length} ที่${hits.length >= 50 ? ' (แสดงแค่ 50)' : ''}`, ...hits].join('\n') : `🔎 ไม่เจอ "${word}"` }
339 }
340
341 case 'backup': {
342 if (!a0) return { text: 'วิธีใช้: /fm backup <ไฟล์>' }
343 const path = resolve(a0)
344 if (!(await $.fs.exists(path))) return { text: `❌ ไม่พบ ${a0}` }
345 const dst = `${path}.bak-${stamp(now)}`
346 await runOp($, 'copy', path, dst)
347 await record($, `backup ${relative(path)}`, 'backup')
348 return { text: `💾 สำรองเป็น ${relative(dst)} แล้ว` }
349 }
350
351 case 'fav': {
352 const favs = await readFavs($)
353 if (!a0) return { text: favs.length ? ['⭐ ไฟล์โปรด', ...favs.map(f => ` ⭐ ${f}`)].join('\n') : '⭐ ยังไม่มีไฟล์โปรด (/fm fav <ไฟล์> เพื่อเพิ่ม)' }
354 const path = resolve(a0)
355 if (favs.includes(path)) {
356 await $.store.set('favs', favs.filter(f => f !== path))
357 return { text: `☆ เอา ${relative(path)} ออกจากไฟล์โปรดแล้ว` }
358 }
359 await $.store.set('favs', [...favs, path])
360 return { text: `⭐ เพิ่ม ${relative(path)} เป็นไฟล์โปรดแล้ว` }
361 }
362
363 case 'history':
364 return { text: history.length ? ['📜 ประวัติ (session นี้)', ...[...history].reverse().slice(0, 30).map(h => ` ${new Date(h.time).toLocaleTimeString('th-TH')} ${h.action}`)].join('\n') : '📜 ยังไม่มีประวัติ' }
365
366 case 'stats': {
367 const rows = [...stats.entries()].sort((x, y) => y[1] - x[1]).map(([op, c]) => ` ${op.padEnd(12)} ${c} ครั้ง`)
368 const trash = await readTrash($)
369 return { text: ['📊 สถิติ (session นี้)', ...(rows.length ? rows : [' ยังไม่ได้ใช้คำสั่ง']), `🗑️ ในถังขยะ: ${trash.length} | ⭐ ไฟล์โปรด: ${(await readFavs($)).length}`].join('\n') }
370 }
371
372 default:
373 return { text: `❌ ไม่รู้จักคำสั่ง "${sub}"\n\n${HELP}` }
374 }
375 } catch (err) {
376 return { text: `❌ ${sub} ไม่สำเร็จ: ${err instanceof Error ? err.message : String(err)}` }
377 }
378 })
379}
380
381/**
382 * ========================================================================
383 * การบ้าน / ต่อยอด
384 * ========================================================================
385 * 1. ทำแผง (Pane) file explorer มีปุ่มกดเข้าโฟลเดอร์ (รวม Mod 04 + Mod 10)
386 * 2. ให้ Claude ใช้ได้ด้วย: $.tool.register เป็น tool "trash" แทนการ rm -rf
387 * 3. ดัก tool.call ของ Bash ที่มี "rm " แล้วแนะนำให้ใช้ /fm rm แทน (แบบ Mod 03)
388 */
389