SLOPSHOPPER

goodfriend

be a goodfriend and remember your friends' birthdays. birthdays from contacts and the people you text show up right in claude code, with a one-tap happy…

newbandspinnerguardcommandtoast
v1.0.4MITupdated 2026-10-05nicodunks/goodfriend
A shopper browsing a rack in a slop shop
README

goodfriend

be a goodfriend and remember your friends' birthdays.

watch the goodfriend mod

A Claude Code mod. While Claude works, the spinner gives you heads-ups ("🎂 Leo's birthday is in 3 days"). On the day, a card pops up and one tap opens Messages with a happy birthday ready to send.

Install

claude plugin marketplace add nicodunks/goodfriend && claude plugin install goodfriend@goodfriend

Restart Claude, then run /seed to add birthdays for the people you text most. macOS only, Claude Code 2.1.286+.

Use

  • /seed: fill in birthdays for the people who matter
  • /goodfriend: see everyone's birthday
  • /goodfriend add Maya Chen 10/7: add one (or just tell Claude)

Birthdays also come from Contacts. goodfriend sees who you talk to, never what you said. /seed asks Claude to rank those names; birthdays stay on your Mac.

MIT

Source 1 files
hooks/register.tsx 296 lines
1import type { Engine, Register } from 'claude-code'
2
3// Birthdays from Contacts, /seed (the people you've been texting) and /goodfriend add.
4
5type Bday = { name: string; month: number; day: number }
6
7const CONTACTS = `const C = Application('Contacts')
8const n = C.people.name(), b = C.people.birthDate()
9JSON.stringify(n.flatMap((x, i) => (b[i] ? [{ name: x, month: b[i].getMonth() + 1, day: b[i].getDate() }] : [])))`
10
11// Every conversation, most recent first: "member count|chat name|name~handle;name~handle;"
12const CHATS = `tell application "Messages"
13  set out to ""
14  repeat with c in (chats)
15    try
16      set ns to ""
17      repeat with p in (participants of c)
18        set ns to ns & (name of p) & "~" & (handle of p) & ";"
19      end repeat
20      set n to name of c
21      if n is missing value then set n to ""
22      set out to out & (count of participants of c) & "|" & n & "|" & ns & linefeed
23    end try
24  end repeat
25  return out
26end tell`
27
28const MONTHS = 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' ')
29const DAY = 86_400_000
30const ENOUGH = "That's enough for now"
31const ACCENT = '#D97757' // Claude orange
32
33const first = (name: string) => name.split(' ')[0].toLowerCase()
34const any = <T,>(xs: T[]) => xs[Math.floor(Math.random() * xs.length)]
35const valid = (m: number, d: number): [number, number] | null => (m >= 1 && m <= 12 && d >= 1 && d <= 31 ? [m, d] : null)
36
37// "10/7", "oct 7", "October 7th", "7 Oct"
38function parseDate(s: string): [number, number] | null {
39  const num = /^\s*(\d{1,2})[/.-](\d{1,2})\s*$/.exec(s)
40  if (num) return valid(+num[1], +num[2])
41  const word = /([a-z]{3})[a-z]*\.?\s+(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]{3})/i.exec(s)
42  if (!word) return null
43  const m = MONTHS.indexOf((word[1] ?? word[4]).toLowerCase()) + 1
44  return m ? valid(m, +(word[2] ?? word[3])) : null
45}
46
47type Candidate = { name: string; handle: string; recent: number; groups: string[] }
48type Chat = { size: number; title: string; members: { name: string; handle: string }[] }
49
50const realName = (n: string) => n && !/^[+\d(]/.test(n) && !n.includes('@')
51
52async function chats($: Engine): Promise<Chat[]> {
53  const r = await $.process.run(['osascript', '-e', CHATS], { timeoutMs: 120_000 })
54  return r.stdout.split('\n').filter(Boolean).map(row => {
55    const [size, title, members = ''] = row.split('|')
56    const people = members.split(';').filter(Boolean).map(m => m.split('~'))
57    return { size: +size, title, members: people.map(([n, handle]) => ({ name: n.replace(/^Maybe:\s*/, '').trim(), handle })) }
58  })
59}
60
61// everyone you talk to, with the signals that say how close you are
62async function candidates($: Engine): Promise<Candidate[]> {
63  const people = new Map<string, Candidate>()
64  for (const [i, chat] of (await chats($)).entries()) {
65    for (const { name, handle } of chat.members.filter(m => realName(m.name))) {
66      const p = people.get(name) ?? { name, handle, recent: Infinity, groups: [] }
67      if (chat.size === 1) p.recent = Math.min(p.recent, i + 1)
68      else p.groups.push(chat.title || 'unnamed group')
69      people.set(name, p)
70    }
71  }
72  return [...people.values()]
73}
74
75const named = (p: Candidate) => p.groups.filter(g => g !== 'unnamed group')
76
77// let Sonnet use judgment (family, close friends) over the raw signals; recency if it can't
78async function rank($: Engine, list: Candidate[], me: string): Promise<string[]> {
79  const top = list.sort((a, b) => a.recent - b.recent || b.groups.length - a.groups.length).slice(0, 80)
80  const facts = top.map(p =>
81    [p.name, p.recent < Infinity && `texted 1:1 (#${p.recent} most recent)`, p.groups.length && `in ${p.groups.length} group chats with me${named(p).length ? `: ${named(p).slice(0, 4).join(', ')}` : ''}`]
82      .filter(Boolean)
83      .join(' · '),
84  )
85  const prompt = `I'm ${me || 'the user'}. Pick the 10 people whose birthdays I'd most regret forgetting, most important first. Family comes first (Mom, Dad, grandparents, siblings, anyone sharing my last name or in family group chats), then the friends I talk to most. Skip businesses and bots.
86
87${facts.join('\n')}
88
89Reply with only a JSON array of names, exactly as written above.`
90  const r = await $.model.complete({ model: 'claude-sonnet-5-5', prompt, maxTokens: 400, timeoutMs: 60_000 })
91  const names = new Set(top.map(p => p.name))
92  try {
93    if (r.isAnswered) return (JSON.parse(/\[[\s\S]*\]/.exec(r.text)![0]) as string[]).filter(n => names.has(n)).slice(0, 10)
94  } catch {}
95  return top.slice(0, 10).map(p => p.name)
96}
97
98async function load($: Engine): Promise<Bday[]> {
99  const today = new Date().toDateString()
100  let cache = (await $.store.get('contacts')) as { day: string; list: Bday[] } | undefined
101  if (cache?.day !== today) {
102    const r = await $.process.run(['osascript', '-l', 'JavaScript', '-e', CONTACTS], { timeoutMs: 90_000 })
103    cache = { day: today, list: r.exitCode ? [] : JSON.parse(r.stdout) }
104    await $.store.set('contacts', cache)
105  }
106  const manual = ((await $.store.get('manual')) as Bday[] | undefined) ?? []
107  return [...new Map([...cache!.list, ...manual].map(b => [b.name.toLowerCase(), b])).values()] // manual wins
108}
109
110// add or correct; a bare first name matches someone already known when it's unambiguous
111async function save($: Engine, name: string, month: number, day: number): Promise<Bday> {
112  const known = (await load($)).filter(b => b.name.toLowerCase() === name.toLowerCase() || first(b.name) === name.toLowerCase())
113  const b = { name: known.length === 1 ? known[0].name : name, month, day }
114  const manual = ((await $.store.get('manual')) as Bday[] | undefined) ?? []
115  await $.store.set('manual', [...manual.filter(x => x.name.toLowerCase() !== b.name.toLowerCase()), b])
116  return b
117}
118
119function until(b: Bday): number {
120  const now = new Date()
121  const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
122  const next = (y: number) => Math.round((Date.UTC(y, b.month - 1, b.day) - today) / DAY)
123  const d = next(now.getFullYear())
124  return d < -7 ? next(now.getFullYear() + 1) : d
125}
126
127function line(b: Bday): string {
128  const d = until(b)
129  if (d === 0) return `🎂 It's ${b.name}'s birthday today`
130  if (d === 1) return `🎂 ${b.name}'s birthday is tomorrow`
131  if (d > 0) return `🎂 ${b.name}'s birthday is in ${d} days`
132  return `🎂 You missed ${b.name}'s birthday ${-d} day${d === -1 ? '' : 's'} ago`
133}
134
135// Birthday today: 40% of turns for its first 3 showings, then 5%. Otherwise 5% for the week ahead / behind.
136async function pick($: Engine): Promise<Bday | null> {
137  const near = await unwished($, (await load($)).filter(b => until(b) <= 7))
138  const todays = near.filter(b => until(b) === 0)
139  if (!todays.length) return near.length && Math.random() < 0.05 ? any(near) : null
140  const day = new Date().toDateString()
141  const shown = (await $.store.get('shown')) as { day: string; n: Record<string, number> } | undefined
142  const n = (shown?.day === day && shown.n) || {}
143  const fresh = todays.filter(b => (n[b.name] ?? 0) < 3)
144  const b = Math.random() < (fresh.length ? 0.4 : 0.05) ? any(fresh.length ? fresh : todays) : null
145  if (!b) return null
146  n[b.name] = (n[b.name] ?? 0) + 1
147  await $.store.set('shown', { day, n })
148  return b
149}
150
151// Text or dismiss means handled: reminders for them stop for 10 days
152async function unwished($: Engine, list: Bday[]): Promise<Bday[]> {
153  const wished = ((await $.store.get('wished')) as Record<string, number> | undefined) ?? {}
154  return list.filter(b => !(Date.now() - (wished[b.name] ?? 0) < 10 * DAY))
155}
156
157async function markWished($: Engine, b: Bday) {
158  const wished = ((await $.store.get('wished')) as Record<string, number> | undefined) ?? {}
159  await $.store.set('wished', { ...wished, [b.name]: Date.now() })
160  $.ui.invalidate('ui.render') // the card goes away now
161}
162
163// the card: someone whose birthday is today or in the last 7 days, not yet wished
164async function due($: Engine): Promise<Bday | undefined> {
165  return (await unwished($, await load($))).filter(b => until(b) <= 0).sort((a, b) => until(b) - until(a))[0]
166}
167
168// their number or email: from your conversations, else from Contacts
169async function handleFor($: Engine, name: string): Promise<string> {
170  const people = (await chats($)).sort((a, b) => a.size - b.size).flatMap(c => c.members)
171  const fromChats = people.find(m => m.name === name)?.handle
172  if (fromChats) return fromChats
173  const js = `const p = Application('Contacts').people.whose({ name: ${JSON.stringify(name)} })()
174p.length ? p[0].phones.value()[0] || p[0].emails.value()[0] || '' : ''`
175  return (await $.process.run(['osascript', '-l', 'JavaScript', '-e', js])).stdout.trim()
176}
177
178// open Messages with the wish already typed; you press send
179async function textThem($: Engine, b: Bday) {
180  const handle = await handleFor($, b.name)
181  if (!handle) return $.ui.toast(`Couldn't find a number for ${b.name}`)
182  const wish = until(b) === 0 ? 'Happy birthday! 🎂' : 'Happy belated birthday! 🎂'
183  await $.process.run(['open', `sms:${handle}&body=${encodeURIComponent(wish)}`])
184  await markWished($, b)
185}
186
187// /seed: walk the people who matter most to you and fill in the birthdays GoodFriend doesn't know yet
188async function seed($: Engine): Promise<string> {
189  const known = new Set((await load($)).map(b => b.name.toLowerCase()))
190  const me = (await $.process.run(['osascript', '-l', 'JavaScript', '-e', "Application('Contacts').myCard().name()"])).stdout.trim()
191  const todo = await rank($, (await candidates($)).filter(p => !known.has(p.name.toLowerCase()) && p.name !== me), me)
192  if (!todo.length) return "🎂 You already know everyone's birthday. Good friend."
193
194  let saved = 0
195  for (const [i, name] of todo.entries()) {
196    const ask = `When's ${name}'s birthday? Type it under Other, like 10/7 or Oct 7.`
197    const a = await $.ui.ask(ask, { header: `🎂 ${i + 1} of ${todo.length}`, options: ['Skip', ENOUGH] }).catch(() => ENOUGH)
198    if (a === ENOUGH) break
199    const date = parseDate(a)
200    if (date) await save($, name, date[0], date[1]), saved++
201    else if (a !== 'Skip') $.ui.toast(`Couldn't read "${a}", skipped ${name}`)
202  }
203  return saved ? `🎂 Saved ${saved} birthday${saved === 1 ? '' : 's'}. Your friends are lucky to have you.` : 'Nothing saved. /seed any time.'
204}
205
206async function welcome($: Engine) {
207  const go = "Let's do it"
208  const ask = 'GoodFriend reminds you of birthdays right here while you work. Want to add birthdays for the people you text most? It takes a minute.'
209  const a = await $.ui.ask(ask, { header: 'GoodFriend', options: [go, 'Later'] }).catch(() => '')
210  $.ui.toast(a === go ? await seed($) : 'Any time: /seed')
211}
212
213export const register: Register = on => {
214  let current: Bday | null = null
215  let test = false
216
217  on('session.start', async ($, e, next) => {
218    await $.command.register({ name: 'goodfriend', description: 'Birthdays: list | add Name MM/DD | remove Name | test' })
219    await $.command.register({ name: 'seed', description: 'Fill in birthdays for the people you text most' })
220    await $.tool.register({
221      name: 'set_birthday',
222      description:
223        "Save or correct a friend's birthday in GoodFriend, the user's birthday reminder. Use when the user says a birthday is wrong or tells you someone's birthday. Call list_birthdays first to use the exact name GoodFriend has.",
224      inputSchema: {
225        type: 'object',
226        properties: { name: { type: 'string' }, month: { type: 'integer', minimum: 1, maximum: 12 }, day: { type: 'integer', minimum: 1, maximum: 31 } },
227        required: ['name', 'month', 'day'],
228      },
229    })
230    await $.tool.register({ name: 'list_birthdays', description: 'List the birthdays GoodFriend knows, as "Full Name: M/D".' })
231    if (!(await $.store.get('welcomed'))) await $.store.set('welcomed', true), void welcome($)
232    return next(e)
233  })
234
235  on('tool.call', { tool: 'mcp__goodfriend__set_birthday' }, async ($, e) => {
236    const { name, month, day } = e as unknown as { name: string; month: number; day: number } // a tool's arguments ride on the event itself
237    const b = await save($, name, month, day)
238    return { result: `Saved ${b.name}: ${b.month}/${b.day}` }
239  })
240
241  on('tool.call', { tool: 'mcp__goodfriend__list_birthdays' }, async $ => ({
242    result: (await load($)).map(b => `${b.name}: ${b.month}/${b.day}`).join('\n') || 'None yet',
243  }))
244
245  on('turn.start', async ($, e, next) => {
246    current = test ? (await load($)).sort((a, b) => until(a) - until(b))[0] : await pick($)
247    test = false
248    return next(e)
249  })
250
251  on('ui.render', { component: 'Spinner' }, ($, e, next) => next(current ? { ...e, props: { ...e.props, word: line(current) } } : e))
252
253  // the card above the prompt: on the day and up to a week late, until you text or dismiss
254  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
255    const b = e.props.hasSurvey ? undefined : await due($)
256    if (!b) return next(e)
257    const { Box, Button, Text } = $.ui.resolve(e)
258    return (
259      <Box columnGap={2} alignItems="center">
260        <Text>{line(b)}</Text>
261        <Box key="send" backgroundColor={ACCENT} paddingX={1}>
262          <Button key="text" plain label={`Text ${b.name.split(' ')[0]}`} onPress={() => textThem($, b)} />
263        </Box>
264        <Button key="dismiss" role="dismiss" label="Dismiss" onPress={() => markWished($, b)} />
265      </Box>
266    )
267  })
268
269  on('command.run', { command: 'seed' }, async $ => ({ text: await seed($) }))
270
271  on('command.run', { command: 'goodfriend' }, async ($, e) => {
272    const [cmd = '', ...rest] = e.args.trim().split(/\s+/)
273    const arg = rest.join(' ')
274
275    if (cmd === 'test') return (test = true), { text: 'Next turn shows the soonest birthday.' }
276    if (cmd === 'add') {
277      // the date is the last one or two words: "10/7", "Oct 7", "7th October"
278      const words = arg.split(' ')
279      const split = [2, 1].map(k => [words.slice(0, -k).join(' '), parseDate(words.slice(-k).join(' '))] as const).find(([n, d]) => n && d)
280      if (!split) return { text: 'Usage: /goodfriend add Full Name MM/DD' }
281      const [name, [month, day]] = split as [string, [number, number]]
282      const b = await save($, name, month, day)
283      return { text: `🎂 Saved ${b.name}: ${b.month}/${b.day}` }
284    }
285    if (cmd === 'remove') {
286      const manual = ((await $.store.get('manual')) as Bday[] | undefined) ?? []
287      await $.store.set('manual', manual.filter(b => b.name.toLowerCase() !== arg.toLowerCase()))
288      return { text: `Removed ${arg}` }
289    }
290
291    const list = (await load($)).sort((a, b) => until(a) - until(b))
292    const rows = list.map(b => `${until(b) === 0 ? '🎂' : '  '} ${b.name}: ${b.month}/${b.day}`)
293    return { text: rows.join('\n') || 'No birthdays yet. Try /seed' }
294  })
295}
296