Skip to content

Commit 6b26d69

Browse files
authored
Merge pull request #15 from DanWahlin/feature/list-unread-books
Rebuild demo pipeline and regenerate all GIFs
2 parents b139530 + f202d2e commit 6b26d69

44 files changed

Lines changed: 299 additions & 271 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/create-tapes.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,26 @@ const config = require('./demos.json');
1919
function generatePromptBlock(entry, defaultWait, index) {
2020
const text = typeof entry === 'string' ? entry : entry.text;
2121
const wait = (typeof entry === 'object' && entry.responseWait) || defaultWait;
22+
const agentSelect = typeof entry === 'object' && entry.agentSelect;
2223
const label = index != null ? `Prompt ${index + 1}` : 'Execute the prompt';
2324

25+
// Agent selection: type /agent, wait for picker, arrow down to agent, select
26+
if (agentSelect) {
27+
const arrowDown = (typeof entry === 'object' && entry.arrowDown) || 0;
28+
const arrowBlock = arrowDown > 0 ? `Down ${arrowDown}\nSleep 1s\n` : '';
29+
return `# ${label} - Select ${agentSelect} agent
30+
Type "${text}"
31+
Sleep 1s
32+
Enter
33+
34+
# Wait for agent picker
35+
Sleep 3s
36+
${arrowBlock}Enter
37+
38+
# Wait for agent to load
39+
Sleep ${wait}s`;
40+
}
41+
2442
// If prompt ends with a file reference (@path), the file picker will be open.
2543
// Need an extra Enter to select the file before submitting the prompt.
2644
const endsWithFileRef = /@\S+$/.test(text);
@@ -63,6 +81,7 @@ Set Padding 20
6381
Set BorderRadius 8
6482
Set Margin 10
6583
Set MarginFill "#282a36"
84+
Set Framerate ${s.framerate}
6685
6786
# Human typing speed
6887
Set TypingSpeed ${s.typingSpeed}

.github/scripts/demos.json

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"height": 600,
66
"theme": "Dracula",
77
"typingSpeed": "60ms",
8+
"framerate": 10,
89
"startupWait": 5,
910
"responseWait": 25,
1011
"exitWait": 2
@@ -102,7 +103,7 @@
102103
"chapter": "05-skills",
103104
"name": "list-skills-demo",
104105
"description": "List available slash commands and skills",
105-
"responseWait": 15,
106+
"responseWait": 25,
106107
"prompt": "What slash commands and skills are available? List the main ones."
107108
},
108109
{
@@ -116,7 +117,7 @@
116117
"chapter": "06-mcp-servers",
117118
"name": "mcp-status-demo",
118119
"description": "Check connected MCP servers with /mcp show",
119-
"responseWait": 15,
120+
"responseWait": 25,
120121
"prompt": "/mcp show"
121122
},
122123
{
@@ -132,18 +133,17 @@
132133
{
133134
"chapter": "07-putting-it-together",
134135
"name": "full-review-demo",
135-
"description": "Full project review prioritized by severity",
136-
"responseWait": 50,
137-
"prompt": "Review @samples/book-app-project/ for all code quality issues. Prioritize by severity."
138-
},
139-
{
140-
"chapter": "07-putting-it-together",
141-
"name": "minimal-workflow-demo",
142-
"description": "Minimal workflow: explore, implement, test",
136+
"description": "Idea to merged PR: plan, implement, test a new feature",
143137
"prompts": [
144-
"@samples/book-app-project/books.py What methods already exist for filtering books?",
145-
{ "text": "Add a get_unread_books method to BookCollection that returns only books where read is False", "responseWait": 30 },
146-
{ "text": "@samples/book-app-project/books.py Generate pytest tests for the get_unread_books method", "responseWait": 30 }
138+
{ "text": "I need to add a 'list unread' command to the book app that shows only books where read is False. What files need to change?", "responseWait": 25 },
139+
{ "text": "/agent", "agentSelect": "python-reviewer", "arrowDown": 3, "responseWait": 3 },
140+
{ "text": "@samples/book-app-project/books.py Design a get_unread_books method. What is the best approach?", "responseWait": 30 },
141+
{ "text": "/agent", "agentSelect": "pytest-helper", "arrowDown": 2, "responseWait": 3 },
142+
{ "text": "@samples/book-app-project/tests/test_books.py Design test cases for filtering unread books.", "responseWait": 30 },
143+
{ "text": "Add a get_unread_books method to BookCollection in books.py. Add a 'list unread' command option in book_app.py. Update the help text in the show_help function.", "responseWait": 30 },
144+
{ "text": "Generate comprehensive tests for the new feature", "responseWait": 35 },
145+
{ "text": "/review", "responseWait": 25 },
146+
{ "text": "Create a pull request titled 'Feature: Add list unread books command'", "responseWait": 25 }
147147
]
148148
}
149149
]

.github/scripts/generate-demos.js

Lines changed: 104 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* npm run generate:vhs # all chapters, 5 concurrent
1111
* npm run generate:vhs -- --chapter 03 # only chapter 03
1212
* npm run generate:vhs -- --chapter 03 --chapter 05
13+
* npm run generate:vhs -- --file path/to/demo.tape # single tape file
1314
* npm run generate:vhs -- --concurrency 3 # limit to 3 at a time
1415
*
1516
* Requirements:
@@ -21,46 +22,97 @@ const { readdirSync, statSync, existsSync, readFileSync, renameSync, writeFileSy
2122
const { join, relative, dirname } = require('path');
2223

2324
const rootDir = join(__dirname, '..', '..');
24-
const copilotConfigPath = join(require('os').homedir(), '.copilot', 'config.json');
25-
26-
// Ensure on-air mode is enabled so recordings don't show model names or quota
27-
function enableOnAirMode() {
25+
const homeDir = require('os').homedir();
26+
const copilotConfigPath = join(homeDir, '.copilot', 'config.json');
27+
// Personal agents live in both ~/.copilot/agents and ~/.claude/agents
28+
const personalAgentsDirs = [
29+
{ dir: join(homeDir, '.copilot', 'agents'), backup: join(homeDir, '.copilot', 'agents.recording-bak') },
30+
{ dir: join(homeDir, '.claude', 'agents'), backup: join(homeDir, '.claude', 'agents.recording-bak') }
31+
];
32+
33+
// Ensure streamer mode is enabled so recordings don't show model names or quota
34+
function enableStreamerMode() {
2835
try {
2936
const config = JSON.parse(readFileSync(copilotConfigPath, 'utf8'));
30-
if (!config.on_air_mode) {
31-
config.on_air_mode = true;
32-
writeFileSync(copilotConfigPath, JSON.stringify(config, null, 2) + '\n');
33-
console.log('🔴 On-air mode: enabled (was off)');
34-
return false; // was off, we turned it on
35-
}
36-
console.log('🔴 On-air mode: already enabled');
37-
return true; // was already on
37+
const wasOn = config.streamer_mode || false;
38+
config.streamer_mode = true;
39+
delete config.on_air_mode;
40+
writeFileSync(copilotConfigPath, JSON.stringify(config, null, 2) + '\n');
41+
console.log(`🔴 Streamer mode: ${wasOn ? 'already enabled' : 'enabled'}`);
42+
return { wasOn };
3843
} catch (e) {
39-
console.warn('⚠ Could not read copilot config, on-air mode not verified');
44+
console.warn('⚠ Could not read copilot config, streamer mode not verified');
4045
return null;
4146
}
4247
}
4348

44-
// Restore on-air mode to its original state
45-
function restoreOnAirMode(wasAlreadyOn) {
46-
if (wasAlreadyOn === false) {
49+
// Restore streamer mode to its original state
50+
function restoreStreamerMode(state) {
51+
if (state && !state.wasOn) {
4752
try {
4853
const config = JSON.parse(readFileSync(copilotConfigPath, 'utf8'));
49-
config.on_air_mode = false;
54+
config.streamer_mode = false;
5055
writeFileSync(copilotConfigPath, JSON.stringify(config, null, 2) + '\n');
51-
console.log('🔴 On-air mode: restored to off');
56+
console.log('🔴 Streamer mode: restored to off');
5257
} catch (e) { /* ignore */ }
5358
}
5459
}
5560

61+
// Hide personal agents so only course agents appear in /agent picker
62+
function hidePersonalAgents() {
63+
const hidden = [];
64+
for (const { dir, backup } of personalAgentsDirs) {
65+
try {
66+
// Restore stale backup from a previous interrupted run
67+
if (!existsSync(dir) && existsSync(backup)) {
68+
renameSync(backup, dir);
69+
console.log(`👤 Restored stale backup: ${backup}`);
70+
}
71+
if (existsSync(dir)) {
72+
renameSync(dir, backup);
73+
hidden.push({ dir, backup });
74+
}
75+
} catch (e) {
76+
console.warn(`⚠ Could not hide ${dir}:`, e.message);
77+
}
78+
}
79+
if (hidden.length > 0) {
80+
console.log(`👤 Personal agents: hidden (${hidden.length} location(s))`);
81+
}
82+
return hidden;
83+
}
84+
85+
// Restore personal agents to their original locations
86+
function restorePersonalAgents(hidden) {
87+
if (!hidden || hidden.length === 0) return;
88+
for (const { dir, backup } of hidden) {
89+
try {
90+
if (existsSync(backup)) {
91+
// Copilot may recreate the agents dir during recording - remove it first
92+
if (existsSync(dir)) {
93+
rmSync(dir, { recursive: true });
94+
}
95+
renameSync(backup, dir);
96+
}
97+
} catch (e) {
98+
console.warn(`⚠ Could not restore ${dir}:`, e.message);
99+
console.warn(` Manual fix: mv "${backup}" "${dir}"`);
100+
}
101+
}
102+
console.log(`👤 Personal agents: restored (${hidden.length} location(s))`);
103+
}
104+
56105
// Parse CLI args
57106
const args = process.argv.slice(2);
58107
const chapters = [];
108+
const files = [];
59109
let concurrency = 5;
60110

61111
for (let i = 0; i < args.length; i++) {
62112
if (args[i] === '--chapter' && args[i + 1]) {
63113
chapters.push(args[++i]);
114+
} else if (args[i] === '--file' && args[i + 1]) {
115+
files.push(args[++i]);
64116
} else if (args[i] === '--concurrency' && args[i + 1]) {
65117
concurrency = parseInt(args[++i], 10);
66118
}
@@ -136,26 +188,28 @@ function runVhs(tapeFile, wrappedPath) {
136188

137189
exec(`vhs ${relativePath}`, {
138190
cwd: rootDir,
139-
timeout: 180000,
191+
timeout: 600000,
140192
env: { ...process.env, PATH: wrappedPath }
141193
}, (error) => {
142194
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
143195

144-
if (error) {
145-
console.log(` ✗ ${relativePath} (${elapsed}s) - ${error.message}`);
146-
resolve({ success: false, path: relativePath });
147-
return;
148-
}
149-
150-
// Move generated GIF to the images folder if it was created in root
196+
// Always move GIF if it was created, even if VHS exited non-zero
197+
let gifCreated = false;
151198
if (outputFilename) {
152199
const generatedPath = join(rootDir, outputFilename);
153200
const targetPath = join(imagesDir, outputFilename);
154201
if (existsSync(generatedPath) && generatedPath !== targetPath) {
155202
renameSync(generatedPath, targetPath);
203+
gifCreated = true;
156204
}
157205
}
158206

207+
if (error && !gifCreated) {
208+
console.log(` ✗ ${relativePath} (${elapsed}s) - ${error.message}`);
209+
resolve({ success: false, path: relativePath });
210+
return;
211+
}
212+
159213
console.log(` ✓ ${relativePath} (${elapsed}s)`);
160214
resolve({ success: true, path: relativePath });
161215
});
@@ -187,17 +241,33 @@ async function runWithConcurrency(tasks, limit) {
187241
async function main() {
188242
console.log('🎬 Generating course demos...\n');
189243

190-
if (chapters.length > 0) {
244+
if (files.length > 0) {
245+
console.log(`Files: ${files.join(', ')}`);
246+
} else if (chapters.length > 0) {
191247
console.log(`Chapters: ${chapters.join(', ')}`);
192248
}
193249
console.log(`Concurrency: ${concurrency}`);
194250
console.log('');
195251

196-
// Enable on-air mode before recording
197-
const wasAlreadyOn = enableOnAirMode();
252+
// Enable streamer mode and hide personal agents before recording
253+
const streamerState = enableStreamerMode();
254+
const agentsWereHidden = hidePersonalAgents();
198255
console.log('');
199256

200-
const tapeFiles = findTapeFiles(rootDir, chapters);
257+
// Resolve tape files: explicit --file paths take priority over chapter scan
258+
let tapeFiles;
259+
if (files.length > 0) {
260+
const { resolve } = require('path');
261+
tapeFiles = files.map(f => resolve(rootDir, f)).filter(f => {
262+
if (!existsSync(f)) {
263+
console.log(` ⚠ File not found: ${f}`);
264+
return false;
265+
}
266+
return true;
267+
});
268+
} else {
269+
tapeFiles = findTapeFiles(rootDir, chapters);
270+
}
201271

202272
if (tapeFiles.length === 0) {
203273
console.log('No .tape files found');
@@ -222,7 +292,8 @@ async function main() {
222292
const results = await runWithConcurrency(tasks, concurrency);
223293

224294
cleanupCopilotWrapper();
225-
restoreOnAirMode(wasAlreadyOn);
295+
restorePersonalAgents(agentsWereHidden);
296+
restoreStreamerMode(streamerState);
226297

227298
const succeeded = results.filter(r => r.success).length;
228299
const failedResults = results.filter(r => !r.success);
@@ -241,6 +312,7 @@ async function main() {
241312
main().catch(e => {
242313
console.error(e);
243314
cleanupCopilotWrapper();
244-
restoreOnAirMode(false); // restore on-air to off on error
315+
restorePersonalAgents(personalAgentsDirs); // always try to restore on error
316+
restoreStreamerMode({ wasOn: false });
245317
process.exit(1);
246318
});
245 KB
Loading

00-quick-start/images/hello-demo.tape

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Set Padding 20
1111
Set BorderRadius 8
1212
Set Margin 10
1313
Set MarginFill "#282a36"
14+
Set Framerate 10
1415

1516
# Human typing speed
1617
Set TypingSpeed 60ms
238 KB
Loading

01-setup-and-first-steps/images/code-review-demo.tape

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Set Padding 20
1111
Set BorderRadius 8
1212
Set Margin 10
1313
Set MarginFill "#282a36"
14+
Set Framerate 10
1415

1516
# Human typing speed
1617
Set TypingSpeed 60ms
419 KB
Loading

01-setup-and-first-steps/images/explain-code-demo.tape

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Set Padding 20
1111
Set BorderRadius 8
1212
Set Margin 10
1313
Set MarginFill "#282a36"
14+
Set Framerate 10
1415

1516
# Human typing speed
1617
Set TypingSpeed 60ms
155 KB
Loading

0 commit comments

Comments
 (0)