A Claude Code mod for Muslim developers.

<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.
| Feature | What it does | |
|---|---|---|
| 📿 | Thikr line · /thikr | A short thikr in Claude's working line while it works, and a closing word when a turn ends. |
| 🌅 | Athkar · /athkar | Morning, evening and after-salah athkar from Hisn al-Muslim, in a reader above the prompt that counts with you. |
| 🕌 | Prayer times · /salah | The next prayer under the prompt, a countdown, and an athan moment when it comes in. Calculated on your machine. |
| 📖 | Quran · /quran | A recitation player with complete Hafs recordings. Desktop. |
| ☾ | Muslim pane · /muslim | Today's prayers, the Quran player, athkar progress and every setting in one pane. Desktop. |
| Command | |
|---|---|
/muslim | Open the Muslim pane (desktop), or see your settings (terminal) |
/muslim help | Every command |
/muslim demo | Play thikr, athkar, salah and the desktop pane tour |
/muslim update | Update to the latest version |
/muslim feedback | Report a bug or send an idea |
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.
hooks/register.tsx 433 lines1import { 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}
433src/shared/setup/hint.ts 6 lines1export 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}
6src/shared/command/failure.ts 9 lines1export 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}
9src/shared/setup/visibility.ts 17 lines1import { 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}
17src/shared/pray/first-run.ts 14 lines1// 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))
14src/shared/reload/config.ts 10 lines1import 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}
10src/shared/reload/signal.ts 10 lines1// 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}
10src/shared/color/index.ts 20 lines1export 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))
20src/shared/arabic/install.tsx 112 lines1import { 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}
112src/config.ts 102 lines1import { 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(' ')
102src/constants.ts 3 lines1export const PLUGIN = 'muslim'
2export const VERSION = '0.1.0'
3src/autoupdate.ts 80 lines1// `/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