compose-agentsmd 3.1.0 → 3.2.1

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
@@ -33,6 +33,25 @@ compose-agentsmd
33
33
  The tool reads `agent-ruleset.json` from the given root directory (default: current working directory), and writes the output file specified by the ruleset. If `output` is omitted, it defaults to `AGENTS.md`.
34
34
 
35
35
  The tool prepends a small "Tool Rules" block to every generated `AGENTS.md` so agents know how to regenerate or update rules.
36
+ Each composed rule section is also prefixed with the source file path that produced it.
37
+
38
+ ## Setup (init)
39
+
40
+ For a project that does not have a ruleset yet, bootstrap one with `init`:
41
+
42
+ ```sh
43
+ compose-agentsmd init --root path/to/project --yes
44
+ ```
45
+
46
+ Defaults:
47
+
48
+ - `source`: `github:owner/repo@latest`
49
+ - `domains`: empty
50
+ - `extra`: empty
51
+ - `global`: omitted (defaults to `true`)
52
+ - `output`: `AGENTS.md`
53
+
54
+ Use `--dry-run` to preview actions, `--force` to overwrite existing files, and `--compose` to generate `AGENTS.md` immediately.
36
55
 
37
56
  ## Updating shared rules
38
57
 
@@ -53,11 +72,17 @@ compose-agentsmd apply-rules
53
72
 
54
73
  ## Project ruleset format
55
74
 
56
- ```json
75
+ Ruleset files accept JSON with `//` or `/* */` comments.
76
+
77
+ ```jsonc
57
78
  {
58
- "source": "github:org/agent-rules@latest",
79
+ // Rules source. Use github:owner/repo@ref or a local path.
80
+ "source": "github:owner/repo@latest",
81
+ // Domain folders under rules/domains.
59
82
  "domains": ["node", "unreal"],
83
+ // Additional local rule files to append.
60
84
  "extra": ["agent-rules-local/custom.md"],
85
+ // Output file name.
61
86
  "output": "AGENTS.md"
62
87
  }
63
88
  ```
@@ -87,8 +112,20 @@ Remote sources are cached under `~/.agentsmd/cache/<owner>/<repo>/<ref>/`. Use `
87
112
  - `--clear-cache`: remove cached remote rules and exit
88
113
  - `--version` / `-V`: show version and exit
89
114
  - `--verbose` / `-v`: show verbose diagnostics
115
+ - `--source <source>`: rules source for `init`
116
+ - `--domains <list>`: comma-separated domains for `init`
117
+ - `--extra <list>`: comma-separated extra rules for `init`
118
+ - `--output <file>`: output filename for `init`
119
+ - `--no-domains`: initialize with no domains
120
+ - `--no-extra`: initialize without extra rule files
121
+ - `--no-global`: initialize without global rules
122
+ - `--compose`: compose `AGENTS.md` after `init`
123
+ - `--dry-run`: show init plan without writing files
124
+ - `--yes`: skip init confirmation prompt
125
+ - `--force`: overwrite existing files during init
90
126
  - `edit-rules`: prepare or locate a writable rules workspace
91
127
  - `apply-rules`: push workspace changes (if GitHub source) and regenerate rules with refresh
128
+ - `init`: generate a new ruleset and optional local rules file
92
129
 
93
130
  ## Development
94
131
 
@@ -3,18 +3,22 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
+ import readline from "node:readline";
6
7
  import { Ajv } from "ajv";
7
8
  const DEFAULT_RULESET_NAME = "agent-ruleset.json";
8
9
  const DEFAULT_OUTPUT = "AGENTS.md";
9
10
  const DEFAULT_CACHE_ROOT = path.join(os.homedir(), ".agentsmd", "cache");
10
11
  const DEFAULT_WORKSPACE_ROOT = path.join(os.homedir(), ".agentsmd", "workspace");
12
+ const DEFAULT_INIT_SOURCE = "github:owner/repo@latest";
13
+ const DEFAULT_INIT_DOMAINS = [];
14
+ const DEFAULT_INIT_EXTRA = [];
11
15
  const RULESET_SCHEMA_PATH = new URL("../agent-ruleset.schema.json", import.meta.url);
12
16
  const PACKAGE_JSON_PATH = new URL("../package.json", import.meta.url);
13
17
  const TOOL_RULES_PATH = new URL("../tools/tool-rules.md", import.meta.url);
14
18
  const USAGE_PATH = new URL("../tools/usage.txt", import.meta.url);
15
19
  const parseArgs = (argv) => {
16
20
  const args = {};
17
- const knownCommands = new Set(["edit-rules", "apply-rules"]);
21
+ const knownCommands = new Set(["edit-rules", "apply-rules", "init"]);
18
22
  const remaining = [...argv];
19
23
  if (remaining.length > 0 && knownCommands.has(remaining[0])) {
20
24
  args.command = remaining.shift();
@@ -68,6 +72,70 @@ const parseArgs = (argv) => {
68
72
  args.clearCache = true;
69
73
  continue;
70
74
  }
75
+ if (arg === "--source") {
76
+ const value = remaining[i + 1];
77
+ if (!value) {
78
+ throw new Error("Missing value for --source");
79
+ }
80
+ args.source = value;
81
+ i += 1;
82
+ continue;
83
+ }
84
+ if (arg === "--domains") {
85
+ const value = remaining[i + 1];
86
+ if (!value) {
87
+ throw new Error("Missing value for --domains");
88
+ }
89
+ args.domains = [...(args.domains ?? []), ...value.split(",").map((entry) => entry.trim())];
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (arg === "--no-domains") {
94
+ args.domains = [];
95
+ continue;
96
+ }
97
+ if (arg === "--extra") {
98
+ const value = remaining[i + 1];
99
+ if (!value) {
100
+ throw new Error("Missing value for --extra");
101
+ }
102
+ args.extra = [...(args.extra ?? []), ...value.split(",").map((entry) => entry.trim())];
103
+ i += 1;
104
+ continue;
105
+ }
106
+ if (arg === "--no-extra") {
107
+ args.extra = [];
108
+ continue;
109
+ }
110
+ if (arg === "--output") {
111
+ const value = remaining[i + 1];
112
+ if (!value) {
113
+ throw new Error("Missing value for --output");
114
+ }
115
+ args.output = value;
116
+ i += 1;
117
+ continue;
118
+ }
119
+ if (arg === "--no-global") {
120
+ args.global = false;
121
+ continue;
122
+ }
123
+ if (arg === "--compose") {
124
+ args.compose = true;
125
+ continue;
126
+ }
127
+ if (arg === "--dry-run") {
128
+ args.dryRun = true;
129
+ continue;
130
+ }
131
+ if (arg === "--yes") {
132
+ args.yes = true;
133
+ continue;
134
+ }
135
+ if (arg === "--force") {
136
+ args.force = true;
137
+ continue;
138
+ }
71
139
  throw new Error(`Unknown argument: ${arg}`);
72
140
  }
73
141
  return args;
@@ -75,6 +143,23 @@ const parseArgs = (argv) => {
75
143
  const normalizeTrailingWhitespace = (content) => content.replace(/\s+$/u, "");
76
144
  const normalizePath = (filePath) => filePath.replace(/\\/g, "/");
77
145
  const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
146
+ const normalizeListOption = (values, label) => {
147
+ if (!values) {
148
+ return undefined;
149
+ }
150
+ const trimmed = values.map((value) => value.trim());
151
+ if (trimmed.some((value) => value.length === 0)) {
152
+ throw new Error(`Invalid value for ${label}`);
153
+ }
154
+ return [...new Set(trimmed)];
155
+ };
156
+ const askQuestion = (prompt) => new Promise((resolve) => {
157
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
158
+ rl.question(prompt, (answer) => {
159
+ rl.close();
160
+ resolve(answer);
161
+ });
162
+ });
78
163
  const usage = normalizeTrailingWhitespace(fs.readFileSync(USAGE_PATH, "utf8"));
79
164
  const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf8"));
80
165
  const getVersion = () => packageJson.version ?? "unknown";
@@ -121,9 +206,69 @@ const ensureDirectoryExists = (dirPath) => {
121
206
  throw new Error(`Not a directory: ${dirPath}`);
122
207
  }
123
208
  };
209
+ const stripJsonComments = (input) => {
210
+ let output = "";
211
+ let inString = false;
212
+ let stringChar = "";
213
+ let escaping = false;
214
+ let inLineComment = false;
215
+ let inBlockComment = false;
216
+ for (let i = 0; i < input.length; i += 1) {
217
+ const char = input[i];
218
+ const next = input[i + 1];
219
+ if (inLineComment) {
220
+ if (char === "\n") {
221
+ inLineComment = false;
222
+ output += char;
223
+ }
224
+ continue;
225
+ }
226
+ if (inBlockComment) {
227
+ if (char === "*" && next === "/") {
228
+ inBlockComment = false;
229
+ i += 1;
230
+ }
231
+ continue;
232
+ }
233
+ if (inString) {
234
+ output += char;
235
+ if (escaping) {
236
+ escaping = false;
237
+ continue;
238
+ }
239
+ if (char === "\\") {
240
+ escaping = true;
241
+ continue;
242
+ }
243
+ if (char === stringChar) {
244
+ inString = false;
245
+ stringChar = "";
246
+ }
247
+ continue;
248
+ }
249
+ if (char === "/" && next === "/") {
250
+ inLineComment = true;
251
+ i += 1;
252
+ continue;
253
+ }
254
+ if (char === "/" && next === "*") {
255
+ inBlockComment = true;
256
+ i += 1;
257
+ continue;
258
+ }
259
+ if (char === "\"" || char === "'") {
260
+ inString = true;
261
+ stringChar = char;
262
+ output += char;
263
+ continue;
264
+ }
265
+ output += char;
266
+ }
267
+ return output;
268
+ };
124
269
  const readJsonFile = (filePath) => {
125
270
  const raw = fs.readFileSync(filePath, "utf8");
126
- return JSON.parse(raw);
271
+ return JSON.parse(stripJsonComments(raw));
127
272
  };
128
273
  const readProjectRuleset = (rulesetPath) => {
129
274
  const parsed = readJsonFile(rulesetPath);
@@ -382,7 +527,10 @@ const composeRuleset = (rulesetPath, rootDir, options) => {
382
527
  const extraRules = Array.isArray(projectRuleset.extra) ? projectRuleset.extra : [];
383
528
  const directRulePaths = extraRules.map((rulePath) => resolveFrom(rulesetDir, rulePath));
384
529
  addRulePaths(directRulePaths, resolvedRules, seenRules);
385
- const parts = resolvedRules.map((rulePath) => normalizeTrailingWhitespace(fs.readFileSync(rulePath, "utf8")));
530
+ const parts = resolvedRules.map((rulePath) => {
531
+ const body = normalizeTrailingWhitespace(fs.readFileSync(rulePath, "utf8"));
532
+ return `Source: ${normalizePath(rulePath)}\n\n${body}`;
533
+ });
386
534
  const lintHeader = "<!-- markdownlint-disable MD025 -->";
387
535
  const toolRules = normalizeTrailingWhitespace(TOOL_RULES);
388
536
  const output = `${lintHeader}\n${[toolRules, ...parts].join("\n\n")}\n`;
@@ -390,6 +538,130 @@ const composeRuleset = (rulesetPath, rootDir, options) => {
390
538
  fs.writeFileSync(outputPath, output, "utf8");
391
539
  return normalizePath(path.relative(rootDir, outputPath));
392
540
  };
541
+ const LOCAL_RULES_TEMPLATE = "# Local Rules\n\n- Add project-specific instructions here.\n";
542
+ const buildInitRuleset = (args) => {
543
+ const domains = normalizeListOption(args.domains, "--domains");
544
+ const extra = normalizeListOption(args.extra, "--extra");
545
+ const ruleset = {
546
+ source: args.source ?? DEFAULT_INIT_SOURCE,
547
+ output: args.output ?? DEFAULT_OUTPUT
548
+ };
549
+ if (args.global === false) {
550
+ ruleset.global = false;
551
+ }
552
+ const resolvedDomains = domains ?? DEFAULT_INIT_DOMAINS;
553
+ if (resolvedDomains.length > 0) {
554
+ ruleset.domains = resolvedDomains;
555
+ }
556
+ const resolvedExtra = extra ?? DEFAULT_INIT_EXTRA;
557
+ if (resolvedExtra.length > 0) {
558
+ ruleset.extra = resolvedExtra;
559
+ }
560
+ return ruleset;
561
+ };
562
+ const formatInitRuleset = (ruleset) => {
563
+ const domainsValue = JSON.stringify(ruleset.domains ?? []);
564
+ const extraValue = JSON.stringify(ruleset.extra ?? []);
565
+ const lines = [
566
+ "{",
567
+ ' // Rules source. Use github:owner/repo@ref or a local path.',
568
+ ` "source": "${ruleset.source}",`,
569
+ ' // Domain folders under rules/domains.',
570
+ ` "domains": ${domainsValue},`,
571
+ ' // Additional local rule files to append.',
572
+ ` "extra": ${extraValue},`
573
+ ];
574
+ if (ruleset.global === false) {
575
+ lines.push(' // Include rules/global from the source.');
576
+ lines.push(' "global": false,');
577
+ }
578
+ lines.push(' // Output file name.');
579
+ lines.push(` "output": "${ruleset.output ?? DEFAULT_OUTPUT}"`);
580
+ lines.push("}");
581
+ return `${lines.join("\n")}\n`;
582
+ };
583
+ const formatPlan = (items, rootDir) => {
584
+ const lines = items.map((item) => {
585
+ const verb = item.action === "overwrite" ? "Overwrite" : "Create";
586
+ const relative = normalizePath(path.relative(rootDir, item.path));
587
+ return `- ${verb}: ${relative}`;
588
+ });
589
+ return `Init plan:\n${lines.join("\n")}\n`;
590
+ };
591
+ const confirmInit = async (args) => {
592
+ if (args.dryRun || args.yes) {
593
+ return;
594
+ }
595
+ if (!process.stdin.isTTY) {
596
+ throw new Error("Confirmation required. Re-run with --yes to continue.");
597
+ }
598
+ const answer = await askQuestion("Proceed with init? [y/N] ");
599
+ if (!/^y(es)?$/iu.test(answer.trim())) {
600
+ throw new Error("Init aborted.");
601
+ }
602
+ };
603
+ const initProject = async (args, rootDir, rulesetName) => {
604
+ const rulesetPath = args.ruleset ? resolveFrom(rootDir, args.ruleset) : path.join(rootDir, rulesetName);
605
+ const rulesetDir = path.dirname(rulesetPath);
606
+ const ruleset = buildInitRuleset(args);
607
+ const outputPath = resolveFrom(rulesetDir, ruleset.output ?? DEFAULT_OUTPUT);
608
+ const plan = [];
609
+ if (fs.existsSync(rulesetPath)) {
610
+ if (!args.force) {
611
+ throw new Error(`Ruleset already exists: ${normalizePath(rulesetPath)}`);
612
+ }
613
+ plan.push({ action: "overwrite", path: rulesetPath });
614
+ }
615
+ else {
616
+ plan.push({ action: "create", path: rulesetPath });
617
+ }
618
+ const extraFiles = (ruleset.extra ?? []).map((rulePath) => resolveFrom(rulesetDir, rulePath));
619
+ const extraToWrite = [];
620
+ for (const extraPath of extraFiles) {
621
+ if (fs.existsSync(extraPath)) {
622
+ if (args.force) {
623
+ plan.push({ action: "overwrite", path: extraPath });
624
+ extraToWrite.push(extraPath);
625
+ }
626
+ continue;
627
+ }
628
+ plan.push({ action: "create", path: extraPath });
629
+ extraToWrite.push(extraPath);
630
+ }
631
+ if (args.compose) {
632
+ if (fs.existsSync(outputPath)) {
633
+ if (!args.force) {
634
+ throw new Error(`Output already exists: ${normalizePath(outputPath)} (use --force to overwrite)`);
635
+ }
636
+ plan.push({ action: "overwrite", path: outputPath });
637
+ }
638
+ else {
639
+ plan.push({ action: "create", path: outputPath });
640
+ }
641
+ }
642
+ process.stdout.write(formatPlan(plan, rootDir));
643
+ if (args.dryRun) {
644
+ process.stdout.write("Dry run: no changes made.\n");
645
+ return;
646
+ }
647
+ await confirmInit(args);
648
+ fs.mkdirSync(path.dirname(rulesetPath), { recursive: true });
649
+ fs.writeFileSync(`${rulesetPath}`, formatInitRuleset(ruleset), "utf8");
650
+ for (const extraPath of extraToWrite) {
651
+ fs.mkdirSync(path.dirname(extraPath), { recursive: true });
652
+ fs.writeFileSync(extraPath, LOCAL_RULES_TEMPLATE, "utf8");
653
+ }
654
+ process.stdout.write(`Initialized ruleset:\n- ${normalizePath(path.relative(rootDir, rulesetPath))}\n`);
655
+ if (extraToWrite.length > 0) {
656
+ process.stdout.write(`Initialized local rules:\n${extraToWrite
657
+ .map((filePath) => `- ${normalizePath(path.relative(rootDir, filePath))}`)
658
+ .join("\n")}\n`);
659
+ }
660
+ if (args.compose) {
661
+ const output = composeRuleset(rulesetPath, rootDir, { refresh: args.refresh ?? false });
662
+ process.stdout.write(`Composed AGENTS.md:\n- ${output}\n`);
663
+ }
664
+ };
393
665
  const getRulesetFiles = (rootDir, specificRuleset, rulesetName) => {
394
666
  if (specificRuleset) {
395
667
  const resolved = resolveFrom(rootDir, specificRuleset);
@@ -409,7 +681,7 @@ const ensureSingleRuleset = (rulesetFiles, rootDir, rulesetName) => {
409
681
  }
410
682
  return rulesetFiles[0];
411
683
  };
412
- const main = () => {
684
+ const main = async () => {
413
685
  const args = parseArgs(process.argv.slice(2));
414
686
  if (args.version) {
415
687
  process.stdout.write(`${getVersion()}\n`);
@@ -448,6 +720,10 @@ const main = () => {
448
720
  process.stdout.write(`Rules workspace: ${workspaceRoot}\n`);
449
721
  return;
450
722
  }
723
+ if (command === "init") {
724
+ await initProject(args, rootDir, rulesetName);
725
+ return;
726
+ }
451
727
  if (command === "apply-rules") {
452
728
  const rulesetPath = ensureSingleRuleset(rulesetFiles, rootDir, rulesetName);
453
729
  const rulesetDir = path.dirname(rulesetPath);
@@ -466,12 +742,15 @@ const main = () => {
466
742
  .map((rulesetPath) => composeRuleset(rulesetPath, rootDir, { refresh: args.refresh }));
467
743
  process.stdout.write(`Composed AGENTS.md:\n${outputs.map((file) => `- ${file}`).join("\n")}\n`);
468
744
  };
469
- try {
470
- main();
471
- }
472
- catch (error) {
473
- const message = error instanceof Error ? error.message : String(error);
474
- process.stderr.write(`${message}\n`);
475
- process.stderr.write(`${usage}\n`);
476
- process.exit(1);
477
- }
745
+ const run = async () => {
746
+ try {
747
+ await main();
748
+ }
749
+ catch (error) {
750
+ const message = error instanceof Error ? error.message : String(error);
751
+ process.stderr.write(`${message}\n`);
752
+ process.stderr.write(`${usage}\n`);
753
+ process.exit(1);
754
+ }
755
+ };
756
+ void run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compose-agentsmd",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "description": "CLI tools for composing per-project AGENTS.md files from modular rule sets",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,4 +2,6 @@
2
2
  - Before starting any work, run `compose-agentsmd` from the project root.
3
3
  - To update shared rules, run `compose-agentsmd edit-rules`, edit the workspace rules, then run `compose-agentsmd apply-rules`.
4
4
  - Do not edit `AGENTS.md` directly; update the source rules and regenerate.
5
- - When updating rules, include a detailed summary of what changed (added/removed/modified items) in the final response.
5
+ - When updating rules, include a colorized diff-style summary in the final response. Use `git diff --stat` first, then include the raw ANSI-colored output of `git diff --color=always` (no sanitizing or reformatting), and limit the output to the rule files that changed.
6
+ - Also provide a short, copy-pasteable command the user can run to view the diff in the same format. Use absolute paths so it works regardless of the current working directory, and scope it to the changed rule files.
7
+ - If a diff is provided, a separate detailed summary is not required. If a diff is not possible, include a detailed summary of what changed (added/removed/modified items).
package/tools/usage.txt CHANGED
@@ -1,11 +1,22 @@
1
- Usage: compose-agentsmd [edit-rules|apply-rules] [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--refresh] [--clear-cache] [--version|-V] [--verbose|-v] [--help|-h]
1
+ Usage: compose-agentsmd [edit-rules|apply-rules|init] [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--source <source>] [--domains <list>] [--extra <list>] [--output <file>] [--no-domains] [--no-extra] [--no-global] [--compose] [--dry-run] [--yes] [--force] [--refresh] [--clear-cache] [--version|-V] [--verbose|-v] [--help|-h]
2
2
 
3
3
  Options:
4
4
  --help, -h Show help and exit
5
5
  --version, -V Show version and exit
6
6
  --verbose, -v Show verbose diagnostics
7
- --root <path> Project root directory (default: current working directory)
8
- --ruleset <path> Only compose a single ruleset file
7
+ --root <path> Project root directory (default: current working directory)
8
+ --ruleset <path> Only compose a single ruleset file
9
9
  --ruleset-name <name> Ruleset filename in the project root (default: agent-ruleset.json)
10
- --refresh Refresh cached remote rules
11
- --clear-cache Remove cached remote rules and exit
10
+ --source <source> Rules source for init (default: github:owner/repo@latest)
11
+ --domains <list> Comma-separated domains for init (default: none)
12
+ --extra <list> Comma-separated extra rules for init
13
+ --output <file> Output filename for init (default: AGENTS.md)
14
+ --no-domains Initialize with no domains
15
+ --no-extra Initialize without extra rule files
16
+ --no-global Initialize without global rules
17
+ --compose Compose AGENTS.md after init
18
+ --dry-run Show init plan without writing files
19
+ --yes Skip init confirmation prompt
20
+ --force Overwrite existing files during init
21
+ --refresh Refresh cached remote rules
22
+ --clear-cache Remove cached remote rules and exit