SLOPSHOPPER

file-manager-th

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

newcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-manager-th
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /fm ⎿ file-manager-th: 🗂️ File Manager TH (Mod 10) ⎿ file-manager-th: ---------------------------------------- ⎿ file-manager-th: /fm ls [path] ดูไฟล์ในโฟลเดอร์ ⎿ file-manager-th: /fm cat <ไฟล์> อ่านไฟล์ (200 บรรทัดแรก) ⎿ file-manager-th: /fm touch <ไฟล์> สร้างไฟล์เปล่า ⎿ file-manager-th: /fm mkdir <โฟลเดอร์> สร้างโฟลเดอร์ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Mod 10: File Manager TH

จัดการไฟล์ด้วยคำสั่ง /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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. อ่าน เขียน และ list ไฟล์ด้วย $.fs
  2. ย้าย ก็อป และลบด้วย $.process.run โดยส่ง path ผ่าน env (FM_SRC, FM_DST) กัน command injection และชื่อไฟล์ที่มีช่องว่าง
  3. รองรับ Windows (PowerShell) และ macOS/Linux (sh)
  4. เดินดูทั้งโปรเจกต์แบบจำกัดจำนวน (ข้าม node_modules, .git)

หมายเหตุ

ชื่อที่มีช่องว่างให้ใส่ในเครื่องหมายคำพูด เช่น /fm mv "old name.txt" new.txt

Source 1 files
hooks/register.tsx 389 lines
1/**
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