compose-agentsmd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 metyatech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # Compose AGENTS.md
2
+
3
+ This repository contains CLI tooling for composing per-project `AGENTS.md` files from modular rule sets.
4
+
5
+ It is intended to be used together with shared rule modules such as:
6
+
7
+ - `agent-rules/` (public rule modules)
8
+ - `agent-rules-private/` (optional, private-only rule modules)
9
+
10
+ ## Install (global CLI)
11
+
12
+ After publishing to npm, install globally:
13
+
14
+ ```sh
15
+ npm install -g compose-agentsmd
16
+ ```
17
+
18
+ This provides the `compose-agentsmd` command.
19
+
20
+ ## Rules setup (this repository)
21
+
22
+ This repository expects the public rules to be available at `agent-rules/rules` via the `agent-rules/` submodule.
23
+
24
+ Initialize submodules and compose the rules:
25
+
26
+ ```sh
27
+ git submodule update --init --recursive
28
+ npm install
29
+ npm run compose
30
+ ```
31
+
32
+ The default ruleset for this repository is `agent-ruleset.json` and currently composes the `node` domain into `AGENTS.md`.
33
+
34
+ ## Compose
35
+
36
+ From each project root, run:
37
+
38
+ ```sh
39
+ compose-agentsmd
40
+ ```
41
+
42
+ The tool searches for `agent-ruleset.json` under the given root directory (default: current working directory), and writes output files as specified by each ruleset. If `output` is omitted, it defaults to `AGENTS.md`.
43
+
44
+ ### Rules root resolution (important for global installs)
45
+
46
+ When installed globally, the rules directory is usually outside the project. You can point to it in either of the following ways:
47
+
48
+ ```sh
49
+ compose-agentsmd --rules-root "C:/path/to/agent-rules/rules"
50
+ ```
51
+
52
+ Or via environment variable:
53
+
54
+ ```sh
55
+ set AGENT_RULES_ROOT=C:/path/to/agent-rules/rules
56
+ compose-agentsmd
57
+ ```
58
+
59
+ Rules root resolution precedence is:
60
+
61
+ - `--rules-root` CLI option
62
+ - `AGENT_RULES_ROOT` environment variable
63
+ - `rulesRoot` in the ruleset file
64
+ - Default: `agent-rules/rules` relative to the ruleset file
65
+
66
+ ## Project ruleset format
67
+
68
+ ```json
69
+ {
70
+ "output": "AGENTS.md",
71
+ "domains": ["node", "unreal"],
72
+ "rules": ["agent-rules-local/custom.md"]
73
+ }
74
+ ```
75
+
76
+ - Global rules are always included from `agent-rules/rules/global`.
77
+ - `output` is optional; when omitted, `AGENTS.md` is used.
78
+ - `domains` selects domain folders under `agent-rules/rules/domains`.
79
+ - `rules` is optional and appends additional rule files.
80
+
81
+ Optional path overrides:
82
+
83
+ - `rulesRoot`: override `agent-rules/rules`.
84
+ - `globalDir`: override `global` (relative to `rulesRoot`).
85
+ - `domainsDir`: override `domains` (relative to `rulesRoot`).
86
+
87
+ ### Optional arguments
88
+
89
+ - `--root <path>`: project root (defaults to current working directory)
90
+ - `--ruleset <path>`: only compose a single ruleset file
91
+ - `--ruleset-name <name>`: override the ruleset filename (default: `agent-ruleset.json`)
92
+ - `--rules-root <path>`: override the rules root for all rulesets (or set `AGENT_RULES_ROOT`)
93
+
94
+ ## Development
95
+
96
+ ```sh
97
+ npm install
98
+ npm run lint
99
+ npm run build
100
+ npm test
101
+ ```
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const DEFAULT_RULESET_NAME = "agent-ruleset.json";
10
+ const DEFAULT_RULES_ROOT = "agent-rules/rules";
11
+ const DEFAULT_GLOBAL_DIR = "global";
12
+ const DEFAULT_DOMAINS_DIR = "domains";
13
+ const RULES_ROOT_ENV_VAR = "AGENT_RULES_ROOT";
14
+ const DEFAULT_OUTPUT = "AGENTS.md";
15
+ const DEFAULT_IGNORE_DIRS = new Set([
16
+ ".git",
17
+ "agent-rules",
18
+ "agent-rules-private",
19
+ "agent-rules-local",
20
+ "agent-rules-tools",
21
+ "compose-agentsmd",
22
+ "node_modules",
23
+ "dist",
24
+ "build",
25
+ "out",
26
+ ".next",
27
+ ".turbo",
28
+ "coverage"
29
+ ]);
30
+ const usage = `Usage: compose-agentsmd [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--rules-root <path>]
31
+
32
+ Options:
33
+ --root <path> Project root directory (default: current working directory)
34
+ --ruleset <path> Only compose a single ruleset file
35
+ --ruleset-name <name> Ruleset filename to search for (default: agent-ruleset.json)
36
+ --rules-root <path> Override rules root directory for all rulesets (or set ${RULES_ROOT_ENV_VAR})
37
+ `;
38
+ const parseArgs = (argv) => {
39
+ const args = {};
40
+ for (let i = 0; i < argv.length; i += 1) {
41
+ const arg = argv[i];
42
+ if (arg === "--help" || arg === "-h") {
43
+ args.help = true;
44
+ continue;
45
+ }
46
+ if (arg === "--root") {
47
+ const value = argv[i + 1];
48
+ if (!value) {
49
+ throw new Error("Missing value for --root");
50
+ }
51
+ args.root = value;
52
+ i += 1;
53
+ continue;
54
+ }
55
+ if (arg === "--ruleset") {
56
+ const value = argv[i + 1];
57
+ if (!value) {
58
+ throw new Error("Missing value for --ruleset");
59
+ }
60
+ args.ruleset = value;
61
+ i += 1;
62
+ continue;
63
+ }
64
+ if (arg === "--ruleset-name") {
65
+ const value = argv[i + 1];
66
+ if (!value) {
67
+ throw new Error("Missing value for --ruleset-name");
68
+ }
69
+ args.rulesetName = value;
70
+ i += 1;
71
+ continue;
72
+ }
73
+ if (arg === "--rules-root") {
74
+ const value = argv[i + 1];
75
+ if (!value) {
76
+ throw new Error("Missing value for --rules-root");
77
+ }
78
+ args.rulesRoot = value;
79
+ i += 1;
80
+ continue;
81
+ }
82
+ throw new Error(`Unknown argument: ${arg}`);
83
+ }
84
+ return args;
85
+ };
86
+ const normalizeTrailingWhitespace = (content) => content.replace(/\s+$/u, "");
87
+ const normalizePath = (filePath) => filePath.replace(/\\/g, "/");
88
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
89
+ const resolveFrom = (baseDir, targetPath) => {
90
+ if (node_path_1.default.isAbsolute(targetPath)) {
91
+ return targetPath;
92
+ }
93
+ return node_path_1.default.resolve(baseDir, targetPath);
94
+ };
95
+ const ensureFileExists = (filePath) => {
96
+ if (!node_fs_1.default.existsSync(filePath)) {
97
+ throw new Error(`Missing file: ${filePath}`);
98
+ }
99
+ };
100
+ const ensureDirectoryExists = (dirPath) => {
101
+ if (!node_fs_1.default.existsSync(dirPath)) {
102
+ throw new Error(`Missing directory: ${dirPath}`);
103
+ }
104
+ const stat = node_fs_1.default.statSync(dirPath);
105
+ if (!stat.isDirectory()) {
106
+ throw new Error(`Not a directory: ${dirPath}`);
107
+ }
108
+ };
109
+ const readJsonFile = (filePath) => {
110
+ const raw = node_fs_1.default.readFileSync(filePath, "utf8");
111
+ return JSON.parse(raw);
112
+ };
113
+ const readProjectRuleset = (rulesetPath) => {
114
+ const parsed = readJsonFile(rulesetPath);
115
+ if (parsed.output === undefined) {
116
+ parsed.output = DEFAULT_OUTPUT;
117
+ }
118
+ else if (!isNonEmptyString(parsed.output)) {
119
+ throw new Error(`Invalid ruleset output in ${rulesetPath}`);
120
+ }
121
+ if (parsed.domains !== undefined) {
122
+ if (!Array.isArray(parsed.domains)) {
123
+ throw new Error(`"domains" must be an array in ${rulesetPath}`);
124
+ }
125
+ for (const domain of parsed.domains) {
126
+ if (!isNonEmptyString(domain)) {
127
+ throw new Error(`"domains" entries must be non-empty strings in ${rulesetPath}`);
128
+ }
129
+ }
130
+ }
131
+ if (parsed.rules !== undefined) {
132
+ if (!Array.isArray(parsed.rules)) {
133
+ throw new Error(`"rules" must be an array in ${rulesetPath}`);
134
+ }
135
+ for (const rule of parsed.rules) {
136
+ if (!isNonEmptyString(rule)) {
137
+ throw new Error(`"rules" entries must be non-empty strings in ${rulesetPath}`);
138
+ }
139
+ }
140
+ }
141
+ return parsed;
142
+ };
143
+ const resolveRulesRoot = (rulesetDir, projectRuleset, options) => {
144
+ if (isNonEmptyString(options.cliRulesRoot)) {
145
+ return resolveFrom(rulesetDir, options.cliRulesRoot);
146
+ }
147
+ const envRulesRoot = process.env[RULES_ROOT_ENV_VAR];
148
+ if (isNonEmptyString(envRulesRoot)) {
149
+ return resolveFrom(rulesetDir, envRulesRoot);
150
+ }
151
+ if (isNonEmptyString(projectRuleset.rulesRoot)) {
152
+ return resolveFrom(rulesetDir, projectRuleset.rulesRoot);
153
+ }
154
+ return node_path_1.default.resolve(rulesetDir, DEFAULT_RULES_ROOT);
155
+ };
156
+ const resolveGlobalRoot = (rulesRoot, projectRuleset) => {
157
+ const globalDirName = isNonEmptyString(projectRuleset.globalDir)
158
+ ? projectRuleset.globalDir
159
+ : DEFAULT_GLOBAL_DIR;
160
+ return node_path_1.default.resolve(rulesRoot, globalDirName);
161
+ };
162
+ const resolveDomainsRoot = (rulesRoot, projectRuleset) => {
163
+ const domainsDirName = isNonEmptyString(projectRuleset.domainsDir)
164
+ ? projectRuleset.domainsDir
165
+ : DEFAULT_DOMAINS_DIR;
166
+ return node_path_1.default.resolve(rulesRoot, domainsDirName);
167
+ };
168
+ const collectMarkdownFiles = (rootDir) => {
169
+ ensureDirectoryExists(rootDir);
170
+ const results = [];
171
+ const pending = [rootDir];
172
+ while (pending.length > 0) {
173
+ const currentDir = pending.pop();
174
+ if (!currentDir) {
175
+ continue;
176
+ }
177
+ const entries = node_fs_1.default.readdirSync(currentDir, { withFileTypes: true });
178
+ for (const entry of entries) {
179
+ const entryPath = node_path_1.default.join(currentDir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ pending.push(entryPath);
182
+ continue;
183
+ }
184
+ if (entry.isFile() && node_path_1.default.extname(entry.name).toLowerCase() === ".md") {
185
+ results.push(entryPath);
186
+ }
187
+ }
188
+ }
189
+ return results.sort((a, b) => {
190
+ const relA = normalizePath(node_path_1.default.relative(rootDir, a));
191
+ const relB = normalizePath(node_path_1.default.relative(rootDir, b));
192
+ return relA.localeCompare(relB);
193
+ });
194
+ };
195
+ const addRulePaths = (rulePaths, resolvedRules, seenRules) => {
196
+ for (const rulePath of rulePaths) {
197
+ const resolvedRulePath = node_path_1.default.resolve(rulePath);
198
+ if (seenRules.has(resolvedRulePath)) {
199
+ continue;
200
+ }
201
+ ensureFileExists(resolvedRulePath);
202
+ resolvedRules.push(resolvedRulePath);
203
+ seenRules.add(resolvedRulePath);
204
+ }
205
+ };
206
+ const composeRuleset = (rulesetPath, rootDir, options) => {
207
+ const rulesetDir = node_path_1.default.dirname(rulesetPath);
208
+ const projectRuleset = readProjectRuleset(rulesetPath);
209
+ const outputFileName = projectRuleset.output ?? DEFAULT_OUTPUT;
210
+ const outputPath = resolveFrom(rulesetDir, outputFileName);
211
+ const rulesRoot = resolveRulesRoot(rulesetDir, projectRuleset, {
212
+ cliRulesRoot: options.cliRulesRoot
213
+ });
214
+ const globalRoot = resolveGlobalRoot(rulesRoot, projectRuleset);
215
+ const domainsRoot = resolveDomainsRoot(rulesRoot, projectRuleset);
216
+ const resolvedRules = [];
217
+ const seenRules = new Set();
218
+ // Global rules always apply.
219
+ addRulePaths(collectMarkdownFiles(globalRoot), resolvedRules, seenRules);
220
+ const domains = Array.isArray(projectRuleset.domains) ? projectRuleset.domains : [];
221
+ for (const domain of domains) {
222
+ const domainRoot = node_path_1.default.resolve(domainsRoot, domain);
223
+ addRulePaths(collectMarkdownFiles(domainRoot), resolvedRules, seenRules);
224
+ }
225
+ const directRules = Array.isArray(projectRuleset.rules) ? projectRuleset.rules : [];
226
+ const directRulePaths = directRules.map((rulePath) => resolveFrom(rulesetDir, rulePath));
227
+ addRulePaths(directRulePaths, resolvedRules, seenRules);
228
+ const parts = resolvedRules.map((rulePath) => normalizeTrailingWhitespace(node_fs_1.default.readFileSync(rulePath, "utf8")));
229
+ const lintHeader = "<!-- markdownlint-disable MD025 -->";
230
+ const output = `${lintHeader}\n${parts.join("\n\n")}\n`;
231
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(outputPath), { recursive: true });
232
+ node_fs_1.default.writeFileSync(outputPath, output, "utf8");
233
+ return normalizePath(node_path_1.default.relative(rootDir, outputPath));
234
+ };
235
+ const findRulesetFiles = (rootDir, rulesetName) => {
236
+ const results = [];
237
+ const pending = [rootDir];
238
+ while (pending.length > 0) {
239
+ const currentDir = pending.pop();
240
+ if (!currentDir) {
241
+ continue;
242
+ }
243
+ const entries = node_fs_1.default.readdirSync(currentDir, { withFileTypes: true });
244
+ for (const entry of entries) {
245
+ const entryPath = node_path_1.default.join(currentDir, entry.name);
246
+ if (entry.isDirectory()) {
247
+ if (DEFAULT_IGNORE_DIRS.has(entry.name)) {
248
+ continue;
249
+ }
250
+ pending.push(entryPath);
251
+ continue;
252
+ }
253
+ if (entry.isFile() && entry.name === rulesetName) {
254
+ results.push(entryPath);
255
+ }
256
+ }
257
+ }
258
+ return results;
259
+ };
260
+ const getRulesetFiles = (rootDir, specificRuleset, rulesetName) => {
261
+ if (specificRuleset) {
262
+ const resolved = resolveFrom(rootDir, specificRuleset);
263
+ ensureFileExists(resolved);
264
+ return [resolved];
265
+ }
266
+ return findRulesetFiles(rootDir, rulesetName);
267
+ };
268
+ const main = () => {
269
+ const args = parseArgs(process.argv.slice(2));
270
+ if (args.help) {
271
+ process.stdout.write(`${usage}\n`);
272
+ return;
273
+ }
274
+ const rootDir = args.root ? node_path_1.default.resolve(args.root) : process.cwd();
275
+ const rulesetName = args.rulesetName || DEFAULT_RULESET_NAME;
276
+ const rulesetFiles = getRulesetFiles(rootDir, args.ruleset, rulesetName);
277
+ if (rulesetFiles.length === 0) {
278
+ throw new Error(`No ruleset files named ${rulesetName} found under ${rootDir}`);
279
+ }
280
+ const outputs = rulesetFiles
281
+ .sort()
282
+ .map((rulesetPath) => composeRuleset(rulesetPath, rootDir, { cliRulesRoot: args.rulesRoot }));
283
+ process.stdout.write(`Composed AGENTS.md:\n${outputs.map((file) => `- ${file}`).join("\n")}\n`);
284
+ };
285
+ try {
286
+ main();
287
+ }
288
+ catch (error) {
289
+ const message = error instanceof Error ? error.message : String(error);
290
+ process.stderr.write(`${message}\n`);
291
+ process.stderr.write(`${usage}\n`);
292
+ process.exit(1);
293
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "compose-agentsmd",
3
+ "version": "1.0.0",
4
+ "description": "CLI tools for composing per-project AGENTS.md files from modular rule sets",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/metyatech/compose-agentsmd.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/metyatech/compose-agentsmd/issues"
12
+ },
13
+ "homepage": "https://github.com/metyatech/compose-agentsmd#readme",
14
+ "keywords": [
15
+ "agents",
16
+ "agentsmd",
17
+ "rules",
18
+ "cli",
19
+ "markdown"
20
+ ],
21
+ "type": "commonjs",
22
+ "bin": {
23
+ "compose-agentsmd": "dist/compose-agents.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "tools"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json",
31
+ "lint": "tsc -p tsconfig.json --noEmit",
32
+ "prepack": "npm run build",
33
+ "test": "npm run build && node --test",
34
+ "compose": "npm run build && node dist/compose-agents.js"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^25.0.10",
41
+ "typescript": "^5.7.3"
42
+ }
43
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../dist/compose-agents.js");