Skip to content
Merged
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
180 changes: 174 additions & 6 deletions packages/create-blit-tech/src/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, CursorHookEntry[]>;
}

// ---------------------------------------------------------------------------
// 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<string, CursorHookEntry[]> = {};

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<string>,
): 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.
Expand Down Expand Up @@ -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') {
Expand Down

This file was deleted.

52 changes: 50 additions & 2 deletions packages/create-blit-tech/test/scaffold.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
26 changes: 26 additions & 0 deletions packages/kit/content/hooks.manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
63 changes: 63 additions & 0 deletions packages/kit/content/hooks/shell-safety.sh
Original file line number Diff line number Diff line change
@@ -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