SLOPSHOPPER

darija-narrator

Claude explains what it is doing in Moroccan Darija, live above the prompt

newbandguardtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · darija-narrator
› fix the failing auth test and add an audit log call ╭────────────────────────────────────╮ │ darija-narrator │ ⏺ Read(src/auth.ts) │ ❌ Tra chi mochkil f Bash, mat9l9ch │ ⎿ Read 6 lines ╰────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇ ⚙️ Yallah n-jerreb: rm -rf build && git push --force origin main ⚙️ Kan-lanci cat .env ✅ Safi, salina f ramchat 3eyn! F 0 tanya, w dert 9 d l-khdmat ◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇ [ Khbbi ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇ ⚙️ Yallah n-jerreb: rm -rf build && git push --force origin main ⚙️ Kan-lanci cat .env ✅ Safi, salina f ramchat 3eyn! F 0 tanya, w dert 9 d l-khdmat ◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇ [ Khbbi ]
README

darija-mods 🇲🇦

Claude Code mods that speak Moroccan Darija, edged like a zrabi (Berber rug).

Claude kaykhddem, w nta katchrb atay. 🫖

◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇
  🫖 4:32  l-brrad kaytbokh... 🫖
  Atay wjed? Khwi lik kas tani, ana mazal khddam
◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆─◇─◆ [Khbbi]

Install

claude plugin marketplace add Mohamedballouch/darija-mods
claude plugin install darija-narrator@darija-mods
claude plugin install zlafa@darija-mods
claude plugin install hajib@darija-mods
claude plugin install brrad@darija-mods

Install all four, or only the ones you like.

The mods

ModWhat it doesExample
darija-narratorNarrates every step Claude takes, live above the prompt🔍 Khllini nchouf ach kayn f app.ts
zlafa (الزلافة)A bowl that fills as the context window fills; warns at 80% and 95%🥣 [██████████░░] 82% L-zlafa 9rbat t3mer, fkker f /compact
hajib (الحاجب)Blocks catastrophic commands (wiping /, formatting a disk…) and asks before risky ones (git push --force, sudo, `curl \sh, editing .env`…)🛑 Force push! Ghadi ybddel l-historique li f remote. N-khllih ydouz?
brrad (البراد)When a task runs long, tells you to step away for tea; calls you back when it's done and counts your cups for the dayHadchi ghadi ytwl chwiya... khud lik pause w chi brrad d atay 🫖

Hajib only ever makes approvals stricter: it never auto-approves anything.

Brrad settings

In the plugin config menu:

  • First tea nudge (seconds): default 90; later nudges (4 min, 8 min, 15 min) scale from it
  • Quiet mode: only the "come back, it's done" toast

Spelling

Darija is written in Latin letters with the usual Arabizi numbers: 3 = ع · 7 = ح · 9 = ق · gh = غ · kh = خ · ch = ش

Look

Every band sits between two rows of rug fringe (◇─◆─◇) in terracotta #B5452A. The text keeps your terminal's own colours, so it reads the same on light and dark themes; only warnings turn red.

Develop

Each mod is a folder with .claude-plugin/plugin.json and a hooks module under hooks/.

claude plugin validate ./brrad
claude plugin test ./brrad
claude --plugin-dir ./brrad

Better Darija wording is very welcome: the phrases live in darija-narrator/hooks/darija.ts, zlafa/hooks/zlafa.ts, hajib/hooks/hajib.ts and brrad/hooks/brrad.ts.

License

MIT

Source 4 files
hooks/register.tsx 83 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Line } from '../types'
5import { done, failed, narrate, started } from './darija'
6
7import { ALERT, TERRACOTTA, fringe } from './zrabi'
8
9const lines = atom({ plugin: 'darija-narrator', key: 'lines' } as const, [] as Line[])
10const isHidden = atom({ plugin: 'darija-narrator', key: 'isHidden' } as const, false)
11
12const KEEP = 3
13
14function push($: EngineInterface, line: Line) {
15  return update($, lines, all => [...all, line].slice(-KEEP))
16}
17
18export const register: Register = on => {
19  let startedAt = 0
20  let tools = 0
21
22  on('prompt.submit', async ($, e, next) => {
23    startedAt = await $.clock.now()
24    tools = 0
25    await push($, { text: started(e.text), isError: false })
26
27    return next(e)
28  })
29
30  on('tool.call', async ($, e, next) => {
31    tools += 1
32    const text = narrate(e.tool, e as Record<string, unknown>)
33    $.ui.status(text)
34    await push($, { text, isError: false })
35
36    const ran = await next(e)
37
38    if (ran.deny === undefined && ran.isError === true) {
39      const bad = failed(e.tool)
40      $.ui.toast(bad)
41      await push($, { text: bad, isError: true })
42    }
43
44    return ran
45  })
46
47  on('turn.complete', async ($, e, next) => {
48    const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
49    $.ui.status(undefined)
50    await push($, { text: done(seconds, tools), isError: false })
51
52    return next(e)
53  })
54
55  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
56    const all = await read($, lines)
57
58    if (e.props.hasSurvey || all.length === 0 || (await read($, isHidden))) {
59      return next(e)
60    }
61
62    const { Box, Button, Text } = $.ui.resolve(e)
63    const width = e.props.bodyColumns - 2
64
65    return (
66      <Box flexDirection="column">
67        <Text color={TERRACOTTA}>{fringe(width)}</Text>
68        <Box flexDirection="column" paddingX={2}>
69          {all.map((l, i) => (
70            <Text key={`l${i}`} color={l.isError ? ALERT : undefined} dimColor={!l.isError && i < all.length - 1}>
71              {l.text}
72            </Text>
73          ))}
74        </Box>
75        <Box>
76          <Text color={TERRACOTTA}>{fringe(width - 10, 2)} </Text>
77          <Button key="hide" label="Khbbi" onPress={() => update($, isHidden, () => true)} />
78        </Box>
79      </Box>
80    )
81  })
82}
83
hooks/darija.ts 137 lines
1// Darija b l-7rouf latiniya: 3 = ع, 7 = ح, 9 = ق, gh = غ, kh = خ, ch = ش
2
3const short = (value: unknown, max = 50): string => {
4  const s = String(value ?? '').replace(/\s+/g, ' ').trim()
5
6  return s.length > max ? `${s.slice(0, max - 1)}…` : s
7}
8
9const base = (path: unknown): string => short(String(path ?? '').split('/').pop(), 40)
10
11// The same call always gets the same phrase; different calls rotate through the variants.
12const pick = (variants: readonly string[], seed: string): string => {
13  let h = 0
14
15  for (const c of seed) {
16    h = (h * 31 + c.charCodeAt(0)) >>> 0
17  }
18
19  return variants[h % variants.length]!
20}
21
22export const narrate = (tool: string, input: Record<string, unknown>): string => {
23  const seed = tool + JSON.stringify(input)
24
25  switch (tool) {
26    case 'Read': {
27      const f = base(input.file_path)
28
29      return pick(
30        [
31          `🔍 Kan9ra l-fichier ${f}`,
32          `🔍 Khllini nchouf ach kayn f ${f}`,
33          `🔍 Kandir chi tlla 3la ${f}`,
34          `🔍 Kan-f7ess ${f}`,
35        ],
36        seed,
37      )
38    }
39    case 'Edit':
40    case 'MultiEdit': {
41      const f = base(input.file_path)
42
43      return pick(
44        [`✏️ Kanbddel chwiya f ${f}`, `✏️ Kanssle7 ${f}`, `✏️ Kanzid lmsa f ${f}`, `✏️ Kan-reggel ${f}`],
45        seed,
46      )
47    }
48    case 'Write': {
49      const f = base(input.file_path)
50
51      return pick([`📝 Kanktb ${f}`, `📝 Kansayb fichier jdid: ${f}`, `📝 Kancriyi fichier jdid: ${f}`], seed)
52    }
53    case 'Bash': {
54      const c = short(input.command)
55
56      return pick(
57        [
58          `⚙️ Kandawer had l-commande: ${c}`,
59          `⚙️ Kan-lanci ${c}`,
60          `⚙️ Yallah n-jerreb: ${c}`,
61          `⚙️ Kan-khddem ${c}`,
62        ],
63        seed,
64      )
65    }
66    case 'Grep': {
67      const p = short(input.pattern, 30)
68
69      return pick(
70        [
71          `🕵️ Kan9elleb 3la "${p}" f l-code`,
72          `🕵️ Fin mkhbbi "${p}"? Kan9elleb...`,
73          `🕵️ Fin ghaber "${p}"? Kan-fttech...`,
74        ],
75        seed,
76      )
77    }
78    case 'Glob': {
79      const p = short(input.pattern, 30)
80
81      return pick([`📂 Kan9elleb 3la l-fichiyat: ${p}`, `📂 Kan-fttech 3la l-fichiyat: ${p}`], seed)
82    }
83    case 'WebSearch': {
84      const q = short(input.query)
85
86      return pick(
87        [`🌐 Kan9elleb f l-internet 3la: ${q}`, `🌐 Nsewlo Google: ${q}`, `🌐 Nchoufo Google ach kay9oul 3la: ${q}`],
88        seed,
89      )
90    }
91    case 'WebFetch': {
92      const u = short(input.url)
93
94      return pick([`🌐 Kanjib had s-saf7a: ${u}`, `🌐 Kan-ft7 had s-saf7a: ${u}`], seed)
95    }
96    case 'Task':
97    case 'Agent':
98      return pick(
99        [
100          `🤝 Kan3eyyet 3la wa7ed l-agent y3awnni`,
101          `🤝 Jbt m3aya wa7ed s-sa7eb y3awnni`,
102          `🤝 Jbt l-renforts y3awnouni`,
103        ],
104        seed,
105      )
106    case 'TodoWrite':
107      return pick([`📋 Kanrettb l-lista d l-khdma`, `📋 Kan-reggel l-lista d l-khdma`], seed)
108    default:
109      return pick([`🛠️ Kankhddem ${tool}`, `🛠️ Yallah, nkhddem ${tool}`], seed)
110  }
111}
112
113export const failed = (tool: string): string =>
114  pick(
115    [
116      `❌ Ouups! ${tool} ma-khdemch, ghadi nchouf achnou w9e3`,
117      `❌ Tra chi mochkil f ${tool}, mat9l9ch`,
118      `❌ Wili! ${tool} ma-khdemch, ghadi n-reggel l-9diyya`,
119    ],
120    tool + Date.now(),
121  )
122
123export const done = (seconds: number, tools: number): string =>
124  seconds < 60
125    ? pick(
126        [
127          `✅ Safi, salina! F ${seconds} tanya, w dert ${tools} d l-khdmat`,
128          `✅ Safi, salina f ramchat 3eyn! F ${seconds} tanya, w dert ${tools} d l-khdmat`,
129        ],
130        `${seconds}:${tools}`,
131      )
132    : `✅ Hamdoulillah, kmmlna! ${Math.round(seconds / 60)} d9i9a w ${tools} d l-khdmat`
133
134// Picked by the prompt's text, so each prompt gets its own greeting.
135export const started = (prompt: string): string =>
136  pick(['Wakha, daba nchouf... 🤔', 'Wakha sidi, daba n-chouf... 🤔', 'Zid, ha ana m3ak... 🤔'], prompt)
137
hooks/zrabi.ts 18 lines
1// Zrabi (الزربية): the diamond fringe of a Berber rug, in terracotta.
2
3export const TERRACOTTA = '#B5452A'
4export const ALERT = '#E5534B'
5
6const UNIT = '◇─◆─'
7
8// A fringe `width` cells wide that starts and ends on a diamond; `offset` 2 starts on ◆.
9export const fringe = (width: number, offset = 0): string => {
10  let s = ''
11
12  for (let i = 0; i < Math.max(0, width); i++) {
13    s += UNIT[(i + offset) % UNIT.length]
14  }
15
16  return s.endsWith('─') ? s.slice(0, -1) : s
17}
18
types/index.d.ts 8 lines
1export type Line = { text: string; isError: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'darija-narrator': { lines: Line[]; isHidden: boolean }
6  }
7}
8