SLOPSHOPPER

muslim

A Claude Code mod for Muslim developers.

newpanebandspinnerrowsguard
★ 5v0.1.0MITupdated 2026-10-24abdelrahmanzied/claude-muslim/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · muslim
│ ┃ muslim ✕ › fix the failing auth test and add an audit log call │ ┃ Muslim pane unavailable. Use /muslim again. │ ⏺ 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. │ │ ✻ تم بحمد الله for 42s · done 4:20 PM │ │ › /muslim │ ⎿ muslim: **Muslim** · v0.1.0 │ ⎿ muslim: To change a setting or see every option: `/muslim help`; │ ⎿ muslim: │ ⎿ muslim: ### Features │ ⎿ muslim: │ ⎿ muslim: | Feature | Description | State | │ │ ✻ سبحان الله… muslim · Terminal Arabic Setup · Which word is written correctly? Some terminals break Arabic letters apart or reverse them — pick the one you can read. 1: arabic-mode-plain 2: arabic-mode-joined 3: arabic-mode-reversed Press 1–3 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
muslim · Terminal Arabic Setup · Which word is written correctly? Some terminals break Arabic letters apart or reverse them — pick the one you can read. 1: arabic-mode-plain 2: arabic-mode-joined 3: arabic-mode-reversed Press 1–3 ⟨Claude Code's own drawing⟩
Pane · muslim
Muslim pane unavailable. Use /muslim again.
README

<img src="https://github.com/abdelrahmanzied/claude-muslim/raw/main/.github/assets/readme-welcome.svg" width="320" alt="The Muslim crab says salam, then counts tasbih">

<h1 align="center">muslim</h1>

Thikr, athkar, prayer times and the Quran, inside Claude Code.<br> In the desktop app and the terminal.

Features

FeatureWhat it does
📿Thikr line · /thikrA short thikr in Claude's working line while it works, and a closing word when a turn ends.
🌅Athkar · /athkarMorning, evening and after-salah athkar from Hisn al-Muslim, in a reader above the prompt that counts with you.
🕌Prayer times · /salahThe next prayer under the prompt, a countdown, and an athan moment when it comes in. Calculated on your machine.
📖Quran · /quranA recitation player with complete Hafs recordings. Desktop.
☾Muslim pane · /muslimToday's prayers, the Quran player, athkar progress and every setting in one pane. Desktop.

Get started

Command
/muslimOpen the Muslim pane (desktop), or see your settings (terminal)
/muslim helpEvery command
/muslim demoPlay thikr, athkar, salah and the desktop pane tour
/muslim updateUpdate to the latest version
/muslim feedbackReport a bug or send an idea

More

Install, updates, privacy (one anonymous install count) and contributing: the repository README. What changed: the changelog.

MIT · the only official source is abdelrahmanzied/claude-muslim.

Source 90 files
hooks/register.tsx 433 lines
1import { withSetupHint } from '../src/shared/setup/hint.ts'
2import { commandFailure } from '../src/shared/command/failure.ts'
3import { setupForm } from '../src/shared/setup/visibility.ts'
4import { SETUP_LATER_KEY } from '../src/shared/pray/first-run.ts'
5import { configFailure, hasConfigRow, MISSING_CONFIG } from '../src/shared/reload/config.ts'
6import { stopBeforeReload } from '../src/shared/reload/signal.ts'
7import { isAccent } from '../src/shared/color/index.ts'
8import { install as installArabicFix } from '../src/shared/arabic/install.tsx'
9import type { EngineInterface, Register } from 'claude-code'
10
11import { readOptions } from '../src/config.ts'
12import { PLUGIN, VERSION } from '../src/constants.ts'
13import { AUTOUPDATE_USAGE, autoUpdateState, changedReply, noEntry, notChanged, parseSettings, settingsPath, shouldSeed, stateText, statusReply, unreadable, withAutoUpdate, type AutoUpdateState } from '../src/autoupdate.ts'
14import { FEEDBACK_SUBMIT_MS, feedbackPrompt } from '../src/feedback.ts'
15import { countedSurface, INSTALL_KEY, INSTALL_URL, installBody, isDone, newRecord, osFrom, readRecord } from '../src/install-count.ts'
16import { failedToRun, parseUpdate, updateReply, UPDATE_ARGV, UPDATE_TIMEOUT_MS, type UpdateResult } from '../src/update.ts'
17import { install as installAthkar } from '../src/features/athkar/install.tsx'
18import { muslimHelp } from '../src/help.ts'
19import { helpHint, statusTable } from '../src/table.ts'
20import { showText } from '../src/features/salah/today.ts'
21import { install as installSalah } from '../src/features/salah/install.tsx'
22import { install as installQuran } from '../src/features/quran/install.tsx'
23import { defaultReciter } from '../src/features/quran/catalog.ts'
24import { QURAN_CATALOG } from '../src/features/quran/catalog-data.ts'
25import { nearestAvailable } from '../src/features/quran/availability.ts'
26import { MODES_KEY, terminalArabic, defaultArabicMode, isArabicMode, terminalIdFromEnv } from '../src/shared/arabic/terminal.ts'
27import { install as installThikr } from '../src/features/thikr/install.tsx'
28import { install as installMuslimPane } from '../src/features/muslim-pane/install.tsx'
29import { connectAutoUpdate, connectPanelAction } from '../src/shared/panel/signal.ts'
30
31// $ stays in same-file helpers; Arabic transformation itself is shared pure logic.
32async function setupShowing($: EngineInterface, hasPlace: boolean): Promise<boolean> {
33  const surface = await $.session.surface().catch(() => 'desktop' as const) ?? 'desktop'
34  const id = terminalIdFromEnv({
35    TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
36    TERMINAL_EMULATOR: await $.env.get('TERMINAL_EMULATOR'),
37    __CFBundleIdentifier: await $.env.get('__CFBundleIdentifier'),
38    WT_SESSION: await $.env.get('WT_SESSION'),
39    KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
40    VTE_VERSION: await $.env.get('VTE_VERSION'),
41    KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
42    ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
43    ALACRITTY_SOCKET: await $.env.get('ALACRITTY_SOCKET'),
44    OS: await $.env.get('OS'),
45  })
46  return !!setupForm({ surface, hasSurvey: false, hasPlace,
47    hasReview: !!(await $.state.get({ plugin: 'muslim', key: 'salahReview' })).value,
48    later: (await $.state.get({ plugin: 'muslim', key: 'salahSetupLater' })).value === true || await $.store.get(SETUP_LATER_KEY) === true,
49    terminal: surface === 'terminal' ? { id, manual: (await $.state.get({ plugin: 'muslim', key: 'arabicSurveyManualTerminal' })).value, saved: await $.store.get(MODES_KEY) as Record<string, unknown> | undefined } : undefined,
50  })
51}
52
53async function settingsArabic($: EngineInterface, surface: string, text: string): Promise<string> {
54  if (surface !== 'terminal') return text
55  const id = terminalIdFromEnv({
56    TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
57    TERMINAL_EMULATOR: await $.env.get('TERMINAL_EMULATOR'),
58    __CFBundleIdentifier: await $.env.get('__CFBundleIdentifier'),
59    WT_SESSION: await $.env.get('WT_SESSION'),
60    KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
61    VTE_VERSION: await $.env.get('VTE_VERSION'),
62    KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
63    ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
64    ALACRITTY_SOCKET: await $.env.get('ALACRITTY_SOCKET'),
65    OS: await $.env.get('OS'),
66  })
67  const cached = (await $.state.get({ plugin: 'muslim', key: 'arabicTerminalMode' })).value
68  const saved = await $.store.get(MODES_KEY) as Record<string, unknown> | undefined
69  const mode = saved?.[id]
70  return terminalArabic(surface, cached?.terminalId === id ? cached.mode : isArabicMode(mode) ? mode : defaultArabicMode(id), text)
71}
72
73// `claude` on PATH; the engine gives no path to its own binary. Without a shell a Windows
74// `claude.cmd` shim does not start, so a failed start retries through `cmd /c` there.
75async function runUpdate($: EngineInterface): Promise<UpdateResult> {
76  const run = (argv: readonly string[]) => $.process.run(argv, { timeoutMs: UPDATE_TIMEOUT_MS })
77  try {
78    let done
79    try {
80      done = await run(UPDATE_ARGV)
81    } catch (error) {
82      if ((await $.env.get('OS')) !== 'Windows_NT') throw error
83      done = await run(['cmd', '/c', ...UPDATE_ARGV])
84    }
85    return parseUpdate(done.stdout, done.stderr, done.exitCode)
86  } catch (error) {
87    return failedToRun(error)
88  }
89}
90
91// The user's settings.json: CLAUDE_CONFIG_DIR, else HOME or USERPROFILE.
92async function userSettingsFile($: EngineInterface): Promise<string | undefined> {
93  return settingsPath({ config: await $.env.get('CLAUDE_CONFIG_DIR'), home: await $.env.get('HOME'), profile: await $.env.get('USERPROFILE') })
94}
95
96async function readAutoUpdate($: EngineInterface): Promise<AutoUpdateState | 'unknown'> {
97  try {
98    return autoUpdateState(await $.settings.read({ source: 'user' }))
99  } catch {
100    return 'unknown'
101  }
102}
103
104// Changes only extraKnownMarketplaces["claude-muslim"].autoUpdate; a missing entry or a file
105// that is not valid JSON is never written. Returns the reason it did not write, or undefined.
106async function writeAutoUpdate($: EngineInterface, value: boolean): Promise<string | undefined> {
107  try {
108    const path = await userSettingsFile($)
109    if (!path) return notChanged('could not find your home folder')
110    const parsed = parseSettings(await $.fs.read(path))
111    if (!parsed.ok) return unreadable(parsed.reason)
112    const text = withAutoUpdate(parsed.data, value)
113    if (text === undefined) return noEntry
114    await $.fs.write(path, text)
115    return undefined
116  } catch (error) {
117    return notChanged(error instanceof Error ? error.message.split('\n')[0]! : 'could not read or write your settings')
118  }
119}
120
121// The pane uses the same reader and writer as /muslim autoupdate, without chat output.
122async function reconcilePaneAutoUpdate($: EngineInterface): Promise<void> {
123  const ref = { plugin: 'muslim', key: 'paneAutoUpdate' } as const
124  const held = await $.state.get(ref)
125  const state = await readAutoUpdate($)
126  if (typeof held.value === 'boolean' && state !== 'unknown' && state !== 'missing' && (state === 'on') === held.value) {
127    await $.state.set(ref, null, { ifVersion: held.version })
128  }
129}
130function connectAutoUpdateToPanel($: EngineInterface): void {
131  connectAutoUpdate(() => readAutoUpdate($))
132  connectPanelAction('muslim-autoupdate', async value => {
133    if (value !== 'on' && value !== 'off') return
134    const failed = await writeAutoUpdate($, value === 'on')
135    if (failed) {
136      const ref = { plugin: 'muslim', key: 'paneAutoUpdate' } as const
137      const held = await $.state.get(ref)
138      if (held.value === (value === 'on')) await $.state.set(ref, null, { ifVersion: held.version })
139      $.ui.toast(failed)
140    } else {
141      await reconcilePaneAutoUpdate($)
142      $.ui.invalidate('ui.render')
143    }
144  })
145}
146
147// The first run turns auto-update on once and records it, so a later "off" stays off.
148// Nothing here may fail the session: any problem just means "try again next start".
149async function seedAutoUpdate($: EngineInterface) {
150  try {
151    if (await $.store.get('autoUpdateSeeded')) return
152    const path = await userSettingsFile($)
153    if (!path) return
154    const parsed = parseSettings(await $.fs.read(path))
155    if (!parsed.ok || autoUpdateState(parsed.data) === 'missing') return
156    if (shouldSeed(parsed.data) && (await writeAutoUpdate($, true)) !== undefined) return
157    await $.store.set('autoUpdateSeeded', true)
158  } catch {}
159}
160
161// The anonymous install count: the first start on a machine with a surface saves the record
162// (the only time the environment is read) and sends it once. Nothing here may fail the session:
163// a failed send tries again next start; a sent record is never sent again.
164let counting = false
165async function countInstall($: EngineInterface, shown: string | null) {
166  const surface = countedSurface(shown)
167  if (!surface || counting) return
168  counting = true
169  try {
170    let record = readRecord(await $.store.get(INSTALL_KEY))
171    if (record?.sent) return
172    if (!record) {
173      record = newRecord(await $.clock.now(), VERSION, surface, osFrom({ OS: await $.env.get('OS'), HOME: await $.env.get('HOME') }))
174      await $.store.set(INSTALL_KEY, record)
175    }
176    const { status } = await $.http.fetch(INSTALL_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: installBody(record) })
177    if (isDone(status)) await $.store.set(INSTALL_KEY, { ...record, sent: true })
178  } catch {} finally { counting = false }
179}
180
181// The host refuses a submit made while the command still runs, so the turn starts from a timer
182// just after the reply. A refused or dropped submit gets a toast, never a silent nothing.
183async function startFeedback($: EngineInterface, text: string) {
184  const prompt = feedbackPrompt({
185    version: VERSION,
186    surface: await $.session.surface(),
187    os: (await $.env.get('OS')) === 'Windows_NT' ? 'Windows' : undefined,
188    text,
189  })
190  const failed = () => $.ui.toast('Could not start the feedback. Run /muslim feedback again.')
191  $.clock.after(FEEDBACK_SUBMIT_MS, () => {
192    $.prompt.submit({ text: prompt }).then(result => result.drop === undefined || failed(), failed)
193  })
194}
195
196// A command cannot run another from its own hook, so the reload comes from a timer.
197function reloadPlugins($: EngineInterface) {
198  $.clock.after(500, () => {
199    void $.command.run({ command: 'reload-plugins' }).catch(() => $.ui.toast('muslim updated. Run /reload-plugins to load it.'))
200  })
201}
202
203// DEMO BEGIN: shared runner, transient module values only. It plays the demos in order
204// and waits for each to report its end; it knows no demo's length or values.
205import { startDemo, onDemoDone, onDemoFailure } from '../src/shared/demo/signal.ts'
206import { DEMO_STEPS, DEMO_GAP_MS, DEMO_START_MS, demoReply } from '../src/demo.ts'
207
208type DemoRun = { step: number; timer?: { cancel: () => void } }
209let demo: DemoRun | undefined
210
211const stopDemo = (playing: DemoRun) => {
212  if (demo !== playing) return
213  playing.timer?.cancel()
214  demo = undefined
215  onDemoFailure()
216  onDemoDone()
217}
218
219// Signals run after the command has returned; each connected starter owns its feature's $.
220const runDemoStep = async (playing: DemoRun, step: number) => {
221  if (demo !== playing) return
222  playing.step = step
223  try {
224    await startDemo(DEMO_STEPS[step]!)
225  } catch {
226    stopDemo(playing)
227  }
228}
229
230const playDemo = async ($: EngineInterface): Promise<string> => {
231  if (demo) return '**Muslim demo:** already playing.'
232  const playing: DemoRun = { step: 0 }
233  demo = playing
234  onDemoFailure(() => stopDemo(playing))
235  onDemoDone(feature => {
236    if (demo !== playing || feature !== DEMO_STEPS[playing.step]) return
237    const next = playing.step + 1
238    if (next >= DEMO_STEPS.length) return stopDemo(playing)
239    playing.timer = $.clock.after(DEMO_GAP_MS, () => void runDemoStep(playing, next))
240  })
241  playing.timer = $.clock.after(DEMO_START_MS, () => void runDemoStep(playing, 0))
242
243  return demoReply()
244}
245// DEMO END
246
247// Entry point. Commands are all registered here (one session.start per module);
248// each feature answers its own command. Each feature lives in its own folder under src/features/,
249// exports an `install(on, options)` function, and is imported and called
250// here by name (the engine requires `on` be handed only to functions
251// imported from the mod's own files).
252export const register: Register = (on, options) => {
253  const settings = readOptions(options)
254
255  on('session.start', async ($, e, next) => {
256    await $.command.register({
257      name: 'muslim',
258      description: `Show ${PLUGIN} version, terminal Arabic setup, the Muslim pane, and help`,
259    })
260    await $.command.register({
261      name: 'thikr',
262      description: "Tasabih in Claude's working line: help, on, off, speed, end, mode, demo",
263    })
264    await $.command.register({
265      name: 'athkar',
266      description: 'Morning, evening and after-salah athkar above the prompt: help, morning, evening, after-salah, style, color, demo',
267    })
268    await $.command.register({
269      name: 'salah',
270      description: 'Pray times: help, show, athan, city, method, adjust, demo',
271    })
272
273    await $.command.register({ name: 'quran', description: 'Desktop only: open the Muslim pane with its Quran card unfolded' })
274    // Audio handles do not survive a module reload. Never resume stale tracking.
275    await $.state.set({ plugin: 'muslim', key: 'quranPlaying' }, null)
276    await $.state.set({ plugin: 'muslim', key: 'quranRepeat' }, 'off')
277    // Repair restored selection using shipped metadata only; never resume audio.
278    const savedReciter = await $.store.get('quran.reciter').catch(() => undefined)
279    const reciterKey = (QURAN_CATALOG.reciters.find(r => r.key === savedReciter) ?? defaultReciter(QURAN_CATALOG)).key
280    const storedSurah = (await $.state.get({ plugin: 'muslim', key: 'quranSurah' })).value ?? 1
281    const selected = nearestAvailable(reciterKey, storedSurah)
282    if (selected !== null && selected !== storedSurah) await $.state.set({ plugin: 'muslim', key: 'quranSurah' }, selected)
283
284    // A reload fires this again: ask for a redraw so the salah timer re-arms.
285    $.clock.after(0, () => $.ui.invalidate('ui.render'))
286
287    await seedAutoUpdate($)
288    await reconcilePaneAutoUpdate($)
289    // Not awaited: the count never holds up the start.
290    void countInstall($, e.surface ?? await $.session.surface().catch(() => null))
291
292    return next(e)
293  })
294
295  // The desktop app may start a session before it draws: count when it joins.
296  on('session.attach', async ($, e, next) => {
297    void countInstall($, e.surface)
298    return next(e)
299  })
300
301  on('command.run', { command: 'muslim' }, async ($, e, next) => {
302    const words = e.args.trim().split(/\s+/).filter(Boolean)
303    const [word = '', feature = ''] = words
304    // DEMO BEGIN: command entry; no feature settings or saved state.
305    if (word === 'demo') {
306      if (demo) return { text: '**Muslim demo:** already playing.' }
307      // `/muslim demo pane` is the pane tour alone; the muslim-pane feature answers it.
308      if (words.length === 2 && feature === 'pane') return next(e)
309      if (words.length !== 1) return { text: 'Usage: /muslim demo [pane]' }
310      // Walk the demo-only setup hooks once; no self-command dispatch.
311      await next(e)
312
313      return { text: withSetupHint(await playDemo($), !!(await setupShowing($, !!settings.salahPlace))) }
314    }
315    // DEMO END
316    if (word === 'color') {
317      const pick = feature
318      if (words.length > 2 || (pick !== '' && !isAccent(pick))) return { text: 'Usage: /muslim color orange | green | yellow | blue' }
319      if (pick === '') return { text: `Color ${settings.color}. Usage: /muslim color orange | green | yellow | blue` }
320      try {
321        if (!hasConfigRow(await $.config.list(), 'muslim.color')) return { text: MISSING_CONFIG }
322        await stopBeforeReload()
323        const { deny } = await $.config.set({ key: 'muslim.color', value: pick })
324        return { text: deny ? configFailure(deny) : `Color ${pick}` }
325      } catch (error) { return { text: configFailure(error) } }
326    }
327    if (word === 'tashkeel') {
328      const value = feature
329      if (words.length > 2 || (value !== '' && value !== 'on' && value !== 'off')) return { text: 'Usage: /muslim tashkeel on | off' }
330      if (value === '') return { text: `Tashkeel ${settings.tashkeel ? 'on' : 'off'} on desktop; the terminal never shows diacritics. Usage: /muslim tashkeel on | off` }
331      try {
332        if (!hasConfigRow(await $.config.list(), 'muslim.tashkeel')) return { text: MISSING_CONFIG }
333        await stopBeforeReload()
334        const { deny } = await $.config.set({ key: 'muslim.tashkeel', value: value === 'on' })
335        return { text: deny ? configFailure(deny) : `Tashkeel ${value} on desktop; the terminal never shows diacritics` }
336      } catch (error) { return { text: configFailure(error) } }
337    }
338    if (word === 'update') {
339      if (words.length !== 1) return { text: 'Usage: /muslim update' }
340      $.ui.toast('Updating muslim…', { timeoutMs: 8000 })
341      const reply = updateReply(await runUpdate($), VERSION)
342      if (reply.reload) reloadPlugins($)
343      return { text: reply.text }
344    }
345    if (word === 'autoupdate') {
346      if (words.length > 2 || (feature !== '' && feature !== 'on' && feature !== 'off')) return { text: AUTOUPDATE_USAGE }
347      if (feature === '') return { text: statusReply(await readAutoUpdate($)) }
348      const failed = await writeAutoUpdate($, feature === 'on')
349      return { text: failed ?? changedReply(feature === 'on') }
350    }
351    if (word === 'feedback') {
352      await startFeedback($, e.args.trim().slice('feedback'.length))
353      return { text: 'Feedback: Claude will ask what kind it is and show you a draft. Nothing is sent until you say yes.' }
354    }
355    if (word === 'help') return { text: await settingsArabic($, await $.session.surface().catch(() => 'desktop' as const) ?? 'desktop', muslimHelp(feature)) }
356    if (word !== '') {
357      // Words a later hook answers (terminal-arabic, demo pane) pass through. A word nothing answers makes the engine reply with its developer fallback ("no command.run hook answered it"), which resolves instead of rejecting: show the help table in its place.
358      const answer = await next(e).catch(() => undefined)
359      if (answer && !(answer.text ?? '').includes('no command.run hook answered')) return answer
360      return { text: await settingsArabic($, await $.session.surface().catch(() => 'desktop' as const) ?? 'desktop', muslimHelp()) }
361    }
362
363    const surfaces = await $.session.surfaces()
364    if (surfaces.includes('desktop') && !surfaces.includes('terminal')) {
365      try {
366        const answer = await next(e)
367        if (answer.text) return answer
368      } catch { /* Opening failed: keep the existing table fallback. */ }
369    }
370
371    const onOff = (value: boolean) => (value ? 'on' : 'off')
372    // The Arabic setup answers an empty /muslim with this terminal's mode; empty off the terminal.
373    const terminal = surfaces.includes('desktop') && !surfaces.includes('terminal') ? '' : await next(e).then(result => result.text ?? '', () => '')
374    const autoUpdate = await readAutoUpdate($)
375
376    return {
377      text: [
378        `**Muslim** · v${VERSION}`,
379        `${helpHint('/muslim')}; each feature's: \`/thikr help\` · \`/athkar help\` · \`/salah help\``,
380        '',
381        '### Features',
382        '',
383        statusTable([
384          ['Thikr', 'Tasbih in Claude\'s working line', onOff(settings.thikr)],
385          ['Athkar', 'Morning, evening and after-salah athkar above the prompt; reminders morning · evening · after salah', [settings.athkarMorning, settings.athkarEvening, settings.athkarAfterSalah].map(onOff).join(' · ')],
386          ['Salah', 'Pray times and the next pray under the prompt', settings.salahPlace ? showText(settings.salahShow) : 'not set up'],
387          ['Quran', 'Desktop recitation player', 'desktop only'],
388        ], 'Feature'),
389        '',
390        '### Shared',
391        '',
392        statusTable([
393          ['Color', 'The main color in every feature', settings.color],
394          ['Tashkeel', 'Arabic diacritics on desktop; the terminal never shows them', onOff(settings.tashkeel)],
395          ['Terminal Arabic', 'How Arabic shows in this terminal', terminal || 'terminal only'],
396          ['Auto-update', 'Claude Code updates muslim in the background', stateText(autoUpdate)],
397        ]),
398      ].join('\n'),
399    }
400  }).catch(($, e, next) => next.called ? next(e) : { text: commandFailure(e.command, e.args, next.error) })
401
402  // DEMO BEGIN: a session end stops the shared demo.
403  on('session.end', async (_$, e, next) => {
404    if (demo) stopDemo(demo)
405
406    return next(e)
407  })
408  // DEMO END
409
410  installThikr(on, settings)
411  installAthkar(on, settings)
412  // First registered SessionMode hook is outermost: the pane chip prepends to Quran's, which prepends to salah's.
413  installMuslimPane(on, settings)
414  on('ui.render', { component: 'Pane', requestId: 'muslim' }, async ($, e, next) => {
415    connectAutoUpdateToPanel($)
416    // Drawing only reads state; confirmation cleanup runs after the render.
417    if (typeof (await $.state.get({ plugin: 'muslim', key: 'paneAutoUpdate' })).value === 'boolean') {
418      $.clock.after(0, () => { void reconcilePaneAutoUpdate($) })
419    }
420    return next(e)
421  })
422  on('ui.message', { element: 'muslim-pane' }, async ($, e, next) => {
423    connectAutoUpdateToPanel($)
424    if (typeof (await $.state.get({ plugin: 'muslim', key: 'paneAutoUpdate' })).value === 'boolean') {
425      await reconcilePaneAutoUpdate($)
426    }
427    return next(e)
428  })
429  installQuran(on, settings)
430  installSalah(on, settings)
431  installArabicFix(on, settings)
432}
433
src/shared/setup/hint.ts 6 lines
1export const withSetupHint = (text: string, showing: boolean): string => {
2  if (!showing) return text
3  const trimmed = text.trimEnd()
4  return `${trimmed}${/[.!?:]$/.test(trimmed) ? '' : '.'} It shows once the setup above is answered.`
5}
6
src/shared/command/failure.ts 9 lines
1export const commandFailure = (command: string, args: string, error: { kind: string; message?: string } | undefined): string => {
2  const typed = args.trim()
3  const name = `/${command}${typed ? ` ${typed}` : ''}`
4  let reason = error?.kind === 'timeout' ? 'it took too long'
5    : error?.kind === 'throw' ? (error.message ?? '').replace(/\s+/g, ' ').trim().replace(/\.+$/, '') : ''
6  if (reason.length > 160) reason = `${reason.slice(0, 159).trimEnd().replace(/\.+$/, '')}…`
7  return `Could not complete ${name}${reason ? `: ${reason}` : ''}. Try again.`
8}
9
src/shared/setup/visibility.ts 17 lines
1import { needsArabicCheck } from '../arabic/terminal.ts'
2
3export type TerminalSetup = { id: string; manual: string | null | undefined; saved: Record<string, unknown> | undefined }
4export const arabicSetupShowing = (surface: string, hasSurvey: boolean, terminal?: TerminalSetup): boolean =>
5  surface === 'terminal' && !hasSurvey && !!terminal && needsArabicCheck(terminal.id, terminal.manual, terminal.saved)
6
7// The same decision draws setup forms and withholds reminder bands. No engine handles.
8export const setupForm = (input: {
9  surface: string; hasSurvey: boolean; hasPlace: boolean; hasReview: boolean; later: boolean; terminal?: TerminalSetup
10}): 'survey' | 'arabic' | 'review' | 'city' | null => {
11  if (input.hasSurvey) return 'survey'
12  if (arabicSetupShowing(input.surface, input.hasSurvey, input.terminal)) return 'arabic'
13  if (input.hasPlace && input.hasReview) return 'review'
14  if (!input.hasPlace && !input.later) return 'city'
15  return null
16}
17
src/shared/pray/first-run.ts 14 lines
1// Salah records its first setup answer; athkar uses it only for automatic opens.
2export const REMIND_FROM_KEY = 'firstRun.remindFrom'
3export const SETUP_LATER_KEY = 'salahSetupLater'
4
5export const readRemindFrom = (raw: unknown): number | null =>
6  typeof raw === 'number' && Number.isFinite(raw) ? raw : null
7
8// Footer visibility is separate: salah currently always installs its setup card.
9export const setupPending = (hasPlace: boolean, later: boolean, remindFrom: number | null): boolean =>
10  !hasPlace && !later && remindFrom === null
11
12export const mayOpenByItself = (pending: boolean, remindFrom: number | null, startedAt: number | null): boolean =>
13  !pending && (remindFrom === null || (startedAt !== null && startedAt >= remindFrom))
14
src/shared/reload/config.ts 10 lines
1import type { ConfigRow } from 'claude-code'
2
3export const MISSING_CONFIG = 'Not changed: run /reload-plugins, then try again.'
4export const RELOAD_TO_FINISH = 'Run /reload-plugins to finish installing muslim.'
5export const hasConfigRow = (rows: readonly ConfigRow[], key: string): boolean => rows.some(row => row.key === key)
6export const configFailure = (error: unknown): string => {
7  const message = error instanceof Error ? error.message : String(error)
8  return /no \/config row/i.test(message) ? MISSING_CONFIG : `Not changed: ${message}`
9}
10
src/shared/reload/signal.ts 10 lines
1// Quran supplies a closure over its own engine handle; settings writers await it.
2let stop: (() => Promise<void>) | undefined
3let queue: Promise<unknown> = Promise.resolve()
4export const connectBeforeReload = (run: () => Promise<void>): void => { stop = run }
5export const stopBeforeReload = (): Promise<void> => {
6  const task = queue.then(() => stop?.())
7  queue = task.catch(() => undefined)
8  return task
9}
10
src/shared/color/index.ts 20 lines
1export type Accent = 'orange' | 'green' | 'yellow' | 'blue'
2
3// The main color, for a light theme and a dark one.
4export const ACCENTS: Record<Accent, { light: string; dark: string }> = {
5  orange: { light: '#c8642d', dark: '#d97757' },
6  green: { light: '#0b7a50', dark: '#1a9466' },
7  yellow: { light: '#c99a1f', dark: '#e8b93c' },
8  blue: { light: '#3a6aa3', dark: '#4a7fc0' },
9}
10
11export const isAccent = (value: unknown): value is Accent => typeof value === 'string' && Object.hasOwn(ACCENTS, value)
12
13export const readColor = (value: unknown): Accent => isAccent(value) ? value : 'orange'
14
15export const isDarkTheme = (theme: unknown): boolean => !String(theme ?? '').includes('light')
16
17// Drawn surfaces need hex; text keeps Claude's exact theme key for orange.
18export const accentHex = (color: Accent, isDark: boolean): string => ACCENTS[color][isDark ? 'dark' : 'light']
19export const accentText = (color: Accent, theme: unknown): string => color === 'orange' ? 'claude' : accentHex(color, isDarkTheme(theme))
20
src/shared/arabic/install.tsx 112 lines
1import { commandFailure } from '../command/failure.ts'
2import { MODES_KEY } from './terminal.ts'
3import { arabicSetupShowing } from '../setup/visibility.ts'
4import { accentText, type Accent } from '../color/index.ts'
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, On } from 'claude-code'
7import type { Options } from '../../config.ts'
8import { ARABIC_MODES, defaultArabicMode, isArabicMode, terminalArabic, terminalIdFromEnv, type ArabicMode } from './terminal.ts'
9
10
11const manual = atom({ plugin: 'muslim', key: 'arabicSurveyManualTerminal' } as const, null as string | null)
12const cachedMode = atom({ plugin: 'muslim', key: 'arabicTerminalMode' } as const, null as { terminalId: string; mode: ArabicMode } | null)
13export const ARABIC_SAMPLE = 'بِسْمِ اللَّهِ'
14const TITLE = 'Terminal Arabic Setup'
15const QUESTION = 'Which word is written correctly?'
16const HINT = 'Some terminals break Arabic letters apart or reverse them — pick the one you can read.'
17
18const terminalId = async ($: EngineInterface): Promise<string> => {
19  const termProgram = await $.env.get('TERM_PROGRAM')
20  if (termProgram) return terminalIdFromEnv({ TERM_PROGRAM: termProgram })
21  const emulator = await $.env.get('TERMINAL_EMULATOR')
22  if (emulator) return terminalIdFromEnv({ TERMINAL_EMULATOR: emulator })
23  const bundle = await $.env.get('__CFBundleIdentifier')
24  if (bundle) return terminalIdFromEnv({ __CFBundleIdentifier: bundle })
25  const windowsTerminal = await $.env.get('WT_SESSION')
26  if (windowsTerminal !== undefined) return terminalIdFromEnv({ WT_SESSION: windowsTerminal })
27  const konsole = await $.env.get('KONSOLE_VERSION')
28  if (konsole) return terminalIdFromEnv({ KONSOLE_VERSION: konsole })
29  const vte = await $.env.get('VTE_VERSION')
30  if (vte) return terminalIdFromEnv({ VTE_VERSION: vte })
31  const kitty = await $.env.get('KITTY_WINDOW_ID')
32  if (kitty) return terminalIdFromEnv({ KITTY_WINDOW_ID: kitty })
33  const alacrittyWindow = await $.env.get('ALACRITTY_WINDOW_ID')
34  if (alacrittyWindow) return terminalIdFromEnv({ ALACRITTY_WINDOW_ID: alacrittyWindow })
35  const alacrittySocket = await $.env.get('ALACRITTY_SOCKET')
36  if (alacrittySocket) return terminalIdFromEnv({ ALACRITTY_SOCKET: alacrittySocket })
37  const os = await $.env.get('OS')
38
39  return terminalIdFromEnv({ OS: os })
40}
41
42const choose = async ($: EngineInterface, termId: string, mode: ArabicMode): Promise<void> => {
43  const saved = await $.store.get(MODES_KEY) as Record<string, unknown> | undefined
44  await $.store.set(MODES_KEY, { ...(saved ?? {}), [termId]: mode })
45  await update($, cachedMode, () => ({ terminalId: termId, mode }))
46  await update($, manual, value => value === termId ? null : value)
47  $.ui.toast(`muslim: Arabic for ${termId} set — /muslim terminal-arabic to redo`)
48}
49
50// This terminal's saved Arabic mode for the /muslim settings table; empty off the terminal.
51const terminalLine = async ($: EngineInterface): Promise<string> => {
52  if (await $.session.surface() !== 'terminal') return ''
53  const id = await terminalId($)
54  const saved = await $.store.get(MODES_KEY) as Record<string, unknown> | undefined
55  const mode = saved?.[id]
56  const shown = isArabicMode(mode) ? mode : `${defaultArabicMode(id)} (default, not chosen yet)`
57
58  return `${shown} for ${id}`
59}
60
61export const install = (on: On, options: Options): void => {
62  on('command.run', { command: 'muslim' }, async ($, e, next) => {
63    const args = e.args.trim()
64    const words = args ? args.split(/\s+/) : []
65    const [command] = words
66    if (command === undefined) return { text: await terminalLine($) }
67    if (command !== 'terminal-arabic') return next(e)
68    if (words.length !== 1) return { text: 'Usage: /muslim terminal-arabic' }
69    if (await $.session.surface() !== 'terminal') return { text: 'The Arabic display check is available in the terminal only.' }
70    const id = await terminalId($)
71    await update($, manual, () => id)
72    return { text: 'Arabic display check opened.' }
73  }).catch(($, e, next) => next.called ? next(e) : { text: commandFailure(e.command, e.args, next.error) })
74
75  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
76    const theirs = await next(e)
77    if (e.surface !== 'terminal' || e.props.hasSurvey) return theirs
78    const id = await terminalId($)
79    const manualId = await read($, manual)
80    const saved = await $.store.get(MODES_KEY) as Record<string, unknown> | undefined
81    if (!arabicSetupShowing(e.surface, e.props.hasSurvey, { id, manual: manualId, saved })) return theirs
82
83    const { Box, Text, Button } = $.ui.resolve(e)
84
85    return (
86      <Box flexDirection="column">
87        <Box>
88          <Text color={await mainColor($, options.color)}>muslim</Text>
89          <Text> · {TITLE} · {QUESTION}</Text>
90        </Box>
91        <Text dimColor>{HINT}</Text>
92        <Box flexDirection="column">
93          {ARABIC_MODES.map((mode, index) => (
94            <Button key={`arabic-mode-${mode}`} plain hotkey={String(index + 1)} onPress={() => choose($, id, mode)}>
95              <Text>{terminalArabic('terminal', mode, ARABIC_SAMPLE)}</Text>
96            </Button>
97          ))}
98        </Box>
99        <Text dimColor>Press 1–3</Text>
100        {theirs}
101      </Box>
102    )
103  })
104}
105
106// Engine access stays in this file; the shared palette is pure.
107const mainColor = async ($: EngineInterface, color: Accent): Promise<string> => {
108  if (color === 'orange') return accentText(color, undefined)
109  const theme = (await $.config.list().catch(() => [])).find(row => row.key === 'theme')
110  return accentText(color, theme?.value)
111}
112
src/config.ts 102 lines
1import { readColor, type Accent } from './shared/color/index.ts'
2import type { Style } from './features/athkar/look.ts'
3import { METHODS, PRAYS, type MethodId, type Pray } from './features/salah/methods.ts'
4import { readPlace, type Place } from './features/salah/place.ts'
5import type { Speed } from './features/thikr/rotation.ts'
6
7// Typed view of the manifest's userConfig values, defaults filled in.
8export type Options = {
9  paneButton: boolean
10  thikr: boolean
11  thikrSpeed: Speed
12  tashkeel: boolean
13  thikrEnd: boolean
14  thikrMode: Mode
15  athkarMorning: boolean
16  athkarEvening: boolean
17  athkarAfterSalah: boolean
18  athkarStyle: Style
19  color: Accent
20  salahShow: Show
21  salahAthan: boolean
22  salahPlace: Place | undefined
23  salahMethod: MethodId | 'auto'
24  salahAdjust: Record<Pray, number>
25}
26
27// When the pray line shows: `always` (next pray all day), a number (only that
28// many minutes before a pray, counting down) or `off`.
29export type Show = 'always' | 'off' | number
30
31// `all`: every group in turn; `salawat`: one salawat for the whole turn.
32export type Mode = 'all' | 'salawat'
33
34export const SPEED_MIN = 1
35export const SPEED_MAX = 60
36
37export const readOptions = (raw: Record<string, unknown>): Options => ({
38  paneButton: raw.paneButton !== false,
39  thikr: raw.thikr !== false,
40  thikrSpeed: readSpeed(raw.thikrSpeed),
41  tashkeel: raw.tashkeel !== false,
42  thikrEnd: raw.thikrEnd !== false,
43  thikrMode: raw.thikrMode === 'salawat' ? 'salawat' : 'all',
44  // Reminders: each set opens above the prompt by itself; manual opening always works.
45  athkarMorning: raw.athkarMorning !== false,
46  athkarEvening: raw.athkarEvening !== false,
47  athkarAfterSalah: raw.athkarAfterSalah !== false,
48  athkarStyle: raw.athkarStyle === 'frame' ? 'frame' : 'sky',
49  color: readColor(raw.color),
50  salahShow: readShow(raw),
51  salahAthan: raw.salahAthan !== false,
52  salahPlace: readPlace(raw.salahPlace),
53  salahMethod: isMethod(raw.salahMethod) ? raw.salahMethod : 'auto',
54  salahAdjust: readAdjust(raw.salahAdjust),
55})
56
57// The athkar's text size in pixels: fixed. A long thikr shrinks to fit the box; nothing else changes it.
58export const SIZE_DEFAULT = 14
59
60// `auto`, or whole seconds from SPEED_MIN to SPEED_MAX; anything else is auto.
61export const readSpeed = (value: unknown): Speed => {
62  const seconds = Number(value)
63  if (value === undefined || value === 'auto' || !Number.isInteger(seconds)) return 'auto'
64
65  return seconds >= SPEED_MIN && seconds <= SPEED_MAX ? seconds : 'auto'
66}
67
68export const SHOW_MIN = 1
69export const SHOW_MAX = 60
70export const SHOW_DEFAULT = 20
71// With `always`, the countdown runs in the last minutes before a pray.
72export const ALWAYS_COUNTDOWN = 20
73
74// `always`, `off` or whole minutes from SHOW_MIN to SHOW_MAX; else undefined.
75export const parseShow = (value: unknown): Show | undefined => {
76  if (value === 'always' || value === 'off') return value
77  const minutes = Number(value)
78
79  return value !== '' && value !== null && typeof value !== 'boolean' && Number.isInteger(minutes) && minutes >= SHOW_MIN && minutes <= SHOW_MAX ? minutes : undefined
80}
81
82export const readShow = (raw: Record<string, unknown>): Show => parseShow(raw.salahShow) ?? SHOW_DEFAULT
83
84// The minutes the countdown runs before a pray; 0 when off.
85export const countdownMinutes = (show: Show): number => (show === 'always' ? ALWAYS_COUNTDOWN : show === 'off' ? 0 : show)
86
87export const isMethod = (value: unknown): value is MethodId => typeof value === 'string' && Object.hasOwn(METHODS, value)
88
89// Six whole minutes, Fajr to Isha; anything else is 0.
90export const readAdjust = (value: unknown): Record<Pray, number> => {
91  const words = typeof value === 'string' ? value.trim().split(/\s+/) : []
92  const entries = PRAYS.map((pray, i) => {
93    const minutes = Number(words[i])
94
95    return [pray, Number.isInteger(minutes) ? minutes : 0] as const
96  })
97
98  return Object.fromEntries(entries) as Record<Pray, number>
99}
100
101export const writeAdjust = (adjust: Record<Pray, number>): string => PRAYS.map(pray => adjust[pray]).join(' ')
102
src/constants.ts 3 lines
1export const PLUGIN = 'muslim'
2export const VERSION = '0.1.0'
3
src/autoupdate.ts 80 lines
1// `/muslim autoupdate`: the pure part of turning Claude Code's marketplace auto-update on or
2// off for `claude-muslim`. The flag lives in the user's settings.json, on the marketplace
3// entry beside `source`: extraKnownMarketplaces["claude-muslim"].autoUpdate. No `$` here;
4// the file reads and writes happen in hooks/register.tsx.
5
6export const MARKETPLACE = 'claude-muslim'
7export const AUTOUPDATE_USAGE = 'Usage: /muslim autoupdate on | off'
8
9export type Parsed = { ok: true; data: Record<string, unknown> } | { ok: false; reason: string }
10export type AutoUpdateState = 'on' | 'off' | 'default' | 'missing'
11
12const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
13
14// The user settings file: CLAUDE_CONFIG_DIR, else HOME, else USERPROFILE (Windows), + .claude.
15export const settingsPath = (env: { config?: string; home?: string; profile?: string }): string | undefined => {
16  const clean = (value?: string) => (value && value.trim() !== '' ? value.trim().replace(/[\\/]+$/, '') : undefined)
17  const config = clean(env.config)
18  if (config) return `${config}/settings.json`
19  const home = clean(env.home) ?? clean(env.profile)
20
21  return home ? `${home}/.claude/settings.json` : undefined
22}
23
24// Only a file that is a JSON object may be edited; anything else is left alone.
25export const parseSettings = (text: string): Parsed => {
26  try {
27    const data: unknown = JSON.parse(text)
28    return isObject(data) ? { ok: true, data } : { ok: false, reason: 'it is not a JSON object' }
29  } catch {
30    return { ok: false, reason: 'it is not valid JSON' }
31  }
32}
33
34const entry = (settings: unknown): Record<string, unknown> | undefined => {
35  if (!isObject(settings) || !isObject(settings.extraKnownMarketplaces)) return undefined
36  const found = settings.extraKnownMarketplaces[MARKETPLACE]
37  return isObject(found) ? found : undefined
38}
39
40// `missing`: no claude-muslim entry. `default`: entry without a true/false (Claude Code's default is off).
41export const autoUpdateState = (settings: unknown): AutoUpdateState => {
42  const found = entry(settings)
43  if (!found) return 'missing'
44  if (found.autoUpdate === true) return 'on'
45  if (found.autoUpdate === false) return 'off'
46
47  return 'default'
48}
49
50// The text of the settings file with only that one key changed; undefined when the entry is missing.
51export const withAutoUpdate = (data: Record<string, unknown>, value: boolean): string | undefined => {
52  if (!entry(data)) return undefined
53  const markets = data.extraKnownMarketplaces as Record<string, Record<string, unknown>>
54  const next = { ...data, extraKnownMarketplaces: { ...markets, [MARKETPLACE]: { ...markets[MARKETPLACE], autoUpdate: value } } }
55
56  return `${JSON.stringify(next, null, 2)}\n`
57}
58
59// The first run only: turn it on when the entry exists and the user never chose.
60export const shouldSeed = (data: Record<string, unknown>): boolean => autoUpdateState(data) === 'default'
61
62export const stateText = (state: AutoUpdateState | 'unknown'): string =>
63  state === 'on' ? 'on' : state === 'off' ? 'off' : state === 'default' ? 'off (not chosen yet)' : state === 'missing' ? 'not available' : 'unknown'
64
65export const statusReply = (state: AutoUpdateState | 'unknown'): string => {
66  if (state === 'missing') return `Auto-update is not available: no "${MARKETPLACE}" marketplace in your Claude Code user settings. ${AUTOUPDATE_USAGE}`
67  if (state === 'unknown') return `Auto-update: could not read your user settings. ${AUTOUPDATE_USAGE}`
68
69  return `Auto-update ${stateText(state)}. ${AUTOUPDATE_USAGE}`
70}
71
72export const changedReply = (value: boolean): string =>
73  value
74    ? 'Auto-update on: Claude Code refreshes muslim in the background (from the next session).'
75    : 'Auto-update off: update with /muslim update when you want to (from the next session).'
76
77export const notChanged = (reason: string): string => `Not changed: ${reason}`
78export const unreadable = (reason: string): string => `Not changed: your Claude Code settings file is untouched because ${reason}.`
79export const noEntry = `Not changed: no "${MARKETPLACE}" marketplace in your Claude Code user settings (add it with claude plugin marketplace add).`
80