diff --git a/.gitattributes b/.gitattributes index 2217f57..3774626 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json index 0da8839..051932e 100644 --- a/.github/hooks/hooks.json +++ b/.github/hooks/hooks.json @@ -4,7 +4,7 @@ "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 } @@ -12,7 +12,7 @@ "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 } @@ -20,7 +20,7 @@ "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 } diff --git a/.github/hooks/pre-tool-use.js b/.github/hooks/pre-tool-use.js new file mode 100644 index 0000000..479a130 --- /dev/null +++ b/.github/hooks/pre-tool-use.js @@ -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); + } +}); diff --git a/.github/hooks/pre-tool-use.sh b/.github/hooks/pre-tool-use.sh deleted file mode 100644 index e974084..0000000 --- a/.github/hooks/pre-tool-use.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/bash -# 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. -# -# Strategy: try node first (a guaranteed tutorial dependency, already used by -# prompt-context.sh), then fall back to pure POSIX sed/tr for environments -# where node is not on PATH. -# -# Works on: macOS, Linux, Git Bash (Windows), WSL. -# -# Input format (both variants are handled): -# toolArgs as JSON string: { "toolName": "edit", "toolArgs": "{\"path\":\"...\"}" } -# toolArgs as inline object: { "toolName": "edit", "toolArgs": { "path": "..." } } - -# Read all of stdin -INPUT=$(cat) - -# --------------------------------------------------------------------------- -# Primary path: node (reliable JSON parsing, no regex fragility). -# Input is passed via HOOK_INPUT env var to avoid heredoc stdin conflicts. -# --------------------------------------------------------------------------- -if command -v node > /dev/null 2>&1; then - RESULT=$(HOOK_INPUT="$INPUT" node -e " - var path = require('path'); - var raw = process.env.HOOK_INPUT || ''; - var data; - try { data = JSON.parse(raw); } catch(e) { process.exit(0); } - - var toolName = data.toolName || ''; - if (toolName !== 'edit' && toolName !== 'create') process.exit(0); - - // toolArgs may be a JSON string or an inline object - var toolArgs = data.toolArgs || {}; - if (typeof toolArgs === 'string') { - try { toolArgs = JSON.parse(toolArgs); } catch(e) { process.exit(0); } - } - - var 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). - var filePath = path.relative(process.cwd(), path.resolve(rawPath)); - - // Reject directory traversal - 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); - ") - if [ -n "$RESULT" ]; then - printf '%s\n' "$RESULT" - fi - exit 0 -fi - -# --------------------------------------------------------------------------- -# Fallback: pure POSIX utilities (sed, tr) — no external dependencies -# --------------------------------------------------------------------------- - -# Collapse to a single line and strip Windows \r -SINGLE=$(printf '%s' "$INPUT" | tr -d '\n\r') - -# Extract toolName (top-level unescaped quotes — safe from toolArgs content) -TOOL_NAME=$(printf '%s\n' "$SINGLE" | sed -n 's/.*"toolName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') -case "$TOOL_NAME" in - edit|create) ;; - *) exit 0 ;; -esac - -# Extract path — try escaped-string form first, then inline-object form -FILE_PATH=$(printf '%s\n' "$SINGLE" | sed -n 's/.*\\"path\\"[[:space:]]*:[[:space:]]*\\"\([^"\\]*\)\\".*/\1/p') -if [ -z "$FILE_PATH" ]; then - FILE_PATH=$(printf '%s\n' "$SINGLE" | sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') -fi -[ -z "$FILE_PATH" ] && exit 0 - -# Normalize: backslashes → forward slashes, strip leading ./ -FILE_PATH=$(printf '%s' "$FILE_PATH" | tr '\\' '/') -FILE_PATH="${FILE_PATH#./}" - -# Strip absolute CWD prefix so absolute paths become relative -# e.g. /home/user/project/tutorial/state.json → tutorial/state.json -# Use substring comparison (not glob pattern) to avoid misbehaving when -# CWD contains glob metacharacters (*, ?, [). -CWD=$(pwd | tr '\\' '/') -PREFIX="$CWD/" -if [ "${FILE_PATH:0:${#PREFIX}}" = "$PREFIX" ]; then - FILE_PATH="${FILE_PATH:${#PREFIX}}" -fi - -# Reject directory traversal -case "/$FILE_PATH/" in - */../*) exit 0 ;; -esac - -if [ "$FILE_PATH" = "tutorial/state.json" ]; then - printf '{"permissionDecision": "allow"}\n' -fi - -exit 0 diff --git a/.github/hooks/prompt-context.js b/.github/hooks/prompt-context.js new file mode 100644 index 0000000..5ead6fb --- /dev/null +++ b/.github/hooks/prompt-context.js @@ -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); diff --git a/.github/hooks/prompt-context.sh b/.github/hooks/prompt-context.sh deleted file mode 100755 index 61aa622..0000000 --- a/.github/hooks/prompt-context.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -# 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. - -# Consume hook input from stdin (required by the hook protocol) -cat > /dev/null - -# Resolve project root relative to this script's location -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -STATE_FILE="$PROJECT_DIR/tutorial/state.json" -LESSONS_DIR="$PROJECT_DIR/tutorial/lessons" -CURRENT_LESSON_FILE="$PROJECT_DIR/tutorial/current-lesson.md" - -node -e " -const fs = require('fs'); -const path = require('path'); - -const stateFile = process.argv[1]; -const lessonsDir = process.argv[2]; -const currentLessonFile = process.argv[3]; - -// Read state -let current = 0; -try { - const state = JSON.parse(fs.readFileSync(stateFile, '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(lessonsDir) - .filter(f => f.startsWith(padded + '-') && f.endsWith('.md')); - if (files.length > 0) { - content = fs.readFileSync(path.join(lessonsDir, 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(currentLessonFile, content, 'utf8'); -" "$STATE_FILE" "$LESSONS_DIR" "$CURRENT_LESSON_FILE" - -exit 0 diff --git a/.github/hooks/session-start.js b/.github/hooks/session-start.js new file mode 100644 index 0000000..0803e38 --- /dev/null +++ b/.github/hooks/session-start.js @@ -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); diff --git a/.github/hooks/session-start.sh b/.github/hooks/session-start.sh deleted file mode 100755 index 48200a8..0000000 --- a/.github/hooks/session-start.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/bash -# 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. - -# Consume hook input from stdin (required by the hook protocol) -cat > /dev/null - -# Resolve project root relative to this script's location -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -STATE_FILE="$PROJECT_DIR/tutorial/state.json" -LESSONS_DIR="$PROJECT_DIR/tutorial/lessons" - -# Bootstrap state.json on first run -if [ ! -f "$STATE_FILE" ]; then - cat > "$STATE_FILE" << 'EOF' -{ - "currentLesson": 0, - "completedLessons": [], - "skippedLessons": [], - "track": "core", - "startedAt": "" -} -EOF -fi - -# 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.sh 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. -if command -v node > /dev/null 2>&1; then - node -e " - var fs = require('fs'); - var path = require('path'); - var dir = process.argv[1]; - try { - fs.readdirSync(dir) - .filter(function(f) { return f.endsWith('.md'); }) - .forEach(function(f) { - var p = path.join(dir, f); - var 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. - } - " "$LESSONS_DIR" -fi - -exit 0 diff --git a/README.md b/README.md index 11f0364..11a5ab9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ An interactive, hands-on tutorial for learning GitHub Copilot CLI — built enti - A GitHub account with an active Copilot subscription - [Node.js](https://nodejs.org/) (includes `npm`, required for the sample app) -- **Windows only:** [Git for Windows](https://git-scm.com/download/win) (provides Git Bash) ## Getting Started diff --git a/tutorial/lessons/23-graduation.md b/tutorial/lessons/23-graduation.md index 8b24003..aa0d584 100644 --- a/tutorial/lessons/23-graduation.md +++ b/tutorial/lessons/23-graduation.md @@ -111,7 +111,7 @@ You are no longer a beginner with Copilot CLI. Here is where to go from here: - **Instructions mastery** — Build up your own project's `.github/copilot-instructions.md` with real conventions from your actual work. This is the single highest-leverage thing you can do. - **Custom agents for your workflow** — Create agents tailored to your team's needs. A code reviewer agent, a documentation generator, a test writer. -- **Hooks for automation** — Set up auto-formatting after edits, test-on-save, or commit message validation. By the way, this tutorial itself uses a `preToolUse` hook to auto-approve writes to `tutorial/state.json` so you are never interrupted by permission prompts during lesson navigation — see `.github/hooks/pre-tool-use.sh` for a minimal real-world example of returning `permissionDecision: "allow"` from a hook. +- **Hooks for automation** — Set up auto-formatting after edits, test-on-save, or commit message validation. By the way, this tutorial itself uses a `preToolUse` hook to auto-approve writes to `tutorial/state.json` so you are never interrupted by permission prompts during lesson navigation — see `.github/hooks/pre-tool-use.js` for a minimal real-world example of returning `permissionDecision: "allow"` from a hook. - **Real projects** — The best way to get good at Copilot CLI is to use it on real work. Pick a task from your backlog and go. - **QA Track** — If you are a QA engineer (or curious about QA workflows), say "switch to QA track" to start the QA track. Five lessons cover test planning, test case design, automated test generation, and E2E testing with Playwright.