From eeabf50d89d8de0ba311994a12412c7aea6e7e6c Mon Sep 17 00:00:00 2001 From: svincent240 <66322922+svincent240@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:37:14 -0400 Subject: [PATCH 1/2] Fix first-run problems in create and the README The commands create printed failed after npx coursecode create when coursecode wasn't installed globally, so they now use npx. New projects also point people to their AI tool, start at version 1.0.0, and no longer carry the template author's name. The README leads with the plugin. New projects use ESLint 10, create no longer prints npm's audit and funding notices, the narration help lists all five providers, and the npm homepage points to the site. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++++++ README.md | 46 ++++++++++++------------ bin/cli.js | 2 +- framework/scripts/tts-providers/index.js | 2 +- lib/create.js | 31 +++++++++++----- package.json | 2 +- template/course/course-config.js | 3 +- template/package.json | 2 +- tests/create.test.js | 46 +++++++++++++++++++++++- 9 files changed, 107 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1dbc7..d755fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **First-run guidance:** `coursecode create` now tells you to open the project in your AI tool and describe the course. Projects created through the plugin skip that step because the AI tool is already running. +- **README:** Quick Start leads with the Claude Code and Codex plugin, then the `npx` path. +- **New project tooling:** New projects use ESLint 10, and `create` no longer prints npm's audit and funding notices. +- **Package homepage:** The npm `homepage` points to coursecodeframework.com. + +### Fixed +- **Commands that failed without a global install:** The commands `create` prints now use `npx coursecode`, so they work after `npx coursecode create`. +- **New course metadata:** New projects start at version `1.0.0` instead of `2.0.0` and no longer carry the template author's name. Their first package is named `_v1.0.0_.zip`. +- **`narration` help:** The command description lists all five text-to-speech providers instead of only ElevenLabs. + ## [0.4.3] - 2026-10-29 ### Added diff --git a/README.md b/README.md index 02ac03f..845f62c 100644 --- a/README.md +++ b/README.md @@ -33,27 +33,29 @@ Framework documentation lives at [coursecodeframework.com/docs](https://courseco --- -## Installation +## Quick Start -### Required +### With the plugin (recommended) -[Node.js](https://nodejs.org/) 22.15 or later. You can run every command through `npx coursecode`, or install the CLI globally for the shorter `coursecode` command: +The Claude Code and Codex plugin guides your AI tool from a new project to an LMS package, and it works before a project exists. It is not yet in the Claude Code or Codex plugin directories, so install it from this repository: ```bash -npm install -g coursecode -``` +# Claude Code +claude plugin marketplace add course-code-framework/coursecode +claude plugin install coursecode@coursecode -Learner courses support Chrome 111+, Edge 111+, Firefox 114+, and Safari 16.4+. SCORM compatibility describes LMS communication and packaging; it does not imply Internet Explorer support. +# Codex +codex plugin marketplace add course-code-framework/coursecode +codex plugin add coursecode@coursecode +``` -### Recommended +Then start your AI tool in an empty folder and describe the course you want. -- A code or text editor, such as [VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.com/), or similar -- An AI coding tool with MCP support, such as [Claude Code](https://claude.com/claude-code), [Codex](https://developers.openai.com/codex), [Cursor](https://cursor.com/), or VS Code with GitHub Copilot -- [GitHub Desktop](https://desktop.github.com/) for version control +The plugin needs [Node.js](https://nodejs.org/) 22.15 or later but no global `coursecode` install. If Node.js is missing, its `setup` skill offers to install it. The plugin bundles the MCP server below and six skills: setup, new-course, outline, build-slides, review, and publish. See [plugin/README.md](plugin/README.md). ---- +### With npx -## Quick Start +With [Node.js](https://nodejs.org/) 22.15 or later installed, create a project: ```bash npx coursecode create my-course @@ -76,21 +78,21 @@ Start with the [User Guide](framework/docs/USER_GUIDE.md) for a complete walkthr --- -## Claude Code and Codex Plugin +## Requirements -The plugin adds guided skills for each stage, from a new project to an LMS package, and works before a project exists. Start your AI tool in an empty folder and describe the course you want. It is not yet in the Claude Code or Codex plugin directories, so install it from this repository: +[Node.js](https://nodejs.org/) 22.15 or later. You can run every command through `npx coursecode`, or install the CLI globally for the shorter `coursecode` command used in the rest of this guide: ```bash -# Claude Code -claude plugin marketplace add course-code-framework/coursecode -claude plugin install coursecode@coursecode - -# Codex -codex plugin marketplace add course-code-framework/coursecode -codex plugin add coursecode@coursecode +npm install -g coursecode ``` -The plugin bundles the MCP server below and six skills: setup, new-course, outline, build-slides, review, and publish. It needs Node.js but no global `coursecode` install. See [plugin/README.md](plugin/README.md). +Learner courses support Chrome 111+, Edge 111+, Firefox 114+, and Safari 16.4+. SCORM compatibility describes LMS communication and packaging; it does not imply Internet Explorer support. + +Also recommended: + +- A code or text editor, such as [VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.com/), or similar +- An AI coding tool with MCP support, such as [Claude Code](https://claude.com/claude-code), [Codex](https://developers.openai.com/codex), [Cursor](https://cursor.com/), or VS Code with GitHub Copilot +- [GitHub Desktop](https://desktop.github.com/) for version control --- diff --git a/bin/cli.js b/bin/cli.js index bcabbb0..7a949ad 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -184,7 +184,7 @@ program // Narration command program .command('narration') - .description('Generate audio narration from text using ElevenLabs') + .description('Generate audio narration from text using ElevenLabs, OpenAI, Azure, Google Cloud, or Deepgram') .option('-f, --force', 'Regenerate all narration (ignore cache)') .option('-s, --slide ', 'Generate narration for a specific slide only') .option('--dry-run', 'Preview what would be generated') diff --git a/framework/scripts/tts-providers/index.js b/framework/scripts/tts-providers/index.js index 75a1a98..093de8a 100644 --- a/framework/scripts/tts-providers/index.js +++ b/framework/scripts/tts-providers/index.js @@ -9,7 +9,7 @@ * 3. Default to deepgram * * Environment Variables: - * TTS_PROVIDER - Explicit provider selection: elevenlabs, openai, azure + * TTS_PROVIDER - Explicit provider selection: elevenlabs, openai, azure, google, deepgram */ import { ElevenLabsProvider } from './elevenlabs-provider.js'; diff --git a/lib/create.js b/lib/create.js index f22111e..2e13d89 100644 --- a/lib/create.js +++ b/lib/create.js @@ -13,7 +13,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..'); const REQUIRED_PROJECT_DIRECTORIES = [ { path: path.join('course', 'references'), - placeholder: '# Place source documents here (docx, pptx, pdf)\n# Run `coursecode convert` to convert them to markdown\n' + placeholder: '# Place source documents here (docx, pptx, pdf)\n# Run `npx coursecode convert` to convert them to markdown\n' }, { path: path.join('course', 'references', 'converted'), @@ -169,7 +169,8 @@ function npmInstall(cwd) { return new Promise((resolve, reject) => { const npmCli = process.env.COURSECODE_NPM_CLI; const command = npmCli ? process.execPath : 'npm'; - const args = npmCli ? [npmCli, 'install'] : ['install']; + const installArgs = ['install', '--no-audit', '--no-fund']; + const args = npmCli ? [npmCli, ...installArgs] : installArgs; const child = spawn(command, args, { cwd, stdio: 'inherit', @@ -187,6 +188,19 @@ function npmInstall(cwd) { }); } +/** + * Next steps after create. Commands use npx because the project's own CLI + * works whether or not coursecode is installed globally. + */ +export function formatNextSteps({ directoryName, currentDirectory = false, plugin = false }) { + const changeDirectory = currentDirectory ? '' : `cd ${directoryName}\n `; + const preview = `To preview your course:\n\n ${plugin ? changeDirectory : ''}npx coursecode preview\n\n Then open http://localhost:4173\n`; + + if (plugin) return `\n ${preview}`; + + return `\n Open the project in your AI tool and describe the course you want:\n\n ${changeDirectory}claude # or codex, or open the folder in Cursor or VS Code\n\n ${preview}`; +} + /** * Initialize git repository */ @@ -350,8 +364,8 @@ export async function create(name, options = {}) { Next steps: - Edit course-config.js with your course metadata - - Create slides with: coursecode new slide - - Create assessments with: coursecode new assessment + - Create slides with: npx coursecode new slide + - Create assessments with: npx coursecode new assessment `); } else { console.log(` @@ -365,8 +379,8 @@ export async function create(name, options = {}) { The project includes example slides (prefixed with "example-") that demonstrate framework features. When ready to build your own course: - - Run: coursecode clean - - Or create a blank project: coursecode create --blank + - Run: npx coursecode clean + - Or create a blank project: npx coursecode create --blank `); } @@ -384,11 +398,10 @@ export async function create(name, options = {}) { child.on('error', () => { console.warn(' ⚠️ Failed to start preview server. Run manually:'); - console.log(` cd ${directoryName} && coursecode preview\n`); + console.log(` cd ${directoryName} && npx coursecode preview\n`); }); } else { - const changeDirectory = currentDirectory ? '' : `cd ${directoryName}\n `; - console.log(`\n To preview your course:\n\n ${changeDirectory}coursecode preview\n\n Then open http://localhost:4173\n`); + console.log(formatNextSteps({ directoryName, currentDirectory, plugin: options.plugin === true })); } return { diff --git a/package.json b/package.json index 4bb7e36..4e6da73 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "bugs": { "url": "https://github.com/course-code-framework/coursecode/issues" }, - "homepage": "https://github.com/course-code-framework/coursecode/blob/main/framework/docs/USER_GUIDE.md", + "homepage": "https://coursecodeframework.com", "engines": { "node": ">=22.15.0" }, diff --git a/template/course/course-config.js b/template/course/course-config.js index 9ab06d1..de7a92c 100644 --- a/template/course/course-config.js +++ b/template/course/course-config.js @@ -135,8 +135,7 @@ export const courseConfig = { metadata: { title: 'CourseCode', description: 'CourseCode template for course development', - version: '2.0.0', - author: 'Seth Vincent', + version: '1.0.0', language: 'en' }, layout: 'article', diff --git a/template/package.json b/template/package.json index 6486db5..bf70506 100644 --- a/template/package.json +++ b/template/package.json @@ -22,7 +22,7 @@ }, "devDependencies": { "archiver": "^8.0.0", - "eslint": "^9.39.4", + "eslint": "^10.13.0", "globals": "^17.7.0", "vite": "~8.1.6", "vite-plugin-static-copy": "^4.1.1" diff --git a/tests/create.test.js b/tests/create.test.js index 712098a..cce08d5 100644 --- a/tests/create.test.js +++ b/tests/create.test.js @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { create, + formatNextSteps, stampCourseTitle, toCurrentDirectoryCourseTitle, toCurrentDirectoryPackageName, @@ -67,7 +68,50 @@ export const courseConfig = { }); }); +describe('formatNextSteps', () => { + it('sends new projects to an AI tool and keeps the preview command', () => { + const message = formatNextSteps({ directoryName: 'my-course' }); + + expect(message).toContain('cd my-course\n claude'); + expect(message).toContain('Cursor or VS Code'); + expect(message).toContain('npx coursecode preview'); + }); + + it('skips the AI tool step when the plugin created the project', () => { + const message = formatNextSteps({ directoryName: 'my-course', currentDirectory: true, plugin: true }); + + expect(message).not.toContain('claude'); + expect(message).not.toContain('cd my-course'); + expect(message).toContain('npx coursecode preview'); + }); +}); + describe('create', () => { + it.each([ + ['example', {}], + ['blank', { blank: true }] + ])('prints commands that work without a global install for %s projects', async (_label, options) => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + await create('Npx Course', { install: false, ...options }); + const output = log.mock.calls.flat().join('\n'); + + expect(output).toContain('npx coursecode'); + expect(output).not.toMatch(/(? { + await create('Version Course', { install: false }); + + const config = fs.readFileSync(path.join(tempDir, 'version-course', 'course', 'course-config.js'), 'utf-8'); + expect(config).toContain("version: '1.0.0'"); + expect(config).not.toMatch(/^\s*author:/m); + }); + it('creates a directory-safe project and stamps the human course title', async () => { const result = await create('CourseCode Demo', { install: false }); From 5fad8fcec01f4c1ca49b29e8a65ae255b82b4ffc Mon Sep 17 00:00:00 2001 From: svincent240 <66322922+svincent240@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:51:48 -0400 Subject: [PATCH 2/2] Check Node.js at session start in the plugin The plugin's MCP server needs Node.js to start, so when it was missing the CourseCode tools just failed to appear and the launcher's error never ran. A SessionStart hook now tells the AI tool to use the setup skill when Node.js is missing or too old. The hook needs no Node.js and stays silent when the version is fine. The hook runs in sh, Git Bash, and PowerShell, so CI now tests it on a Windows runner in Git Bash and both versions of PowerShell. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 ++ .github/workflows/prerelease-checks.yml | 15 ++++++++ AGENTS.md | 2 +- CHANGELOG.md | 3 ++ plugin/CODEX.md | 2 +- plugin/README.md | 3 +- plugin/hooks/check-node.cmd | 36 +++++++++++++++++++ plugin/hooks/hooks.json | 16 +++++++++ plugin/mcp/coursecode-mcp.mjs | 3 +- scripts/package-codex-plugin.mjs | 2 +- scripts/smoke-codex-plugin.mjs | 2 +- scripts/smoke-plugin-session-check.ps1 | 48 +++++++++++++++++++++++++ tests/lib/plugin-launcher.test.js | 25 +++++++++++++ tests/lib/plugin-manifests.test.js | 32 +++++++++++++++++ 14 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 .gitattributes create mode 100644 plugin/hooks/check-node.cmd create mode 100644 plugin/hooks/hooks.json create mode 100644 scripts/smoke-plugin-session-check.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8978346 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# sh can't read this file with CRLF line endings +plugin/hooks/check-node.cmd text eol=lf diff --git a/.github/workflows/prerelease-checks.yml b/.github/workflows/prerelease-checks.yml index 2dfff41..92a1c4b 100644 --- a/.github/workflows/prerelease-checks.yml +++ b/.github/workflows/prerelease-checks.yml @@ -27,6 +27,21 @@ jobs: - name: Prerelease checks run: npm run prerelease:check + plugin-session-check-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run the plugin session check + shell: pwsh + run: ./scripts/smoke-plugin-session-check.ps1 + responsive-smoke: runs-on: ubuntu-latest needs: checks diff --git a/AGENTS.md b/AGENTS.md index 6ff39da..67a5145 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ This repo is both the framework source and the template for new course projects. - `template/` — what `coursecode create` gives course authors (their `vite.config.js`, `course/`) - `template/course/` — doubles as the test course for framework development - `vite.framework-dev.config.js` — framework dev build (references `lib/` directly); `template/vite.config.js` is the course-author build (imports from the `coursecode` package) -- `plugin/` — one plugin for Claude Code (`.claude-plugin/plugin.json`, `.mcp.json`) and Codex (`plugin.json`, `mcp.json`), sharing `skills/` and the `mcp/coursecode-mcp.mjs` launcher. Both repo marketplaces (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`) point at it. `npm run plugin:pack` builds the Codex directory package with a bundled runtime in ignored `artifacts/codex-plugin/`; see `plugin/CODEX.md`. +- `plugin/` — one plugin for Claude Code (`.claude-plugin/plugin.json`, `.mcp.json`, `hooks/`) and Codex (`plugin.json`, `mcp.json`), sharing `skills/` and the `mcp/coursecode-mcp.mjs` launcher. Both repo marketplaces (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`) point at it. `npm run plugin:pack` builds the Codex directory package with a bundled runtime in ignored `artifacts/codex-plugin/`; see `plugin/CODEX.md`. - `lib/agent-setup.js` — MCP settings and `AGENTS.md` that `coursecode create` and `coursecode agent-setup` write into course projects Changes under `template/` ship to every new course project. Keep framework-dev-only content out of it. diff --git a/CHANGELOG.md b/CHANGELOG.md index d755fd4..911d353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Node.js check at session start:** In Claude Code, the plugin now tells the AI when Node.js is missing or older than 22.15, so it can run the `setup` skill, which asks before installing anything. Before, the CourseCode tools just failed to appear, because the MCP server needs Node.js to start. The check prints nothing when Node.js is fine, and it isn't included in the Codex directory package. + ### Changed - **First-run guidance:** `coursecode create` now tells you to open the project in your AI tool and describe the course. Projects created through the plugin skip that step because the AI tool is already running. - **README:** Quick Start leads with the Claude Code and Codex plugin, then the `npx` path. diff --git a/plugin/CODEX.md b/plugin/CODEX.md index 57c6a0f..5424755 100644 --- a/plugin/CODEX.md +++ b/plugin/CODEX.md @@ -31,7 +31,7 @@ npm run plugin:pack npm run plugin:smoke ``` -Packaging copies `plugin/` without the Claude Code files and README, adds a `runtime/` folder with the framework's production dependencies, and writes `artifacts/codex-plugin/coursecode/`, `artifacts/codex-plugin/coursecode--codex.zip`, and a local marketplace at `artifacts/codex-plugin/`. The launcher uses `runtime/` when the project has no `coursecode` dependency, and the `new-course` skill runs the first `create` through the launcher when `runtime/` exists. The ZIP excludes the repository's dependencies, credentials, Cloud code, and marketplace catalogs. Third-party source maps, type declarations, tests, and examples are omitted; executable code and license notices remain. The ZIP has no directory entries, and packaging fails above the portal's 5,000-entry limit. +Packaging copies `plugin/` without the Claude Code files (including the `hooks/` session check) and README, adds a `runtime/` folder with the framework's production dependencies, and writes `artifacts/codex-plugin/coursecode/`, `artifacts/codex-plugin/coursecode--codex.zip`, and a local marketplace at `artifacts/codex-plugin/`. The launcher uses `runtime/` when the project has no `coursecode` dependency, and the `new-course` skill runs the first `create` through the launcher when `runtime/` exists. The ZIP excludes the repository's dependencies, credentials, Cloud code, and marketplace catalogs. Third-party source maps, type declarations, tests, and examples are omitted; executable code and license notices remain. The ZIP has no directory entries, and packaging fails above the portal's 5,000-entry limit. The runtime is built from this checkout through `npm pack`, not fetched as a published release. `BUILD.json` records its provenance and package integrity; `runtime/package-lock.json` records resolved runtime dependencies. Framework and plugin versions must match. Do not present a development ZIP with unreleased changes as the published npm version. Before public release, choose an aligned release version and rerun the framework's release checks and plugin smoke test. diff --git a/plugin/README.md b/plugin/README.md index 799bd7d..7db48cc 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -26,7 +26,7 @@ codex plugin add coursecode@coursecode ## Requirements -[Node.js](https://nodejs.org/) 22.15 or later. If it's missing, the `setup` skill offers to install it (Homebrew or the official installer on macOS, `winget` on Windows). Nothing else needs installing first: +[Node.js](https://nodejs.org/) 22.15 or later. If it's missing, the `setup` skill offers to install it (Homebrew or the official installer on macOS, `winget` on Windows). In Claude Code, a check at the start of each session tells the AI when Node.js is missing or too old, so it can run that skill. The check installs nothing itself and says nothing when Node.js is fine. Nothing else needs installing first: - **No global `coursecode` install.** Inside a course project the plugin runs the project's own CourseCode version. Elsewhere it runs the matching published version through `npx`. - **No browser setup.** Previews and screenshots use a headless copy of Chrome, Edge, Chromium, or Brave if one is installed. If none is, the first screenshot downloads Chrome Headless Shell (about 100 MB, once) into `~/.cache/puppeteer`, shared with other Puppeteer-based tools. Set `CHROME_PATH` to use a specific browser. @@ -50,6 +50,7 @@ The preview runs at http://localhost:4173, so you can watch the course change as - **MCP server** (`coursecode`): headless preview, screenshots, navigation, interaction testing, lint, live diagnostics, builds, and catalogs of components, interactions, CSS classes, and icons. Projects the plugin creates leave out `.mcp.json` so Claude Code doesn't list each tool twice; run `npx coursecode agent-setup` in a project to add it for people who don't have the plugin. - **Skills**: the six workflows above. They follow the guides in each project's `framework/docs/`, so they stay in step with the framework version the course uses. +- **Session check** (Claude Code only): a `SessionStart` hook, `hooks/check-node.cmd`, that needs no Node.js and works in `sh`, Git Bash, and PowerShell. The same file holds a shell version and a Windows batch version of the check, so update both. ## What it downloads and sends diff --git a/plugin/hooks/check-node.cmd b/plugin/hooks/check-node.cmd new file mode 100644 index 0000000..bed11a6 --- /dev/null +++ b/plugin/hooks/check-node.cmd @@ -0,0 +1,36 @@ +: << 'BATCH' +@echo off +rem SessionStart check. It can't use Node.js, because Node.js is what it checks. +rem This one file is a batch file and a shell script. The hook runs it with +rem ". ": sh and Git Bash source it and skip this block as a heredoc, and +rem PowerShell runs it through cmd.exe, so execution policy doesn't apply. +rem Keep it LF-only (.gitattributes) and free of batch labels. +rem Minimum version: keep both halves in step with the launcher in +rem mcp/coursecode-mcp.mjs (tests/lib/plugin-manifests.test.js checks this). +setlocal +set "MAJOR=0" +set "MINOR=0" +set "FOUND=no Node.js" +for /f "tokens=1,2 delims=v." %%a in ('node --version 2^>nul') do ( + set "MAJOR=%%a" + set "MINOR=%%b" + set "FOUND=Node.js %%a.%%b" +) +if %MAJOR% GTR 22 exit /b 0 +if %MAJOR% EQU 22 if %MINOR% GEQ 15 exit /b 0 +echo CourseCode: the tools can't start because they need Node.js 22.15 or later and this computer has %FOUND%. Use the coursecode:setup skill before any CourseCode work; it asks before installing anything. +exit /b 0 +BATCH + +version=$(node --version 2>/dev/null) || version= +version=${version#v} +major=${version%%.*} +minor=${version#*.} +minor=${minor%%.*} +case "$major$minor" in + '' | *[!0-9]*) major=0 minor=0 found='no Node.js' ;; + *) found="Node.js $major.$minor" ;; +esac +if [ "$major" -gt 22 ] || { [ "$major" -eq 22 ] && [ "$minor" -ge 15 ]; }; then exit 0; fi +echo "CourseCode: the tools can't start because they need Node.js 22.15 or later and this computer has $found. Use the coursecode:setup skill before any CourseCode work; it asks before installing anything." +exit 0 diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 0000000..a2c2b52 --- /dev/null +++ b/plugin/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "description": "Tells the AI tool at session start when Node.js is missing or too old for the CourseCode tools.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": ". \"${CLAUDE_PLUGIN_ROOT}/hooks/check-node.cmd\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/plugin/mcp/coursecode-mcp.mjs b/plugin/mcp/coursecode-mcp.mjs index f3feece..3598589 100644 --- a/plugin/mcp/coursecode-mcp.mjs +++ b/plugin/mcp/coursecode-mcp.mjs @@ -18,7 +18,8 @@ * FALLBACK_VERSION must match package.json and both plugin manifests * (tests/lib/claude-plugin.test.js checks this). Spawning node/npx from here * rather than from the MCP config keeps the plugin working on Windows, where npx - * is a .cmd shim. + * is a .cmd shim. The minimum Node.js version in main() must match package.json + * engines and hooks/check-node.cmd (tests/lib/plugin-manifests.test.js checks this). */ import { spawn } from 'node:child_process'; import fs from 'node:fs'; diff --git a/scripts/package-codex-plugin.mjs b/scripts/package-codex-plugin.mjs index 4a0ed3c..66d8575 100644 --- a/scripts/package-codex-plugin.mjs +++ b/scripts/package-codex-plugin.mjs @@ -9,7 +9,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const outputRoot = path.join(root, 'artifacts', 'codex-plugin'); const source = path.join(root, 'plugin'); // Claude Code files and repository docs stay out of the Codex package -const CLAUDE_ONLY = new Set(['.claude-plugin', '.mcp.json', 'README.md', 'CODEX.md']); +const CLAUDE_ONLY = new Set(['.claude-plugin', '.mcp.json', 'hooks', 'README.md', 'CODEX.md']); const manifest = JSON.parse(fs.readFileSync(path.join(source, 'plugin.json'), 'utf8')); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); if (manifest.version !== pkg.version) throw new Error('Keep the Codex plugin and framework package versions aligned.'); diff --git a/scripts/smoke-codex-plugin.mjs b/scripts/smoke-codex-plugin.mjs index 975b9eb..ba801fe 100644 --- a/scripts/smoke-codex-plugin.mjs +++ b/scripts/smoke-codex-plugin.mjs @@ -56,7 +56,7 @@ try { assert.equal(review.negative.length, 3); const skills = fs.readdirSync(path.join(plugin, 'skills')); assert.deepEqual(skills.sort(), ['build-slides', 'new-course', 'outline', 'publish', 'review', 'setup']); - assert(!fs.existsSync(path.join(plugin, '.claude-plugin')) && !fs.existsSync(path.join(plugin, '.mcp.json'))); + assert(!['.claude-plugin', '.mcp.json', 'hooks'].some(name => fs.existsSync(path.join(plugin, name)))); const icon = fs.readFileSync(path.join(plugin, ui.logo)); assert.equal(icon.subarray(1, 4).toString(), 'PNG'); assert.equal(icon.readUInt32BE(16), 256); diff --git a/scripts/smoke-plugin-session-check.ps1 b/scripts/smoke-plugin-session-check.ps1 new file mode 100644 index 0000000..4c779bd --- /dev/null +++ b/scripts/smoke-plugin-session-check.ps1 @@ -0,0 +1,48 @@ +# Runs the plugin's SessionStart check the way Claude Code does on Windows: +# through Git Bash, or through PowerShell when Git Bash isn't installed. +$ErrorActionPreference = 'Stop' + +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\plugin')).Path -replace '\\', '/' +$hooks = Get-Content "$root/hooks/hooks.json" -Raw | ConvertFrom-Json +$hook = $hooks.hooks.SessionStart[0].hooks[0].command.Replace('${CLAUDE_PLUGIN_ROOT}', $root) + +$shells = @( + @{ Name = 'Git Bash'; Path = (Join-Path $env:ProgramFiles 'Git\bin\bash.exe'); Arguments = @('-c', $hook); FakeNode = 'node'; FakeBody = "#!/bin/sh`necho v22.14.9`n" }, + @{ Name = 'Windows PowerShell'; Path = (Get-Command powershell.exe).Source; Arguments = @('-NoProfile', '-Command', $hook); FakeNode = 'node.cmd'; FakeBody = "@echo v22.14.9`r`n" }, + @{ Name = 'PowerShell 7'; Path = (Get-Command pwsh.exe).Source; Arguments = @('-NoProfile', '-Command', $hook); FakeNode = 'node.cmd'; FakeBody = "@echo v22.14.9`r`n" } +) + +$systemPath = Join-Path $env:SystemRoot 'System32' +$originalPath = $env:PATH +$failures = 0 + +function Assert-Hook($shell, $label, $path, $expected) { + $env:PATH = $path + try { + $arguments = $shell.Arguments + $output = (& $shell.Path @arguments 2>&1 | Out-String).Trim() + $exitCode = $LASTEXITCODE + } finally { + $env:PATH = $originalPath + } + + $passed = $exitCode -eq 0 -and $(if ($expected) { $output.Contains($expected) -and $output.Contains('coursecode:setup skill') } else { $output -eq '' }) + Write-Host "$(if ($passed) { 'PASS' } else { 'FAIL' }) $($shell.Name), ${label}: exit $exitCode, output: $output" + if (-not $passed) { $script:failures++ } +} + +foreach ($shell in $shells) { + $fakeDir = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $fakeDir | Out-Null + [IO.File]::WriteAllText((Join-Path $fakeDir $shell.FakeNode), $shell.FakeBody) + + Assert-Hook $shell 'supported Node.js' $originalPath $null + Assert-Hook $shell 'old Node.js' "$fakeDir;$systemPath" 'this computer has Node.js 22.14.' + Assert-Hook $shell 'missing Node.js' $systemPath 'this computer has no Node.js.' + + Remove-Item -Recurse -Force $fakeDir +} + +if ($failures -gt 0) { + throw "$failures session check case(s) failed." +} diff --git a/tests/lib/plugin-launcher.test.js b/tests/lib/plugin-launcher.test.js index cd7888f..4081cf0 100644 --- a/tests/lib/plugin-launcher.test.js +++ b/tests/lib/plugin-launcher.test.js @@ -77,6 +77,31 @@ describe('plugin launcher', () => { }); }); +describe.skipIf(process.platform === 'win32')('plugin Node.js session check', () => { + const pluginRoot = fileURLToPath(new URL('../../plugin', import.meta.url)); + const { command } = JSON.parse(fs.readFileSync(path.join(pluginRoot, 'hooks', 'hooks.json'), 'utf-8')).hooks.SessionStart[0].hooks[0]; + function runHook(nodeOutput) { + const { root } = fixture(); + if (nodeOutput !== null) fs.writeFileSync(path.join(root, 'node'), `#!/bin/sh\n${nodeOutput}\n`, { mode: 0o755 }); + return spawnSync('/bin/sh', ['-c', command], { env: { PATH: root, CLAUDE_PLUGIN_ROOT: pluginRoot }, encoding: 'utf8' }); + } + + it.each(['v22.15.0', 'v22.15.1', 'v22.100.0', 'v23.0.0', 'v100.1.0'])('stays silent with Node.js %s', (nodeVersion) => { + expect(runHook(`echo ${nodeVersion}`)).toMatchObject({ status: 0, stdout: '', stderr: '' }); + }); + it.each([['v22.14.9', '22.14'], ['v21.99.0', '21.99'], ['v8.0.0', '8.0']])('asks for setup with Node.js %s', (nodeVersion, found) => { + const result = runHook(`echo ${nodeVersion}`); + expect(result).toMatchObject({ status: 0, stderr: '' }); + expect(result.stdout).toContain(`Node.js ${found}.`); + expect(result.stdout).toContain('coursecode:setup skill'); + }); + it.each([['is missing', null], ['fails', 'exit 1'], ['prints something unexpected', 'echo nonsense']])('asks for setup when node %s', (_case, nodeOutput) => { + const result = runHook(nodeOutput); + expect(result).toMatchObject({ status: 0, stderr: '' }); + expect(result.stdout).toContain('this computer has no Node.js.'); + }); +}); + describe('public MCP operation annotations', () => { it('declares all required safety annotations for every operation', () => { for (const tool of TOOLS) { diff --git a/tests/lib/plugin-manifests.test.js b/tests/lib/plugin-manifests.test.js index e121c19..dfde495 100644 --- a/tests/lib/plugin-manifests.test.js +++ b/tests/lib/plugin-manifests.test.js @@ -32,6 +32,38 @@ describe('Claude Code and Codex plugin', () => { expect(codex.args).toEqual(['${PLUGIN_ROOT}/mcp/coursecode-mcp.mjs', 'mcp']); }); + it('keeps the launcher and the session-start check on the package.json Node.js minimum', () => { + const [, major, minor] = readJson('package.json').engines.node.match(/^>=(\d+)\.(\d+)\.0$/); + const launcher = fs.readFileSync(new URL('../../plugin/mcp/coursecode-mcp.mjs', import.meta.url), 'utf-8'); + const check = fs.readFileSync(new URL('../../plugin/hooks/check-node.cmd', import.meta.url), 'utf-8'); + const [batch, sh] = check.split('\nBATCH\n'); + + expect(launcher).toContain(`major < ${major} || (major === ${major} && minor < ${minor})`); + expect(batch).toContain(`if %MAJOR% GTR ${major} exit /b 0`); + expect(batch).toContain(`if %MAJOR% EQU ${major} if %MINOR% GEQ ${minor} exit /b 0`); + expect(sh).toContain(`[ "$major" -gt ${major} ] || { [ "$major" -eq ${major} ] && [ "$minor" -ge ${minor} ]; }`); + expect(`${batch}${sh}`.match(/Node\.js \d+\.\d+ or later/g)).toEqual(Array(2).fill(`Node.js ${major}.${minor} or later`)); + }); + + it('gives both shells the same session-start message and keeps the check portable', () => { + const check = fs.readFileSync(new URL('../../plugin/hooks/check-node.cmd', import.meta.url), 'utf-8'); + const [batch, sh] = check.split('\nBATCH\n'); + const message = (text, found) => text.match(new RegExp(`^echo "?(CourseCode: .*?)"?$`, 'm'))[1].replace(found, ''); + + expect(message(batch, '%FOUND%')).toBe(message(sh, '$found')); + expect(check).not.toContain('\r'); + expect(batch).not.toMatch(/^:\w/m); + expect(fs.readFileSync(new URL('../../.gitattributes', import.meta.url), 'utf-8')).toContain('plugin/hooks/check-node.cmd text eol=lf'); + }); + + it('checks Node.js at session start without a Cowork-blocking bin directory', () => { + const [entry] = readJson('plugin/hooks/hooks.json').hooks.SessionStart; + + expect(entry.hooks).toEqual([{ type: 'command', command: '. "${CLAUDE_PLUGIN_ROOT}/hooks/check-node.cmd"', timeout: 10 }]); + expect(fs.existsSync(new URL('../../plugin/bin', import.meta.url))).toBe(false); + expect(fs.readFileSync(new URL('../../scripts/package-codex-plugin.mjs', import.meta.url), 'utf-8')).toMatch(/CLAUDE_ONLY = new Set\(\[[^\]]*'hooks'/); + }); + it('lists the plugin folder in both repository marketplaces', () => { const claude = readJson('.claude-plugin/marketplace.json'); const codex = readJson('.agents/plugins/marketplace.json');