diff --git a/packages/create-blit-tech/src/scaffold.ts b/packages/create-blit-tech/src/scaffold.ts index 3970eff..8c8bdde 100644 --- a/packages/create-blit-tech/src/scaffold.ts +++ b/packages/create-blit-tech/src/scaffold.ts @@ -82,6 +82,7 @@ function classifyFile(relPath: string): FileClass { normalized.startsWith('.cursor/rules/') || normalized.startsWith('.cursor/hooks/') || normalized.startsWith('.cursor/commands/') || + normalized === '.cursor/hooks.json' || normalized.startsWith('.claude/skills/') || normalized.startsWith('.claude/rules/') || normalized === '.claude/settings.json' @@ -382,6 +383,178 @@ function generateClaudeAdapter( } } +// --------------------------------------------------------------------------- +// Cursor adapter types (mirrors the Cursor hooks.json wire format) +// --------------------------------------------------------------------------- + +interface CursorHookEntry { + /** Shell command to run for this hook event. */ + command?: string; + /** Regex string used by Cursor to match which tool calls trigger this hook. */ + matcher?: string; + /** Seconds before the hook is considered timed out. */ + timeout?: number; + /** When true, Cursor blocks the triggering action if the hook fails or exits non-zero. */ + failClosed?: boolean; +} + +interface CursorHooksJson { + version: number; + hooks: Record; +} + +// --------------------------------------------------------------------------- +// Hooks-manifest types (canonical intent format in content/hooks.manifest.json) +// --------------------------------------------------------------------------- + +interface HookManifestCursorBlock extends CursorHookEntry { + /** Cursor hook event: "afterFileEdit" | "beforeShellExecution" | "preToolUse" */ + event: string; +} + +interface HookManifestEntry { + /** Stable identifier. */ + id: string; + /** Human-readable intent used in AGENTS.md prose and as documentation. */ + intent: string; + /** Cursor-specific rendering of this hook. */ + cursor?: HookManifestCursorBlock; +} + +interface HooksManifest { + version: string; + hooks: HookManifestEntry[]; +} + +/** Translate the canonical hooks manifest into Cursor's `hooks.json` structure, rendering template vars. */ +function buildCursorHooks(manifest: HooksManifest, vars: TemplateVars): CursorHooksJson { + const hooks: Record = {}; + + for (const hook of manifest.hooks) { + if (!hook.cursor) { + continue; + } + + const { event, ...rest } = hook.cursor; + const entry: CursorHookEntry = {}; + + if (rest.command !== undefined) { + entry.command = render(rest.command, vars); + } + + if (rest.matcher !== undefined) { + entry.matcher = rest.matcher; + } + + if (rest.timeout !== undefined) { + entry.timeout = rest.timeout; + } + + if (rest.failClosed !== undefined) { + entry.failClosed = rest.failClosed; + } + + if (!hooks[event]) { + hooks[event] = []; + } + + hooks[event].push(entry); + } + + return { version: 1, hooks }; +} + +/** + * Generate the Cursor adapter files from the kit's canonical IR: + * - `.cursor/rules/{name}.mdc` (kit-owned, MDC format; frontmatter from rule files) + * - `.cursor/hooks.json` (kit-owned; translated from content/hooks.manifest.json) + * - `.cursor/hooks/shell-safety.sh` (kit-owned; shell hook script) + * - `.cursor/commands/{name}.md` (kit-owned, one per skill in content/skills/) + * + * Replaces the static `templates/optional/cursor/` tree. + */ +function generateCursorAdapter( + kitContentRoot: string, + targetDir: string, + vars: TemplateVars, + writtenPaths: Set, +): void { + const contentRoot = join(kitContentRoot, 'content'); + const cursorDir = join(targetDir, CURSOR_DIR); + mkdirSync(cursorDir, { recursive: true }); + + // Emit .cursor/rules/*.mdc from content/rules/*.md. + // Kit rules carry YAML frontmatter with description/alwaysApply/globs — Cursor reads this as MDC. + const rulesDir = join(contentRoot, 'rules'); + if (existsSync(rulesDir)) { + const cursorRulesDir = join(cursorDir, 'rules'); + mkdirSync(cursorRulesDir, { recursive: true }); + + for (const entry of readdirSync(rulesDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) { + continue; + } + + const src = join(rulesDir, entry.name); + const destName = entry.name.replace(/\.md$/, '.mdc'); + const dest = join(cursorRulesDir, destName); + writeFileSync(dest, render(readFileSync(src, 'utf8'), vars)); + writtenPaths.add(dest); + } + } + + // Emit .cursor/hooks.json from content/hooks.manifest.json. + const hookManifestPath = join(contentRoot, 'hooks.manifest.json'); + if (existsSync(hookManifestPath)) { + const manifest = JSON.parse(readFileSync(hookManifestPath, 'utf8')) as HooksManifest; + const cursorHooks = buildCursorHooks(manifest, vars); + + const hooksJsonPath = join(cursorDir, 'hooks.json'); + writeFileSync(hooksJsonPath, `${JSON.stringify(cursorHooks, null, 2)}\n`); + writtenPaths.add(hooksJsonPath); + } + + // Copy hook scripts referenced in the manifest (e.g. shell-safety.sh). + const hooksScriptsDir = join(contentRoot, 'hooks'); + if (existsSync(hooksScriptsDir)) { + const cursorHooksDir = join(cursorDir, 'hooks'); + mkdirSync(cursorHooksDir, { recursive: true }); + + for (const entry of readdirSync(hooksScriptsDir, { withFileTypes: true })) { + if (!entry.isFile()) { + continue; + } + + const src = join(hooksScriptsDir, entry.name); + const dest = join(cursorHooksDir, entry.name); + writeFileSync(dest, readFileSync(src, 'utf8')); + writtenPaths.add(dest); + } + } + + // Emit .cursor/commands/ from content/skills/*/SKILL.md. + const skillsDir = join(contentRoot, 'skills'); + if (existsSync(skillsDir)) { + const commandsDir = join(cursorDir, 'commands'); + mkdirSync(commandsDir, { recursive: true }); + + for (const entry of readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const skillSrc = join(skillsDir, entry.name, 'SKILL.md'); + if (!existsSync(skillSrc)) { + continue; + } + + const dest = join(commandsDir, `${entry.name}.md`); + writeFileSync(dest, render(readFileSync(skillSrc, 'utf8'), vars)); + writtenPaths.add(dest); + } + } +} + /** Generate the project at `targetDir`. The caller guarantees the folder is empty. */ export function scaffold(options: ScaffoldOptions): void { // Resolve the actual kit version string (not the range) for the manifest. @@ -426,12 +599,7 @@ export function scaffold(options: ScaffoldOptions): void { } if (options.agent === 'cursor') { - copyTemplateTree( - join(templates, 'optional', 'cursor', 'dot-cursor'), - join(options.targetDir, CURSOR_DIR), - vars, - writtenPaths, - ); + generateCursorAdapter(kitRoot(), options.targetDir, vars, writtenPaths); } if (options.agent === 'claude') { diff --git a/packages/create-blit-tech/templates/optional/cursor/dot-cursor/rules/blit-tech-api-names.mdc b/packages/create-blit-tech/templates/optional/cursor/dot-cursor/rules/blit-tech-api-names.mdc deleted file mode 100644 index 5c639f4..0000000 --- a/packages/create-blit-tech/templates/optional/cursor/dot-cursor/rules/blit-tech-api-names.mdc +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Blit-Tech public API names for generated game projects -alwaysApply: true ---- - -# Blit-Tech API names - -Use the public `BT` namespace and `configure()` field names from the blit-tech engine. - -## Configure flags - -Use grammatical `is*` names: `isOverlayEnabled`, `isDetectingDroppedFrames`, `canvasID`, `containerID`. - -## Input on BT - -- Hold: `BT.isDown(...)`, `BT.isKeyDown(...)` -- Edge: `BT.isPressed(...)`, `BT.isReleased(...)`, `BT.isKeyPressed(...)`, `BT.isKeyReleased(...)` -- Pointer: `BT.isPointerActive(slot?)` - -## Getters (no parentheses) - -`BT.displaySize`, `BT.targetFPS`, `BT.ticks`, `BT.deltaSeconds`, `BT.timeSeconds`, `BT.activeBackend`, `BT.camera`, `BT.palette` - -## Do not use removed names - -`BT.isButtonDown`, `buttonDown`, `overlayEnabled`, `canvasId`. - -Full reference: `AGENTS.md` and `docs/` in this project, or blit-tech `docs/api-core.md`. diff --git a/packages/create-blit-tech/test/scaffold.test.mjs b/packages/create-blit-tech/test/scaffold.test.mjs index aec213c..1eabaad 100644 --- a/packages/create-blit-tech/test/scaffold.test.mjs +++ b/packages/create-blit-tech/test/scaffold.test.mjs @@ -187,10 +187,58 @@ test('scaffold copies optional CI and agent files when requested', () => { agent: 'cursor', }); + // Cursor adapter: rules, hooks, and commands should all be generated. assert.ok( - existsSync(join(cursorProject, '.cursor', 'rules', 'blit-tech-api-names.mdc')), - 'Cursor rules should be generated', + existsSync(join(cursorProject, '.cursor', 'rules', 'blit-api-names.mdc')), + 'Cursor rule blit-api-names.mdc should be generated', ); + assert.ok( + existsSync(join(cursorProject, '.cursor', 'rules', 'blit-integer-coords.mdc')), + 'Cursor rule blit-integer-coords.mdc should be generated', + ); + assert.ok(existsSync(join(cursorProject, '.cursor', 'hooks.json')), '.cursor/hooks.json should be generated'); + assert.ok( + existsSync(join(cursorProject, '.cursor', 'hooks', 'shell-safety.sh')), + '.cursor/hooks/shell-safety.sh should be generated', + ); + assert.ok( + existsSync(join(cursorProject, '.cursor', 'commands', 'run.md')), + '.cursor/commands/run.md should be generated', + ); + assert.ok( + existsSync(join(cursorProject, '.cursor', 'commands', 'fix.md')), + '.cursor/commands/fix.md should be generated', + ); + + // Cursor rule files should keep their MDC frontmatter (Cursor reads alwaysApply from it). + const apiRule = readFileSync(join(cursorProject, '.cursor', 'rules', 'blit-api-names.mdc'), 'utf8'); + assert.ok(apiRule.startsWith('---'), 'Cursor rule files should keep YAML frontmatter'); + assert.ok(apiRule.includes('alwaysApply: true'), 'Cursor rule should include alwaysApply flag'); + + // hooks.json should have the expected structure with afterFileEdit and beforeShellExecution. + const hooksJson = JSON.parse(readFileSync(join(cursorProject, '.cursor', 'hooks.json'), 'utf8')); + assert.equal(hooksJson.version, 1, 'hooks.json version should be 1'); + assert.ok(Array.isArray(hooksJson.hooks.afterFileEdit), 'hooks.json should have afterFileEdit entries'); + assert.ok(hooksJson.hooks.afterFileEdit.length > 0, 'afterFileEdit should contain at least one entry'); + assert.ok( + Array.isArray(hooksJson.hooks.beforeShellExecution), + 'hooks.json should have beforeShellExecution entries', + ); + assert.ok( + hooksJson.hooks.beforeShellExecution.length > 0, + 'beforeShellExecution should contain at least one entry', + ); + const safetyHook = hooksJson.hooks.beforeShellExecution[0]; + assert.ok(safetyHook.failClosed === true, 'shell safety hook should be failClosed'); + + // Template vars should be rendered in hooks.json. + const formatHook = hooksJson.hooks.afterFileEdit[0]; + assert.ok(formatHook.command.includes('format'), 'format hook should reference the format command'); + assert.ok(!formatHook.command.includes('{{'), 'format hook should not have unrendered placeholders'); + + // Commands should have template vars rendered. + const runCmd = readFileSync(join(cursorProject, '.cursor', 'commands', 'run.md'), 'utf8'); + assert.ok(!runCmd.includes('{{'), 'run command should not have unrendered placeholders'); } finally { rmSync(work, { recursive: true, force: true }); } diff --git a/packages/kit/content/hooks.manifest.json b/packages/kit/content/hooks.manifest.json new file mode 100644 index 0000000..cabb1bf --- /dev/null +++ b/packages/kit/content/hooks.manifest.json @@ -0,0 +1,26 @@ +{ + "version": "1", + "hooks": [ + { + "id": "format-on-edit", + "intent": "Format source files after each AI-assisted edit to keep code consistent", + "cursor": { + "event": "afterFileEdit", + "command": "{{pmRunFormat}}", + "matcher": "Write|TabWrite", + "timeout": 30, + "failClosed": false + } + }, + { + "id": "block-dangerous-shell", + "intent": "Block risky destructive git commands that could lose game progress", + "cursor": { + "event": "beforeShellExecution", + "command": "sh .cursor/hooks/shell-safety.sh", + "timeout": 10, + "failClosed": true + } + } + ] +} diff --git a/packages/kit/content/hooks/shell-safety.sh b/packages/kit/content/hooks/shell-safety.sh new file mode 100644 index 0000000..a4677fa --- /dev/null +++ b/packages/kit/content/hooks/shell-safety.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# Cursor beforeShellExecution hook - blocks risky destructive git commands. +# Source: @blit-tech/kit (generated by `npx blit agents sync`). + +set -u + +# python3 is required to parse the JSON payload from Cursor. +# If it is not available the hook exits non-zero so Cursor treats this as a +# fail-closed event and blocks the triggering action rather than silently +# allowing it through. +if ! command -v python3 > /dev/null 2>&1; then + printf '{"permission":"deny","user_message":"Shell safety hook could not run: python3 is not installed.","agent_message":"python3 is required by the blit shell-safety hook. Install it or run `npx blit agents sync` to regenerate hooks."}\n' >&2 + exit 1 +fi + +INPUT_JSON="$(cat)" + +COMMAND_TEXT="$(printf '%s' "$INPUT_JSON" | python3 -c " +import json, sys + +def walk(node): + if isinstance(node, dict): + for key in ('command', 'raw_command'): + value = node.get(key) + if isinstance(value, str) and value: + return value + for value in node.values(): + found = walk(value) + if found: + return found + elif isinstance(node, list): + for value in node: + found = walk(value) + if found: + return found + return '' + +try: + data = json.load(sys.stdin) +except Exception: + print('') + raise SystemExit(0) + +print(walk(data)) +")" + +if [ -z "$COMMAND_TEXT" ]; then + printf '{"permission":"allow"}\n' + exit 0 +fi + +if printf '%s' "$COMMAND_TEXT" | grep -Eq 'git[[:space:]]+reset[[:space:]]+--hard|git[[:space:]]+clean[[:space:]]+-[^[:cntrl:]]*f|git[[:space:]]+checkout[[:space:]]+--'; then + printf '{"permission":"deny","user_message":"Blocked a destructive git command that could lose your game changes.","agent_message":"Use safer git operations. Ask the user before discarding any work."}\n' + exit 0 +fi + +if printf '%s' "$COMMAND_TEXT" | grep -Eq 'git[[:space:]]+push[^[:cntrl:]]*--force|git[[:space:]]+push[^[:cntrl:]]*-f'; then + printf '{"permission":"ask","user_message":"Force push detected. Confirm before continuing.","agent_message":"Force push rewrites history. Ask the user for explicit confirmation first."}\n' + exit 0 +fi + +printf '{"permission":"allow"}\n' +exit 0