compose-agentsmd 1.0.0 → 1.1.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/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  This repository contains CLI tooling for composing per-project `AGENTS.md` files from modular rule sets.
4
4
 
5
+ ## Release notes
6
+
7
+ See `CHANGELOG.md` for release notes.
8
+
5
9
  It is intended to be used together with shared rule modules such as:
6
10
 
7
11
  - `agent-rules/` (public rule modules)
@@ -41,6 +45,8 @@ compose-agentsmd
41
45
 
42
46
  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
47
 
48
+ The tool prepends a small "Tool Rules" block to every generated `AGENTS.md` so agents know how to regenerate or update rules.
49
+
44
50
  ### Rules root resolution (important for global installs)
45
51
 
46
52
  When installed globally, the rules directory is usually outside the project. You can point to it in either of the following ways:
@@ -78,6 +84,10 @@ Rules root resolution precedence is:
78
84
  - `domains` selects domain folders under `agent-rules/rules/domains`.
79
85
  - `rules` is optional and appends additional rule files.
80
86
 
87
+ ### Ruleset schema validation
88
+
89
+ `compose-agentsmd` validates rulesets against `agent-ruleset.schema.json` on every run. If the ruleset does not conform to the schema, the tool exits with a schema error.
90
+
81
91
  Optional path overrides:
82
92
 
83
93
  - `rulesRoot`: override `agent-rules/rules`.
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Compose AGENTS.md ruleset",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "output": {
8
+ "type": "string",
9
+ "minLength": 1
10
+ },
11
+ "domains": {
12
+ "type": "array",
13
+ "items": {
14
+ "type": "string",
15
+ "minLength": 1
16
+ }
17
+ },
18
+ "rules": {
19
+ "type": "array",
20
+ "items": {
21
+ "type": "string",
22
+ "minLength": 1
23
+ }
24
+ },
25
+ "rulesRoot": {
26
+ "type": "string",
27
+ "minLength": 1
28
+ },
29
+ "globalDir": {
30
+ "type": "string",
31
+ "minLength": 1
32
+ },
33
+ "domainsDir": {
34
+ "type": "string",
35
+ "minLength": 1
36
+ }
37
+ }
38
+ }
@@ -1,17 +1,20 @@
1
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"));
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { Ajv } from "ajv";
9
5
  const DEFAULT_RULESET_NAME = "agent-ruleset.json";
10
6
  const DEFAULT_RULES_ROOT = "agent-rules/rules";
11
7
  const DEFAULT_GLOBAL_DIR = "global";
12
8
  const DEFAULT_DOMAINS_DIR = "domains";
13
9
  const RULES_ROOT_ENV_VAR = "AGENT_RULES_ROOT";
14
10
  const DEFAULT_OUTPUT = "AGENTS.md";
11
+ const RULESET_SCHEMA_PATH = new URL("../agent-ruleset.schema.json", import.meta.url);
12
+ const TOOL_RULES = [
13
+ "# Tool Rules (compose-agentsmd)",
14
+ "- Before starting any work, run `compose-agentsmd` from the project root.",
15
+ "- To update rules, update the source rule modules or ruleset, then rerun `compose-agentsmd`.",
16
+ "- Do not edit `AGENTS.md` directly; update the source rules and regenerate."
17
+ ].join("\n");
15
18
  const DEFAULT_IGNORE_DIRS = new Set([
16
19
  ".git",
17
20
  "agent-rules",
@@ -86,59 +89,56 @@ const parseArgs = (argv) => {
86
89
  const normalizeTrailingWhitespace = (content) => content.replace(/\s+$/u, "");
87
90
  const normalizePath = (filePath) => filePath.replace(/\\/g, "/");
88
91
  const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
92
+ const rulesetSchema = JSON.parse(fs.readFileSync(RULESET_SCHEMA_PATH, "utf8"));
93
+ const ajv = new Ajv({ allErrors: true, strict: false });
94
+ const validateRulesetSchema = ajv.compile(rulesetSchema);
95
+ const formatSchemaErrors = (errors) => {
96
+ if (!errors || errors.length === 0) {
97
+ return "Unknown schema validation error";
98
+ }
99
+ return errors
100
+ .map((error) => {
101
+ const pathLabel = error.instancePath ? error.instancePath : "(root)";
102
+ return `${pathLabel} ${error.message ?? "is invalid"}`;
103
+ })
104
+ .join("; ");
105
+ };
89
106
  const resolveFrom = (baseDir, targetPath) => {
90
- if (node_path_1.default.isAbsolute(targetPath)) {
107
+ if (path.isAbsolute(targetPath)) {
91
108
  return targetPath;
92
109
  }
93
- return node_path_1.default.resolve(baseDir, targetPath);
110
+ return path.resolve(baseDir, targetPath);
94
111
  };
95
112
  const ensureFileExists = (filePath) => {
96
- if (!node_fs_1.default.existsSync(filePath)) {
113
+ if (!fs.existsSync(filePath)) {
97
114
  throw new Error(`Missing file: ${filePath}`);
98
115
  }
99
116
  };
100
117
  const ensureDirectoryExists = (dirPath) => {
101
- if (!node_fs_1.default.existsSync(dirPath)) {
118
+ if (!fs.existsSync(dirPath)) {
102
119
  throw new Error(`Missing directory: ${dirPath}`);
103
120
  }
104
- const stat = node_fs_1.default.statSync(dirPath);
121
+ const stat = fs.statSync(dirPath);
105
122
  if (!stat.isDirectory()) {
106
123
  throw new Error(`Not a directory: ${dirPath}`);
107
124
  }
108
125
  };
109
126
  const readJsonFile = (filePath) => {
110
- const raw = node_fs_1.default.readFileSync(filePath, "utf8");
127
+ const raw = fs.readFileSync(filePath, "utf8");
111
128
  return JSON.parse(raw);
112
129
  };
113
130
  const readProjectRuleset = (rulesetPath) => {
114
131
  const parsed = readJsonFile(rulesetPath);
115
- if (parsed.output === undefined) {
116
- parsed.output = DEFAULT_OUTPUT;
132
+ const isValid = validateRulesetSchema(parsed);
133
+ if (!isValid) {
134
+ const message = formatSchemaErrors(validateRulesetSchema.errors);
135
+ throw new Error(`Invalid ruleset schema in ${rulesetPath}: ${message}`);
117
136
  }
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
- }
137
+ const ruleset = parsed;
138
+ if (ruleset.output === undefined) {
139
+ ruleset.output = DEFAULT_OUTPUT;
140
140
  }
141
- return parsed;
141
+ return ruleset;
142
142
  };
143
143
  const resolveRulesRoot = (rulesetDir, projectRuleset, options) => {
144
144
  if (isNonEmptyString(options.cliRulesRoot)) {
@@ -151,19 +151,19 @@ const resolveRulesRoot = (rulesetDir, projectRuleset, options) => {
151
151
  if (isNonEmptyString(projectRuleset.rulesRoot)) {
152
152
  return resolveFrom(rulesetDir, projectRuleset.rulesRoot);
153
153
  }
154
- return node_path_1.default.resolve(rulesetDir, DEFAULT_RULES_ROOT);
154
+ return path.resolve(rulesetDir, DEFAULT_RULES_ROOT);
155
155
  };
156
156
  const resolveGlobalRoot = (rulesRoot, projectRuleset) => {
157
157
  const globalDirName = isNonEmptyString(projectRuleset.globalDir)
158
158
  ? projectRuleset.globalDir
159
159
  : DEFAULT_GLOBAL_DIR;
160
- return node_path_1.default.resolve(rulesRoot, globalDirName);
160
+ return path.resolve(rulesRoot, globalDirName);
161
161
  };
162
162
  const resolveDomainsRoot = (rulesRoot, projectRuleset) => {
163
163
  const domainsDirName = isNonEmptyString(projectRuleset.domainsDir)
164
164
  ? projectRuleset.domainsDir
165
165
  : DEFAULT_DOMAINS_DIR;
166
- return node_path_1.default.resolve(rulesRoot, domainsDirName);
166
+ return path.resolve(rulesRoot, domainsDirName);
167
167
  };
168
168
  const collectMarkdownFiles = (rootDir) => {
169
169
  ensureDirectoryExists(rootDir);
@@ -174,27 +174,27 @@ const collectMarkdownFiles = (rootDir) => {
174
174
  if (!currentDir) {
175
175
  continue;
176
176
  }
177
- const entries = node_fs_1.default.readdirSync(currentDir, { withFileTypes: true });
177
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
178
178
  for (const entry of entries) {
179
- const entryPath = node_path_1.default.join(currentDir, entry.name);
179
+ const entryPath = path.join(currentDir, entry.name);
180
180
  if (entry.isDirectory()) {
181
181
  pending.push(entryPath);
182
182
  continue;
183
183
  }
184
- if (entry.isFile() && node_path_1.default.extname(entry.name).toLowerCase() === ".md") {
184
+ if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") {
185
185
  results.push(entryPath);
186
186
  }
187
187
  }
188
188
  }
189
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));
190
+ const relA = normalizePath(path.relative(rootDir, a));
191
+ const relB = normalizePath(path.relative(rootDir, b));
192
192
  return relA.localeCompare(relB);
193
193
  });
194
194
  };
195
195
  const addRulePaths = (rulePaths, resolvedRules, seenRules) => {
196
196
  for (const rulePath of rulePaths) {
197
- const resolvedRulePath = node_path_1.default.resolve(rulePath);
197
+ const resolvedRulePath = path.resolve(rulePath);
198
198
  if (seenRules.has(resolvedRulePath)) {
199
199
  continue;
200
200
  }
@@ -204,7 +204,7 @@ const addRulePaths = (rulePaths, resolvedRules, seenRules) => {
204
204
  }
205
205
  };
206
206
  const composeRuleset = (rulesetPath, rootDir, options) => {
207
- const rulesetDir = node_path_1.default.dirname(rulesetPath);
207
+ const rulesetDir = path.dirname(rulesetPath);
208
208
  const projectRuleset = readProjectRuleset(rulesetPath);
209
209
  const outputFileName = projectRuleset.output ?? DEFAULT_OUTPUT;
210
210
  const outputPath = resolveFrom(rulesetDir, outputFileName);
@@ -219,18 +219,19 @@ const composeRuleset = (rulesetPath, rootDir, options) => {
219
219
  addRulePaths(collectMarkdownFiles(globalRoot), resolvedRules, seenRules);
220
220
  const domains = Array.isArray(projectRuleset.domains) ? projectRuleset.domains : [];
221
221
  for (const domain of domains) {
222
- const domainRoot = node_path_1.default.resolve(domainsRoot, domain);
222
+ const domainRoot = path.resolve(domainsRoot, domain);
223
223
  addRulePaths(collectMarkdownFiles(domainRoot), resolvedRules, seenRules);
224
224
  }
225
225
  const directRules = Array.isArray(projectRuleset.rules) ? projectRuleset.rules : [];
226
226
  const directRulePaths = directRules.map((rulePath) => resolveFrom(rulesetDir, rulePath));
227
227
  addRulePaths(directRulePaths, resolvedRules, seenRules);
228
- const parts = resolvedRules.map((rulePath) => normalizeTrailingWhitespace(node_fs_1.default.readFileSync(rulePath, "utf8")));
228
+ const parts = resolvedRules.map((rulePath) => normalizeTrailingWhitespace(fs.readFileSync(rulePath, "utf8")));
229
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));
230
+ const toolRules = normalizeTrailingWhitespace(TOOL_RULES);
231
+ const output = `${lintHeader}\n${[toolRules, ...parts].join("\n\n")}\n`;
232
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
233
+ fs.writeFileSync(outputPath, output, "utf8");
234
+ return normalizePath(path.relative(rootDir, outputPath));
234
235
  };
235
236
  const findRulesetFiles = (rootDir, rulesetName) => {
236
237
  const results = [];
@@ -240,9 +241,9 @@ const findRulesetFiles = (rootDir, rulesetName) => {
240
241
  if (!currentDir) {
241
242
  continue;
242
243
  }
243
- const entries = node_fs_1.default.readdirSync(currentDir, { withFileTypes: true });
244
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
244
245
  for (const entry of entries) {
245
- const entryPath = node_path_1.default.join(currentDir, entry.name);
246
+ const entryPath = path.join(currentDir, entry.name);
246
247
  if (entry.isDirectory()) {
247
248
  if (DEFAULT_IGNORE_DIRS.has(entry.name)) {
248
249
  continue;
@@ -271,7 +272,7 @@ const main = () => {
271
272
  process.stdout.write(`${usage}\n`);
272
273
  return;
273
274
  }
274
- const rootDir = args.root ? node_path_1.default.resolve(args.root) : process.cwd();
275
+ const rootDir = args.root ? path.resolve(args.root) : process.cwd();
275
276
  const rulesetName = args.rulesetName || DEFAULT_RULESET_NAME;
276
277
  const rulesetFiles = getRulesetFiles(rootDir, args.ruleset, rulesetName);
277
278
  if (rulesetFiles.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compose-agentsmd",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tools for composing per-project AGENTS.md files from modular rule sets",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,17 +18,24 @@
18
18
  "cli",
19
19
  "markdown"
20
20
  ],
21
- "type": "commonjs",
21
+ "type": "module",
22
22
  "bin": {
23
23
  "compose-agentsmd": "dist/compose-agents.js"
24
24
  },
25
25
  "files": [
26
26
  "dist",
27
- "tools"
27
+ "agent-ruleset.schema.json",
28
+ "tools",
29
+ "README.md",
30
+ "LICENSE"
28
31
  ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
29
35
  "scripts": {
30
36
  "build": "tsc -p tsconfig.json",
31
37
  "lint": "tsc -p tsconfig.json --noEmit",
38
+ "prepare": "npm run build",
32
39
  "prepack": "npm run build",
33
40
  "test": "npm run build && node --test",
34
41
  "compose": "npm run build && node dist/compose-agents.js"
@@ -39,5 +46,8 @@
39
46
  "devDependencies": {
40
47
  "@types/node": "^25.0.10",
41
48
  "typescript": "^5.7.3"
49
+ },
50
+ "dependencies": {
51
+ "ajv": "^8.17.1"
42
52
  }
43
53
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/compose-agents.js";
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- require("../dist/compose-agents.js");