diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3642d2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Least privilege: these jobs only read the repo and run tests. +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18, 20, 22] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test + + smoke: + # Proves the headline claim — the CLI runs non-interactively and writes files. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + - run: npm install + - name: Smoke test the CLI in a sample project + run: | + mkdir -p /tmp/sample && cd /tmp/sample + echo '{"dependencies":{"next":"14.0.0","typescript":"5.0.0"}}' > package.json + node "$GITHUB_WORKSPACE/src/cli.js" --all --yes + test -f .cursor/rules/030-nextjs.mdc && echo "✅ .mdc generated" || (echo "❌ no .mdc" && exit 1) + test -f AGENTS.md && echo "✅ AGENTS.md generated" || (echo "❌ no AGENTS.md" && exit 1) + grep -q "cursor-compose:start" AGENTS.md && echo "✅ managed markers present" || (echo "❌ no markers" && exit 1) + ! grep -q "alwaysApply:" AGENTS.md && echo "✅ AGENTS.md is plain markdown" || (echo "❌ frontmatter leaked into AGENTS.md" && exit 1) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc340e4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Kabi10 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c9174ae..c147e21 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,127 @@ +
+ # cursor-compose -Auto-detect your project's stack and generate Cursor project rules in one command. No cloning required. +**Stop hand-writing AI coding rules. Detect your stack, generate them — for every tool.** + +One command scans your project, detects your frameworks, and writes a tailored rules file. Output a portable **`AGENTS.md`** (read by Cursor, Copilot, Codex & Claude Code) or Cursor's native `.cursor/rules/*.mdc` — from one source. + +[![npm version](https://img.shields.io/npm/v/cursor-compose.svg)](https://www.npmjs.com/package/cursor-compose) +[![CI](https://github.com/Kabi10/cursor-rules/actions/workflows/ci.yml/badge.svg)](https://github.com/Kabi10/cursor-rules/actions/workflows/ci.yml) +[![Node](https://img.shields.io/node/v/cursor-compose.svg)](https://nodejs.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +
+ +```bash +npx cursor-compose --agents # → AGENTS.md, works across every major AI editor +``` + + +![demo](docs/demo.gif) + +--- + +## Why + +Everyone is pasting the same recycled rules files between projects. They go stale, they don't match your actual stack, and every editor wants its own format. + +`cursor-compose` reads your **real dependencies** (`package.json`, `requirements.txt`, `pubspec.yaml`) and composes a rules file from modular, stack-specific building blocks — and writes it as the portable **`AGENTS.md`** standard so the same source works in Cursor, Copilot, Codex and Claude Code. Zero config to start. + +## Usage ```bash -npx cursor-compose +# In your project root: +npx cursor-compose # default → per-module .cursor/rules/*.mdc +npx cursor-compose --agents # → AGENTS.md (portable, cross-tool) +npx cursor-compose --all # → both .mdc and AGENTS.md, one pass ``` -Writes `.cursor/rules/project.mdc` — the current Cursor format. Legacy `.cursorrules` also supported. +It will: +1. **Scan** your project for dependency files. +2. **Show** the modules it detected and let you toggle optional ones. +3. **Write** your chosen output(s). -## What it does +No install, no config file required. -1. Scans your project for `package.json`, `requirements.txt`, `pubspec.yaml`, etc. -2. Shows which modules were detected, lets you toggle extras -3. Writes a composed `.cursor/rules/project.mdc` to your project root +### Flags -## Detected stacks +| Flag | Output | +|---|---| +| _(default)_ | per-module `.cursor/rules/*.mdc` (current Cursor format) | +| `--agents` | `AGENTS.md` at repo root — portable across Cursor, Copilot, Codex, Claude Code | +| `--all` | both `.mdc` and `AGENTS.md` in one pass | +| `--legacy` | a single `.cursorrules` file | +| `--yes`, `-y` | non-interactive: use detected modules, no prompts (great for CI/scripts) | +| `-h`, `--help` | show help | -| File found | Modules added | -|-----------|--------------| -| `next` in package.json | `nextjs` + `typescript` | -| `typescript` in package.json | `typescript` | -| `@supabase/supabase-js` | `supabase` | -| `drizzle-orm` | `drizzle` | -| `shadcn` / `@radix-ui` | `shadcn` | -| `fastapi` in requirements.txt | `fastapi` | -| `flutter:` in pubspec.yaml | `flutter` | +### AGENTS.md is safe to hand-edit -`core` is always included. +The generated content lives between managed markers: + +```markdown + +# AGENTS.md +...generated from your stack... + + +## Your own notes (preserved on regenerate) +``` + +Re-running `--agents` only replaces the managed block — anything you add outside it is kept. + +## What it detects + +| Auto-detected | From | +|---|---| +| Next.js + TypeScript | `package.json` | +| Supabase | `package.json` | +| Drizzle ORM | `package.json` | +| shadcn / Radix UI | `package.json` | +| FastAPI | `requirements.txt` | +| Flutter | `pubspec.yaml` | + +Core conventions always load by default. ## Optional modules -Select these manually during init: +Toggle these on during the interactive prompt: -- `saas` — multi-tenancy, billing, feature flags -- `ecommerce` — cart, checkout, inventory, payments -- `claude-code` — CLAUDE.md conventions, memory system -- `agentic` — agent loop patterns, tool use safety +- **`saas`** — multi-tenancy, billing, auth patterns +- **`ecommerce`** — cart, checkout, payment flows +- **`claude-code`** — `CLAUDE.md` / documentation conventions +- **`agentic`** — agent & tool-calling patterns ## Requirements -Node.js 18+ +- Node.js 18+ + +## Roadmap + +- [ ] **`cursor-compose check`** — CI command that fails the build when your committed rules drift from your actual dependencies. +- [ ] More stacks (Django, Rails, SvelteKit, Expo). -## Modular builder (advanced) +Want a stack supported? [Open an issue](https://github.com/Kabi10/cursor-rules/issues) or send a PR — adding a module is just a markdown file (see [CONTRIBUTING.md](CONTRIBUTING.md)). -If you prefer to compose rules with a script instead of `npx`, the original -`build-rules.ps1` (Windows) and `build-rules.sh` (Unix) are still available after cloning. +## How it works + +``` +src/ + cli.js # interactive prompt + orchestration + flags + detect.js # reads dependency files → detected modules + compose.js # assembles selected modules; writes .mdc / .cursorrules / AGENTS.md + frontmatter.js # Cursor .mdc YAML frontmatter (not used for AGENTS.md) +modules/ # one .md per framework — the rule building blocks +patterns/ # reusable code-organization patterns +``` ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md). Adding a new module is just adding a `.md` file to `modules/`. +New modules are welcome and easy — drop a markdown file in `modules/`. See [CONTRIBUTING.md](CONTRIBUTING.md). ## License diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..dbdbca7 Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape new file mode 100644 index 0000000..a5a3f22 --- /dev/null +++ b/docs/demo.tape @@ -0,0 +1,29 @@ +Output docs/demo.gif + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 680 +Set Theme "Dracula" +Set TypingSpeed 55ms +Set Padding 18 + +Hide +Type 'npx() { shift; node /workspaces/cursor-rules/src/cli.js "$@"; }' +Enter +Type "cd ~/ccdemo && rm -f AGENTS.md && clear" +Enter +Sleep 1s +Show + +Sleep 1s +Type "npx cursor-compose --agents" +Sleep 700ms +Enter +Sleep 2800ms +Enter +Sleep 1800ms +Type "head -16 AGENTS.md" +Sleep 400ms +Enter +Sleep 3500ms diff --git a/package.json b/package.json index b690c93..f8cd640 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cursor-compose", - "version": "1.1.0", - "description": "Auto-detect your stack and generate Cursor project rules (.cursor/rules/project.mdc). npx cursor-compose", + "version": "1.2.0", + "description": "Auto-detect your stack and generate AI editor rules — AGENTS.md (Cursor, Copilot, Codex, Claude Code) or Cursor .mdc. npx cursor-compose", "type": "module", "bin": { "cursor-compose": "src/cli.js" @@ -11,7 +11,7 @@ "modules/" ], "scripts": { - "test": "node --test tests/**/*.test.js" + "test": "node --test tests/*.test.js" }, "dependencies": { "kleur": "^4.1.5", @@ -24,8 +24,16 @@ "cursor", "cursorrules", "cursor-ai", + "agents-md", + "agents.md", + "ai", + "llm", + "claude", + "copilot", + "codex", "developer-tools", - "cli" + "cli", + "npx" ], "license": "MIT", "homepage": "https://github.com/Kabi10/cursor-rules", diff --git a/src/cli.js b/src/cli.js index 1de034b..ba03c7c 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,10 +1,17 @@ #!/usr/bin/env node import prompts from 'prompts'; import kleur from 'kleur'; -import { existsSync, readdirSync } from 'fs'; +import { existsSync, readdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { detectModules } from './detect.js'; -import { writeRulesMdc, writeRulesLegacy, estimateTokens, composeModules } from './compose.js'; +import { + writeRulesMdc, + writeRulesLegacy, + writeRulesAgentsMd, + estimateTokens, + composeModules, + findManagedBlock, +} from './compose.js'; const ALL_MODULES = [ 'core', @@ -21,6 +28,26 @@ const ALL_MODULES = [ 'agentic', ]; +// Warn when an always-on AGENTS.md gets large enough to eat real context. +const TOKEN_WARN_THRESHOLD = 2000; + +const HELP = ` +${kleur.bold('cursor-compose')} — detect your stack and generate AI editor rules + +${kleur.bold('Usage:')} cursor-compose [options] + +${kleur.bold('Options:')} + ${kleur.cyan('(default)')} write per-module .mdc files to .cursor/rules/ + ${kleur.cyan('--agents')} write AGENTS.md (portable: Cursor, Copilot, Codex, Claude Code) + ${kleur.cyan('--all')} write both .cursor/rules/*.mdc and AGENTS.md + ${kleur.cyan('--legacy')} write a single .cursorrules file + ${kleur.cyan('--yes, -y')} non-interactive: use detected modules, update/overwrite without prompts + ${kleur.cyan('-h, --help')} show this help + +AGENTS.md is written between managed markers; anything you add outside them is +preserved when you regenerate. +`; + // Exit cleanly on Ctrl+C process.on('SIGINT', () => { console.log(kleur.yellow('\nCancelled.')); @@ -28,6 +55,13 @@ process.on('SIGINT', () => { }); async function main() { + const argv = process.argv.slice(2); + + if (argv.includes('-h') || argv.includes('--help')) { + console.log(HELP); + process.exit(0); + } + // Node version check const [major] = process.versions.node.split('.').map(Number); if (major < 18) { @@ -37,8 +71,16 @@ async function main() { process.exit(1); } - // --legacy flag - const isLegacy = process.argv.includes('--legacy'); + // Output mode flags ({--agents, --legacy, --all} are mutually exclusive). + const isAgents = argv.includes('--agents'); + const isLegacy = argv.includes('--legacy'); + const isAll = argv.includes('--all'); + const isYes = argv.includes('--yes') || argv.includes('-y'); + + if ([isAgents, isLegacy, isAll].filter(Boolean).length > 1) { + console.error(kleur.red('Choose only one of --agents / --legacy / --all.')); + process.exit(1); + } console.log(kleur.bold('\ncursor-compose\n')); @@ -50,28 +92,46 @@ async function main() { console.log(kleur.dim('No stack detected — select modules manually.\n')); } - // Module selection - const { selected } = await prompts({ - type: 'multiselect', - name: 'selected', - message: 'Select modules (space to toggle, enter to confirm):', - choices: ALL_MODULES.map((m) => ({ - title: m, - value: m, - selected: detected.includes(m), - })), - min: 1, - }); + // Module selection (skipped in non-interactive mode). + let selected; + if (isYes) { + selected = detected.length > 0 ? detected : ['core']; + console.log(kleur.dim('Non-interactive: using ') + selected.join(', ') + '\n'); + } else { + ({ selected } = await prompts({ + type: 'multiselect', + name: 'selected', + message: 'Select modules (space to toggle, enter to confirm):', + choices: ALL_MODULES.map((m) => ({ + title: m, + value: m, + selected: detected.includes(m), + })), + min: 1, + })); + } if (!selected || selected.length === 0) { console.log(kleur.yellow('No modules selected. Exiting.')); process.exit(0); } + // Reports estimated tokens for an always-on file and warns when it's large. + const reportTokens = () => { + const tokens = estimateTokens(composeModules(selected)); + if (tokens > TOKEN_WARN_THRESHOLD) { + console.log( + kleur.yellow(`\n⚠ AGENTS.md is ~${tokens} tokens (${selected.length} modules).`) + + kleur.dim(' Consider fewer modules to save context.') + ); + } + return tokens; + }; + + // --legacy: single .cursorrules file if (isLegacy) { - // Legacy: single .cursorrules file const outFile = '.cursorrules'; - if (existsSync(outFile)) { + if (!isYes && existsSync(outFile)) { const { action } = await prompts({ type: 'select', name: 'action', @@ -94,13 +154,45 @@ async function main() { return; } - // Modern: one .mdc per module in .cursor/rules/ + // --agents: portable AGENTS.md (managed block) + if (isAgents) { + const tokens = reportTokens(); + const onForeignFile = await resolveForeignDecision(isYes); + const result = writeRulesAgentsMd(selected, process.cwd(), { onForeignFile }); + if (result.action === 'cancelled') { + console.log(kleur.yellow('Cancelled.')); + process.exit(0); + } + console.log('\n' + kleur.green('Done! ') + `${actionVerb(result.action)} ${kleur.bold('AGENTS.md')} (~${tokens} tokens)`); + console.log(kleur.dim('Works with Cursor, Copilot, Codex & Claude Code.\n')); + return; + } + + // --all: per-module .mdc AND AGENTS.md in one pass + if (isAll) { + const tokens = reportTokens(); + // Resolve the AGENTS.md decision and write it first so a cancel aborts + // before any .mdc files are written (no half-done state). + const onForeignFile = await resolveForeignDecision(isYes); + const result = writeRulesAgentsMd(selected, process.cwd(), { onForeignFile }); + if (result.action === 'cancelled') { + console.log(kleur.yellow('Cancelled.')); + process.exit(0); + } + const written = writeRulesMdc(selected); + console.log('\n' + kleur.green('Done! ') + `Wrote ${written.length} rule file(s) to ${kleur.bold('.cursor/rules/')}`); + console.log(kleur.green(' ') + `${actionVerb(result.action)} ${kleur.bold('AGENTS.md')} (~${tokens} tokens)`); + console.log(kleur.dim('Restart Cursor to apply.\n')); + return; + } + + // Default: one .mdc per module in .cursor/rules/ const rulesDir = join(process.cwd(), '.cursor', 'rules'); const existingMdc = existsSync(rulesDir) ? readdirSync(rulesDir).filter(f => f.endsWith('.mdc')) : []; - if (existingMdc.length > 0) { + if (!isYes && existingMdc.length > 0) { const { action } = await prompts({ type: 'select', name: 'action', @@ -124,6 +216,44 @@ async function main() { console.log(kleur.dim('\nRestart Cursor to apply.\n')); } +/** + * Resolves how to handle an existing AGENTS.md, returning a sync callback for + * writeRulesAgentsMd. Only a foreign file (exists, no managed markers) prompts; + * a missing file or one with markers is handled non-destructively by the writer. + */ +async function resolveForeignDecision(isYes) { + const p = join(process.cwd(), 'AGENTS.md'); + if (!existsSync(p)) return () => 'append'; + // A valid managed block is replaced in place by the writer — no prompt needed. + if (findManagedBlock(readFileSync(p, 'utf8'))) return () => 'append'; + // Non-interactive: never prompt; append a managed block, preserving the file. + if (isYes) return () => 'append'; + + const { action } = await prompts({ + type: 'select', + name: 'action', + message: `${kleur.yellow('AGENTS.md')} exists without cursor-compose markers:`, + choices: [ + { title: 'Append a managed block (keep existing content)', value: 'append' }, + { title: 'Overwrite the whole file', value: 'overwrite' }, + { title: 'Cancel', value: 'cancel' }, + ], + initial: 0, + }); + const decision = action || 'cancel'; + return () => decision; +} + +function actionVerb(action) { + switch (action) { + case 'created': return 'Created'; + case 'updated': return 'Updated'; + case 'appended': return 'Appended managed block to'; + case 'overwritten': return 'Overwrote'; + default: return 'Wrote'; + } +} + main().catch((err) => { console.error(kleur.red('Error: ') + err.message); process.exit(1); diff --git a/src/compose.js b/src/compose.js index 7e0f0cb..59af05c 100644 --- a/src/compose.js +++ b/src/compose.js @@ -6,6 +6,11 @@ import { FRONTMATTER, buildFrontmatter } from './frontmatter.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MODULES_DIR = join(__dirname, '..', 'modules'); +// Markers delimiting cursor-compose's managed region inside AGENTS.md. +// Content outside these markers is preserved across regenerations. +export const AGENTS_START = ''; +export const AGENTS_END = ''; + /** * Loads selected module .md files and concatenates them (legacy single-file mode). * @@ -35,6 +40,49 @@ export function estimateTokens(text) { return Math.ceil(text.length / 4); } +/** + * Demotes every ATX markdown heading one level (h1→h2 … h5→h6), capping at h6, + * so a composed AGENTS.md keeps a single top-level H1 while preserving each + * module's internal heading hierarchy. Headings inside fenced code blocks + * (``` / ~~~) are left untouched. + * + * @param {string} md + * @returns {string} + */ +export function demoteHeadings(md) { + let inFence = false; + return md + .split('\n') + .map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return line; + } + if (inFence) return line; + return line.replace(/^(#{1,6})(\s)/, (_, hashes, ws) => + (hashes.length >= 6 ? hashes : hashes + '#') + ws + ); + }) + .join('\n'); +} + +/** + * Locates cursor-compose's managed block: the start marker and the first end + * marker that follows it. Returns null when a valid, ordered pair is absent + * (e.g. an orphaned start marker), so callers treat the file as foreign rather + * than corrupt it. + * + * @param {string} content + * @returns {{ startIdx: number, endIdx: number } | null} + */ +export function findManagedBlock(content) { + const startIdx = content.indexOf(AGENTS_START); + if (startIdx === -1) return null; + const endIdx = content.indexOf(AGENTS_END, startIdx + AGENTS_START.length); + if (endIdx === -1) return null; + return { startIdx, endIdx }; +} + /** * Writes each selected module as its own .mdc file with proper YAML frontmatter. * Files are written to .cursor/rules/ with numeric prefixes for load order. @@ -84,6 +132,86 @@ export function writeRulesLegacy(moduleIds, cwd = process.cwd()) { return outPath; } +/** + * Builds the managed AGENTS.md block for the selected modules. + * + * AGENTS.md is the portable, cross-tool standard (Cursor, Copilot, Codex, + * Claude Code), so this is plain markdown — NOT the Cursor-specific .mdc + * frontmatter. Each module's top-level `# Heading` is demoted to `## Heading` + * so the composed file keeps a single H1. + * + * @param {string[]} moduleIds + * @returns {string} the managed block, wrapped in start/end markers + */ +export function buildAgentsBlock(moduleIds) { + const sections = moduleIds.map((id) => { + const modPath = join(MODULES_DIR, `${id}.md`); + if (!existsSync(modPath)) { + console.warn(`Warning: module "${id}" not found, skipping`); + return null; + } + const content = readFileSync(modPath, 'utf8').trim(); + // Demote every module heading one level so AGENTS.md keeps a single H1. + return demoteHeadings(content); + }).filter(Boolean); + + const header = + '# AGENTS.md\n\n' + + ''; + + const body = [header, ...sections].join('\n\n'); + return `${AGENTS_START}\n${body}\n${AGENTS_END}`; +} + +/** + * Writes/updates AGENTS.md at the project root using a managed block, so the + * tool can be re-run without clobbering content a user added by hand. + * + * - No file → create it. + * - Has markers → replace only the managed block, preserve everything else. + * - Foreign file → decision via opts.onForeignFile() returning + * 'append' | 'overwrite' | 'cancel' (defaults to 'append'). + * + * @param {string[]} moduleIds + * @param {string} cwd - target directory (defaults to process.cwd()) + * @param {{ onForeignFile?: () => ('append'|'overwrite'|'cancel') }} [opts] + * @returns {{ outPath: string, action: ('created'|'updated'|'appended'|'overwritten'|'cancelled') }} + */ +export function writeRulesAgentsMd(moduleIds, cwd = process.cwd(), opts = {}) { + const outPath = join(cwd, 'AGENTS.md'); + const block = buildAgentsBlock(moduleIds); + + if (!existsSync(outPath)) { + writeFileSync(outPath, block + '\n', 'utf8'); + return { outPath, action: 'created' }; + } + + const existing = readFileSync(outPath, 'utf8'); + const managed = findManagedBlock(existing); + + if (managed) { + const before = existing.slice(0, managed.startIdx); + const after = existing.slice(managed.endIdx + AGENTS_END.length); + writeFileSync(outPath, `${before}${block}${after}`, 'utf8'); + return { outPath, action: 'updated' }; + } + + // Foreign file with no managed markers — don't silently destroy it. + const decision = opts.onForeignFile ? opts.onForeignFile() : 'append'; + if (decision === 'cancel') { + return { outPath, action: 'cancelled' }; + } + if (decision === 'overwrite') { + writeFileSync(outPath, block + '\n', 'utf8'); + return { outPath, action: 'overwritten' }; + } + const sep = existing.endsWith('\n') ? '\n' : '\n\n'; + writeFileSync(outPath, `${existing}${sep}${block}\n`, 'utf8'); + return { outPath, action: 'appended' }; +} + /** * @deprecated Use writeRulesMdc or writeRulesLegacy directly. * Kept for backwards compat with existing tests. diff --git a/tests/agents.test.js b/tests/agents.test.js new file mode 100644 index 0000000..dfcaede --- /dev/null +++ b/tests/agents.test.js @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + writeRulesAgentsMd, + buildAgentsBlock, + AGENTS_START, + AGENTS_END, +} from '../src/compose.js'; + +function tmp() { + return mkdtempSync(join(tmpdir(), 'cc-agents-')); +} + +test('creates AGENTS.md with managed markers and a single H1', () => { + const dir = tmp(); + try { + const { outPath, action } = writeRulesAgentsMd(['core'], dir); + assert.equal(action, 'created'); + const content = readFileSync(outPath, 'utf8'); + + assert.ok(content.includes(AGENTS_START), 'has start marker'); + assert.ok(content.includes(AGENTS_END), 'has end marker'); + + // Exactly one top-level H1 (the AGENTS.md title); module titles demoted to ##. + const h1s = content.split('\n').filter((l) => /^# /.test(l)); + assert.equal(h1s.length, 1, 'exactly one H1'); + assert.equal(h1s[0], '# AGENTS.md'); + assert.ok(/^## /m.test(content), 'module headings demoted to H2'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('AGENTS.md is plain markdown — no Cursor frontmatter', () => { + const dir = tmp(); + try { + const { outPath } = writeRulesAgentsMd(['core', 'typescript'], dir); + const content = readFileSync(outPath, 'utf8'); + assert.ok(!content.includes('alwaysApply:'), 'no alwaysApply'); + assert.ok(!content.includes('globs:'), 'no globs'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('regeneration replaces the managed block but preserves user content', () => { + const dir = tmp(); + try { + const { outPath } = writeRulesAgentsMd(['core'], dir); + // User appends custom content after the managed block. + const custom = '\n\n## Team notes\nDo not deploy on Fridays.\n'; + writeFileSync(outPath, readFileSync(outPath, 'utf8') + custom, 'utf8'); + + const { action } = writeRulesAgentsMd(['core', 'typescript'], dir); + assert.equal(action, 'updated'); + + const after = readFileSync(outPath, 'utf8'); + assert.ok(after.includes('Do not deploy on Fridays.'), 'user content preserved'); + // New module set reflected inside the block. + const block = buildAgentsBlock(['core', 'typescript']); + assert.ok(after.includes(block), 'managed block updated to new module set'); + // Still exactly one managed region. + assert.equal(after.split(AGENTS_START).length - 1, 1, 'single start marker'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('foreign AGENTS.md (no markers) is appended to, not clobbered', () => { + const dir = tmp(); + try { + const outPath = join(dir, 'AGENTS.md'); + writeFileSync(outPath, '# My hand-written rules\nKeep me.\n', 'utf8'); + + const { action } = writeRulesAgentsMd(['core'], dir, { + onForeignFile: () => 'append', + }); + assert.equal(action, 'appended'); + + const content = readFileSync(outPath, 'utf8'); + assert.ok(content.includes('Keep me.'), 'original content preserved'); + assert.ok(content.includes(AGENTS_START), 'managed block appended'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('foreign AGENTS.md can be overwritten or cancelled on request', () => { + const dir = tmp(); + try { + const outPath = join(dir, 'AGENTS.md'); + writeFileSync(outPath, 'old\n', 'utf8'); + + const cancelled = writeRulesAgentsMd(['core'], dir, { onForeignFile: () => 'cancel' }); + assert.equal(cancelled.action, 'cancelled'); + assert.equal(readFileSync(outPath, 'utf8'), 'old\n', 'unchanged on cancel'); + + const overwritten = writeRulesAgentsMd(['core'], dir, { onForeignFile: () => 'overwrite' }); + assert.equal(overwritten.action, 'overwritten'); + const content = readFileSync(outPath, 'utf8'); + assert.ok(!content.includes('old'), 'old content gone'); + assert.ok(content.includes(AGENTS_START), 'managed block written'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/fixes.test.js b/tests/fixes.test.js new file mode 100644 index 0000000..91ee7c0 --- /dev/null +++ b/tests/fixes.test.js @@ -0,0 +1,124 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + findManagedBlock, + demoteHeadings, + writeRulesAgentsMd, + AGENTS_START, + AGENTS_END, +} from '../src/compose.js'; + +const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.js'); +const tmp = () => mkdtempSync(join(tmpdir(), 'cc-')); + +// Runs the real CLI as a subprocess with no stdin. Throws on non-zero exit or +// if it blocks on a prompt (timeout) — exactly the regression we're guarding. +function runCli(cwd, args) { + return execFileSync(process.execPath, [CLI, ...args], { + cwd, + encoding: 'utf8', + timeout: 20000, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +// ── findManagedBlock: both markers, in order ──────────────────────────────── +test('findManagedBlock finds a valid, ordered marker pair', () => { + const m = findManagedBlock(`intro\n${AGENTS_START}\nbody\n${AGENTS_END}\noutro`); + assert.ok(m && m.startIdx < m.endIdx); +}); + +test('findManagedBlock returns null for an orphaned start marker', () => { + assert.equal(findManagedBlock(`${AGENTS_START}\nno end marker here`), null); +}); + +test('findManagedBlock ignores an end marker that precedes the start', () => { + assert.equal(findManagedBlock(`${AGENTS_END}\n${AGENTS_START}\nstill no trailing end`), null); +}); + +// ── demoteHeadings: incremental, capped at h6, fence-aware ─────────────────── +test('demoteHeadings shifts every level by one and caps at h6', () => { + assert.equal( + demoteHeadings('# A\n## B\n###### F\nbody'), + '## A\n### B\n###### F\nbody' + ); +}); + +test('demoteHeadings leaves headings inside fenced code blocks untouched', () => { + assert.equal( + demoteHeadings('# Title\n```\n# not a heading\n```'), + '## Title\n```\n# not a heading\n```' + ); +}); + +// ── writeRulesAgentsMd: marker safety ─────────────────────────────────────── +test('a file with an orphaned start marker is treated as foreign, not corrupted', () => { + const dir = tmp(); + const p = join(dir, 'AGENTS.md'); + const original = `${AGENTS_START}\nhand-written, missing an end marker\n`; + writeFileSync(p, original, 'utf8'); + + const res = writeRulesAgentsMd(['core'], dir, { onForeignFile: () => 'cancel' }); + + assert.equal(res.action, 'cancelled'); + assert.equal(readFileSync(p, 'utf8'), original); // left byte-for-byte intact +}); + +test('regenerating a managed block preserves content outside the markers', () => { + const dir = tmp(); + const p = join(dir, 'AGENTS.md'); + writeRulesAgentsMd(['core'], dir); // create + writeFileSync(p, readFileSync(p, 'utf8') + '\n## Hand-added section\nkeep me\n', 'utf8'); + + const res = writeRulesAgentsMd(['core'], dir); + + assert.equal(res.action, 'updated'); + assert.match(readFileSync(p, 'utf8'), /Hand-added section/); +}); + +// ── CLI non-interactive mode: the --yes hang regressions ──────────────────── +test('CLI: default --yes overwrites existing .cursor/rules without prompting', () => { + const dir = tmp(); + const rules = join(dir, '.cursor', 'rules'); + mkdirSync(rules, { recursive: true }); + writeFileSync(join(rules, '010-core.mdc'), 'stale', 'utf8'); + writeFileSync(join(dir, 'package.json'), '{"dependencies":{"next":"14.0.0"}}', 'utf8'); + + runCli(dir, ['--yes']); // hangs on the old code (prompt); throws if so + + assert.ok(existsSync(join(rules, '030-nextjs.mdc'))); +}); + +test('CLI: --legacy --yes overwrites existing .cursorrules without prompting', () => { + const dir = tmp(); + writeFileSync(join(dir, '.cursorrules'), 'STALE', 'utf8'); + + runCli(dir, ['--legacy', '--yes']); // hangs on the old code (prompt); throws if so + + const out = readFileSync(join(dir, '.cursorrules'), 'utf8'); + assert.notEqual(out, 'STALE'); + assert.ok(out.length > 0); +}); + +test('CLI: --agents --yes appends to a foreign AGENTS.md without prompting', () => { + const dir = tmp(); + writeFileSync(join(dir, 'AGENTS.md'), '# My existing rules\nkeep this line\n', 'utf8'); + + runCli(dir, ['--agents', '--yes']); + + const md = readFileSync(join(dir, 'AGENTS.md'), 'utf8'); + assert.match(md, /keep this line/); // original content preserved + assert.ok(findManagedBlock(md)); // a valid managed block was added +});