Movie-style closing credits for your Claude Code session. A native Claude Mod with local, deterministic statistics.

A Claude Mod that turns your coding session into movie credits. Your most-edited file gets top billing, tools get a thank-you, and failed calls become plot twists.
/credits demo
Credits scroll in a native pane with Still / Play, Replay, and Close controls. Everything runs locally, without model calls or telemetry.
Watch the 38-second video. This is a rendered UI preview using fictional demo data, not a live screen recording.
Requires Claude Code with early-access function hooks enabled. Tested with 2.1.272. The Mods API is experimental.
claude plugin marketplace add smukh/roll-credits
claude plugin install roll-credits@roll-credits-marketplace
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
Then run /credits demo. Restart Claude after installing. Enable the flag in every session where you want the Mod to load. On PowerShell, set $env:CLAUDE_CODE_ENABLE_FUNCTION_HOOKS="1" before running claude.
Try from source: no build or npm installation needed to use the Mod.
git clone https://github.com/smukh/roll-credits.git
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./roll-credits
If /credits is missing, check claude --version, restart with the flag, and inspect /plugin for load errors. Another plugin already owning /credits takes precedence; Roll Credits leaves that command unchanged.
| Command | Result |
|---|---|
/credits | Roll the current session's credits |
/credits The Tiny Launch | Give the session a movie title |
/credits --still | Show the full credits without animation; scroll to read |
/credits --text | Print the complete credits as text |
/credits demo | Try fictional sample data, without changing your statistics |
/credits demo --still The Tiny Launch | A named, motion-free demo |
/credits --help | Show usage |
Put options before the title. The pane opens only when requested. Use its buttons to switch to a still view, replay, or close; Escape closes it while it has focus. At the end, animation stops automatically. Each invocation takes a snapshot, so edits during the roll don't move the cast around.
Output from /credits demo --text:
STARRING
App.tsx
8 successful edit calls
SUPPORTING CAST
stars.ts · 3 edits
README.md · 2 edits
SPECIAL THANKS
Read · Edit · Bash · Write
BY THE NUMBERS
42 tool calls · 7 completed turns
12m 34s together · 2 plot twists
POST-CREDITS SCENE
The bugs have signed on for a sequel.
The demo is fictional. Complete CLI output.
This uses Anthropic's published Mods integration:
.claude-plugin/plugin.json identifies the plugin.hooks/hooks.json loads hooks/register.tsx as a function-hooks module.register(on) observes session.start, tool.call, and turn.complete.$.command.register installs /credits; command.run handles it locally.$.ui.open, ui.render, and $.ui.resolve render a native pane using Claude's Box, Text, and Button elements.$.clock.every drives the roll; timers stop on still mode, close, or completion.Runtime code is in hooks/. See API references and compatibility.
Edit, Write, and NotebookEdit calls determine the cast. These are call counts, not changed-line counts. Edits performed through Bash, external editors, or other tools aren't cast credits.Everything stays in memory. Full file paths are used as distinct keys, but only basenames are displayed. No prompts, answers, file contents, or command arguments are saved. Filenames may still be sensitive when you share credits. Up to 512 distinct files and 512 tool names are retained; aggregate call/failure counters continue after that limit. Identical basenames can appear twice. This measures activity, not code quality.
Noninteractive sessions or an unavailable pane fall back to text. The native element trees are tested for terminal, desktop, and mobile surfaces; those tests do not establish that every Claude app exposes Mods.
Node 22+ is needed for the pinned development tooling. A Claude login is not needed for the tests.
npm ci
npm run types:fetch
npm run check
The type-fetch step downloads a checksum-verified declaration file from a pinned Anthropic commit into gitignored .api/. Anthropic's reference source has its own license; it is not republished under this project's MIT license.
npm test runs claude plugin test at the user tier. Tests cover event dispatch, native rendering, button presses, and timers. npm run validate checks the plugin and marketplace manifests.
See CONTRIBUTING.md. MIT licensed. Independent community project; not affiliated with Anthropic.
hooks/register.tsx 207 lines1import type { EngineInterface, Register, Timer } from 'claude-code';
2import { credits, demoStats, fresh, parse, record } from './credits';
3import type { Line } from './credits';
4const ID = 'roll-credits';
5const HELP =
6 'Roll Credits\n/credits [demo] [--text] [--still] [title]\nStill/play, replay, or close using the pane buttons. --still disables animation; --text prints all credits. Demo uses fictional data. Statistics stay in memory and reset when this Mod loads.';
7interface Playback {
8 offset: number;
9 height: number;
10 paused: boolean;
11 opened: boolean;
12 lines: Line[];
13 timer?: Timer;
14}
15function stop(p: Playback): void {
16 p.timer?.cancel();
17 p.timer = undefined;
18}
19function end(p: Playback): number {
20 return Math.max(0, p.lines.length - p.height);
21}
22// Claude's capability auditor requires helpers receiving $ at module scope.
23function animate($: EngineInterface, p: Playback): void {
24 stop(p);
25 if (!p.opened || p.paused || p.offset >= end(p)) return;
26 p.timer = $.clock.every(650, () => {
27 p.offset = Math.min(p.offset + 1, end(p));
28 $.ui.invalidate('ui.render');
29 if (p.offset >= end(p)) stop(p);
30 });
31}
32export const register: Register = (on) => {
33 let stats = fresh(0, 'Untitled project');
34 let active = false,
35 interactive = false;
36 const p: Playback = {
37 offset: 0,
38 height: 12,
39 paused: false,
40 opened: false,
41 lines: [],
42 };
43 on('session.start', async ($, e, next) => {
44 const result = await next(e);
45 stop(p);
46 p.opened = false;
47 active = false;
48 interactive = e.isInteractive;
49 try {
50 stats = fresh(await $.clock.now(), e.cwd);
51 await $.command.register({
52 name: 'credits',
53 description: 'Roll movie credits for your coding session',
54 argumentHint: '[demo] [--text] [--still] [title]',
55 });
56 active = true;
57 } catch {
58 /* A denied capability or command conflict must not break startup. */
59 }
60 return result;
61 });
62 on('tool.call', async ($, e, next) => {
63 try {
64 const result = await next(e);
65 if (active)
66 record(
67 stats,
68 e.tool,
69 'file_path' in e
70 ? e.file_path
71 : 'notebook_path' in e
72 ? e.notebook_path
73 : undefined,
74 result.deny !== undefined || result.isError === true,
75 );
76 return result;
77 } catch (error) {
78 if (active) record(stats, e.tool, undefined, true);
79 throw error;
80 }
81 });
82 on('turn.complete', async ($, e, next) => {
83 const result = await next(e);
84 if (active && !e.agentId && !e.isAborted && e.reason === 'answer')
85 stats.turns++;
86 return result;
87 });
88 on('command.run', { command: 'credits' }, async ($, e, next) => {
89 if (!active) return next(e);
90 const options = parse(e.args);
91 if (options.help) return { text: HELP };
92 const now = await $.clock.now();
93 const snapshot = credits(
94 options.demo ? demoStats(now) : stats,
95 now,
96 options.title,
97 options.demo,
98 );
99 if (options.text || !interactive)
100 return { text: snapshot.map((line) => line.text).join('\n') };
101 stop(p);
102 p.lines = snapshot;
103 p.offset = 0;
104 p.paused = options.still;
105 try {
106 await $.ui.open({
107 id: ID,
108 title: 'Roll Credits',
109 focus: true,
110 closeOnEscape: true,
111 rows: 20,
112 });
113 p.opened = true;
114 $.ui.invalidate('ui.render');
115 animate($, p);
116 return {};
117 } catch {
118 p.opened = false;
119 return { text: snapshot.map((line) => line.text).join('\n') };
120 }
121 });
122 on('ui.close', { id: ID }, async ($, e, next) => {
123 const result = await next(e);
124 if (!('deny' in result)) {
125 p.opened = false;
126 stop(p);
127 }
128 return result;
129 });
130 on('ui.render', { component: 'Pane' }, ($, e, next) => {
131 if (e.requestId !== ID || !p.opened) return next(e);
132 const { Box, Text, Button } = $.ui.resolve(e);
133 p.height = Math.max(1, Math.min(18, e.props.scroll.bodyRows - 5));
134 p.offset = Math.min(p.offset, end(p));
135 const visible = p.paused
136 ? p.lines
137 : p.lines.slice(p.offset, p.offset + p.height);
138 return (
139 <Box flexDirection="column" paddingX={1}>
140 <Text color="yellow" bold>
141 ★ ROLL CREDITS ★
142 </Text>
143 <Text dimColor>
144 {p.paused
145 ? 'Still · scroll to read'
146 : p.offset >= end(p)
147 ? 'That’s a wrap.'
148 : 'Now playing…'}
149 </Text>
150 <Box flexDirection="row" gap={2}>
151 <Button
152 key="pause"
153 label={p.paused ? 'Play' : 'Still'}
154 onPress={() => {
155 p.paused = !p.paused;
156 animate($, p);
157 $.ui.invalidate('ui.render');
158 }}
159 />
160 <Button
161 key="replay"
162 label="Replay"
163 onPress={() => {
164 p.offset = 0;
165 p.paused = false;
166 animate($, p);
167 $.ui.invalidate('ui.render');
168 }}
169 />
170 <Button
171 key="close"
172 label="Close"
173 onPress={() => {
174 void $.ui
175 .close({ id: ID })
176 .then(() => {
177 p.opened = false;
178 stop(p);
179 })
180 .catch(() => {});
181 }}
182 />
183 </Box>
184 <Text> </Text>
185 <Box flexDirection="column" minHeight={p.height}>
186 {visible.map((line, index) => (
187 <Box key={`${index}`} justifyContent="center">
188 <Text
189 bold={line.kind === 'title'}
190 color={
191 line.kind === 'heading' || line.kind === 'title'
192 ? 'yellow'
193 : undefined
194 }
195 dimColor={line.kind === 'muted'}
196 wrap="truncate-end"
197 >
198 {line.text || ' '}
199 </Text>
200 </Box>
201 ))}
202 </Box>
203 </Box>
204 );
205 });
206};
207hooks/credits.ts 171 lines1/** Deterministic credits; never reads transcript text or file contents. */
2export interface Stats {
3 startedAt: number;
4 project: string;
5 calls: number;
6 failures: number;
7 turns: number;
8 files: Map<string, number>;
9 tools: Map<string, number>;
10}
11export interface Line {
12 text: string;
13 kind: 'title' | 'heading' | 'body' | 'muted';
14}
15export function clean(value: string, limit = 100): string {
16 return value
17 .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
18 .slice(0, limit);
19}
20export function basename(path: string): string {
21 return clean(
22 path.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() ||
23 'Untitled project',
24 );
25}
26export function fresh(now: number, cwd: string): Stats {
27 return {
28 startedAt: now,
29 project: basename(cwd),
30 calls: 0,
31 failures: 0,
32 turns: 0,
33 files: new Map(),
34 tools: new Map(),
35 };
36}
37// Bound cardinality for sessions with thousands of different files.
38function bump(map: Map<string, number>, key: string): void {
39 if (map.has(key) || map.size < 512) map.set(key, (map.get(key) || 0) + 1);
40}
41export function record(
42 stats: Stats,
43 tool: string,
44 path: unknown,
45 failed: boolean,
46): void {
47 stats.calls++;
48 bump(stats.tools, clean(tool));
49 if (failed) {
50 stats.failures++;
51 return;
52 }
53 if (
54 ['Edit', 'Write', 'NotebookEdit'].includes(tool) &&
55 typeof path === 'string'
56 )
57 bump(stats.files, path);
58}
59function ranked(map: Map<string, number>): [string, number][] {
60 return [...map].sort(
61 (a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0),
62 );
63}
64export function credits(
65 stats: Stats,
66 now: number,
67 title?: string,
68 demo = false,
69): Line[] {
70 const lines: Line[] = [];
71 const add = (text: string, kind: Line['kind'] = 'body') =>
72 lines.push({ text, kind });
73 add(demo ? 'DEMO · FICTIONAL SESSION' : 'A SESSION IN CODE', 'muted');
74 add(clean(title || stats.project, 80), 'title');
75 add('');
76 add('DIRECTED BY', 'heading');
77 add('You');
78 add('WRITTEN WITH', 'heading');
79 add('Claude');
80 add('');
81 const cast = ranked(stats.files);
82 add('STARRING', 'heading');
83 add(cast[0] ? basename(cast[0][0]) : 'The blank canvas');
84 add(
85 cast[0]
86 ? `${cast[0][1]} successful edit calls`
87 : 'No successful edits yet.',
88 'muted',
89 );
90 if (cast.length > 1) {
91 add('');
92 add('SUPPORTING CAST', 'heading');
93 for (const [file, count] of cast.slice(1, 6))
94 add(`${basename(file)} · ${count} edit${count === 1 ? '' : 's'}`);
95 if (cast.length > 6) add(`And ${cast.length - 6} more files`, 'muted');
96 }
97 add('');
98 add('SPECIAL THANKS', 'heading');
99 add(
100 ranked(stats.tools)
101 .slice(0, 4)
102 .map(([tool]) => tool)
103 .join(' · ') || 'Your curiosity',
104 );
105 add('');
106 add('BY THE NUMBERS', 'heading');
107 const seconds = Math.floor(Math.max(0, now - stats.startedAt) / 1000);
108 const elapsed =
109 seconds < 60
110 ? `${seconds}s`
111 : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
112 add(`${stats.calls} tool calls · ${stats.turns} completed turns`);
113 add(
114 `${elapsed} together · ${stats.failures} plot twist${stats.failures === 1 ? '' : 's'}`,
115 );
116 add('');
117 add('POST-CREDITS SCENE', 'heading');
118 add(
119 stats.failures
120 ? 'The bugs have signed on for a sequel.'
121 : 'One more tiny change?',
122 );
123 add('');
124 add('THE END', 'title');
125 add('Made with patience. And a few tool calls.', 'muted');
126 add(
127 demo
128 ? 'Sample data only.'
129 : 'Observed since this Mod loaded. Edits via Bash are not counted.',
130 'muted',
131 );
132 return lines;
133}
134export function demoStats(now: number): Stats {
135 const stats = fresh(now - 754000, 'the-little-launch');
136 stats.calls = 42;
137 stats.turns = 7;
138 stats.failures = 2;
139 stats.files = new Map([
140 ['src/App.tsx', 8],
141 ['src/stars.ts', 3],
142 ['README.md', 2],
143 ]);
144 stats.tools = new Map([
145 ['Read', 18],
146 ['Edit', 11],
147 ['Bash', 9],
148 ['Write', 4],
149 ]);
150 return stats;
151}
152export function parse(args: string): {
153 demo: boolean;
154 text: boolean;
155 still: boolean;
156 title?: string;
157 help: boolean;
158} {
159 const words = args.trim().split(/\s+/).filter(Boolean);
160 const flags = new Set<string>();
161 while (words[0] && ['demo', '--text', '--still', '--help'].includes(words[0]))
162 flags.add(words.shift()!);
163 return {
164 demo: flags.has('demo'),
165 text: flags.has('--text'),
166 still: flags.has('--still'),
167 help: flags.has('--help'),
168 title: words.join(' ') || undefined,
169 };
170}
171