compose-agentsmd 1.1.1 → 2.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/README.md CHANGED
@@ -47,59 +47,40 @@ The tool searches for `agent-ruleset.json` under the given root directory (defau
47
47
 
48
48
  The tool prepends a small "Tool Rules" block to every generated `AGENTS.md` so agents know how to regenerate or update rules.
49
49
 
50
- ### Rules root resolution (important for global installs)
51
-
52
- When installed globally, the rules directory is usually outside the project. You can point to it in either of the following ways:
53
-
54
- ```sh
55
- compose-agentsmd --rules-root "C:/path/to/agent-rules/rules"
56
- ```
57
-
58
- Or via environment variable:
59
-
60
- ```sh
61
- set AGENT_RULES_ROOT=C:/path/to/agent-rules/rules
62
- compose-agentsmd
63
- ```
64
-
65
- Rules root resolution precedence is:
66
-
67
- - `--rules-root` CLI option
68
- - `AGENT_RULES_ROOT` environment variable
69
- - `rulesRoot` in the ruleset file
70
- - Default: `agent-rules/rules` relative to the ruleset file
71
-
72
50
  ## Project ruleset format
73
51
 
74
52
  ```json
75
53
  {
76
- "output": "AGENTS.md",
54
+ "source": "github:org/agent-rules@latest",
77
55
  "domains": ["node", "unreal"],
78
- "rules": ["agent-rules-local/custom.md"]
56
+ "extra": ["agent-rules-local/custom.md"],
57
+ "output": "AGENTS.md"
79
58
  }
80
59
  ```
81
60
 
82
- - Global rules are always included from `agent-rules/rules/global`.
83
- - `output` is optional; when omitted, `AGENTS.md` is used.
84
- - `domains` selects domain folders under `agent-rules/rules/domains`.
85
- - `rules` is optional and appends additional rule files.
61
+ Ruleset keys:
62
+
63
+ - `source` (required): rules source. Use `github:owner/repo@ref` or a local path.
64
+ - `global` (optional): include `rules/global` (defaults to true). Omit this unless you want to disable globals.
65
+ - `domains` (optional): domain folders under `rules/domains/<domain>`.
66
+ - `extra` (optional): additional local rule files to append.
67
+ - `output` (optional): output file name (defaults to `AGENTS.md`).
86
68
 
87
69
  ### Ruleset schema validation
88
70
 
89
71
  `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
72
 
91
- Optional path overrides:
73
+ ### Cache
92
74
 
93
- - `rulesRoot`: override `agent-rules/rules`.
94
- - `globalDir`: override `global` (relative to `rulesRoot`).
95
- - `domainsDir`: override `domains` (relative to `rulesRoot`).
75
+ Remote sources are cached under `~/.agentsmd/<owner>/<repo>/<ref>/`. Use `--refresh` to re-fetch or `--clear-cache` to remove cached rules.
96
76
 
97
77
  ### Optional arguments
98
78
 
99
79
  - `--root <path>`: project root (defaults to current working directory)
100
80
  - `--ruleset <path>`: only compose a single ruleset file
101
81
  - `--ruleset-name <name>`: override the ruleset filename (default: `agent-ruleset.json`)
102
- - `--rules-root <path>`: override the rules root for all rulesets (or set `AGENT_RULES_ROOT`)
82
+ - `--refresh`: refresh cached remote rules
83
+ - `--clear-cache`: remove cached remote rules and exit
103
84
 
104
85
  ## Development
105
86
 
@@ -3,7 +3,15 @@
3
3
  "title": "Compose AGENTS.md ruleset",
4
4
  "type": "object",
5
5
  "additionalProperties": false,
6
+ "required": ["source"],
6
7
  "properties": {
8
+ "source": {
9
+ "type": "string",
10
+ "minLength": 1
11
+ },
12
+ "global": {
13
+ "type": "boolean"
14
+ },
7
15
  "output": {
8
16
  "type": "string",
9
17
  "minLength": 1
@@ -15,24 +23,12 @@
15
23
  "minLength": 1
16
24
  }
17
25
  },
18
- "rules": {
26
+ "extra": {
19
27
  "type": "array",
20
28
  "items": {
21
29
  "type": "string",
22
30
  "minLength": 1
23
31
  }
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
32
  }
37
33
  }
38
34
  }
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import os from "node:os";
5
+ import { execFileSync } from "node:child_process";
4
6
  import { Ajv } from "ajv";
5
7
  const DEFAULT_RULESET_NAME = "agent-ruleset.json";
6
- const DEFAULT_RULES_ROOT = "agent-rules/rules";
7
- const DEFAULT_GLOBAL_DIR = "global";
8
- const DEFAULT_DOMAINS_DIR = "domains";
9
- const RULES_ROOT_ENV_VAR = "AGENT_RULES_ROOT";
10
8
  const DEFAULT_OUTPUT = "AGENTS.md";
9
+ const DEFAULT_CACHE_ROOT = path.join(os.homedir(), ".agentsmd");
11
10
  const RULESET_SCHEMA_PATH = new URL("../agent-ruleset.schema.json", import.meta.url);
12
11
  const TOOL_RULES = [
13
12
  "# Tool Rules (compose-agentsmd)",
@@ -30,13 +29,14 @@ const DEFAULT_IGNORE_DIRS = new Set([
30
29
  ".turbo",
31
30
  "coverage"
32
31
  ]);
33
- const usage = `Usage: compose-agentsmd [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--rules-root <path>]
32
+ const usage = `Usage: compose-agentsmd [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--refresh] [--clear-cache]
34
33
 
35
34
  Options:
36
35
  --root <path> Project root directory (default: current working directory)
37
36
  --ruleset <path> Only compose a single ruleset file
38
37
  --ruleset-name <name> Ruleset filename to search for (default: agent-ruleset.json)
39
- --rules-root <path> Override rules root directory for all rulesets (or set ${RULES_ROOT_ENV_VAR})
38
+ --refresh Refresh cached remote rules
39
+ --clear-cache Remove cached remote rules and exit
40
40
  `;
41
41
  const parseArgs = (argv) => {
42
42
  const args = {};
@@ -73,13 +73,12 @@ const parseArgs = (argv) => {
73
73
  i += 1;
74
74
  continue;
75
75
  }
76
- if (arg === "--rules-root") {
77
- const value = argv[i + 1];
78
- if (!value) {
79
- throw new Error("Missing value for --rules-root");
80
- }
81
- args.rulesRoot = value;
82
- i += 1;
76
+ if (arg === "--refresh") {
77
+ args.refresh = true;
78
+ continue;
79
+ }
80
+ if (arg === "--clear-cache") {
81
+ args.clearCache = true;
83
82
  continue;
84
83
  }
85
84
  throw new Error(`Unknown argument: ${arg}`);
@@ -109,6 +108,11 @@ const resolveFrom = (baseDir, targetPath) => {
109
108
  }
110
109
  return path.resolve(baseDir, targetPath);
111
110
  };
111
+ const clearCache = () => {
112
+ if (fs.existsSync(DEFAULT_CACHE_ROOT)) {
113
+ fs.rmSync(DEFAULT_CACHE_ROOT, { recursive: true, force: true });
114
+ }
115
+ };
112
116
  const ensureFileExists = (filePath) => {
113
117
  if (!fs.existsSync(filePath)) {
114
118
  throw new Error(`Missing file: ${filePath}`);
@@ -138,32 +142,10 @@ const readProjectRuleset = (rulesetPath) => {
138
142
  if (ruleset.output === undefined) {
139
143
  ruleset.output = DEFAULT_OUTPUT;
140
144
  }
141
- return ruleset;
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);
145
+ if (ruleset.global === undefined) {
146
+ ruleset.global = true;
150
147
  }
151
- if (isNonEmptyString(projectRuleset.rulesRoot)) {
152
- return resolveFrom(rulesetDir, projectRuleset.rulesRoot);
153
- }
154
- return path.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 path.resolve(rulesRoot, globalDirName);
161
- };
162
- const resolveDomainsRoot = (rulesRoot, projectRuleset) => {
163
- const domainsDirName = isNonEmptyString(projectRuleset.domainsDir)
164
- ? projectRuleset.domainsDir
165
- : DEFAULT_DOMAINS_DIR;
166
- return path.resolve(rulesRoot, domainsDirName);
148
+ return ruleset;
167
149
  };
168
150
  const collectMarkdownFiles = (rootDir) => {
169
151
  ensureDirectoryExists(rootDir);
@@ -203,27 +185,176 @@ const addRulePaths = (rulePaths, resolvedRules, seenRules) => {
203
185
  seenRules.add(resolvedRulePath);
204
186
  }
205
187
  };
188
+ const sanitizeCacheSegment = (value) => value.replace(/[\\/]/gu, "__");
189
+ const looksLikeCommitHash = (value) => /^[a-f0-9]{7,40}$/iu.test(value);
190
+ const execGit = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
191
+ const parseGithubSource = (source) => {
192
+ const trimmed = source.trim();
193
+ if (!trimmed.startsWith("github:")) {
194
+ throw new Error(`Unsupported source: ${source}`);
195
+ }
196
+ const withoutPrefix = trimmed.slice("github:".length);
197
+ const [repoPart, refPart] = withoutPrefix.split("@");
198
+ const [owner, repo] = repoPart.split("/");
199
+ if (!isNonEmptyString(owner) || !isNonEmptyString(repo)) {
200
+ throw new Error(`Invalid GitHub source (expected github:owner/repo@ref): ${source}`);
201
+ }
202
+ const ref = isNonEmptyString(refPart) ? refPart : "latest";
203
+ return { owner, repo, ref, url: `https://github.com/${owner}/${repo}.git` };
204
+ };
205
+ const parseSemver = (tag) => {
206
+ const cleaned = tag.startsWith("v") ? tag.slice(1) : tag;
207
+ const parts = cleaned.split(".");
208
+ if (parts.length < 2 || parts.length > 3) {
209
+ return null;
210
+ }
211
+ const numbers = parts.map((part) => Number(part));
212
+ if (numbers.some((value) => Number.isNaN(value))) {
213
+ return null;
214
+ }
215
+ return numbers;
216
+ };
217
+ const compareSemver = (a, b) => {
218
+ const maxLength = Math.max(a.length, b.length);
219
+ for (let i = 0; i < maxLength; i += 1) {
220
+ const left = a[i] ?? 0;
221
+ const right = b[i] ?? 0;
222
+ if (left !== right) {
223
+ return left - right;
224
+ }
225
+ }
226
+ return 0;
227
+ };
228
+ const resolveLatestTag = (repoUrl) => {
229
+ const raw = execGit(["ls-remote", "--tags", "--refs", repoUrl]);
230
+ if (!raw) {
231
+ return {};
232
+ }
233
+ const candidates = raw
234
+ .split("\n")
235
+ .map((line) => line.trim())
236
+ .filter(Boolean)
237
+ .map((line) => {
238
+ const [hash, ref] = line.split(/\s+/u);
239
+ const tag = ref?.replace("refs/tags/", "");
240
+ if (!hash || !tag) {
241
+ return null;
242
+ }
243
+ const semver = parseSemver(tag);
244
+ if (!semver) {
245
+ return null;
246
+ }
247
+ return { hash, tag, semver };
248
+ })
249
+ .filter((item) => Boolean(item));
250
+ if (candidates.length === 0) {
251
+ return {};
252
+ }
253
+ candidates.sort((a, b) => compareSemver(a.semver, b.semver));
254
+ const latest = candidates[candidates.length - 1];
255
+ return { tag: latest.tag, hash: latest.hash };
256
+ };
257
+ const resolveHeadHash = (repoUrl) => {
258
+ const raw = execGit(["ls-remote", repoUrl, "HEAD"]);
259
+ const [hash] = raw.split(/\s+/u);
260
+ if (!hash) {
261
+ throw new Error(`Unable to resolve HEAD for ${repoUrl}`);
262
+ }
263
+ return hash;
264
+ };
265
+ const resolveRefHash = (repoUrl, ref) => {
266
+ const raw = execGit(["ls-remote", repoUrl, ref, `refs/tags/${ref}`, `refs/heads/${ref}`]);
267
+ if (!raw) {
268
+ return null;
269
+ }
270
+ const [hash] = raw.split(/\s+/u);
271
+ return hash ?? null;
272
+ };
273
+ const ensureDir = (dirPath) => {
274
+ fs.mkdirSync(dirPath, { recursive: true });
275
+ };
276
+ const cloneAtRef = (repoUrl, ref, destination) => {
277
+ execGit(["clone", "--depth", "1", "--branch", ref, repoUrl, destination]);
278
+ };
279
+ const fetchCommit = (repoUrl, commitHash, destination) => {
280
+ ensureDir(destination);
281
+ execGit(["init"], destination);
282
+ execGit(["remote", "add", "origin", repoUrl], destination);
283
+ execGit(["fetch", "--depth", "1", "origin", commitHash], destination);
284
+ execGit(["checkout", "FETCH_HEAD"], destination);
285
+ };
286
+ const resolveGithubRulesRoot = (source, refresh) => {
287
+ const parsed = parseGithubSource(source);
288
+ const resolved = parsed.ref === "latest" ? resolveLatestTag(parsed.url) : null;
289
+ const resolvedRef = resolved?.tag ?? (parsed.ref === "latest" ? "HEAD" : parsed.ref);
290
+ const resolvedHash = resolved?.hash ??
291
+ (resolvedRef === "HEAD"
292
+ ? resolveHeadHash(parsed.url)
293
+ : resolveRefHash(parsed.url, resolvedRef));
294
+ if (!resolvedHash && !looksLikeCommitHash(resolvedRef)) {
295
+ throw new Error(`Unable to resolve ref ${resolvedRef} for ${parsed.url}`);
296
+ }
297
+ const cacheSegment = resolvedRef === "HEAD" ? sanitizeCacheSegment(resolvedHash ?? resolvedRef) : sanitizeCacheSegment(resolvedRef);
298
+ const cacheDir = path.join(DEFAULT_CACHE_ROOT, parsed.owner, parsed.repo, cacheSegment);
299
+ if (refresh && fs.existsSync(cacheDir)) {
300
+ fs.rmSync(cacheDir, { recursive: true, force: true });
301
+ }
302
+ if (!fs.existsSync(cacheDir)) {
303
+ ensureDir(path.dirname(cacheDir));
304
+ try {
305
+ cloneAtRef(parsed.url, resolvedRef, cacheDir);
306
+ }
307
+ catch (error) {
308
+ if (resolvedHash && looksLikeCommitHash(resolvedHash)) {
309
+ fetchCommit(parsed.url, resolvedHash, cacheDir);
310
+ }
311
+ else if (looksLikeCommitHash(resolvedRef)) {
312
+ fetchCommit(parsed.url, resolvedRef, cacheDir);
313
+ }
314
+ else {
315
+ throw error;
316
+ }
317
+ }
318
+ }
319
+ const rulesRoot = path.join(cacheDir, "rules");
320
+ ensureDirectoryExists(rulesRoot);
321
+ return { rulesRoot, resolvedRef };
322
+ };
323
+ const resolveLocalRulesRoot = (rulesetDir, source) => {
324
+ const resolvedSource = resolveFrom(rulesetDir, source);
325
+ if (!fs.existsSync(resolvedSource)) {
326
+ throw new Error(`Missing source path: ${resolvedSource}`);
327
+ }
328
+ const candidate = path.basename(resolvedSource) === "rules" ? resolvedSource : path.join(resolvedSource, "rules");
329
+ ensureDirectoryExists(candidate);
330
+ return candidate;
331
+ };
332
+ const resolveRulesRoot = (rulesetDir, source, refresh) => {
333
+ if (source.startsWith("github:")) {
334
+ return resolveGithubRulesRoot(source, refresh);
335
+ }
336
+ return { rulesRoot: resolveLocalRulesRoot(rulesetDir, source) };
337
+ };
206
338
  const composeRuleset = (rulesetPath, rootDir, options) => {
207
339
  const rulesetDir = path.dirname(rulesetPath);
208
340
  const projectRuleset = readProjectRuleset(rulesetPath);
209
341
  const outputFileName = projectRuleset.output ?? DEFAULT_OUTPUT;
210
342
  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);
343
+ const { rulesRoot } = resolveRulesRoot(rulesetDir, projectRuleset.source, options.refresh ?? false);
344
+ const globalRoot = path.join(rulesRoot, "global");
345
+ const domainsRoot = path.join(rulesRoot, "domains");
216
346
  const resolvedRules = [];
217
347
  const seenRules = new Set();
218
- // Global rules always apply.
219
- addRulePaths(collectMarkdownFiles(globalRoot), resolvedRules, seenRules);
348
+ if (projectRuleset.global !== false) {
349
+ addRulePaths(collectMarkdownFiles(globalRoot), resolvedRules, seenRules);
350
+ }
220
351
  const domains = Array.isArray(projectRuleset.domains) ? projectRuleset.domains : [];
221
352
  for (const domain of domains) {
222
353
  const domainRoot = path.resolve(domainsRoot, domain);
223
354
  addRulePaths(collectMarkdownFiles(domainRoot), resolvedRules, seenRules);
224
355
  }
225
- const directRules = Array.isArray(projectRuleset.rules) ? projectRuleset.rules : [];
226
- const directRulePaths = directRules.map((rulePath) => resolveFrom(rulesetDir, rulePath));
356
+ const extraRules = Array.isArray(projectRuleset.extra) ? projectRuleset.extra : [];
357
+ const directRulePaths = extraRules.map((rulePath) => resolveFrom(rulesetDir, rulePath));
227
358
  addRulePaths(directRulePaths, resolvedRules, seenRules);
228
359
  const parts = resolvedRules.map((rulePath) => normalizeTrailingWhitespace(fs.readFileSync(rulePath, "utf8")));
229
360
  const lintHeader = "<!-- markdownlint-disable MD025 -->";
@@ -272,6 +403,11 @@ const main = () => {
272
403
  process.stdout.write(`${usage}\n`);
273
404
  return;
274
405
  }
406
+ if (args.clearCache) {
407
+ clearCache();
408
+ process.stdout.write("Cache cleared.\n");
409
+ return;
410
+ }
275
411
  const rootDir = args.root ? path.resolve(args.root) : process.cwd();
276
412
  const rulesetName = args.rulesetName || DEFAULT_RULESET_NAME;
277
413
  const rulesetFiles = getRulesetFiles(rootDir, args.ruleset, rulesetName);
@@ -280,7 +416,7 @@ const main = () => {
280
416
  }
281
417
  const outputs = rulesetFiles
282
418
  .sort()
283
- .map((rulesetPath) => composeRuleset(rulesetPath, rootDir, { cliRulesRoot: args.rulesRoot }));
419
+ .map((rulesetPath) => composeRuleset(rulesetPath, rootDir, { refresh: args.refresh }));
284
420
  process.stdout.write(`Composed AGENTS.md:\n${outputs.map((file) => `- ${file}`).join("\n")}\n`);
285
421
  };
286
422
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compose-agentsmd",
3
- "version": "1.1.1",
3
+ "version": "2.0.0",
4
4
  "description": "CLI tools for composing per-project AGENTS.md files from modular rule sets",
5
5
  "license": "MIT",
6
6
  "repository": {