Learn AI with LessonFolk: the learn, progress and review skills, and a side pane showing your course, its lessons and the current lesson's key ideas

<h1 align="center"> <img src="assets/brand/logo.svg" alt="LessonFolk" width="320"> </h1>
<a href="https://www.claudemarket.ai/plugins/cgseb-lessonfolk/lessonfolk-tutor"><img src="https://www.claudemarket.ai/badge-claudemarket.svg" alt="Listed on Claude Market" width="190" height="44" /></a>
https://github.com/user-attachments/assets/bc8eccbd-1f95-4d20-92f5-15d528b05d47
LessonFolk is a collection of open-source AI courses designed to be taught by an AI. Connect your AI chat (Claude Code, Codex, Claude Desktop, Cursor…) to LessonFolk, say "let's start", and it guides you through lessons in a conversation: explaining, asking questions, adapting to your level, and saving your progress.
LessonFolk is a website and an MCP server (Model Context Protocol, a standard way for AI apps to use tools). Your AI chat connects to it: it gets the courses and the tutor's instructions, and saves your progress there. The website shows your courses, your path and your progress.
The tutor asks about your level and interests, recommends a path of courses, and teaches one short lesson at a time. Courses are grouped by theme: Understanding AI, Using AI tools, Building with AI and AI and society. The full list, in the recommended order, is courses/en/index.yaml, or ask your tutor "what can I learn?".
Coming soon: Projects. Real projects to practise what the courses teach: a subject, a few milestones to reach, and your tutor as a coach who gives hints instead of the solution. Some will close a course, others will stand alone.
Pick one of two ways:
Hosted (lessonfolk.com): sign in with GitHub or Google, open its Connect page and add LessonFolk to your AI chat (in Claude Code, a plugin does it for you). Your progress is saved with your account.
Self-hosted: private, on your computer, no data sent to us. With Docker running:
git clone https://github.com/CGSeb/lessonfolk.git
cd lessonfolk
docker compose up -d --build
Open http://127.0.0.1:4321 to see the dashboard. Then start your AI chat in this folder:
claude and approve the lessonfolk MCP server when asked.codex mcp add lessonfolk --url http://localhost:4321/mcp once, then codex.Then say:
Let's start learning AI.
The learner guide has the details.
LessonFolk grows with its courses, and writing one needs no code. Open your AI agent in this folder and say "create a course about…": it interviews you, proposes an outline, writes the lessons with you and checks them. You are credited as the author on the dashboard.
LessonFolk is developed by CG Seb.
hooks/register.tsx 425 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Choices, IdeaMarks, LessonState, View } from '../types'
5import {
6 changes,
7 DEFAULT_INSTANCE,
8 instanceUrl,
9 load,
10 markIdea,
11 parseChoices,
12 PLUGIN_SERVER,
13 replyOf,
14 SERVER,
15 TOOL_PREFIX,
16 togglePick,
17} from './companion'
18import { type Lang, words } from './i18n'
19
20const PANE = 'course-companion'
21// The tools this mod registers: `mcp__<plugin>__<tool>`.
22const OWN_PREFIX = 'mcp__lessonfolk-tutor__'
23const TOOL = 'mcp__lessonfolk-tutor__key_idea'
24const CHOICES_TOOL = 'mcp__lessonfolk-tutor__choices'
25
26const NO_IDEAS: IdeaMarks = { covered: [] }
27
28const isActive = atom({ plugin: 'lessonfolk-tutor', key: 'isActive' } as const, false)
29const choices = atom({ plugin: 'lessonfolk-tutor', key: 'choices' } as const, null as Choices | null)
30const view = atom({ plugin: 'lessonfolk-tutor', key: 'view' } as const, { state: 'no-progress' } as View)
31const snapshot = atom({ plugin: 'lessonfolk-tutor', key: 'snapshot' } as const, null)
32const ideas = atom({ plugin: 'lessonfolk-tutor', key: 'ideas' } as const, NO_IDEAS)
33// The LessonFolk instance the Dashboard button opens (local or hosted).
34const instance = atom({ plugin: 'lessonfolk-tutor', key: 'instance' } as const, DEFAULT_INSTANCE)
35
36const isSkill = (name: string | undefined, skill: string) => name === skill || name?.endsWith(`:${skill}`)
37
38// The server's name as this session knows it: `lessonfolk` from `.mcp.json` or added by hand,
39// the installed plugin's own, or the name of the connector a hosted instance was added under,
40// learned from the tutor's first call to it. Until one answers, both known names are tried.
41let server: string | undefined
42
43const isLessonfolkTool = (tool: string) =>
44 tool.startsWith(TOOL_PREFIX) ||
45 (/^mcp__[^_].*lessonfolk.*?__/i.test(tool) && !tool.startsWith(OWN_PREFIX) && !tool.startsWith('mcp__course-'))
46
47// Calls a tool of the LessonFolk MCP server with the session's own connection (and sign-in),
48// so it reads the same progress as the tutor, from a local or a hosted instance.
49const callServer = ($: EngineInterface, failures: string[] = []) => async (tool: string, args?: Record<string, unknown>) => {
50 for (const name of server ? [server] : [SERVER, PLUGIN_SERVER]) {
51 try {
52 const { content, isError } = await $.mcp.call(name, tool, args)
53 const text = content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n')
54 if (isError) {
55 failures.push(`${name} ${tool}: ${text.slice(0, 120)}`)
56 continue
57 }
58 server = name
59
60 return text
61 } catch (error) {
62 // Not connected under this name: try the next one.
63 failures.push(`${name} ${tool}: ${error instanceof Error ? error.message : String(error)}`.slice(0, 160))
64 }
65 }
66
67 return undefined
68}
69
70// When the app refused the pane's own reads (auto permission mode, typically), the rules that
71// allow them, spelled for the server name each refusal came from.
72const refusedRules = (reason: string[] = []) => {
73 const names = [...new Set(reason.filter(line => /refused/i.test(line)).map(line => line.split(' ')[0] ?? ''))]
74
75 return names.flatMap(name =>
76 ['get_progress', 'list_courses'].map(tool => `mcp__${name.replace(/[:.]/g, '_')}__${tool}`),
77 )
78}
79
80// The settings.json fragment to paste: the pane's text cannot be selected, so it is copied.
81const rulesSnippet = (rules: string[]) => JSON.stringify({ permissions: { allow: rules } }, null, 2)
82
83// Re-reads the progress and the courses from the server; toasts what changed since the last read.
84async function refresh($: EngineInterface, isQuiet = false) {
85 // A relative path would resolve against the mod's own folder: read from the session's.
86 const root = (await $.session.cwd()).replace(/\\/g, '/')
87 const config = await $.fs.read(`${root}/.mcp.json`).then(text => text, () => undefined)
88 await update($, instance, () => instanceUrl(config))
89
90 const failures: string[] = []
91 const loaded = await load(callServer($, failures))
92 if (loaded.view.state === 'offline') loaded.view = { ...loaded.view, reason: [...new Set(failures)].slice(0, 3) }
93 const before = await read($, snapshot)
94 // Offline, the language of the last read is kept.
95 loaded.view.lang ??= lastLang
96 lastLang = loaded.view.lang
97 const previous = before ? { snapshot: before, view: await read($, view) } : undefined
98 await update($, view, () => loaded.view)
99 await update($, snapshot, () => loaded.snapshot)
100 await update($, ideas, marks =>
101 marks.lessonId && marks.lessonId === loaded.view.current?.id ? marks : NO_IDEAS,
102 )
103 if (!isQuiet) for (const text of changes(previous, loaded)) $.ui.toast(text)
104}
105
106// Whether this run of the app opened the pane yet. `isActive` is kept with the session and
107// survives a restart, but the pane does not, so the pane opens once per run of the module.
108let hasOpened = false
109
110// The learner's language as of the last read, for words shown outside a render.
111let lastLang: Lang | undefined
112
113// Opens the pane; when the app keeps it waiting undrawn, says why instead of failing silently.
114async function openPane($: EngineInterface) {
115 hasOpened = true
116 const text = words(lastLang)
117 const opened = await $.ui.open({ id: PANE, title: text.title }).catch((error: unknown) => ({
118 isPlaced: false as const,
119 reason: error instanceof Error ? error.message : String(error),
120 }))
121 if (!opened.isPlaced) $.ui.toast(text.waits(opened.reason))
122
123 return opened
124}
125
126// Learning has started: the commands band shows and the pane opens, once per run.
127async function activate($: EngineInterface) {
128 await update($, isActive, () => true)
129 if (!hasOpened) void openPane($)
130}
131
132export const register: Register = on => {
133 on('session.start', async ($, e, next) => {
134 await $.command.register({
135 name: 'course-companion',
136 description: 'Show the LessonFolk course companion pane',
137 })
138 await $.tool.register({
139 name: 'key_idea',
140 description:
141 'Updates the Course companion pane while you teach a LessonFolk lesson. Call it when ' +
142 'you start teaching a key idea of the current lesson (status "active") and when the ' +
143 'learner has understood it (status "done"). Key ideas are numbered from 1 in the ' +
144 'order of the lesson\'s "## Key ideas" section. Lesson progress comes from ' +
145 'the LessonFolk server on its own; this tool only marks key ideas.',
146 inputSchema: {
147 type: 'object',
148 properties: {
149 lesson: { type: 'string', description: 'Lesson id, e.g. ai-foundations/02-how-machines-learn' },
150 keyIdea: { type: 'integer', minimum: 1, description: 'Key idea number, from 1' },
151 status: {
152 type: 'string',
153 enum: ['active', 'done'],
154 description: 'Started teaching it, or understood (default active)',
155 },
156 },
157 required: ['lesson', 'keyIdea'],
158 },
159 })
160 await $.tool.register({
161 name: 'choices',
162 description:
163 'Shows the answers to the question you just asked the LessonFolk learner as buttons ' +
164 'above the prompt; a press sends that answer as the learner\'s reply. Call it after ' +
165 'asking a question with a fixed set of answers you listed: onboarding (experience, ' +
166 'goal, themes to explore), accepting a path, "continue now or stop here?", yes/no. ' +
167 'Never call it for "Check your understanding", review or level check questions: the ' +
168 'learner answers those in their own words. The learner can always type instead.',
169 inputSchema: {
170 type: 'object',
171 properties: {
172 options: {
173 type: 'array',
174 minItems: 2,
175 maxItems: 8,
176 description: 'The answers, in the order you listed them',
177 items: {
178 type: 'object',
179 properties: {
180 label: { type: 'string', description: 'Short button text (1-4 words)' },
181 reply: { type: 'string', description: 'What a press sends, when not the label' },
182 },
183 required: ['label'],
184 },
185 },
186 multiSelect: {
187 type: 'boolean',
188 description: 'True when the learner may pick several (then a Send button sends them)',
189 },
190 },
191 required: ['options'],
192 },
193 })
194 await refresh($, true)
195 // A resumed session where the learner was already learning gets its pane back.
196 if (await read($, isActive)) void openPane($)
197
198 return next(e)
199 })
200
201 on('command.run', { command: 'course-companion' }, async $ => {
202 await refresh($, true)
203 const opened = await openPane($)
204
205 return {
206 text: opened.isPlaced
207 ? 'Course companion pane opened.'
208 : `Course companion pane could not be shown: ${opened.reason}`,
209 }
210 })
211
212 // The learn skill starting is what opens the pane; review and progress keep it current.
213 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
214 const ran = await next(e)
215 if (ran.deny !== undefined || ran.isError) return ran
216 if (!['learn', 'review', 'progress'].some(skill => isSkill(e.skill, skill))) return ran
217
218 await refresh($, true)
219 if (!isSkill(e.skill, 'learn')) return ran
220
221 await activate($)
222
223 return {
224 ...ran,
225 context: [
226 ...(ran.context ?? []),
227 `A "Course companion" pane shows the learner their course, lessons and the current lesson's ` +
228 `key ideas. It follows the LessonFolk server on its own. While you teach, call ` +
229 `the ${TOOL} tool as you start each key idea (status "active") and once the learner ` +
230 `has it (status "done"). After asking a question with a fixed set of answers ` +
231 `(onboarding, path, continue or stop), call ${CHOICES_TOOL} so the learner can ` +
232 `answer with a button; never for check-your-understanding, review or level check questions.`,
233 ],
234 }
235 })
236
237 on('tool.call', { tool: TOOL }, async ($, e) => {
238 const input = (e as unknown as { input?: Record<string, unknown> }).input ??
239 (e as unknown as Record<string, unknown>)
240 const lesson = typeof input.lesson === 'string' ? input.lesson : undefined
241 const keyIdea = Number(input.keyIdea)
242 const status = input.status === 'done' ? 'done' : 'active'
243 if (!lesson || !Number.isInteger(keyIdea) || keyIdea < 1) {
244 return { result: 'Nothing updated: give the lesson id and a key idea number from 1.' }
245 }
246
247 const current = (await read($, view)).current
248 if (current?.id !== lesson) await refresh($, true)
249 await update($, ideas, marks => markIdea(marks, lesson, keyIdea, status))
250 await activate($)
251
252 return { result: 'Course companion pane updated.' }
253 })
254
255 on('tool.call', { tool: CHOICES_TOOL }, async ($, e) => {
256 const input = (e as unknown as { input?: Record<string, unknown> }).input ??
257 (e as unknown as Record<string, unknown>)
258 const offered = parseChoices(input)
259 if (!offered) return { result: 'Nothing shown: give 2 to 8 options, each with a label.' }
260
261 await update($, choices, () => offered)
262 // The tutor asking the learner something means a learning session: the band stays once
263 // the question is answered, even before any progress exists (onboarding).
264 await update($, isActive, () => true)
265
266 return { result: 'The answers are shown as buttons above the prompt.' }
267 })
268
269 // Any reply, pressed or typed, answers the question: its buttons go.
270 on('prompt.submit', async ($, e, next) => {
271 await update($, choices, () => null)
272
273 return next(e)
274 })
275
276 // Any call of the tutor to the LessonFolk server (a save, or reading the progress) is the
277 // moment to re-read it: that is the source of truth for lessons and courses.
278 let isRefreshing = false
279 on('tool.call', async ($, e, next) => {
280 if (!isLessonfolkTool(e.tool)) return next(e)
281 const ran = await next(e)
282 if (isRefreshing || ran.deny !== undefined || ran.isError) return ran
283
284 server = e.tool.split('__')[1] ?? server
285 isRefreshing = true
286 try {
287 await refresh($)
288 await activate($)
289 } finally {
290 isRefreshing = false
291 }
292
293 return ran
294 })
295
296 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
297 if (e.props.hasSurvey) return next(e)
298 const offered = await read($, choices)
299 if (!offered && !(await read($, isActive))) return next(e)
300
301 const { Box, Button, Link, Text } = $.ui.resolve(e)
302 const text = words((await read($, view)).lang)
303
304 // Always last: the pane, and the dashboard of the instance the tutor works with.
305 const tools = [
306 <Text key="sep" dimColor>·</Text>,
307 <Button key="pane" label={text.companion} onPress={() => void openPane($)} />,
308 <Link key="dashboard" href={await read($, instance)} label={text.dashboard} />,
309 ]
310
311 // The tutor's question takes the band until it is answered; then the commands come back.
312 if (offered) {
313 const send = (reply: string) => {
314 void update($, choices, () => null)
315 void $.prompt.submit({ text: reply, asUser: true })
316 }
317
318 return (
319 <Box flexWrap="wrap" gap={1}>
320 <Text dimColor>{text.answer}</Text>
321 {offered.options.map((o, i) => (
322 <Button
323 key={`choice-${i}`}
324 label={offered.isMulti && offered.picked.includes(i) ? `✓ ${o.label}` : o.label}
325 onPress={() =>
326 offered.isMulti ? void update($, choices, c => (c ? togglePick(c, i) : c)) : send(o.reply)
327 }
328 />
329 ))}
330 {offered.isMulti && offered.picked.length > 0 && (
331 <Button key="send" label={text.send} onPress={() => send(replyOf(offered))} />
332 )}
333 {tools}
334 </Box>
335 )
336 }
337
338 return (
339 <Box gap={1}>
340 <Text dimColor>{text.say}</Text>
341 {text.commands.map(c => (
342 <Button key={c.key} label={c.label} onPress={() => void $.prompt.submit({ text: c.text, asUser: true })} />
343 ))}
344 {tools}
345 </Box>
346 )
347 })
348
349 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
350 const { Box, Button, Text } = $.ui.resolve(e)
351 const v = await read($, view)
352 const marks = await read($, ideas)
353 const text = words(v.lang)
354
355 if (v.state === 'no-progress' || v.state === 'offline' || v.state === 'all-done') {
356 const lines = {
357 'no-progress': text.noProgress,
358 offline: text.offline,
359 'all-done': text.allDone,
360 }[v.state]
361
362 const rules = refusedRules(v.reason)
363 const hint = rules.length > 0 ? text.refused : (v.reason ?? [])
364
365 return (
366 <Box flexDirection="column">
367 {[...lines, ...hint, ...rules].map(line => (
368 <Text dimColor>{line}</Text>
369 ))}
370 {rules.length > 0 && (
371 <Button
372 key="copy-rules"
373 label={text.copyRules}
374 onPress={async press => {
375 const { isCopied } = await $.ui.copy({ text: rulesSnippet(rules), surface: press.surface })
376 $.ui.toast(isCopied ? text.copied : text.copyFailed)
377 }}
378 />
379 )}
380 </Box>
381 )
382 }
383
384 const lessons = v.lessons ?? []
385 const finished = lessons.filter(l => l.status === 'done' || l.status === 'skipped').length
386 const mark = (s: LessonState['status']) =>
387 ({ done: '✓', skipped: '↷', current: '●', todo: '○' })[s]
388 const ideaMark = (i: number) =>
389 marks.covered.includes(i) ? '✓' : marks.active === i ? '●' : '○'
390
391 return (
392 <Box flexDirection="column">
393 <Text bold>{v.courseTitle}</Text>
394 {(v.themeTitle || v.level) && (
395 <Text dimColor>{[v.themeTitle, v.level && (text.levels[v.level] ?? v.level)].filter(Boolean).join(' · ')}</Text>
396 )}
397 <Text> </Text>
398 <Text bold>
399 {text.lessons} {finished}/{lessons.length}
400 </Text>
401 {lessons.map(l => (
402 <Text dimColor={l.status !== 'current'} bold={l.status === 'current'}>
403 {mark(l.status)} {l.title}
404 {l.status === 'skipped' ? ` ${text.skipped}` : ''}
405 </Text>
406 ))}
407 {v.current && v.current.ideas.length > 0 && <Text> </Text>}
408 {v.current && v.current.ideas.length > 0 && <Text bold>{text.keyIdeas}</Text>}
409 {v.current?.ideas.map((idea, i) => (
410 <Text dimColor={ideaMark(i + 1) === '✓'} bold={ideaMark(i + 1) === '●'}>
411 {ideaMark(i + 1)} {i + 1}. {idea}
412 </Text>
413 ))}
414 {v.next && <Text> </Text>}
415 {v.next && (
416 <Text>
417 {text.next} {v.next.title}
418 {v.next.courseTitle ? ` (${v.next.courseTitle})` : ''}
419 </Text>
420 )}
421 </Box>
422 )
423 })
424}
425hooks/companion.ts 219 lines1// Pure logic of the Course companion: reads the learner's progress and the courses from the
2// LessonFolk MCP server through a `call` function, so it runs the same in the mod and in its
3// tests, with a local or a hosted instance.
4
5import type { Choices, IdeaMarks, LessonState, Snapshot, View } from '../types'
6import { langOf, words } from './i18n'
7
8// Calls a tool of the LessonFolk MCP server: the tool's text result, or undefined when it
9// failed or the server could not be reached.
10export type Caller = (tool: string, args?: Record<string, unknown>) => Promise<string | undefined>
11
12// The name of the server in `.mcp.json` (what `claude mcp add lessonfolk …` registers).
13export const SERVER = 'lessonfolk'
14export const TOOL_PREFIX = `mcp__${SERVER}__`
15// The same server when the installed plugin brought it (the marketplace entry declares it).
16export const PLUGIN_SERVER = `plugin:lessonfolk-tutor:${SERVER}`
17
18type Progress = {
19 profile?: { level?: string; language?: string }
20 path?: string[]
21 current?: string | null
22 lessons?: Record<string, { status?: string }>
23}
24
25type CourseSummary = {
26 id: string
27 title: string
28 level?: string
29 themeTitle?: string
30 lessons: { id: string; title: string; prerequisites?: string[]; status?: string }[]
31}
32
33// The `## Key ideas` items: their bold lead when they have one, else their first words.
34export const keyIdeas = (lesson: string): string[] => {
35 const section = /^## Key ideas\s*$([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(lesson)?.[1] ?? ''
36
37 return [...section.matchAll(/^\d+\.\s+(.+)$/gm)].map(m => {
38 const text = m[1] ?? ''
39 const bold = /^\*\*(.+?)\*\*/.exec(text)?.[1]
40 const label = (bold ?? text).replace(/\*\*|\*|`/g, '').replace(/[.:]\s*$/, '').trim()
41
42 return label.length > 70 ? `${label.slice(0, 67).trimEnd()}…` : label
43 })
44}
45
46// `list_courses` statuses: not_started, in_progress, done, skipped, skipped_after_level_check.
47const isFinished = (status?: string) =>
48 status === 'done' || status === 'skipped' || status === 'skipped_after_level_check'
49
50const courseOf = (lessonId: string) => lessonId.split('/')[0] ?? lessonId
51
52const parseJson = <T>(text?: string): T | undefined => {
53 if (text === undefined) return undefined
54 try {
55 const parsed = JSON.parse(text) as T
56
57 return parsed && typeof parsed === 'object' ? parsed : undefined
58 } catch {
59 return undefined
60 }
61}
62
63export const statusesOf = (progress?: Progress): Snapshot['statuses'] =>
64 Object.fromEntries(
65 Object.entries(progress?.lessons ?? {}).map(([id, l]) => [id, l?.status ?? 'in_progress']),
66 )
67
68export type Loaded = { view: View; snapshot: Snapshot }
69
70// Builds the pane's view: the course in focus (the current lesson's, else the next lesson's),
71// its lessons, the current lesson's key ideas and the next lesson, following the order of
72// "Start or resume" (the learner's path first, then the catalog order of list_courses).
73export const load = async (call: Caller): Promise<Loaded> => {
74 const progress = parseJson<Progress>(await call('get_progress'))
75 const catalog = parseJson<CourseSummary[]>(await call('list_courses'))
76 const statuses = statusesOf(progress)
77 const snapshot: Snapshot = { current: progress?.current ?? undefined, statuses }
78 const lang = progress ? langOf(progress.profile?.language) : undefined
79
80 if (!progress || !Array.isArray(catalog)) return { view: { state: 'offline', lang }, snapshot }
81 if (!progress.profile?.level && Object.keys(statuses).length === 0) {
82 return { view: { state: 'no-progress', lang }, snapshot }
83 }
84
85 const byId = new Map(catalog.map(course => [course.id, course]))
86 const order = [...(progress.path ?? []).filter(id => byId.has(id))]
87 for (const course of catalog) if (!order.includes(course.id)) order.push(course.id)
88
89 const lessonTitle = (id: string) =>
90 catalog.flatMap(course => course.lessons).find(lesson => lesson.id === id)?.title ?? id
91
92 // The first unfinished lesson whose prerequisites are finished; `current` counts as finished.
93 const done = (id: string) => isFinished(statuses[id]) || id === progress.current
94 const next = order
95 .flatMap(id => byId.get(id)?.lessons ?? [])
96 .find(lesson => !done(lesson.id) && (lesson.prerequisites ?? []).every(done))?.id
97
98 const current = progress.current && statuses[progress.current] !== undefined &&
99 !isFinished(statuses[progress.current])
100 ? progress.current
101 : undefined
102 const focus = current ?? next
103 if (!focus) return { view: { state: 'all-done', lang }, snapshot }
104
105 const courseId = courseOf(focus)
106 const course = byId.get(courseId)
107 const lessons: LessonState[] = (course?.lessons ?? []).map(lesson => ({
108 id: lesson.id,
109 title: lesson.title,
110 status: lesson.id === current
111 ? 'current'
112 : isFinished(statuses[lesson.id]) ? (statuses[lesson.id] === 'done' ? 'done' : 'skipped') : 'todo',
113 }))
114 const ideas = current ? keyIdeas((await call('get_lesson', { lessonId: current })) ?? '') : []
115
116 return {
117 view: {
118 state: current ? 'lesson' : 'between',
119 lang,
120 courseId,
121 courseTitle: course?.title ?? courseId,
122 themeTitle: course?.themeTitle,
123 level: course?.level,
124 lessons,
125 current: current ? { id: current, title: lessonTitle(current), ideas } : undefined,
126 next: next
127 ? {
128 id: next,
129 title: lessonTitle(next),
130 courseTitle: courseOf(next) === courseId ? undefined : byId.get(courseOf(next))?.title ?? courseOf(next),
131 }
132 : undefined,
133 },
134 snapshot,
135 }
136}
137
138// The LessonFolk instance the Dashboard button opens: the origin of the `lessonfolk` url in the
139// project's `.mcp.json` (the MCP endpoint is `<instance>/mcp`), as in a clone of the repository;
140// otherwise the hosted instance, the one the installed plugin connects to (the same address as
141// in `.claude-plugin/marketplace.json`).
142export const DEFAULT_INSTANCE = 'https://lessonfolk.com/'
143
144export const instanceUrl = (mcpJson?: string): string => {
145 try {
146 const config = JSON.parse(mcpJson ?? '{}') as { mcpServers?: Record<string, { url?: string }> }
147 const url = new URL(config.mcpServers?.[SERVER]?.url ?? '')
148
149 return url.protocol === 'http:' || url.protocol === 'https:' ? `${url.origin}/` : DEFAULT_INSTANCE
150 } catch {
151 return DEFAULT_INSTANCE
152 }
153}
154
155// What changed between two reads of the progress, worth a toast.
156export const changes = (previous: Loaded | undefined, loaded: Loaded): string[] => {
157 if (!previous) return []
158 const { snapshot: before } = previous
159 const { snapshot: after, view } = loaded
160 const toasts: string[] = []
161 const text = words(view.lang)
162 const titleOf = (id: string) =>
163 [...(view.lessons ?? []), ...(previous.view.lessons ?? [])].find(l => l.id === id)?.title ?? id
164
165 for (const [id, status] of Object.entries(after.statuses)) {
166 if (status === 'done' && before.statuses[id] !== 'done') toasts.push(text.lessonComplete(titleOf(id)))
167 }
168
169 const course = after.current ? courseOf(after.current) : undefined
170 const hadStarted = Object.entries(before.statuses).some(
171 ([id, status]) => courseOf(id) === course && (isFinished(status) || id === before.current),
172 )
173 if (course && after.current !== before.current && !hadStarted && view.courseId === course) {
174 toasts.push(text.newCourse(view.courseTitle ?? course, view.themeTitle))
175 }
176
177 return toasts
178}
179
180// The tutor's report on a key idea: `active` starts it, `done` finishes it; earlier ones are covered.
181export const markIdea = (marks: IdeaMarks, lessonId: string, keyIdea: number, status: 'active' | 'done'): IdeaMarks => {
182 const covered = marks.lessonId === lessonId ? marks.covered : []
183 const upTo = status === 'done' ? keyIdea : keyIdea - 1
184 const all = new Set(covered)
185 for (let i = 1; i <= upTo; i++) all.add(i)
186
187 return {
188 lessonId,
189 covered: [...all].sort((a, b) => a - b),
190 active: status === 'active' ? keyIdea : undefined,
191 }
192}
193
194// The tutor's offered answers, from the choices tool's input: 2 to 8 options with a label.
195export const parseChoices = (input: Record<string, unknown>): Choices | undefined => {
196 const raw = Array.isArray(input.options) ? (input.options as unknown[]) : []
197 const options = raw.flatMap(option => {
198 const o = (typeof option === 'string' ? { label: option } : option ?? {}) as Record<string, unknown>
199 const label = typeof o.label === 'string' ? o.label.trim() : ''
200 const reply = typeof o.reply === 'string' && o.reply.trim() ? o.reply.trim() : label
201
202 return label ? [{ label, reply }] : []
203 })
204 if (options.length < 2 || options.length > 8) return undefined
205
206 return { options, isMulti: input.multiSelect === true, picked: [] }
207}
208
209// What the learner sends: the picked options' replies, in the order the tutor listed them.
210export const replyOf = (choices: Choices) =>
211 choices.options.filter((_, i) => choices.picked.includes(i)).map(o => o.reply).join(', ')
212
213export const togglePick =(choices: Choices, index: number): Choices => ({
214 ...choices,
215 picked: choices.picked.includes(index)
216 ? choices.picked.filter(i => i !== index)
217 : [...choices.picked, index].sort((a, b) => a - b),
218})
219hooks/i18n.ts 100 lines1// The words of the Course companion (pane, buttons, toasts) in the learner's language, which
2// is the language saved in their profile (`get_progress`). English is the fallback. What the
3// mod tells the model (tool descriptions and results) stays in English.
4
5export type Lang = 'en' | 'fr'
6
7// A profile language code ("fr", "fr-CA"…) to a language the mod has words for.
8export const langOf = (code?: string): Lang =>
9 code?.trim().toLowerCase().split(/[-_]/)[0] === 'fr' ? 'fr' : 'en'
10
11type Words = {
12 title: string
13 // The learner commands offered as buttons: what the button says, and what a press sends.
14 commands: { key: string; label: string; text: string }[]
15 say: string
16 answer: string
17 send: string
18 companion: string
19 dashboard: string
20 lessons: string
21 keyIdeas: string
22 next: string
23 skipped: string
24 levels: Record<string, string>
25 noProgress: string[]
26 offline: string[]
27 allDone: string[]
28 refused: string[]
29 copyRules: string
30 copied: string
31 copyFailed: string
32 lessonComplete: (title: string) => string
33 newCourse: (course: string, theme?: string) => string
34 waits: (reason: string) => string
35}
36
37const en: Words = {
38 title: 'Course companion',
39 commands: [
40 { key: 'continue', label: 'Continue', text: 'continue' },
41 { key: 'quiz', label: 'Quiz me', text: 'quiz me' },
42 { key: 'skip', label: 'Skip', text: 'skip this' },
43 { key: 'progress', label: 'My progress', text: 'show my progress' },
44 ],
45 say: 'Say:',
46 answer: 'Answer:',
47 send: 'Send',
48 companion: 'Companion',
49 dashboard: 'Dashboard ↗',
50 lessons: 'Lessons',
51 keyIdeas: 'Key ideas',
52 next: 'Next:',
53 skipped: '(skipped)',
54 levels: {},
55 noProgress: ['No course started yet.', 'Say "start" to begin.'],
56 offline: ['Your progress could not be read.', 'Check that the LessonFolk server is connected (/mcp).'],
57 allDone: ['You have finished every course. Well done!', 'Ask the tutor what to explore next.'],
58 refused: ["The app refused the pane's read.", 'Allow it in ~/.claude/settings.json (permissions.allow):'],
59 copyRules: 'Copy rules',
60 copied: 'Copied: paste it into ~/.claude/settings.json',
61 copyFailed: 'Could not copy to the clipboard',
62 lessonComplete: title => `Lesson complete: ${title}`,
63 newCourse: (course, theme) => `New course: ${course}${theme ? ` (${theme})` : ''}`,
64 waits: reason => `Course companion waits: ${reason}`,
65}
66
67const fr: Words = {
68 title: 'Compagnon de cours',
69 commands: [
70 { key: 'continue', label: 'Continuer', text: 'continue' },
71 { key: 'quiz', label: 'Interroge-moi', text: 'interroge-moi' },
72 { key: 'skip', label: 'Passer', text: 'passe cette leçon' },
73 { key: 'progress', label: 'Ma progression', text: 'montre ma progression' },
74 ],
75 say: 'Dites :',
76 answer: 'Répondez :',
77 send: 'Envoyer',
78 companion: 'Compagnon',
79 dashboard: 'Tableau de bord ↗',
80 lessons: 'Leçons',
81 keyIdeas: 'Idées clés',
82 next: 'Suivante :',
83 skipped: '(passée)',
84 levels: { beginner: 'débutant', intermediate: 'intermédiaire', advanced: 'avancé' },
85 noProgress: ['Aucun cours commencé pour l’instant.', 'Dites « commencer » pour démarrer.'],
86 offline: ['Votre progression n’a pas pu être lue.', 'Vérifiez que le serveur LessonFolk est connecté (/mcp).'],
87 allDone: ['Vous avez terminé tous les cours. Bravo !', 'Demandez au tuteur quoi explorer ensuite.'],
88 refused: ['L’application a refusé la lecture du panneau.', 'Autorisez-la dans ~/.claude/settings.json (permissions.allow) :'],
89 copyRules: 'Copier les règles',
90 copied: 'Copié : collez-le dans ~/.claude/settings.json',
91 copyFailed: 'Impossible de copier dans le presse-papiers',
92 lessonComplete: title => `Leçon terminée : ${title}`,
93 newCourse: (course, theme) => `Nouveau cours : ${course}${theme ? ` (${theme})` : ''}`,
94 waits: reason => `Le compagnon de cours attend : ${reason}`,
95}
96
97const WORDS: Record<Lang, Words> = { en, fr }
98
99export const words = (lang?: Lang): Words => WORDS[lang ?? 'en']
100types/index.d.ts 48 lines1export type LessonState = {
2 id: string
3 title: string
4 status: 'done' | 'skipped' | 'current' | 'todo'
5}
6
7export type View = {
8 state: 'no-progress' | 'offline' | 'all-done' | 'lesson' | 'between'
9 // The learner's language (profile), for the words of the pane; unknown when offline.
10 lang?: 'en' | 'fr'
11 courseId?: string
12 courseTitle?: string
13 themeTitle?: string
14 level?: string
15 lessons?: LessonState[]
16 current?: { id: string; title: string; ideas: string[] }
17 next?: { id: string; title: string; courseTitle?: string }
18 // Offline only: why each attempt to reach the server failed.
19 reason?: string[]
20}
21
22// Lesson statuses as last read from the server, to spot what a save changed.
23export type Snapshot = { current?: string; statuses: Record<string, string> }
24
25// Key ideas of `lessonId` the tutor reported: 1-based numbers.
26export type IdeaMarks = { lessonId?: string; covered: number[]; active?: number }
27
28// The answers the tutor offered for the question it just asked, shown as buttons above the prompt.
29export type Choices = {
30 options: { label: string; reply: string }[]
31 isMulti: boolean
32 picked: number[]
33}
34
35
36declare module 'claude-code' {
37 interface PluginState {
38 'lessonfolk-tutor': {
39 isActive: boolean
40 instance: string
41 choices: Choices | null
42 view: View
43 snapshot: Snapshot | null
44 ideas: IdeaMarks
45 }
46 }
47}
48