Pane, status line and a no-model /study-panel command for the study-* skills

Claude Code skills to build and follow a study plan on any topic: a certification, a framework, a book or a course, with any duration and cadence.
Claude builds the syllabus from verified official sources and splits it into sessions with readings, an analogy, practice and optional videos. Each session ends with an evaluation; errors are corrected one at a time, and your notes are kept in your own words. The plan is a queue of sessions, not a calendar: miss a week and nothing breaks. Dates are re-projected and you pick up where you left off.
Are you an agent asked to install this? Follow INSTALL.md.
Requirements: Claude Code and Python 3.9 or newer (python3). Nothing else to install.
git clone https://github.com/kleyver14/study-skills.git study-skills
cd study-skills
./install.sh # copies the skills into ~/.claude/skills/
# ./install.sh --link # or symlinks them, so `git pull` updates them
Then open a new Claude Code session. You can also hand the repo link to your agent and ask it to "install these skills": it installs or updates as needed.
From the new version's folder:
./install.sh --check # installed version vs this folder's, and whether the panel is installed
./install.sh --force --panel # update the skills and the panel (drop --panel if you do not use it)
The previous skills are kept in ~/.study/backup/, and your plans are never touched. Open a new session afterwards. CHANGELOG.md lists what changed.
| Command | What it does | Also triggered by |
|---|---|---|
/study-new | Interviews you and generates the plan: PLAN.md plus one file per session | "I want to learn X", "I need to get certified in Y" |
/study-next | Opens the next pending session and tells you if you are behind | "what do I study today", "let's continue the plan" |
/study-eval | Evaluates the current session, or runs a mock exam | "quiz me", "test me", "mock exam" |
/study-close | Asks you to explain what you learned in your own words and closes the session | "done for today", "let's close the session" |
/study-status | Shows how you are doing, your projected end at your real pace, and switches plans | "how am I doing", "my exam date moved" |
Each session's cycle is /study-next → study → /study-eval → /study-close. The triggers work in any language: "hazme la evaluación" or "cómo voy" work as well.
| Mode | What it looks like | Default for |
|---|---|---|
--clicks | Dialogs where you click the options (batches of 3 questions plus "which ones did you guess?"), multi-select included | Evaluations of 12 questions or fewer |
--text | Every question in one message; you answer 1. A, 2. C? (? marks a guess) | Mocks and diagnostics, so you can review everything before submitting, as in the real exam |
Examples: /study-eval --text, /study-eval mock --clicks, or just say it: "quiz me with clicks". Questions with 5 or more options and open questions always go as text, because the dialog shows at most 4 options. In both modes the position of the correct answer is randomized.
/study-new i want to learn laravel 12 gives an English plan; "quiero estudiar AWS" gives a Spanish one. Say it explicitly to override ("the files in English"). The summary before generating shows it, so you can change it there./study-new interviewFour short screens:
<folder you choose>/
├── PLAN.md config, calendar, concepts to fix, evaluation log
├── diagnostic.md starting point
└── sessions/
├── session-01-<topic>.md
└── …
~/.study/ registry of your plans and which one is active
Everything is local Markdown: read it, version it, edit it. Each session's state lives in its frontmatter, and the PLAN.md sections between <!-- study:… --> markers are regenerated by the skills, so do not edit those by hand. You can run several plans and switch between them with /study-status all.
mods/study-companion adds a panel to Claude Code (terminal and desktop app) with where your active plan stands: progress, next session, drift, projected end, buffer, concepts to fix and the last evaluation, plus buttons for the next step (Open S05, Evaluate or Close session, depending on where the session is) and the full status.
/study-panel opens it, answering from study_state.py without calling the model.📚 my-plan · S05 · 4/28 · 7 behind shows only in study sessions: after a study-* skill runs, after /study-panel, or when the session opens in the plan folder.It is a Claude Code plugin, installed apart from the skills (which work without it). From the repo folder:
./install.sh --panel # skills already installed: adds only the panel; run again to update it
New sessions load it. Mods are an early-access Claude Code feature, so their API may change.
verify_links.py before it is written, and every claim in a session is backed by a reading.<placeholders> and respect the access level you declared. If something fails for permissions, that area's level goes down.skills/
├── study-new/ study-next/ study-eval/ study-close/ study-status/ one SKILL.md per command
└── study-shared/
├── DESIGN.md the spec and the reason behind each decision
├── scripts/ study_state.py (state), verify_links.py, platzi.py (stdlib only)
├── templates/ plan, session and diagnostic
└── references/ evaluation protocol, recovery, practice, videos, language, labels
mods/study-companion/ optional panel, status line and /study-panel (Claude Code plugin)
.claude-plugin/ marketplace that lists the mod
tests/ unittest, no network
install.sh
Run the tests: cd tests && python3 -m unittest test_study_state test_verify_links test_platzi.
AskUserQuestion. Untested on other agents../install.sh --uninstall # removes the skills; your plans and ~/.study stay untouched
./install.sh --uninstall --panel # the same, plus the panel
Changes per version: CHANGELOG.md.
hooks/register.tsx 348 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StudyEval, StudyNext, StudySummary } from '../types'
5
6const PANE = 'study'
7const REFRESH_MS = 10 * 60 * 1000
8const IDLE_DAYS = 7
9const summary = atom({ plugin: 'study-companion', key: 'summary' } as const, null)
10const problem = atom({ plugin: 'study-companion', key: 'problem' } as const, null)
11// Set once the session studies: a study-* skill ran, /study-panel, or it opened in the plan folder.
12const isActive = atom({ plugin: 'study-companion', key: 'isActive' } as const, false)
13// The pane opens by itself once per session, on the first study command.
14const wasPaneOpened = atom({ plugin: 'study-companion', key: 'wasPaneOpened' } as const, false)
15
16type Words = typeof WORDS.en
17
18const WORDS = {
19 en: {
20 title: 'Study',
21 loading: 'Reading the active plan…',
22 next: 'Next',
23 noNext: 'Plan finished',
24 planned: 'planned',
25 onTrack: 'On track',
26 behind: (n: number) => `${n} session${n === 1 ? '' : 's'} behind`,
27 ahead: (n: number) => `${n} session${n === 1 ? '' : 's'} ahead`,
28 behindShort: 'behind',
29 buffer: 'Buffer',
30 end: 'End',
31 plan: 'plan',
32 atPace: 'at your pace',
33 concepts: 'Concepts to fix',
34 lastEval: 'Last evaluation',
35 passed: 'passed',
36 failed: 'not passed',
37 none: 'none yet',
38 idle: (n: number) => `${n} days without studying`,
39 status: { pending: 'pending', studied: 'open', evaluated: 'evaluated', closed: 'closed' } as Record<string, string>,
40 open: (n: string) => `Open ${n}`,
41 evaluate: 'Evaluate',
42 close: 'Close session',
43 details: 'Full status',
44 refresh: 'Refresh',
45 hide: 'Close pane',
46 running: (command: string) => `Running /${command}…`,
47 opened: 'Study pane opened.',
48 nudge: (days: number, slug: string) => `${days} days since you last studied ${slug} · /study-next`,
49 },
50 es: {
51 title: 'Estudio',
52 loading: 'Leyendo el plan activo…',
53 next: 'Próxima',
54 noNext: 'Plan terminado',
55 planned: 'prevista',
56 onTrack: 'Al día',
57 behind: (n: number) => `${n} ${n === 1 ? 'sesión' : 'sesiones'} de atraso`,
58 ahead: (n: number) => `${n} ${n === 1 ? 'sesión' : 'sesiones'} adelantado`,
59 behindShort: 'atrás',
60 buffer: 'Buffer',
61 end: 'Fin',
62 plan: 'plan',
63 atPace: 'a tu ritmo',
64 concepts: 'Conceptos por corregir',
65 lastEval: 'Última evaluación',
66 passed: 'aprobada',
67 failed: 'no aprobada',
68 none: 'ninguna aún',
69 idle: (n: number) => `${n} días sin estudiar`,
70 status: { pending: 'pendiente', studied: 'abierta', evaluated: 'evaluada', closed: 'cerrada' } as Record<string, string>,
71 open: (n: string) => `Abrir ${n}`,
72 evaluate: 'Evaluar',
73 close: 'Cerrar sesión',
74 details: 'Estado completo',
75 refresh: 'Actualizar',
76 hide: 'Cerrar panel',
77 running: (command: string) => `Ejecutando /${command}…`,
78 opened: 'Panel de estudio abierto.',
79 nudge: (days: number, slug: string) => `Hace ${days} días que no estudias ${slug} · /study-next`,
80 },
81}
82
83const words = (language: string): Words => (language === 'es' ? WORDS.es : WORDS.en)
84
85const pad = (n: number) => `S${String(n).padStart(2, '0')}`
86
87// Concept rows whose status cell is anything but consolidated.
88export function countPending(plan: string): number {
89 const start = plan.indexOf('<!-- study:concepts:begin -->')
90 const end = plan.indexOf('<!-- study:concepts:end -->')
91 if (start === -1 || end === -1) return 0
92 const states = ['pending', 'explained', 'confirmed', 'consolidated']
93 return plan
94 .slice(start, end)
95 .split('\n')
96 .map(line => line.split('|').map(cell => cell.trim()))
97 .map(cells => cells.find(cell => states.some(s => cell.startsWith(s))))
98 .filter(state => state !== undefined && !state.startsWith('consolidated')).length
99}
100
101type RawStatus = {
102 total: number
103 closed: number
104 next: { session: number; title: string; status: string; planned_date: string | null } | null
105 behind_sessions: number
106 ahead_sessions: number
107 buffer_total: number
108 buffer_left: number
109 planned_end: string | null
110 projected_end: string | null
111 last_eval: StudyEval | null
112 days_since_last_activity: number | null
113 plan: { slug: string; topic: string; language: string }
114}
115
116export function toSummary(raw: RawStatus, pendingConcepts: number): StudySummary {
117 const next: StudyNext | null = raw.next
118 ? { session: raw.next.session, title: raw.next.title, status: raw.next.status, plannedDate: raw.next.planned_date }
119 : null
120 return {
121 slug: raw.plan.slug,
122 topic: raw.plan.topic,
123 language: raw.plan.language,
124 closed: raw.closed,
125 total: raw.total,
126 next,
127 behind: raw.behind_sessions,
128 ahead: raw.ahead_sessions,
129 bufferLeft: raw.buffer_left,
130 bufferTotal: raw.buffer_total,
131 plannedEnd: raw.planned_end,
132 projectedEnd: raw.projected_end,
133 lastEval: raw.last_eval,
134 daysIdle: raw.days_since_last_activity,
135 pendingConcepts,
136 }
137}
138
139async function resolvePlan($: EngineInterface): Promise<{ script: string; path: string } | null> {
140 const home = await $.env.get('HOME')
141 const script = `${home}/.claude/skills/study-shared/scripts/study_state.py`
142 const resolved = await $.process.run(['python3', script, 'registry', 'resolve'])
143 return resolved.exitCode === 0 ? { script, path: resolved.stdout.trim() } : null
144}
145
146async function load($: EngineInterface): Promise<StudySummary | string> {
147 const plan = await resolvePlan($)
148 if (!plan) return 'No active study plan: /study-new'
149 const { script, path } = plan
150 const status = await $.process.run(['python3', script, 'status', path])
151 if (status.exitCode !== 0) return `study_state.py failed: ${status.stderr.trim().slice(0, 160)}`
152 const planText = await $.fs.read(`${path}/PLAN.md`).then(text => (typeof text === 'string' ? text : ''), () => '')
153 return toSummary(JSON.parse(status.stdout) as RawStatus, countPending(planText))
154}
155
156export function statusLine(s: StudySummary): string {
157 const t = words(s.language)
158 const at = s.next ? pad(s.next.session) : t.noNext
159 const drift = s.behind > 0 ? ` · ${s.behind} ${t.behindShort}` : ''
160 return `📚 ${s.slug} · ${at} · ${s.closed}/${s.total}${drift}`
161}
162
163export function summaryText(s: StudySummary): string {
164 const t = words(s.language)
165 const lines = [`**${s.topic}** · ${s.closed}/${s.total}`]
166 if (s.next) {
167 const state = t.status[s.next.status] ?? s.next.status
168 lines.push(`${t.next}: ${pad(s.next.session)} · ${s.next.title} (${state})`)
169 }
170 lines.push(s.behind > 0 ? t.behind(s.behind) : s.ahead > 0 ? t.ahead(s.ahead) : t.onTrack)
171 lines.push(`${t.end}: ${t.plan} ${s.plannedEnd ?? '—'} · ${t.atPace} ${s.projectedEnd ?? '—'}`)
172 lines.push(`${t.concepts}: ${s.pendingConcepts} · ${t.buffer} ${s.bufferLeft}/${s.bufferTotal}`)
173 if (s.daysIdle !== null && s.daysIdle >= IDLE_DAYS) lines.push(t.idle(s.daysIdle))
174 return lines.join('\n')
175}
176
177async function activate($: EngineInterface): Promise<StudySummary | null> {
178 await update($, isActive, () => true)
179
180 return refresh($)
181}
182
183// Only a study session reads the plan and shows the status line.
184async function refresh($: EngineInterface): Promise<StudySummary | null> {
185 if (!(await read($, isActive))) {
186 $.ui.status(undefined)
187 return null
188 }
189 const loaded = await load($).catch((error: unknown) => `study-companion: ${String(error)}`)
190 if (typeof loaded === 'string') {
191 await update($, problem, () => loaded)
192 $.ui.status(undefined)
193 return null
194 }
195 await update($, summary, () => loaded)
196 await update($, problem, () => null)
197 $.ui.status(statusLine(loaded))
198 return loaded
199}
200
201async function openPane($: EngineInterface, loaded: StudySummary | null): Promise<void> {
202 await update($, wasPaneOpened, () => true)
203 await $.ui.open({ id: PANE, title: loaded ? words(loaded.language).title : 'Study' })
204}
205
206// The action that moves the current session forward, by its state.
207function nextStep(s: StudySummary, t: Words): { label: string; command: string } | null {
208 if (!s.next) return null
209 if (s.next.status === 'studied') return { label: t.evaluate, command: 'study-eval' }
210 if (s.next.status === 'evaluated') return { label: t.close, command: 'study-close' }
211 return { label: t.open(pad(s.next.session)), command: 'study-next' }
212}
213
214// Runs a slash command as if typed; the toast confirms the click or says why it failed.
215async function runCommand($: EngineInterface, t: Words, command: string): Promise<void> {
216 $.ui.toast(t.running(command))
217 await $.command.run({ command }).catch((error: unknown) => $.ui.toast(`/${command}: ${String(error)}`, { timeoutMs: 8000 }))
218}
219
220// Box-drawing lines render in every surface's font, unlike shade blocks.
221const bar = (done: number, total: number, width: number) => {
222 const filled = total > 0 ? Math.round((done / total) * width) : 0
223 return { filled: '━'.repeat(filled), empty: '─'.repeat(width - filled) }
224}
225
226export const register: Register = on => {
227 on('session.start', async ($, e, next) => {
228 await $.command.register({
229 name: 'study-panel',
230 description: 'Open the study panel: where your active plan stands, without calling the model',
231 })
232 const plan = await resolvePlan($).catch(() => null)
233 const isInPlan = plan !== null && (e.cwd === plan.path || e.cwd.startsWith(`${plan.path}/`))
234 const loaded = isInPlan ? await activate($) : await refresh($)
235 if (loaded && loaded.daysIdle !== null && loaded.daysIdle >= IDLE_DAYS) {
236 $.ui.toast(words(loaded.language).nudge(loaded.daysIdle, loaded.slug), { timeoutMs: 8000 })
237 }
238 $.clock.every(REFRESH_MS, () => void refresh($))
239
240 return next(e)
241 })
242
243 on('command.run', { command: 'study-panel' }, async $ => {
244 const loaded = await activate($)
245 await openPane($, loaded)
246 if (!loaded) return { text: (await read($, problem)) ?? 'No active study plan.' }
247
248 return { text: summaryText(loaded) }
249 })
250
251 on('skill.prompt', async ($, e, next) => {
252 const prompt = await next(e)
253 if (e.skill.startsWith('study-')) {
254 const loaded = await activate($)
255 if (!(await read($, wasPaneOpened))) await openPane($, loaded)
256 }
257
258 return prompt
259 })
260
261 // A study command may have changed the plan: re-read it once the turn ends.
262 on('turn.complete', async ($, e, next) => {
263 const done = await next(e)
264 await refresh($)
265
266 return done
267 })
268
269 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270 const { Box, Text, Button } = $.ui.resolve(e)
271 const s = await read($, summary)
272 const issue = await read($, problem)
273
274 if (!s) {
275 return (
276 <Box flexDirection="column" gap={1}>
277 <Text dimColor>{issue ?? WORDS.en.loading}</Text>
278 <Button key="refresh" label={WORDS.en.refresh} onPress={() => void refresh($)} />
279 </Box>
280 )
281 }
282
283 const t = words(s.language)
284 const step = nextStep(s, t)
285 const width = Math.max(8, Math.min(24, e.props.bodyColumns - 12))
286 const progress = bar(s.closed, s.total, width)
287 const drift = s.behind > 0 ? t.behind(s.behind) : s.ahead > 0 ? t.ahead(s.ahead) : t.onTrack
288 const isIdle = s.daysIdle !== null && s.daysIdle >= IDLE_DAYS
289
290 return (
291 <Box flexDirection="column" gap={1}>
292 <Box flexDirection="column">
293 <Text bold>{s.topic}</Text>
294 <Text>
295 <Text color="green">{progress.filled}</Text>
296 <Text dimColor>{progress.empty}</Text> {s.closed}/{s.total}
297 </Text>
298 </Box>
299 <Box flexDirection="column">
300 {s.next ? (
301 <Text>
302 {t.next}: <Text bold>{pad(s.next.session)}</Text> {s.next.title}
303 </Text>
304 ) : (
305 <Text>{t.noNext}</Text>
306 )}
307 {s.next && (
308 <Text dimColor>
309 {t.status[s.next.status] ?? s.next.status}
310 {s.next.plannedDate ? ` · ${t.planned} ${s.next.plannedDate}` : ''}
311 </Text>
312 )}
313 </Box>
314 <Box flexDirection="column">
315 <Text color={s.behind > 0 ? 'yellow' : 'green'}>{drift}</Text>
316 {isIdle && <Text color="yellow">{t.idle(s.daysIdle ?? 0)}</Text>}
317 <Text dimColor>
318 {t.end}: {t.plan} {s.plannedEnd ?? '—'} · {t.atPace} {s.projectedEnd ?? '—'}
319 </Text>
320 <Text dimColor>
321 {t.buffer} {s.bufferLeft}/{s.bufferTotal} · {t.concepts}: {s.pendingConcepts}
322 </Text>
323 <Text dimColor>
324 {t.lastEval}:{' '}
325 {s.lastEval
326 ? `${pad(s.lastEval.session)} · ${s.lastEval.score} · ${s.lastEval.passed ? t.passed : t.failed} (${s.lastEval.date})`
327 : t.none}
328 </Text>
329 </Box>
330 <Box flexDirection="row" flexWrap="wrap" gap={1}>
331 {step && (
332 <Button
333 key="step"
334 label={step.label}
335
336 variant="primary"
337 onPress={() => runCommand($, t, step.command)}
338 />
339 )}
340 <Button key="details" label={t.details} onPress={() => runCommand($, t, 'study-status')} />
341 <Button key="refresh" label={t.refresh} onPress={() => void refresh($)} />
342 <Button key="hide" label={t.hide} role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
343 </Box>
344 </Box>
345 )
346 })
347}
348types/index.d.ts 33 lines1export type StudyNext = {
2 session: number
3 title: string
4 status: string
5 plannedDate: string | null
6}
7
8export type StudyEval = { session: number; score: string; passed: boolean; date: string }
9
10export type StudySummary = {
11 slug: string
12 topic: string
13 language: string
14 closed: number
15 total: number
16 next: StudyNext | null
17 behind: number
18 ahead: number
19 bufferLeft: number
20 bufferTotal: number
21 plannedEnd: string | null
22 projectedEnd: string | null
23 lastEval: StudyEval | null
24 daysIdle: number | null
25 pendingConcepts: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'study-companion': { summary: StudySummary | null; problem: string | null; isActive: boolean; wasPaneOpened: boolean }
31 }
32}
33