Skip to content

Commit 2d74012

Browse files
Claudeclaude
authored andcommitted
feat: add terminal-gif skill to gitmem-hooks plugin
New skill for the blog publishing pipeline — guides agents through creating animated terminal GIFs from synthetic asciinema v3 cast files. Includes: - SKILL.md with full pipeline (cast authoring → agg rendering → OG compositing) - Claude Code 16-color palette reference with correct orange (#DA7756) - Sharp OG image compositing recipe - Working example: session start ceremony generator - agg binary installer script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 495441b commit 2d74012

5 files changed

Lines changed: 612 additions & 0 deletions

File tree

hooks/skills/terminal-gif/SKILL.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
---
2+
name: Terminal GIF
3+
description: >
4+
This skill should be used when the user asks to "create a terminal GIF",
5+
"record a terminal animation", "make a terminal screenshot for the blog",
6+
"generate a cast file", "create an asciinema recording", "animate a CLI demo",
7+
or needs animated terminal visuals for blog posts, documentation, or social media.
8+
Covers the full pipeline: synthetic cast file authoring, GIF rendering with agg,
9+
and optional OG image compositing with Sharp.
10+
---
11+
12+
# Terminal GIF — Animated Terminal Recordings for Content
13+
14+
Generate animated terminal GIFs from synthetic asciinema v3 cast files. The
15+
pipeline produces pixel-perfect terminal animations without requiring screen
16+
recording — content is authored programmatically with full control over timing,
17+
colors, and text.
18+
19+
## When to Use
20+
21+
- Blog posts that need a terminal demo (session start, CLI output, etc.)
22+
- OG/feature images that embed a mini terminal screenshot
23+
- Documentation showing CLI workflows
24+
- Social media content with terminal visuals
25+
26+
## Pipeline Overview
27+
28+
```
29+
1. Author content → Node.js script generates .cast file (ANSI escape sequences)
30+
2. Render GIF → agg binary converts .cast → .gif with terminal fonts
31+
3. (Optional) → Sharp composites GIF frame onto OG image
32+
```
33+
34+
## Step 1: Create the Cast Generator Script
35+
36+
Create a Node.js script that builds an asciinema v3 cast file. The cast format
37+
is JSONL — a JSON header line followed by event lines: `[delta_seconds, "o", "data"]`.
38+
39+
### Cast File Structure
40+
41+
```jsonl
42+
{"version":3,"term":{"cols":80,"rows":36,"type":"xterm","theme":{...}},"timestamp":1234567890}
43+
[0.5,"o","$ "]
44+
[0.1,"o","c"]
45+
[0.08,"o","l"]
46+
```
47+
48+
- **Header**: version, terminal dimensions, color theme
49+
- **Events**: `[delay_from_previous, "o", "output_text"]`
50+
- **ANSI escapes**: Use standard escape sequences for color/formatting
51+
52+
### Claude Code Color Palette (CRITICAL)
53+
54+
Use the exact Claude Code terminal theme. See `references/claude-code-palette.md` for
55+
the full 16-color palette and ANSI escape code mapping.
56+
57+
Key colors:
58+
- **Background**: `#30302d` (warm dark gray)
59+
- **Foreground**: `#a1a1a1` (medium gray)
60+
- **Claude logo**: Palette index 1 = `#DA7756` (orange/terracotta, NOT red)
61+
- **Bright red** (scar HIGH): Palette index 9 = `#e65535`
62+
63+
### Timing Guidelines
64+
65+
| Element | Delay | Notes |
66+
|---------|-------|-------|
67+
| Typing each character | 0.06-0.14s | Randomize for natural feel |
68+
| Enter key | 0.3-0.4s | Slight pause before submit |
69+
| Section transitions | 0.6-1.2s | Simulates processing |
70+
| Final hold | 2-3s | Let viewer read the result |
71+
72+
### Working Example
73+
74+
See `examples/generate-ceremony-cast.mjs` for a complete cast generator that produces
75+
the GitMem session start ceremony animation. Key patterns:
76+
77+
- `out(delay, text)` / `outln(delay, text)` helper functions
78+
- Character-by-character typing with randomized delays
79+
- Multi-color ANSI sequences for styled output
80+
- Claude Code block-art logo rendering
81+
82+
## Step 2: Render with agg
83+
84+
[agg](https://github.com/asciinema/agg) is the asciinema GIF generator. It renders
85+
`.cast` files to `.gif` with proper terminal fonts and anti-aliasing.
86+
87+
### Install agg
88+
89+
```bash
90+
# Download pre-built binary (no compiler needed)
91+
# Detect architecture
92+
ARCH=$(uname -m)
93+
if [ "$ARCH" = "aarch64" ]; then
94+
AGG_BIN="agg-aarch64-unknown-linux-gnu"
95+
elif [ "$ARCH" = "x86_64" ]; then
96+
AGG_BIN="agg-x86_64-unknown-linux-gnu"
97+
fi
98+
99+
curl -L "https://github.com/asciinema/agg/releases/download/v1.7.0/${AGG_BIN}" -o /tmp/agg
100+
chmod +x /tmp/agg
101+
```
102+
103+
### Render Command
104+
105+
```bash
106+
/tmp/agg input.cast output.gif --speed 1 --font-size 14 --line-height 1.4
107+
```
108+
109+
Options:
110+
- `--speed 1`: Real-time playback (increase to speed up)
111+
- `--font-size 14`: Good for blog embeds (12-16 range)
112+
- `--line-height 1.4`: Comfortable reading spacing
113+
- `--cols 80 --rows 36`: Override terminal dimensions
114+
115+
### Verify Output
116+
117+
The GIF will typically be 100-200KB for a 10-15 second animation. Check the
118+
last frame visually — the Read tool only shows the first frame of animated GIFs.
119+
Extract the last frame with Sharp to verify:
120+
121+
```javascript
122+
await sharp('output.gif', { page: -1 }).png().toFile('/tmp/last-frame.png');
123+
```
124+
125+
## Step 3: Composite into OG Image (Optional)
126+
127+
To embed a mini terminal screenshot in a feature/OG image:
128+
129+
1. Extract the best frame from the GIF (usually the last)
130+
2. Crop to the relevant content area
131+
3. Resize to fit (~400-500px wide)
132+
4. Apply rounded corners
133+
5. Composite onto the base OG image with Sharp
134+
135+
See `references/og-compositing.md` for the Sharp compositing recipe.
136+
137+
### Key Dimensions
138+
139+
| Context | Terminal Width | Position |
140+
|---------|--------------|----------|
141+
| OG image (1200x630) | 440-480px | Right side, vertically centered |
142+
| Blog embed | 600px max | Centered, `.terminal-frame` CSS class |
143+
| X/Twitter card | 400px | Right side |
144+
145+
## Blog Integration
146+
147+
### CSS for Terminal Frames
148+
149+
Add to the blog build if not already present:
150+
151+
```css
152+
.terminal-frame {
153+
margin: 32px auto;
154+
max-width: 600px;
155+
border-radius: 8px;
156+
overflow: hidden;
157+
border: 2px solid var(--color-primary);
158+
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
159+
}
160+
.terminal-frame img { width: 100%; height: auto; display: block; }
161+
```
162+
163+
### Markdown Embed
164+
165+
```html
166+
<div class="terminal-frame">
167+
<img src="/blog/images/my-animation.gif" alt="Description" loading="lazy" />
168+
</div>
169+
```
170+
171+
### MIME Type
172+
173+
Ensure the serving layer handles `.gif`:
174+
175+
```javascript
176+
'.gif': 'image/gif' // Add to mime type map
177+
```
178+
179+
## Common Issues
180+
181+
| Issue | Cause | Fix |
182+
|-------|-------|-----|
183+
| Logo renders red, not orange | Wrong palette index 1 | Set to `#DA7756` |
184+
| GIF clipped at bottom | Terminal rows too small | Increase `rows` in header |
185+
| Block characters garbled | Wrong font in renderer | Use agg (has built-in fonts) |
186+
| Read tool shows blank frame | Shows frame 1, which may be empty | Extract last frame with Sharp |
187+
| SVG rendering looks bad | Unicode blocks render poorly in SVG | Use agg — do not attempt SVG-based rendering |
188+
189+
## Additional Resources
190+
191+
### Reference Files
192+
193+
- **`references/claude-code-palette.md`** — Full 16-color palette, ANSI codes, theme JSON
194+
- **`references/og-compositing.md`** — Sharp recipe for OG image compositing
195+
196+
### Examples
197+
198+
- **`examples/generate-ceremony-cast.mjs`** — Complete session start ceremony generator
199+
200+
### Scripts
201+
202+
- **`scripts/install-agg.sh`** — Download and install the agg binary
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Generate a synthetic .cast (asciinema v3) file showing a GitMem session start.
4+
* Content is anonymized. Then render it to GIF with `agg`.
5+
*/
6+
7+
import { writeFileSync } from 'fs';
8+
import { join, dirname } from 'path';
9+
import { fileURLToPath } from 'url';
10+
import { execSync } from 'child_process';
11+
12+
const __dirname = dirname(fileURLToPath(import.meta.url));
13+
14+
// ANSI escape helpers
15+
const ESC = '\x1b';
16+
const RESET = `${ESC}[0m`;
17+
const BOLD = `${ESC}[1m`;
18+
const DIM = `${ESC}[2m`;
19+
const NORMAL = `${ESC}[22m`;
20+
const RED = `${ESC}[31m`; // palette 1: #DA7756 (Claude orange)
21+
const GREEN = `${ESC}[32m`; // palette 2: #00a600
22+
const YELLOW = `${ESC}[33m`; // palette 3: #999900
23+
const CYAN = `${ESC}[36m`; // palette 6: #00a6b3
24+
const WHITE = `${ESC}[37m`; // palette 7: #bfbfbf
25+
const BG_BLK = `${ESC}[40m`; // black bg (for logo blocks)
26+
const BG_DEF = `${ESC}[49m`; // default bg
27+
const FG_DEF = `${ESC}[39m`; // default fg
28+
const BR_RED = `${ESC}[91m`; // bright red: #e65535
29+
const BR_GREEN = `${ESC}[92m`; // bright green: #00d900
30+
const BR_YELLOW= `${ESC}[93m`; // bright yellow: #e6e600
31+
const BR_CYAN = `${ESC}[96m`; // bright cyan: #00e6e6
32+
const BR_WHITE = `${ESC}[97m`; // bright white: #e6e6e6
33+
const BR_MAG = `${ESC}[95m`; // bright magenta: #e600e6
34+
35+
// Build the cast file events: [delta_seconds, "o", "data"]
36+
const events = [];
37+
let t = 0;
38+
39+
function out(delay, text) {
40+
events.push([delay, 'o', text]);
41+
t += delay;
42+
}
43+
44+
function outln(delay, text) {
45+
out(delay, text + '\r\n');
46+
}
47+
48+
// --- Scene 1: Shell prompt + type "claude" ---
49+
out(0.5, '$ ');
50+
// Type "claude" character by character
51+
for (const ch of 'claude') {
52+
out(0.08 + Math.random() * 0.06, ch);
53+
}
54+
out(0.3, '\r\n');
55+
56+
// --- Scene 2: Claude Code header (actual layout from recordings) ---
57+
out(1.2, '');
58+
59+
// Line 1: logo + Claude Code
60+
outln(0.0, ` ${RED}${BG_BLK}▛███▜${BG_DEF}${FG_DEF} ${BOLD}Claude Code${NORMAL} ${WHITE}v2.1.32${FG_DEF}`);
61+
62+
// Line 2: logo + model
63+
outln(0.0, `${RED}▝▜${BG_BLK}█████${BG_DEF}▛▘${FG_DEF} ${WHITE}Sonnet 4.5 · API key${FG_DEF}`);
64+
65+
// Line 3: logo feet + path
66+
outln(0.0, `${RED} ▘▘ ▝▝${FG_DEF} ${WHITE}~/my-project${FG_DEF}`);
67+
68+
outln(0.0, '');
69+
70+
// Horizontal rule
71+
outln(0.0, `${DIM}${WHITE}${'─'.repeat(76)}${FG_DEF}${NORMAL}`);
72+
73+
// Prompt hint
74+
outln(0.0, `${DIM}${NORMAL}Try "how does <filepath> work?"${RESET}`);
75+
76+
// Horizontal rule
77+
outln(0.0, `${DIM}${WHITE}${'─'.repeat(76)}${FG_DEF}${NORMAL}`);
78+
79+
// Status bar
80+
outln(0.0, ` ${BR_MAG}⏵⏵ bypass permissions on${FG_DEF}`);
81+
82+
// --- Scene 3: User types "lets start" ---
83+
out(1.5, '');
84+
85+
// Replace prompt area
86+
outln(0.0, '');
87+
out(0.0, `${BR_WHITE}${RESET}`);
88+
for (const ch of 'lets start') {
89+
out(0.06 + Math.random() * 0.05, `${BR_WHITE}${ch}${RESET}`);
90+
}
91+
out(0.4, '\r\n');
92+
93+
// --- Scene 4: Frosting spinner ---
94+
outln(0.3, `${RED}· Frosting…${FG_DEF}`);
95+
outln(0.8, ` ${BR_YELLOW}SessionStart hook firing...${FG_DEF}`);
96+
97+
// --- Scene 5: GitMem session start ---
98+
out(1.0, '\r\n');
99+
outln(0.0, ` ${BR_GREEN}${BOLD}gitmem ── session started${NORMAL}${FG_DEF}`);
100+
outln(0.1, ` ${DIM}a3f8b291 · cli · my-project${NORMAL}`);
101+
outln(0.1, '');
102+
103+
// --- Scene 6: Threads ---
104+
out(0.8, '');
105+
outln(0.0, ` ${BR_CYAN}${BOLD}Threads (5)${NORMAL}${FG_DEF}`);
106+
outln(0.15, ` Fix auth token refresh — stale JWT after 24h`);
107+
outln(0.1, ` Migrate user table to use UUID primary keys`);
108+
outln(0.1, ` Add rate limiting to /api/search endpoint`);
109+
outln(0.1, ` Investigate Docker build cache misses on CI`);
110+
outln(0.1, ` ${DIM}+1 more${NORMAL}`);
111+
outln(0.0, '');
112+
113+
// --- Scene 7: Decisions ---
114+
out(0.8, '');
115+
outln(0.0, ` ${BR_YELLOW}${BOLD}Decisions (3)${NORMAL}${FG_DEF}`);
116+
outln(0.15, ` Use Zod for API validation, not Joi ${DIM}· Feb 18${NORMAL}`);
117+
outln(0.1, ` Keep Postgres — no migration to Mongo ${DIM}· Feb 17${NORMAL}`);
118+
outln(0.1, ` Rate limit: token bucket, not sliding ${DIM}· Feb 16${NORMAL}`);
119+
outln(0.0, '');
120+
121+
// --- Scene 8: Summary ---
122+
out(0.6, '');
123+
outln(0.0, ` ${DIM}${'─'.repeat(50)}${NORMAL}`);
124+
outln(0.2, ` ${BR_GREEN}3 scars recalled · 5 threads open · ready${FG_DEF}`);
125+
outln(0.0, '');
126+
127+
// --- Scene 9: Agent response with scars ---
128+
out(1.2, '');
129+
outln(0.0, ` ${BR_WHITE}Loaded institutional memory. 3 scars surfaced:${FG_DEF}`);
130+
outln(0.3, ` [${BR_RED}HIGH${FG_DEF}] JWT refresh tokens expire silently after rotation`);
131+
outln(0.2, ` [${BR_YELLOW}MED${FG_DEF}] Docker layer caching requires consistent COPY order`);
132+
outln(0.2, ` [${DIM}LOW${NORMAL}] Rate limit headers must include X-RateLimit-Reset`);
133+
outln(0.0, '');
134+
outln(0.5, ` ${BR_GREEN}Acknowledging scars before proceeding...${FG_DEF}`);
135+
136+
// Hold at end
137+
out(3.0, '');
138+
139+
// --- Write .cast file ---
140+
const header = {
141+
version: 3,
142+
term: {
143+
cols: 80,
144+
rows: 36,
145+
type: 'xterm',
146+
theme: {
147+
fg: '#a1a1a1',
148+
bg: '#30302d',
149+
palette: '#000000:#DA7756:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#262624:#e65535:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6'
150+
}
151+
},
152+
timestamp: Math.floor(Date.now() / 1000),
153+
idle_time_limit: 5.0,
154+
};
155+
156+
const castPath = join(__dirname, 'images', 'session-start-ceremony.cast');
157+
const gifPath = join(__dirname, 'images', 'session-start-ceremony.gif');
158+
159+
let castContent = JSON.stringify(header) + '\n';
160+
for (const [delay, type, data] of events) {
161+
castContent += JSON.stringify([delay, type, data]) + '\n';
162+
}
163+
164+
writeFileSync(castPath, castContent);
165+
console.log(`Cast file: ${castPath} (${events.length} events, ${Math.round(t)}s total)`);
166+
167+
// --- Render with agg ---
168+
try {
169+
execSync(`/tmp/agg ${castPath} ${gifPath} --speed 1 --font-size 14 --line-height 1.4`, {
170+
stdio: 'inherit',
171+
});
172+
console.log(`GIF: ${gifPath}`);
173+
} catch (e) {
174+
console.error('agg render failed:', e.message);
175+
console.log('Cast file written — render manually with: agg', castPath, gifPath);
176+
}

0 commit comments

Comments
 (0)