compose-agentsmd 3.1.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
  ```
@@ -87,8 +111,20 @@ Remote sources are cached under `~/.agentsmd/cache/<owner>/<repo>/<ref>/`. Use `
87
111
  - `--clear-cache`: remove cached remote rules and exit
88
112
  - `--version` / `-V`: show version and exit
89
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
90
125
  - `edit-rules`: prepare or locate a writable rules workspace
91
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
92
128
 
93
129
  ## Development
94
130
 
@@ -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);
@@ -390,6 +535,130 @@ const composeRuleset = (rulesetPath, rootDir, options) => {
390
535
  fs.writeFileSync(outputPath, output, "utf8");
391
536
  return normalizePath(path.relative(rootDir, outputPath));
392
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
+ };
393
662
  const getRulesetFiles = (rootDir, specificRuleset, rulesetName) => {
394
663
  if (specificRuleset) {
395
664
  const resolved = resolveFrom(rootDir, specificRuleset);
@@ -409,7 +678,7 @@ const ensureSingleRuleset = (rulesetFiles, rootDir, rulesetName) => {
409
678
  }
410
679
  return rulesetFiles[0];
411
680
  };
412
- const main = () => {
681
+ const main = async () => {
413
682
  const args = parseArgs(process.argv.slice(2));
414
683
  if (args.version) {
415
684
  process.stdout.write(`${getVersion()}\n`);
@@ -448,6 +717,10 @@ const main = () => {
448
717
  process.stdout.write(`Rules workspace: ${workspaceRoot}\n`);
449
718
  return;
450
719
  }
720
+ if (command === "init") {
721
+ await initProject(args, rootDir, rulesetName);
722
+ return;
723
+ }
451
724
  if (command === "apply-rules") {
452
725
  const rulesetPath = ensureSingleRuleset(rulesetFiles, rootDir, rulesetName);
453
726
  const rulesetDir = path.dirname(rulesetPath);
@@ -466,12 +739,15 @@ const main = () => {
466
739
  .map((rulesetPath) => composeRuleset(rulesetPath, rootDir, { refresh: args.refresh }));
467
740
  process.stdout.write(`Composed AGENTS.md:\n${outputs.map((file) => `- ${file}`).join("\n")}\n`);
468
741
  };
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
- }
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.1.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": {
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