Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
# Ensure consistent LF line endings on all platforms (prevents CRLF issues on Windows)
* text=auto eol=lf

# Shell scripts must always use LF so bash does not embed \r in variable values
*.sh text eol=lf

# Markdown lesson files must use LF to avoid \r causing display corruption in terminals
*.md text eol=lf

Expand Down
6 changes: 3 additions & 3 deletions .github/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@
"sessionStart": [
{
"type": "command",
"bash": "bash .github/hooks/session-start.sh",
"command": "node .github/hooks/session-start.js",
"comment": "Bootstrap tutorial state.json on first run",
"timeoutSec": 10
}
],
"userPromptSubmitted": [
{
"type": "command",
"bash": "bash .github/hooks/prompt-context.sh",
"command": "node .github/hooks/prompt-context.js",
"comment": "Write current lesson to tutorial/current-lesson.md for context",
"timeoutSec": 5
}
],
"preToolUse": [
{
"type": "command",
"bash": "bash .github/hooks/pre-tool-use.sh",
"command": "node .github/hooks/pre-tool-use.js",
"comment": "Auto-approve edits to tutorial/state.json so navigation agents don't trigger permission prompts",
"timeoutSec": 5
}
Expand Down
61 changes: 61 additions & 0 deletions .github/hooks/pre-tool-use.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// preToolUse hook — auto-approves tool calls that edit tutorial/state.json.
//
// The tutorial navigation agents (next, previous, start, skip, etc.) all write
// to tutorial/state.json to track lesson progress. Without this hook, Copilot
// would prompt the user for permission on every state update — interrupting the
// tutorial flow.
//
// Input format (both variants are handled):
// toolArgs as JSON string: { "toolName": "edit", "toolArgs": "{\"path\":\"...\"}" }
// toolArgs as inline object: { "toolName": "edit", "toolArgs": { "path": "..." } }

const path = require('path');

const chunks = [];
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('error', () => process.exit(0));
process.stdin.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');

let data;
try {
data = JSON.parse(raw);
} catch (e) {
process.exit(0);
}

const toolName = data.toolName || '';
if (toolName !== 'edit' && toolName !== 'create') process.exit(0);

// toolArgs may be a JSON string or an inline object
let toolArgs = data.toolArgs || {};
if (typeof toolArgs === 'string') {
try {
toolArgs = JSON.parse(toolArgs);
} catch (e) {
process.exit(0);
}
}

const rawPath = (toolArgs.path || '').replace(/\\/g, '/');

// Normalise to a path relative to CWD (handles both absolute and relative
// paths, equivalent to Python's os.path.relpath).
let filePath = path.relative(process.cwd(), path.resolve(rawPath));

// Reject directory traversal (checked before backslash normalisation so a
// Windows-style traversal path can't slip past the guard).
if (filePath.split(path.sep).indexOf('..') !== -1) process.exit(0);

// Normalise separator to forward slash (Windows path.sep is backslash)
filePath = filePath.replace(/\\/g, '/');

if (filePath === 'tutorial/state.json') {
process.stdout.write('{"permissionDecision": "allow"}\n');
}
process.exit(0);
} catch (e) {
process.exit(0);
}
});
109 changes: 0 additions & 109 deletions .github/hooks/pre-tool-use.sh

This file was deleted.

45 changes: 45 additions & 0 deletions .github/hooks/prompt-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// userPromptSubmitted hook — keeps tutorial/current-lesson.md up to date.
//
// In Copilot CLI, userPromptSubmitted output is ignored (it cannot inject
// context into the conversation). Instead, this hook writes the current
// lesson content to tutorial/current-lesson.md on every user message.
// The copilot-instructions.md tells Copilot to read this file for context.

const fs = require('fs');
const path = require('path');

const PROJECT_DIR = path.resolve(__dirname, '..', '..');
const STATE_FILE = path.join(PROJECT_DIR, 'tutorial', 'state.json');
const LESSONS_DIR = path.join(PROJECT_DIR, 'tutorial', 'lessons');
const CURRENT_LESSON_FILE = path.join(PROJECT_DIR, 'tutorial', 'current-lesson.md');

// Consume hook input from stdin (required by the hook protocol) — this hook
// never reads its payload, only needs to drain the handshake.
process.stdin.resume();

// Read state
let current = 0;
try {
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
current = state.currentLesson || 0;
} catch (e) {}

// Find and read lesson
const padded = String(current).padStart(2, '0');
let content = '# No lesson found\n\nType /start to begin the tutorial.\n';
try {
const files = fs
.readdirSync(LESSONS_DIR)
.filter((f) => f.startsWith(padded + '-') && f.endsWith('.md'));
if (files.length > 0) {
content = fs.readFileSync(path.join(LESSONS_DIR, files[0]), 'utf8');
}
} catch (e) {}

// Normalise to LF-only so Windows checkouts with CRLF don't corrupt terminal output
content = content.replace(/\r\n?/g, '\n');

// Write current lesson to a well-known file
fs.writeFileSync(CURRENT_LESSON_FILE, content, 'utf8');

process.exit(0);
53 changes: 0 additions & 53 deletions .github/hooks/prompt-context.sh

This file was deleted.

59 changes: 59 additions & 0 deletions .github/hooks/session-start.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SessionStart hook — bootstraps tutorial/state.json on first run.
//
// In Copilot CLI, sessionStart hook output is ignored (it cannot display banners
// or inject context). Instead, context injection is handled by copilot-instructions.md
// which tells Copilot to read the current lesson file on startup.
//
// This hook ensures state.json exists so the agents and instructions can find it.

const fs = require('fs');
const path = require('path');

const PROJECT_DIR = path.resolve(__dirname, '..', '..');
const STATE_FILE = path.join(PROJECT_DIR, 'tutorial', 'state.json');
const LESSONS_DIR = path.join(PROJECT_DIR, 'tutorial', 'lessons');

// Consume hook input from stdin (required by the hook protocol) — this hook
// never reads its payload, only needs to drain the handshake.
process.stdin.resume();

// Bootstrap state.json on first run
if (!fs.existsSync(STATE_FILE)) {
const state = {
currentLesson: 0,
completedLessons: [],
skippedLessons: [],
track: 'core',
startedAt: '',
};
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + '\n', 'utf8');
}

// Normalise lesson files to LF-only so Windows checkouts with CRLF don't
// produce \r characters when agents read the files directly (e.g. next, hint).
// prompt-context.js already normalises current-lesson.md, but agents bypass
// that file and read the source lesson files from tutorial/lessons/.
//
// This also resolves the "modified" git status entries that the eol=lf
// .gitattributes rule (added in PR #62) creates for existing Windows clones:
// once the files are LF on disk they match what git expects, so git status
// goes clean rather than showing every lesson file as modified.
//
// The operation is a no-op for files already using LF line endings.
try {
fs.readdirSync(LESSONS_DIR)
.filter((f) => f.endsWith('.md'))
.forEach((f) => {
const p = path.join(LESSONS_DIR, f);
const src = fs.readFileSync(p, 'utf8');
if (src.indexOf('\r') !== -1) {
fs.writeFileSync(p, src.replace(/\r\n?/g, '\n'), 'utf8');
}
});
} catch (e) {
// Graceful degradation: if the lessons directory is missing or a file
// cannot be read/written, skip normalisation and continue. The session
// will still start; the only downside is that CRLF files remain untouched.
}

process.exit(0);
Loading