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.
+
+[](https://www.npmjs.com/package/cursor-compose)
+[](https://github.com/Kabi10/cursor-rules/actions/workflows/ci.yml)
+[](https://nodejs.org)
+[](LICENSE)
+
+
+
+```bash
+npx cursor-compose --agents # → AGENTS.md, works across every major AI editor
+```
+
+
+
+
+---
+
+## 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
+});