Skip to content

Commit 06e32e6

Browse files
authored
feat: support AGENTS.md (#63)
1 parent 3d993ed commit 06e32e6

File tree

10 files changed

+276
-0
lines changed

10 files changed

+276
-0
lines changed

src/index.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ export async function create({
254254
});
255255

256256
const packageRoot = path.resolve(__dirname, '..');
257+
const agentsMdSearchDirs = [srcFolder, commonFolder];
258+
257259
for (const tool of tools) {
258260
const toolFolder = path.join(packageRoot, `template-${tool}`);
259261

@@ -275,6 +277,8 @@ export async function create({
275277
isMergePackageJson: true,
276278
});
277279

280+
agentsMdSearchDirs.push(toolFolder);
281+
agentsMdSearchDirs.push(subFolder);
278282
continue;
279283
}
280284

@@ -286,6 +290,8 @@ export async function create({
286290
isMergePackageJson: true,
287291
});
288292

293+
agentsMdSearchDirs.push(toolFolder);
294+
289295
if (tool === 'biome') {
290296
await fs.promises.rename(
291297
path.join(distFolder, 'biome.json.template'),
@@ -294,6 +300,13 @@ export async function create({
294300
}
295301
}
296302

303+
const agentsFiles = collectAgentsFiles(agentsMdSearchDirs);
304+
if (agentsFiles.length > 0) {
305+
const mergedAgents = mergeAgentsFiles(agentsFiles);
306+
const agentsPath = path.join(distFolder, 'AGENTS.md');
307+
fs.writeFileSync(agentsPath, `${mergedAgents}\n`);
308+
}
309+
297310
const nextSteps = noteInformation
298311
? noteInformation
299312
: [
@@ -460,3 +473,105 @@ const updatePackageJson = (
460473

461474
fs.writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
462475
};
476+
477+
/**
478+
* Read AGENTS.md files from template directories
479+
*/
480+
function readAgentsFile(filePath: string): string | null {
481+
if (!fs.existsSync(filePath)) {
482+
return null;
483+
}
484+
return fs.readFileSync(filePath, 'utf-8');
485+
}
486+
487+
/**
488+
* Parse AGENTS.md content and extract sections
489+
*/
490+
function parseAgentsContent(
491+
content: string,
492+
): Record<string, { title: string; content: string }> {
493+
const sections: Record<string, { title: string; content: string }> = {};
494+
const lines = content.split('\n');
495+
let currentKey = '';
496+
let currentTitle = '';
497+
let currentContent: string[] = [];
498+
499+
for (const line of lines) {
500+
const sectionMatch = line.match(/^##\s+(.+)$/);
501+
if (sectionMatch) {
502+
if (currentKey) {
503+
sections[currentKey] = {
504+
title: currentTitle,
505+
content: currentContent.join('\n').trim(),
506+
};
507+
}
508+
currentTitle = sectionMatch[1];
509+
currentKey = sectionMatch[1].toLowerCase();
510+
currentContent = [];
511+
} else if (currentKey) {
512+
currentContent.push(line);
513+
}
514+
}
515+
516+
if (currentKey) {
517+
sections[currentKey] = {
518+
title: currentTitle,
519+
content: currentContent.join('\n').trim(),
520+
};
521+
}
522+
523+
return sections;
524+
}
525+
526+
/**
527+
* Merge AGENTS.md files from multiple sources
528+
*/
529+
function mergeAgentsFiles(agentsFiles: string[]): string {
530+
const allSections: Record<string, { title: string; contents: string[] }> = {};
531+
532+
for (const fileContent of agentsFiles) {
533+
if (!fileContent) continue;
534+
const sections = parseAgentsContent(fileContent);
535+
536+
for (const [key, section] of Object.entries(sections)) {
537+
if (!allSections[key]) {
538+
allSections[key] = { title: section.title, contents: [] };
539+
}
540+
if (
541+
section.content &&
542+
!allSections[key].contents.includes(section.content)
543+
) {
544+
allSections[key].contents.push(section.content);
545+
}
546+
}
547+
}
548+
549+
const result: string[] = [];
550+
551+
for (const [, section] of Object.entries(allSections)) {
552+
result.push(`## ${section.title}`);
553+
result.push('');
554+
for (const content of section.contents) {
555+
result.push(content);
556+
result.push('');
557+
}
558+
}
559+
560+
return result.join('\n').trim();
561+
}
562+
563+
/**
564+
* Collect AGENTS.md files from template directories
565+
*/
566+
function collectAgentsFiles(agentsMdSearchDirs: string[]): string[] {
567+
const agentsFiles: string[] = [];
568+
569+
for (const dir of agentsMdSearchDirs) {
570+
const agentsContent = readAgentsFile(path.join(dir, 'AGENTS.md'));
571+
if (agentsContent) {
572+
agentsFiles.push(agentsContent);
573+
}
574+
}
575+
576+
return agentsFiles;
577+
}

template-biome/AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
## Tools
2+
3+
### Biome
4+
5+
- Run `npm run lint` to lint your code
6+
- Run `npm run format` to format your code

template-eslint/AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## Tools
2+
3+
### ESLint
4+
5+
- Run `npm run lint` to lint your code

template-prettier/AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## Tools
2+
3+
### Prettier
4+
5+
- Run `npm run format` to format your code

test/agents.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { assert, beforeEach, test } from '@rstest/core';
5+
import { create } from '../dist/index.js';
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
8+
const testDir = path.join(__dirname, 'temp');
9+
const fixturesDir = path.join(__dirname, 'fixtures', 'agents-md');
10+
11+
beforeEach(() => {
12+
// Clean up test directory before each test
13+
if (fs.existsSync(testDir)) {
14+
fs.rmSync(testDir, { recursive: true });
15+
}
16+
fs.mkdirSync(testDir, { recursive: true });
17+
18+
// Store original argv
19+
const originalArgv = process.argv;
20+
21+
// Return cleanup function
22+
return () => {
23+
// Restore original argv and clean up
24+
process.argv = originalArgv;
25+
if (fs.existsSync(testDir)) {
26+
fs.rmSync(testDir, { recursive: true });
27+
}
28+
};
29+
});
30+
31+
test('should generate AGENTS.md with no tools selected', async () => {
32+
const projectDir = path.join(testDir, 'no-tools');
33+
process.argv = ['node', 'test', '--dir', projectDir, '--template', 'vanilla'];
34+
35+
await create({
36+
name: 'test',
37+
root: fixturesDir,
38+
templates: ['vanilla'],
39+
getTemplateName: async () => 'vanilla',
40+
mapESLintTemplate: () => null,
41+
});
42+
43+
const agentsPath = path.join(projectDir, 'AGENTS.md');
44+
assert.strictEqual(fs.existsSync(agentsPath), true);
45+
46+
const content = fs.readFileSync(agentsPath, 'utf-8');
47+
assert.match(content, /## Template Info/);
48+
assert.match(content, /## Development/);
49+
// template-common has Tools section
50+
assert.match(content, /## Tools/);
51+
assert.match(content, /### Common Tools/);
52+
});
53+
54+
test('should generate AGENTS.md with single tool selected', async () => {
55+
const projectDir = path.join(testDir, 'single-tool');
56+
process.argv = [
57+
'node',
58+
'test',
59+
'--dir',
60+
projectDir,
61+
'--template',
62+
'vanilla',
63+
'--tools',
64+
'biome',
65+
];
66+
67+
await create({
68+
name: 'test',
69+
root: fixturesDir,
70+
templates: ['vanilla'],
71+
getTemplateName: async () => 'vanilla',
72+
mapESLintTemplate: () => null,
73+
});
74+
75+
const agentsPath = path.join(projectDir, 'AGENTS.md');
76+
assert.strictEqual(fs.existsSync(agentsPath), true);
77+
78+
const content = fs.readFileSync(agentsPath, 'utf-8');
79+
assert.match(content, /## Template Info/);
80+
assert.match(content, /## Development/);
81+
assert.match(content, /## Tools/);
82+
assert.match(content, /### Common Tools/); // from template-common
83+
assert.match(content, /### Biome/); // from template-biome
84+
});
85+
86+
test('should generate AGENTS.md with eslint tool and template mapping', async () => {
87+
const projectDir = path.join(testDir, 'eslint-tool');
88+
process.argv = [
89+
'node',
90+
'test',
91+
'--dir',
92+
projectDir,
93+
'--template',
94+
'vanilla',
95+
'--tools',
96+
'eslint',
97+
];
98+
99+
await create({
100+
name: 'test',
101+
root: fixturesDir,
102+
templates: ['vanilla'],
103+
getTemplateName: async () => 'vanilla',
104+
mapESLintTemplate: (templateName) => {
105+
if (templateName === 'vanilla') return 'vanilla-ts';
106+
return null;
107+
},
108+
});
109+
110+
const agentsPath = path.join(projectDir, 'AGENTS.md');
111+
assert.strictEqual(fs.existsSync(agentsPath), true);
112+
113+
const content = fs.readFileSync(agentsPath, 'utf-8');
114+
assert.match(content, /## Template Info/);
115+
assert.match(content, /## Development/);
116+
assert.match(content, /## Tools/);
117+
assert.match(content, /### ESLint/); // from template-eslint/AGENTS.md
118+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "test-fixtures-agents-md",
3+
"version": "1.0.0"
4+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## Development
2+
3+
### Common Development
4+
- Common development instructions
5+
- Available in all templates
6+
7+
## Tools
8+
9+
### Common Tools
10+
- Tools that apply to all templates
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "test-common",
3+
"version": "1.0.0"
4+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## Template Info
2+
3+
### Vanilla Template
4+
- This is vanilla template specific content
5+
- Only available in vanilla template
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "test-vanilla",
3+
"version": "1.0.0"
4+
}

0 commit comments

Comments
 (0)