SLOPSHOPPER

study-companion

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

newpanecommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · study-companion
│ ┃ Study ✕ › fix the failing auth test and add an audit log call │ ┃ study-companion: SyntaxError: JSON Parse │ ┃ error: Unexpected identifier "dev" ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Refresh ] ⏺ 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 │ │ › /study-panel │ ⎿ study-companion: study-companion: SyntaxError: JSON Parse error: │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Study
study-companion: SyntaxError: JSON Parse error: Unexpected identifier "dev" [ Refresh ]
README

study-skills

Leer en español

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.

Install

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.

Update

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.

Usage

CommandWhat it doesAlso triggered by
/study-newInterviews 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-nextOpens the next pending session and tells you if you are behind"what do I study today", "let's continue the plan"
/study-evalEvaluates the current session, or runs a mock exam"quiz me", "test me", "mock exam"
/study-closeAsks you to explain what you learned in your own words and closes the session"done for today", "let's close the session"
/study-statusShows 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.

How you answer evaluations

ModeWhat it looks likeDefault for
--clicksDialogs where you click the options (batches of 3 questions plus "which ones did you guess?"), multi-select includedEvaluations of 12 questions or fewer
--textEvery 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.

Languages

  • Files are written in the language you asked in: /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.
  • Conversation follows the language of your latest message, whatever the plan's language. Studying for an exam in English while chatting in Spanish works.
  • Evaluation questions come in the plan's language (they come from the readings); ask for another language for a given evaluation if you want. Your notes are kept in your own words, untranslated.
  • Fixed texts exist in Spanish (neutral), English and Portuguese. For another language, Claude offers English labels or a one-time translation for the whole plan.

The /study-new interview

Four short screens:

  1. Goal and sources. What you want to study and what material you have. Claude looks up the official sources and proposes a syllabus; nothing is generated until you approve it.
  2. Time and level.
  3. Deadline: exact, approximate ("about 3 months") or none.
  4. Days per week and minutes per session.
  5. Your current level, which decides between a diagnostic and a prerequisite check.
  6. Practice, videos and follow-up.
  7. Whether you have real access, a sandbox or nothing to practise on, per area.
  8. Whether you use a course platform: Platzi, or any other by pasting the course index.
  9. Whether you report progress to someone, e.g. in 1:1s.
  10. Location. Where to save the plan.

What ends up on disk

<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.

Optional: the study panel (mod)

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.
  • The first study command of a session opens it by itself, once; close it and it stays closed.
  • A status line such as 📚 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.
  • After 7 days without studying, a study session greets you with a reminder.

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.

Principles

  • Nothing invented. Every link goes through verify_links.py before it is written, and every claim in a session is backed by a reading.
  • Nothing published. No Jira, Slack or any external service. Block closure summaries are ready for you to paste wherever you want.
  • Practice runs as-is. Commands carry no <placeholders> and respect the access level you declared. If something fails for permissions, that area's level goes down.
  • Evaluations with judgement. You mark your guesses, and the correct answer's position is randomized, so there is no pattern to learn. Errors are corrected one at a time, and a concept stays "pending" until you get it right in separate evaluations.
  • Videos, respectfully. From Platzi only title, duration and URL are stored, never the content (its terms of use require it).

Repository layout

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.

Limitations

  • Claude Code only, because it uses AskUserQuestion. Untested on other agents.
  • Videos: v1 supports Platzi and a manually pasted index. Platzi sometimes blocks automated requests; the plan is then generated without videos and says so.
  • No reminders and no sync between machines. To use your plans on several computers, version the plan folder with git.

Uninstall

./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.

License

MIT

Source 2 files
hooks/register.tsx 348 lines
1import { 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}
348
types/index.d.ts 33 lines
1export 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