SLOPSHOPPER

mods

A Mods pane that finds every mod on this Mac by itself, also the ones installed from a marketplace, turns each one on or off in every chat, and opens the…

newpanecommandprocesstimer
v0.3.0no licenseupdated 2026-10-26Anirach/Anirach.github.io/.claude/skills/mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Cache Clock [ show-cache- ⏺ Read(src/auth.ts) │ ┃ Counts down until the cache g… ⎿ Read 6 lines │ ┃ /cacheclock to show or hide ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Handoff (Refresh) [ show-handof ⏺ Bash(bun test) │ ┃ Moves a long chat to a fresh … ⎿ 3 pass, 1 fail │ ┃ /refresh to show it │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Screen-Share Mode [ toggle-sc │ ┃ Hides emails, phones and amou… ✻ Worked for 42s · done 4:20 PM │ ┃ /screen-share to hide private… │ ┃ › /mods │ ┃ Brain-Dump Shaper [ toggle- ⎿ mods: Opened the Mods pane. │ ┃ Turns long rambling prompts i… │ ┃ │ ┃ Reflect [ togg │ ┃ Saves your corrections as las… │ ┃ │ ┃ Helper Desk [ Open ] [ sh │ ┃ Task progress bar above the p… │ ┃ /helpers to open │ ┃ │ ┃ Chat Inventory [ Open ] [ sh │ ┃ Connectors, files and skills … ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
Cache Clock [ show-cache-clock ] [ toggle Counts down until the cache g… /cacheclock to show or hide Handoff (Refresh) [ show-handoff ] [ toggle-han Moves a long chat to a fresh … /refresh to show it Screen-Share Mode [ toggle-screen-share ] Hides emails, phones and amou… /screen-share to hide private… Brain-Dump Shaper [ toggle-brain-dump ] Turns long rambling prompts i… Reflect [ toggle-reflect ] Saves your corrections as las… Helper Desk [ Open ] [ show-helper-desk ] Task progress bar above the p… /helpers to open Chat Inventory [ Open ] [ show-chat-inventor Connectors, files and skills … /inventory to open Off means off in every chat. Hidden keeps it on but out of sight. Changes apply within 3 seconds. New mods appear here by themselves.
README

anirach.com

Current publication inventory — 2026-10-24: 135 bilingual posts across10 series (7 Tutorials /96 articles,3 Thoughts /39 essays),147 HTML files (146 enumerated), 466 images,135 feed items and146 sitemap URLs. AI-Core Functional Workflows adds twelve lessons paired with Anirach/ai-core-functional-tutorial,108 offline tests and twelve exercises. Earlier dated measurements below retain their historical context.

The personal site of Dr. Anirach Mingkhwan — Associate Professor at King Mongkut's University of Technology North Bangkok (KMUTNB), PhD (Liverpool John Moores University, UK), researcher in Agentic AI, computer networks, and big data analytics.

🌐 Live: anirach.com

A hand-written static site — seven top-level pages, four per-book detail pages, and 135 self-contained blog posts catalogued on two pages: Tutorials (/blog/, seven technical series) and Thoughts (/thoughts/, thirty-nine essays on living well and working well). No build system, no package manager, no dependencies, no JavaScript framework. Push to main and GitHub Pages publishes it.


At a glance

PagesHome · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates
Posts135, in 10 series across two catalogs, every one bilingual Thai/English
Files147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded)
StackPlain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency
HostingGitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare
Build stepNone
TestsFour scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite

The seven pages

PageWhat it holds
index.htmlSingle-scroll consultant profile: hero, at-a-glance stats, six services, engagement approach, selected work, about and career, research, free resources, latest news with the featured book, contact
blog/The Tutorials catalog — 96 cards in seven technical series, plus all 135 post files
thoughts/The Thoughts catalog — 39 essay cards in three series, each linking ../blog/<slug>.html
publications/The academic record: the Springer book Libraries in Transformation, 8 book chapters, and a selected-publications table
books/Books & writing: four works — One Day of Light (the free last-lecture event book, EN/TH editions with free PDF downloads served from books/), the published novel Three Old Men: The Last Conversation, and two complete bilingual manuscripts — each with its own detail page
projects/Live apps (each verified working before it ships) and research-code repositories
news/Reverse-chronological timeline of publications, talks and appointments, plus a career timeline

The posts never move. Every post is blog/<slug>.html, including the 39 essays that /thoughts/ catalogues — the 2026-09-08 split separated the two catalogs, not the files.

books/ was one "Books & Writing" page until 2026-08-23, when it split: academic content moved to the new publications/, and books/ became the fiction section with one detail page per book. On 2026-08-24 it gained a fourth work, the Last Lecture companion book One Day of Light, whose EN/TH PDFs are downloadable for free from books/.

script.js is gone — deleted 2026-08-26. The site loads no executable JavaScript at all, and check_site.py INV-38 fails the build on any <script> that is not application/ld+json (schema.org metadata, which the browser parses as data and never executes — 128 pages carry one). Each of the script's four jobs has a CSS replacement in style.css: the scroll-reveal and the nav scroll state are scroll-driven CSS animations (animation-timeline: view() / scroll(root)), the landing page's mobile menu is .nav__links:target, and anchor scrolling is native scroll-behavior: smooth. The other pages' mobile menu is a pure-CSS checkbox toggle — a second mechanism, also without JavaScript.

The two catalogs

Both are static, zero-JavaScript and generated by scripts/reindex_blog.py — re-run it rather than hand-ordering cards. Each is a hero, a sticky chip strip, a "Start here" feature, a row of series tiles, then one numbered-row-card section per series. There is no filtering, sorting or search UI.

Tutorials — /blog/, hero reads 7 Series · 96 Articles

SeriesPostsNavigation
⧉ AI-Core Functional Workflows12generated12-chip strip · GitHub code, tests and exercises
🛡️ Engineering AI-Core Systems10chip strip · generated
💻 Hermes Desktop Hands-On7chip strip · generated
🧭 AI Transformation for Organizations20grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated
⚡ Hermes Agent in Practice10chip strip
🤖 OpenClaw for Organizations13seven are a numbered chip-strip series; six stand alone with no nav
🔧 DevOps & Vibe Coding24a single prev/next chain

Thoughts — /thoughts/, hero reads 3 Series · 39 Essays

SeriesEssaysNavigation
🌱 Wisdom for a Good Life20grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated
🧭 Working Philosophies10chip strip, one working week Monday to Friday · generated
🌅 Life Thought & Philosophy9chip strip, one day in three parts (Morning · Noon · Twilight)

Five of the nine series are generated from a manifest plus per-post content sheets: python3 scripts/build_series.py --series <name>, with manifests in scripts/series/ (ai-core, ai-transformation, good-life, hermes-desktop, working). For those, the manifest and the sheets are the edit surface — never the emitted post.

Every post is bilingual. All 123 carry a pure-CSS Thai ⇄ English switch: a ไทย · English pill in the hero swaps between two complete tracks of the same article. Thai is the default, there is no JavaScript, and the page-level <html lang="th"> never changes — the English track is lang="en" inside it. The seven top-level pages and the four book detail pages are lang="en" chrome and wrap their Thai passages in <span lang="th">.

The Thoughts essays are written in a different register from the tutorials — literary Thai in the author's own voice, not code-switched technical prose. The brief, with ten rules and worked before/after pairs, is .claude/skills/blog-post/references/thai-essay-voice.md.

Repository structure

.
├── index.html          # Landing page — the ONLY file that loads style.css
├── style.css           # Landing-page styles; carries the canonical :root token block
├── _config.yml         # Jekyll: keeps internal working docs out of the published site
├── CNAME               # anirach.com
├── sitemap.xml         # 134 URLs, generated · feed.xml — 123 items, generated · llms.txt · robots.txt
├── blog/               # index.html (the Tutorials catalog, 96 cards) + all 123 self-contained posts
├── thoughts/           # index.html (the Thoughts catalog — 39 essay cards linking ../blog/)
├── books/              # index.html + 4 per-book detail pages + 3 free PDFs, all self-contained
├── publications/  projects/  news/   # one self-contained index.html each
├── images/             # 431 files — 124 covers (800×800) + 8 book faces, 123 card thumbs (144×144),
│                       #   130 share cards (1200×630), diagrams, series figures, posters, profile photo
├── scripts/            # 13 generators and sweeps (build_series, reindex_blog, gen_feed, gen_sitemap,
│                       #   make_cover, make_figure, bilingualize, check_visibility, …)
├── docs/               # design spec, implementation plan, OpenClaw runbook (not published)
└── .claude/skills/     # the four maintenance skills (not published)

Architecture: every page is an island

The single most important fact before editing:

index.html ──▶ style.css                 (the ONLY consumer of it)

blog/index.html   ──▶ its own <style> block, no JS
blog/<post>.html  ──▶ its own <style> block, no JS     × 123
thoughts/ books/ (index + 4 detail) publications/ projects/ news/ 404.html ──▶ same   × 10

Every page embeds its complete stylesheet. There is no shared partial, template engine or token file, so a "global" change means editing N files by hand — and nothing tells you when file 23 of 135 got missed. That is what the linter is for. The compensating upside: blast radius is exactly one file.

Local development

No install, no build. Serve the repository root:

python3 -m http.server 8000     # → http://localhost:8000

Serve the root, not blog/. Every blog page references images as ../images/….

Known local-dev trap. The eight chip-strip series — 93 posts in all — link to each other with root-absolute, extensionless URLs (/blog/openclaw-101). GitHub Pages resolves those; http.server does not and returns 404. That is not a regression.

Verification

Run all four from the repo root before pushing. All must pass:

python3 .claude/skills/site-check/scripts/check_site.py      # 62 cross-file integrity checks
python3 scripts/check_visibility.py --strict                 # search/AI-visibility, JSON-LD, metadata
python3 docs/openclaw/check-news-sync.py                     # news↔homepage sync, provenance, 4 counters
python3 .claude/skills/blog-post/assets/verify-wiring.py     # blog post wiring

check_site.py runs 62 checks — 43 fail-severity, 17 warn, 2 info — and exits 0 when no fail-severity violation appears. Its baseline of pre-existing debt is empty since 2026-08-26 — the tree is clean (0 new, 0 known), so any violation it prints is new and any fail-severity one blocks the push. INV-25 fails the build if a baseline entry is ever added and goes stale, so the safety net cannot silently rot. INV-26 (added with the books split) ties every section-directory detail page to its own index.html: an orphan detail page, or an index card linking a file that does not exist, fails the build.

check-news-sync.py verifies the homepage strip mirrors the three newest news items, that every news item carries a <!-- source: … --> provenance comment, and that all four hand-typed counters match reality: news/index.html "7 updates", publications/index.html "8 chapters", and books/index.html "1 novel" and "2 complete" (it strips HTML comments first, so a commented-out card cannot satisfy a counter).

feed.xml and sitemap.xml are generated, not hand-written — scripts/gen_feed.py and scripts/gen_sitemap.py. INV-31 and INV-32 fail the build when either drifts from the catalogs. Run the sitemap in the commit after a content change, so the git lastmod dates exist.

Deployment

git push origin main

That is the whole pipeline; GitHub Pages rebuilds in 1–3 minutes. _config.yml excludes docs/ and CLAUDE.md from the published output — they stay in the repository but are not served. Anything beginning with . or _ (including .claude/) is omitted by Jekyll automatically.

Cloudflare caches HTML for 10 minutes and assets for 4 hours. After a deploy, append ?cb=$RANDOM to check the real state before diagnosing a problem that is not there — and remember your own browser holds its copy for the same 10 minutes, so a stale tab needs a hard reload rather than a bug report.

Performance notes

Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:

  • **Fonts are requested with the variable range syntax. Inter:wght@300..900 gets one variable file; the semicolon form Inter:wght@300;400;…;900 makes the API serve one static file per weight — 7 files and 331 KB for the latin subset against 1 file and 47 KB. Switching saved 189 KB per page, 56% of the font bytes. Sarabun keeps the semicolon form**: it has no variable version, a range request returns HTTP 400, and all five of its static weights are used.
  • Catalog cards ship a 144px derivative through srcset. The row card renders its cover in a 72px box, so an 800×800 source was ~11× oversampled. /blog/ went from 3.63 MB to 0.42 MB, /thoughts/ from 2.11 MB to 0.38 MB.

Both are documented with their measurements in the a11y-perf skill (R8b and R1b).

Adding a new blog post

A post is not one file — it is a file plus a cover plus a share card plus a card plus counters plus its neighbours' nav links plus a feed item, a sitemap entry and an llms.txt line, in two language tracks, with no build step to catch a miss. The full recipe, including which of the four navigation patterns applies, is in .claude/skills/blog-post/SKILL.md. Start from .claude/skills/page-design/assets/post-template.html — the repo's single canonical template. For one of the five generated series, add a manifest row and run build_series.py instead; never hand-edit those posts.

Maintenance

Four skills in .claude/skills/ carry the verified detail:

SkillCovers
page-designThe house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by
blog-postAdding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief
a11y-perfAccessibility and performance rules with this site's real measured numbers
site-checkThe linter, what every check means, and how to repair each failure

Every number in those skills is load-bearing: a change that invalidates one must update it in the same commit. A confidently wrong measurement is worse than none, because it gets acted on.

News updates have their own runbook at docs/openclaw/latest-updates-runbook.md, written for an autonomous agent. Its gate enforces that every news item carries a verified source — no structural check can tell a true item from an invented one, so instead an item cannot exist without a stated source.

Contact

Open to research collaborations, speaking engagements, and academic partnerships.


No LICENSE file is present. Content and code are © Anirach Mingkhwan; all rights reserved by default.

AI-Core Functional Workflows

Twelve bilingual lessons and the matching code are maintained together: Anirach/ai-core-functional-tutorial. The offline lab ZIP is downloads/ai-core-functional-lab.zip. The series manifest is scripts/series/ai-core-functional.json; article source links pin the reviewed companion commit. Default fixtures require no SDK install, API key or paid calls. Run the companion tests/advertised commands and the four website checks when revising. No additional reuse license is granted by the companion's public availability.

Source 2 files
hooks/register.tsx 482 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// The Mods pane: one row per knowledge-work mod, with an On/Off switch that works
4// in every chat (switches.json). Mods that draw something also get Shown/Hidden,
5// kept as "<name>:hidden": true in the same file. Showing a side pane, or the
6// Refresh panel, also leaves an open request for this chat.
7// The list is found on disk, not typed in: every folder under ~/.claude/mods with a
8// plugin.json and a hooks.json that has "modules", every CLAUDE_CODE_PLUGIN_DIRS
9// folder, and this chat's own ~/.claude/dev-mods/<session id>/ mods.
10
11const PANE = 'mods'
12const TITLE = 'Mods'
13const NOTE = { plugin: 'mods', key: 'note' } as const
14const POLL_MS = 3000
15const SCAN_MS = 10_000
16const BUTTONS_COLS = 26
17
18const GOOD = '#16a34a'
19
20// hide: has a Shown/Hidden switch. ask: Show also leaves an open request. tip: the slash command.
21type Mod = { name: string; title: string; what: string; pane: boolean; hide: boolean; ask: boolean; tip: string }
22// A row in the pane: a known mod or one found on disk. switchable: its source reads switches.json.
23type Row = Mod & { switchable: boolean; chatOnly: boolean }
24
25const MODS: readonly Mod[] = [
26  { name: 'cache-clock', title: 'Cache Clock', what: 'Counts down until the cache goes cold', pane: false, hide: true, ask: false, tip: '/cacheclock to show or hide' },
27  { name: 'handoff', title: 'Handoff (Refresh)', what: 'Moves a long chat to a fresh one', pane: false, hide: true, ask: true, tip: '/refresh to show it' },
28  { name: 'screen-share', title: 'Screen-Share Mode', what: 'Hides emails, phones and amounts on screen', pane: false, hide: false, ask: false, tip: '/screen-share to hide private data' },
29  { name: 'brain-dump', title: 'Brain-Dump Shaper', what: 'Turns long rambling prompts into clear briefs', pane: false, hide: false, ask: false, tip: '' },
30  { name: 'reflect', title: 'Reflect', what: 'Saves your corrections as lasting rules', pane: false, hide: false, ask: false, tip: '' },
31  { name: 'helper-desk', title: 'Helper Desk', what: 'Task progress bar above the prompt, plus a card per subagent', pane: true, hide: true, ask: true, tip: '/helpers to open' },
32  { name: 'chat-inventory', title: 'Chat Inventory', what: 'Connectors, files and skills this chat used', pane: true, hide: true, ask: true, tip: '/inventory to open' },
33]
34
35// Module variables reset on a reload; the switches themselves live in the file.
36let sid = ''
37let lastRaw = ''
38let cache: { at: number; rows: Row[] } | null = null
39
40const NOT_SWITCHABLE = 'Not switchable yet. Ask Claude: add the /mods switch to this mod.'
41
42const homeOf = ($: EngineInterface) => $.plugin.root.split('/.claude/')[0] ?? ''
43const switchesPath = ($: EngineInterface) => `${homeOf($)}/.claude/mods/switches.json`
44
45function cut(s: string, n: number) {
46  if (n <= 0) return ''
47  return s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}…`
48}
49
50async function log($: EngineInterface, where: string, err: unknown) {
51  try {
52    const dir = `${homeOf($)}/.claude/mods-logs`
53    const file = `${dir}/mods.log`
54    await $.process.run(['mkdir', '-p', dir])
55    let old = ''
56    try {
57      old = String(await $.fs.read(file))
58    } catch {
59      old = ''
60    }
61    const when = new Date(await $.clock.now()).toISOString()
62    const msg = err instanceof Error ? err.message : String(err)
63    await $.fs.write(file, `${old}${when} ${where}: ${msg}\n`.slice(-50_000))
64  } catch {
65    // Nowhere left to report it.
66  }
67}
68
69async function readRaw($: EngineInterface) {
70  try {
71    const path = switchesPath($)
72    if (!(await $.fs.exists(path))) return ''
73    return String(await $.fs.read(path))
74  } catch {
75    return ''
76  }
77}
78
79function parse(raw: string): Record<string, unknown> {
80  try {
81    const json = JSON.parse(raw) as unknown
82    return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
83  } catch {
84    return {}
85  }
86}
87
88async function readSwitches($: EngineInterface) {
89  return parse(await readRaw($))
90}
91
92const isOff = (json: Record<string, unknown>, name: string) => json[name] === false
93const isHidden = (json: Record<string, unknown>, name: string) => json[`${name}:hidden`] === true
94
95async function save($: EngineInterface, json: Record<string, unknown>) {
96  await $.process.run(['mkdir', '-p', `${homeOf($)}/.claude/mods`])
97  const text = `${JSON.stringify(json, null, 2)}\n`
98  await $.fs.write(switchesPath($), text)
99  lastRaw = text
100}
101
102// Read, change, write the whole file. Off is `false`; on deletes the key.
103async function flip($: EngineInterface, name: string) {
104  const json = await readSwitches($)
105  const turnOff = !isOff(json, name)
106  if (turnOff) json[name] = false
107  else delete json[name]
108  await save($, json)
109  return turnOff
110}
111
112// Hidden is `"<name>:hidden": true`; shown deletes the key.
113async function flipHidden($: EngineInterface, name: string) {
114  const json = await readSwitches($)
115  const key = `${name}:hidden`
116  const hide = !isHidden(json, name)
117  if (hide) json[key] = true
118  else delete json[key]
119  await save($, json)
120  return hide
121}
122
123async function askOpen($: EngineInterface, name: string) {
124  if (!sid) throw new Error('no session id')
125  await $.fs.write(`${homeOf($)}/.claude/mods/.open-${name}-${sid}`, '')
126}
127
128async function readText($: EngineInterface, path: string) {
129  try {
130    if (!(await $.fs.exists(path))) return ''
131    return String(await $.fs.read(path))
132  } catch {
133    return ''
134  }
135}
136
137// Folders (or links to folders) directly inside dir. A missing dir is an empty list.
138async function listDirs($: EngineInterface, dir: string) {
139  try {
140    const entries = await $.fs.list(dir)
141    return entries.filter(x => !x.name.startsWith('.') && (x.kind === 'dir' || x.isLink)).map(x => `${dir}/${x.name}`)
142  } catch {
143    return []
144  }
145}
146
147// Plugins installed from a marketplace: ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>, newest version name last.
148async function cacheDirs($: EngineInterface, home: string) {
149  const out: string[] = []
150  for (const market of await listDirs($, `${home}/.claude/plugins/cache`)) {
151    for (const plugin of await listDirs($, market)) {
152      const versions = (await listDirs($, plugin)).sort()
153      const last = versions[versions.length - 1]
154      if (last) out.push(last)
155    }
156  }
157  return out
158}
159
160// CLAUDE_CODE_PLUGIN_DIRS: folders split by ':', '~' meaning the home folder.
161async function envDirs($: EngineInterface) {
162  let raw: string | undefined
163  try {
164    raw = await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')
165  } catch {
166    return []
167  }
168  if (!raw) return []
169  const home = homeOf($)
170  return raw
171    .split(':')
172    .map(x => x.trim())
173    .filter(Boolean)
174    .map(x => (x === '~' ? home : x.startsWith('~/') ? `${home}${x.slice(1)}` : x))
175    .map(x => x.replace(/\/+$/, ''))
176}
177
178function titleCase(name: string) {
179  return name
180    .split(/[-_\s]+/)
181    .filter(Boolean)
182    .map(w => w.charAt(0).toUpperCase() + w.slice(1))
183    .join(' ')
184}
185
186// Every hooks source file, tests and backups left out.
187async function hooksSource($: EngineInterface, dir: string) {
188  let text = ''
189  try {
190    const entries = await $.fs.list(`${dir}/hooks`)
191    for (const x of entries) {
192      if (x.kind !== 'file' || !/\.(tsx?|jsx?|mjs|cjs)$/.test(x.name) || /\.test\./.test(x.name)) continue
193      if (x.size > 2_000_000) continue
194      text += `${await readText($, `${dir}/hooks/${x.name}`)}\n`
195    }
196  } catch {
197    // An unreadable hooks folder means nothing is found in it.
198  }
199  return text
200}
201
202function commandsIn(source: string) {
203  const out: string[] = []
204  for (const m of source.matchAll(/command\.register\(\s*\{\s*name:\s*['"`]([^'"`]+)['"`]/g)) {
205    const name = m[1] ?? ''
206    if (name && !out.includes(name)) out.push(name)
207  }
208  return out
209}
210
211// A mod is a plugin.json plus a hooks.json with "modules". Anything else is not one.
212async function inspect($: EngineInterface, dir: string, chatOnly: boolean): Promise<Row | null> {
213  const pluginRaw = await readText($, `${dir}/.claude-plugin/plugin.json`)
214  if (!pluginRaw) return null
215  const hooksJson = parse(await readText($, `${dir}/hooks/hooks.json`))
216  if (!('modules' in hooksJson)) return null
217  const plugin = parse(pluginRaw)
218  const folder = dir.split('/').pop() ?? ''
219  const name = typeof plugin.name === 'string' && plugin.name ? plugin.name : folder
220  if (!name || name === 'mods') return null
221  const source = await hooksSource($, dir)
222  const known = MODS.find(m => m.name === name)
223  const commands = commandsIn(source).map(c => `/${c}`)
224  return {
225    name,
226    title: known?.title ?? titleCase(name),
227    what: known?.what ?? (typeof plugin.description === 'string' ? plugin.description : ''),
228    tip: known?.tip ?? commands.join(', '),
229    pane: known?.pane ?? false,
230    hide: known?.hide ?? false,
231    ask: known?.ask ?? false,
232    switchable: source.includes('switches.json'),
233    chatOnly,
234  }
235}
236
237// Known mods first in their old order, then new ones A to Z, then this chat's own.
238async function scan($: EngineInterface): Promise<Row[]> {
239  const home = homeOf($)
240  const found = new Map<string, Row>()
241  const dirs: [string, boolean][] = []
242  for (const d of await listDirs($, `${home}/.claude/mods`)) dirs.push([d, false])
243  for (const d of await envDirs($)) dirs.push([d, false])
244  for (const d of await cacheDirs($, home)) dirs.push([d, false])
245  if (sid) for (const d of await listDirs($, `${home}/.claude/dev-mods/${sid}`)) dirs.push([d, true])
246  const seen = new Set<string>()
247  for (const [dir, chatOnly] of dirs) {
248    if (seen.has(dir)) continue
249    seen.add(dir)
250    const row = await inspect($, dir, chatOnly)
251    if (row && !found.has(row.name)) found.set(row.name, row)
252  }
253  const rows: Row[] = MODS.map(m => {
254    const hit = found.get(m.name)
255    found.delete(m.name)
256    return { ...m, switchable: hit ? hit.switchable : true, chatOnly: hit ? hit.chatOnly : false }
257  })
258  const rest = [...found.values()].sort((a, b) => a.title.localeCompare(b.title))
259  return [...rows, ...rest.filter(r => !r.chatOnly), ...rest.filter(r => r.chatOnly)]
260}
261
262// The scan is kept for 10 seconds, so drawing stays fast.
263async function getRows($: EngineInterface, force = false) {
264  const now = await $.clock.now()
265  if (!force && cache && now - cache.at < SCAN_MS) return cache.rows
266  const rows = await scan($)
267  cache = { at: now, rows }
268  return rows
269}
270
271const sigOf = (rows: readonly Row[]) => rows.map(r => `${r.name}|${r.title}|${r.what}|${r.tip}|${r.switchable}|${r.chatOnly}`).join('\n')
272
273// While the pane is open, look again every 10 seconds and redraw when the list changed.
274async function scanPoll($: EngineInterface) {
275  let open = false
276  try {
277    open = (await $.ui.panes()).some(p => p.id === PANE)
278  } catch {
279    open = false
280  }
281  if (!open) return
282  const before = sigOf(cache?.rows ?? [])
283  const rows = await getRows($, true)
284  if (sigOf(rows) !== before) $.ui.invalidate('ui.render')
285}
286
287// Another chat may flip a switch: redraw when the file changes.
288async function poll($: EngineInterface) {
289  const raw = await readRaw($)
290  if (raw === lastRaw) return
291  lastRaw = raw
292  $.ui.invalidate('ui.render')
293}
294
295export const register: Register = on => {
296  on('session.start', async ($, e, next) => {
297    try {
298      sid = await $.session.id()
299      cache = null
300      await $.command.register({ name: 'mods', description: 'Open the Mods pane: turn each mod on or off, or show or hide it, in every chat', immediate: true })
301      lastRaw = await readRaw($)
302      $.clock.every(POLL_MS, () => {
303        void poll($).catch(err => log($, 'poll', err))
304      })
305      $.clock.every(SCAN_MS, () => {
306        void scanPoll($).catch(err => log($, 'scan', err))
307      })
308    } catch (err) {
309      await log($, 'session start', err)
310    }
311    return next(e)
312  })
313
314  on('command.run', { command: 'mods' }, async $ => {
315    try {
316      await getRows($)
317    } catch (err) {
318      await log($, 'scan on open', err)
319    }
320    await $.ui.open({ id: PANE, title: TITLE })
321    return { text: 'Opened the Mods pane.' }
322  })
323
324  // Presses come here, not to onPress: a closure lives for one drawing only.
325  on('ui.press', { plugin: 'mods', element: /^toggle-.+$/ }, async ($, e) => {
326    const name = e.element.slice('toggle-'.length)
327    const mod = (await getRows($)).find(m => m.name === name && m.switchable)
328    if (!mod) return { element: e.element }
329    try {
330      const nowOff = await flip($, name)
331      await $.state.set(NOTE, `${mod.title} is ${nowOff ? 'off' : 'on'} in every chat.`)
332    } catch (err) {
333      await log($, `toggle ${name}`, err)
334      await $.state.set(NOTE, `Could not change ${mod.title}. See ~/.claude/mods-logs/mods.log`)
335    }
336    return { element: e.element }
337  })
338
339  on('ui.press', { plugin: 'mods', element: /^show-.+$/ }, async ($, e) => {
340    const name = e.element.slice('show-'.length)
341    const mod = (await getRows($)).find(m => m.name === name && m.hide && m.switchable)
342    if (!mod) return { element: e.element }
343    try {
344      if (isOff(await readSwitches($), name)) {
345        await $.state.set(NOTE, `${mod.title} is off. Turn it on first.`)
346        return { element: e.element }
347      }
348      const nowHidden = await flipHidden($, name)
349      if (!nowHidden && mod.ask) await askOpen($, name)
350      await $.state.set(NOTE, `${mod.title} is ${nowHidden ? 'hidden' : 'shown'} in every chat.`)
351    } catch (err) {
352      await log($, `show ${name}`, err)
353      await $.state.set(NOTE, `Could not change ${mod.title}. See ~/.claude/mods-logs/mods.log`)
354    }
355    return { element: e.element }
356  })
357
358  // Open: for a side pane that is On and Shown, asks this chat to open it again.
359  on('ui.press', { plugin: 'mods', element: /^open-.+$/ }, async ($, e) => {
360    const name = e.element.slice('open-'.length)
361    const mod = (await getRows($)).find(m => m.name === name && m.pane && m.switchable)
362    if (!mod) return { element: e.element }
363    try {
364      const json = await readSwitches($)
365      if (isOff(json, name) || isHidden(json, name)) {
366        await $.state.set(NOTE, `${mod.title} is ${isOff(json, name) ? 'off' : 'hidden'}. Turn it on and show it first.`)
367        return { element: e.element }
368      }
369      await askOpen($, name)
370      await $.state.set(NOTE, `${mod.title} opens in this chat within 3 seconds.`)
371    } catch (err) {
372      await log($, `open ${name}`, err)
373      await $.state.set(NOTE, `Could not open ${mod.title}. See ~/.claude/mods-logs/mods.log`)
374    }
375    return { element: e.element }
376  })
377
378  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
379    const { Box, Text, Button } = $.ui.resolve(e)
380    const noop = () => undefined
381    const note = (await $.state.get(NOTE)).value ?? ''
382    const json = await readSwitches($)
383    const cols = Math.max(24, e.props.bodyColumns)
384    const left = Math.max(10, cols - BUTTONS_COLS)
385
386    let list: Row[] = []
387    try {
388      list = await getRows($)
389    } catch (err) {
390      await log($, 'scan', err)
391      list = MODS.map(m => ({ ...m, switchable: true, chatOnly: false }))
392    }
393
394    const rows = list.map(m => {
395      const off = m.switchable && isOff(json, m.name)
396      const hidden = isHidden(json, m.name)
397      const width = m.switchable ? left : cols
398      const about = (
399        <Box flexDirection="column" width={width}>
400          <Text bold dimColor={off} wrap="truncate-end">
401            {cut(m.title, width)}
402          </Text>
403          {m.what ? (
404            <Text dimColor wrap="truncate-end">
405              {cut(m.what, width)}
406            </Text>
407          ) : null}
408          {m.tip ? (
409            <Text dimColor italic wrap="truncate-end">
410              {cut(m.tip, width)}
411            </Text>
412          ) : null}
413          {m.chatOnly ? (
414            <Text dimColor italic wrap="truncate-end">
415              This chat only
416            </Text>
417          ) : null}
418          {m.switchable ? null : (
419            <Text dimColor wrap="wrap">
420              {NOT_SWITCHABLE}
421            </Text>
422          )}
423        </Box>
424      )
425      if (!m.switchable) {
426        return (
427          <Box key={`row-${m.name}`} flexDirection="row" marginTop={1}>
428            {about}
429          </Box>
430        )
431      }
432      return (
433        <Box key={`row-${m.name}`} flexDirection="row" justifyContent="space-between" gap={1} marginTop={1}>
434          {about}
435          <Box flexDirection="row" gap={1}>
436            {m.pane ? (
437              off || hidden ? (
438                <Text dimColor>Open</Text>
439              ) : (
440                <Button key={`open-${m.name}`} onPress={noop}>
441                  Open
442                </Button>
443              )
444            ) : null}
445            {m.hide ? (
446              off ? (
447                <Text dimColor>{hidden ? 'Hidden' : 'Shown'}</Text>
448              ) : (
449                <Button key={`show-${m.name}`} onPress={noop}>
450                  {hidden ? <Text dimColor>Hidden</Text> : <Text color={GOOD}>Shown</Text>}
451                </Button>
452              )
453            ) : null}
454            <Button key={`toggle-${m.name}`} onPress={noop}>
455              {off ? <Text dimColor>Off</Text> : <Text color={GOOD}>On</Text>}
456            </Button>
457          </Box>
458        </Box>
459      )
460    })
461
462    return (
463      <Box flexDirection="column" width={cols}>
464        {rows}
465        <Box marginTop={1} flexDirection="column">
466          {note ? (
467            <Text color={GOOD} wrap="truncate-end">
468              {cut(note, cols)}
469            </Text>
470          ) : null}
471          <Text dimColor wrap="wrap">
472            Off means off in every chat. Hidden keeps it on but out of sight. Changes apply within 3 seconds.
473          </Text>
474          <Text dimColor wrap="wrap">
475            New mods appear here by themselves.
476          </Text>
477        </Box>
478      </Box>
479    )
480  })
481}
482
types/index.d.ts 9 lines
1/** A short line under the list after a press: "Cache Clock is off in every chat." */
2export type Note = string
3
4declare module 'claude-code' {
5  interface PluginState {
6    mods: { note: Note }
7  }
8}
9