compose-agentsmd 3.0.0 → 3.2.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
@@ -34,6 +34,24 @@ The tool reads `agent-ruleset.json` from the given root directory (default: curr
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
36
 
37
+ ## Setup (init)
38
+
39
+ For a project that does not have a ruleset yet, bootstrap one with `init`:
40
+
41
+ ```sh
42
+ compose-agentsmd init --root path/to/project --yes
43
+ ```
44
+
45
+ Defaults:
46
+
47
+ - `source`: `github:owner/repo@latest`
48
+ - `domains`: empty
49
+ - `extra`: empty
50
+ - `global`: omitted (defaults to `true`)
51
+ - `output`: `AGENTS.md`
52
+
53
+ Use `--dry-run` to preview actions, `--force` to overwrite existing files, and `--compose` to generate `AGENTS.md` immediately.
54
+
37
55
  ## Updating shared rules
38
56
 
39
57
  For GitHub sources, the tool keeps two locations:
@@ -53,11 +71,17 @@ compose-agentsmd apply-rules
53
71
 
54
72
  ## Project ruleset format
55
73
 
56
- ```json
74
+ Ruleset files accept JSON with `//` or `/* */` comments.
75
+
76
+ ```jsonc
57
77
  {
58
- "source": "github:org/agent-rules@latest",
78
+ // Rules source. Use github:owner/repo@ref or a local path.
79
+ "source": "github:owner/repo@latest",
80
+ // Domain folders under rules/domains.
59
81
  "domains": ["node", "unreal"],
82
+ // Additional local rule files to append.
60
83
  "extra": ["agent-rules-local/custom.md"],
84
+ // Output file name.
61
85
  "output": "AGENTS.md"
62
86
  }
63
87
  ```
@@ -85,8 +109,22 @@ Remote sources are cached under `~/.agentsmd/cache/<owner>/<repo>/<ref>/`. Use `
85
109
  - `--ruleset-name <name>`: override the ruleset filename (default: `agent-ruleset.json`)
86
110
  - `--refresh`: refresh cached remote rules
87
111
  - `--clear-cache`: remove cached remote rules and exit
112
+ - `--version` / `-V`: show version and exit
113
+ - `--verbose` / `-v`: show verbose diagnostics
114
+ - `--source <source>`: rules source for `init`
115
+ - `--domains <list>`: comma-separated domains for `init`
116
+ - `--extra <list>`: comma-separated extra rules for `init`
117
+ - `--output <file>`: output filename for `init`
118
+ - `--no-domains`: initialize with no domains
119
+ - `--no-extra`: initialize without extra rule files
120
+ - `--no-global`: initialize without global rules
121
+ - `--compose`: compose `AGENTS.md` after `init`
122
+ - `--dry-run`: show init plan without writing files
123
+ - `--yes`: skip init confirmation prompt
124
+ - `--force`: overwrite existing files during init
88
125
  - `edit-rules`: prepare or locate a writable rules workspace
89
126
  - `apply-rules`: push workspace changes (if GitHub source) and regenerate rules with refresh
127
+ - `init`: generate a new ruleset and optional local rules file
90
128
 
91
129
  ## Development
92
130
 
@@ -3,17 +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);
16
+ const PACKAGE_JSON_PATH = new URL("../package.json", import.meta.url);
12
17
  const TOOL_RULES_PATH = new URL("../tools/tool-rules.md", import.meta.url);
13
18
  const USAGE_PATH = new URL("../tools/usage.txt", import.meta.url);
14
19
  const parseArgs = (argv) => {
15
20
  const args = {};
16
- const knownCommands = new Set(["edit-rules", "apply-rules"]);
21
+ const knownCommands = new Set(["edit-rules", "apply-rules", "init"]);
17
22
  const remaining = [...argv];
18
23
  if (remaining.length > 0 && knownCommands.has(remaining[0])) {
19
24
  args.command = remaining.shift();
@@ -24,6 +29,14 @@ const parseArgs = (argv) => {
24
29
  args.help = true;
25
30
  continue;
26
31
  }
32
+ if (arg === "--version" || arg === "-V") {
33
+ args.version = true;
34
+ continue;
35
+ }
36
+ if (arg === "--verbose" || arg === "-v") {
37
+ args.verbose = true;
38
+ continue;
39
+ }
27
40
  if (arg === "--root") {
28
41
  const value = remaining[i + 1];
29
42
  if (!value) {
@@ -59,6 +72,70 @@ const parseArgs = (argv) => {
59
72
  args.clearCache = true;
60
73
  continue;
61
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
+ }
62
139
  throw new Error(`Unknown argument: ${arg}`);
63
140
  }
64
141
  return args;
@@ -66,7 +143,26 @@ const parseArgs = (argv) => {
66
143
  const normalizeTrailingWhitespace = (content) => content.replace(/\s+$/u, "");
67
144
  const normalizePath = (filePath) => filePath.replace(/\\/g, "/");
68
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
+ });
69
163
  const usage = normalizeTrailingWhitespace(fs.readFileSync(USAGE_PATH, "utf8"));
164
+ const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf8"));
165
+ const getVersion = () => packageJson.version ?? "unknown";
70
166
  const rulesetSchema = JSON.parse(fs.readFileSync(RULESET_SCHEMA_PATH, "utf8"));
71
167
  const TOOL_RULES = normalizeTrailingWhitespace(fs.readFileSync(TOOL_RULES_PATH, "utf8"));
72
168
  const ajv = new Ajv({ allErrors: true, strict: false });
@@ -110,9 +206,69 @@ const ensureDirectoryExists = (dirPath) => {
110
206
  throw new Error(`Not a directory: ${dirPath}`);
111
207
  }
112
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
+ };
113
269
  const readJsonFile = (filePath) => {
114
270
  const raw = fs.readFileSync(filePath, "utf8");
115
- return JSON.parse(raw);
271
+ return JSON.parse(stripJsonComments(raw));
116
272
  };
117
273
  const readProjectRuleset = (rulesetPath) => {
118
274
  const parsed = readJsonFile(rulesetPath);
@@ -379,6 +535,130 @@ const composeRuleset = (rulesetPath, rootDir, options) => {
379
535
  fs.writeFileSync(outputPath, output, "utf8");
380
536
  return normalizePath(path.relative(rootDir, outputPath));
381
537
  };
538
+ const LOCAL_RULES_TEMPLATE = "# Local Rules\n\n- Add project-specific instructions here.\n";
539
+ const buildInitRuleset = (args) => {
540
+ const domains = normalizeListOption(args.domains, "--domains");
541
+ const extra = normalizeListOption(args.extra, "--extra");
542
+ const ruleset = {
543
+ source: args.source ?? DEFAULT_INIT_SOURCE,
544
+ output: args.output ?? DEFAULT_OUTPUT
545
+ };
546
+ if (args.global === false) {
547
+ ruleset.global = false;
548
+ }
549
+ const resolvedDomains = domains ?? DEFAULT_INIT_DOMAINS;
550
+ if (resolvedDomains.length > 0) {
551
+ ruleset.domains = resolvedDomains;
552
+ }
553
+ const resolvedExtra = extra ?? DEFAULT_INIT_EXTRA;
554
+ if (resolvedExtra.length > 0) {
555
+ ruleset.extra = resolvedExtra;
556
+ }
557
+ return ruleset;
558
+ };
559
+ const formatInitRuleset = (ruleset) => {
560
+ const domainsValue = JSON.stringify(ruleset.domains ?? []);
561
+ const extraValue = JSON.stringify(ruleset.extra ?? []);
562
+ const lines = [
563
+ "{",
564
+ ' // Rules source. Use github:owner/repo@ref or a local path.',
565
+ ` "source": "${ruleset.source}",`,
566
+ ' // Domain folders under rules/domains.',
567
+ ` "domains": ${domainsValue},`,
568
+ ' // Additional local rule files to append.',
569
+ ` "extra": ${extraValue},`
570
+ ];
571
+ if (ruleset.global === false) {
572
+ lines.push(' // Include rules/global from the source.');
573
+ lines.push(' "global": false,');
574
+ }
575
+ lines.push(' // Output file name.');
576
+ lines.push(` "output": "${ruleset.output ?? DEFAULT_OUTPUT}"`);
577
+ lines.push("}");
578
+ return `${lines.join("\n")}\n`;
579
+ };
580
+ const formatPlan = (items, rootDir) => {
581
+ const lines = items.map((item) => {
582
+ const verb = item.action === "overwrite" ? "Overwrite" : "Create";
583
+ const relative = normalizePath(path.relative(rootDir, item.path));
584
+ return `- ${verb}: ${relative}`;
585
+ });
586
+ return `Init plan:\n${lines.join("\n")}\n`;
587
+ };
588
+ const confirmInit = async (args) => {
589
+ if (args.dryRun || args.yes) {
590
+ return;
591
+ }
592
+ if (!process.stdin.isTTY) {
593
+ throw new Error("Confirmation required. Re-run with --yes to continue.");
594
+ }
595
+ const answer = await askQuestion("Proceed with init? [y/N] ");
596
+ if (!/^y(es)?$/iu.test(answer.trim())) {
597
+ throw new Error("Init aborted.");
598
+ }
599
+ };
600
+ const initProject = async (args, rootDir, rulesetName) => {
601
+ const rulesetPath = args.ruleset ? resolveFrom(rootDir, args.ruleset) : path.join(rootDir, rulesetName);
602
+ const rulesetDir = path.dirname(rulesetPath);
603
+ const ruleset = buildInitRuleset(args);
604
+ const outputPath = resolveFrom(rulesetDir, ruleset.output ?? DEFAULT_OUTPUT);
605
+ const plan = [];
606
+ if (fs.existsSync(rulesetPath)) {
607
+ if (!args.force) {
608
+ throw new Error(`Ruleset already exists: ${normalizePath(rulesetPath)}`);
609
+ }
610
+ plan.push({ action: "overwrite", path: rulesetPath });
611
+ }
612
+ else {
613
+ plan.push({ action: "create", path: rulesetPath });
614
+ }
615
+ const extraFiles = (ruleset.extra ?? []).map((rulePath) => resolveFrom(rulesetDir, rulePath));
616
+ const extraToWrite = [];
617
+ for (const extraPath of extraFiles) {
618
+ if (fs.existsSync(extraPath)) {
619
+ if (args.force) {
620
+ plan.push({ action: "overwrite", path: extraPath });
621
+ extraToWrite.push(extraPath);
622
+ }
623
+ continue;
624
+ }
625
+ plan.push({ action: "create", path: extraPath });
626
+ extraToWrite.push(extraPath);
627
+ }
628
+ if (args.compose) {
629
+ if (fs.existsSync(outputPath)) {
630
+ if (!args.force) {
631
+ throw new Error(`Output already exists: ${normalizePath(outputPath)} (use --force to overwrite)`);
632
+ }
633
+ plan.push({ action: "overwrite", path: outputPath });
634
+ }
635
+ else {
636
+ plan.push({ action: "create", path: outputPath });
637
+ }
638
+ }
639
+ process.stdout.write(formatPlan(plan, rootDir));
640
+ if (args.dryRun) {
641
+ process.stdout.write("Dry run: no changes made.\n");
642
+ return;
643
+ }
644
+ await confirmInit(args);
645
+ fs.mkdirSync(path.dirname(rulesetPath), { recursive: true });
646
+ fs.writeFileSync(`${rulesetPath}`, formatInitRuleset(ruleset), "utf8");
647
+ for (const extraPath of extraToWrite) {
648
+ fs.mkdirSync(path.dirname(extraPath), { recursive: true });
649
+ fs.writeFileSync(extraPath, LOCAL_RULES_TEMPLATE, "utf8");
650
+ }
651
+ process.stdout.write(`Initialized ruleset:\n- ${normalizePath(path.relative(rootDir, rulesetPath))}\n`);
652
+ if (extraToWrite.length > 0) {
653
+ process.stdout.write(`Initialized local rules:\n${extraToWrite
654
+ .map((filePath) => `- ${normalizePath(path.relative(rootDir, filePath))}`)
655
+ .join("\n")}\n`);
656
+ }
657
+ if (args.compose) {
658
+ const output = composeRuleset(rulesetPath, rootDir, { refresh: args.refresh ?? false });
659
+ process.stdout.write(`Composed AGENTS.md:\n- ${output}\n`);
660
+ }
661
+ };
382
662
  const getRulesetFiles = (rootDir, specificRuleset, rulesetName) => {
383
663
  if (specificRuleset) {
384
664
  const resolved = resolveFrom(rootDir, specificRuleset);
@@ -398,8 +678,12 @@ const ensureSingleRuleset = (rulesetFiles, rootDir, rulesetName) => {
398
678
  }
399
679
  return rulesetFiles[0];
400
680
  };
401
- const main = () => {
681
+ const main = async () => {
402
682
  const args = parseArgs(process.argv.slice(2));
683
+ if (args.version) {
684
+ process.stdout.write(`${getVersion()}\n`);
685
+ return;
686
+ }
403
687
  if (args.help) {
404
688
  process.stdout.write(`${usage}\n`);
405
689
  return;
@@ -413,6 +697,15 @@ const main = () => {
413
697
  const rulesetName = args.rulesetName || DEFAULT_RULESET_NAME;
414
698
  const rulesetFiles = getRulesetFiles(rootDir, args.ruleset, rulesetName);
415
699
  const command = args.command ?? "compose";
700
+ const logVerbose = (message) => {
701
+ if (args.verbose) {
702
+ process.stdout.write(`${message}\n`);
703
+ }
704
+ };
705
+ logVerbose("Verbose:");
706
+ logVerbose(`- Root: ${rootDir}`);
707
+ logVerbose(`- Ruleset name: ${rulesetName}`);
708
+ logVerbose(`- Ruleset files:\n${rulesetFiles.map((file) => ` - ${normalizePath(path.relative(rootDir, file))}`).join("\n")}`);
416
709
  if (command === "edit-rules") {
417
710
  const rulesetPath = ensureSingleRuleset(rulesetFiles, rootDir, rulesetName);
418
711
  const rulesetDir = path.dirname(rulesetPath);
@@ -424,6 +717,10 @@ const main = () => {
424
717
  process.stdout.write(`Rules workspace: ${workspaceRoot}\n`);
425
718
  return;
426
719
  }
720
+ if (command === "init") {
721
+ await initProject(args, rootDir, rulesetName);
722
+ return;
723
+ }
427
724
  if (command === "apply-rules") {
428
725
  const rulesetPath = ensureSingleRuleset(rulesetFiles, rootDir, rulesetName);
429
726
  const rulesetDir = path.dirname(rulesetPath);
@@ -442,12 +739,15 @@ const main = () => {
442
739
  .map((rulesetPath) => composeRuleset(rulesetPath, rootDir, { refresh: args.refresh }));
443
740
  process.stdout.write(`Composed AGENTS.md:\n${outputs.map((file) => `- ${file}`).join("\n")}\n`);
444
741
  };
445
- try {
446
- main();
447
- }
448
- catch (error) {
449
- const message = error instanceof Error ? error.message : String(error);
450
- process.stderr.write(`${message}\n`);
451
- process.stderr.write(`${usage}\n`);
452
- process.exit(1);
453
- }
742
+ const run = async () => {
743
+ try {
744
+ await main();
745
+ }
746
+ catch (error) {
747
+ const message = error instanceof Error ? error.message : String(error);
748
+ process.stderr.write(`${message}\n`);
749
+ process.stderr.write(`${usage}\n`);
750
+ process.exit(1);
751
+ }
752
+ };
753
+ void run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compose-agentsmd",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "CLI tools for composing per-project AGENTS.md files from modular rule sets",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  # Tool Rules (compose-agentsmd)
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
- - Do not edit `AGENTS.md` directly; update the source rules and regenerate.
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.
package/tools/usage.txt CHANGED
@@ -1,8 +1,22 @@
1
- Usage: compose-agentsmd [edit-rules|apply-rules] [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--refresh] [--clear-cache]
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
- --root <path> Project root directory (default: current working directory)
5
- --ruleset <path> Only compose a single ruleset file
4
+ --help, -h Show help and exit
5
+ --version, -V Show version and exit
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
6
9
  --ruleset-name <name> Ruleset filename in the project root (default: agent-ruleset.json)
7
- --refresh Refresh cached remote rules
8
- --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