compose-agentsmd 2.0.1 → 3.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
@@ -30,10 +30,27 @@ From each project root, run:
30
30
  compose-agentsmd
31
31
  ```
32
32
 
33
- 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`.
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
36
 
37
+ ## Updating shared rules
38
+
39
+ For GitHub sources, the tool keeps two locations:
40
+
41
+ - Cache: `~/.agentsmd/cache/<owner>/<repo>/<ref>/` (read-only, used for compose)
42
+ - Workspace: `~/.agentsmd/workspace/<owner>/<repo>/` (editable)
43
+
44
+ Update flow:
45
+
46
+ ```sh
47
+ compose-agentsmd edit-rules
48
+ # edit files under rules/ in the workspace
49
+ compose-agentsmd apply-rules
50
+ ```
51
+
52
+ `edit-rules` clones the GitHub source into the workspace (or reuses it). `apply-rules` pushes the workspace (if clean) and regenerates `AGENTS.md` by refreshing the cache. If your `source` is a local path, `edit-rules` will just print that path and `apply-rules` will skip the push.
53
+
37
54
  ## Project ruleset format
38
55
 
39
56
  ```json
@@ -59,7 +76,7 @@ Ruleset keys:
59
76
 
60
77
  ### Cache
61
78
 
62
- Remote sources are cached under `~/.agentsmd/<owner>/<repo>/<ref>/`. Use `--refresh` to re-fetch or `--clear-cache` to remove cached rules.
79
+ Remote sources are cached under `~/.agentsmd/cache/<owner>/<repo>/<ref>/`. Use `--refresh` to re-fetch or `--clear-cache` to remove cached rules.
63
80
 
64
81
  ### Optional arguments
65
82
 
@@ -68,6 +85,8 @@ Remote sources are cached under `~/.agentsmd/<owner>/<repo>/<ref>/`. Use `--refr
68
85
  - `--ruleset-name <name>`: override the ruleset filename (default: `agent-ruleset.json`)
69
86
  - `--refresh`: refresh cached remote rules
70
87
  - `--clear-cache`: remove cached remote rules and exit
88
+ - `edit-rules`: prepare or locate a writable rules workspace
89
+ - `apply-rules`: push workspace changes (if GitHub source) and regenerate rules with refresh
71
90
 
72
91
  ## Development
73
92
 
@@ -6,48 +6,26 @@ import { execFileSync } from "node:child_process";
6
6
  import { Ajv } from "ajv";
7
7
  const DEFAULT_RULESET_NAME = "agent-ruleset.json";
8
8
  const DEFAULT_OUTPUT = "AGENTS.md";
9
- const DEFAULT_CACHE_ROOT = path.join(os.homedir(), ".agentsmd");
9
+ const DEFAULT_CACHE_ROOT = path.join(os.homedir(), ".agentsmd", "cache");
10
+ const DEFAULT_WORKSPACE_ROOT = path.join(os.homedir(), ".agentsmd", "workspace");
10
11
  const RULESET_SCHEMA_PATH = new URL("../agent-ruleset.schema.json", import.meta.url);
11
- const TOOL_RULES = [
12
- "# Tool Rules (compose-agentsmd)",
13
- "- Before starting any work, run `compose-agentsmd` from the project root.",
14
- "- To update rules, update the source rule modules or ruleset, then rerun `compose-agentsmd`.",
15
- "- Do not edit `AGENTS.md` directly; update the source rules and regenerate."
16
- ].join("\n");
17
- const DEFAULT_IGNORE_DIRS = new Set([
18
- ".git",
19
- "agent-rules",
20
- "agent-rules-private",
21
- "agent-rules-local",
22
- "agent-rules-tools",
23
- "compose-agentsmd",
24
- "node_modules",
25
- "dist",
26
- "build",
27
- "out",
28
- ".next",
29
- ".turbo",
30
- "coverage"
31
- ]);
32
- const usage = `Usage: compose-agentsmd [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--refresh] [--clear-cache]
33
-
34
- Options:
35
- --root <path> Project root directory (default: current working directory)
36
- --ruleset <path> Only compose a single ruleset file
37
- --ruleset-name <name> Ruleset filename to search for (default: agent-ruleset.json)
38
- --refresh Refresh cached remote rules
39
- --clear-cache Remove cached remote rules and exit
40
- `;
12
+ const TOOL_RULES_PATH = new URL("../tools/tool-rules.md", import.meta.url);
13
+ const USAGE_PATH = new URL("../tools/usage.txt", import.meta.url);
41
14
  const parseArgs = (argv) => {
42
15
  const args = {};
43
- for (let i = 0; i < argv.length; i += 1) {
44
- const arg = argv[i];
16
+ const knownCommands = new Set(["edit-rules", "apply-rules"]);
17
+ const remaining = [...argv];
18
+ if (remaining.length > 0 && knownCommands.has(remaining[0])) {
19
+ args.command = remaining.shift();
20
+ }
21
+ for (let i = 0; i < remaining.length; i += 1) {
22
+ const arg = remaining[i];
45
23
  if (arg === "--help" || arg === "-h") {
46
24
  args.help = true;
47
25
  continue;
48
26
  }
49
27
  if (arg === "--root") {
50
- const value = argv[i + 1];
28
+ const value = remaining[i + 1];
51
29
  if (!value) {
52
30
  throw new Error("Missing value for --root");
53
31
  }
@@ -56,7 +34,7 @@ const parseArgs = (argv) => {
56
34
  continue;
57
35
  }
58
36
  if (arg === "--ruleset") {
59
- const value = argv[i + 1];
37
+ const value = remaining[i + 1];
60
38
  if (!value) {
61
39
  throw new Error("Missing value for --ruleset");
62
40
  }
@@ -65,7 +43,7 @@ const parseArgs = (argv) => {
65
43
  continue;
66
44
  }
67
45
  if (arg === "--ruleset-name") {
68
- const value = argv[i + 1];
46
+ const value = remaining[i + 1];
69
47
  if (!value) {
70
48
  throw new Error("Missing value for --ruleset-name");
71
49
  }
@@ -88,7 +66,9 @@ const parseArgs = (argv) => {
88
66
  const normalizeTrailingWhitespace = (content) => content.replace(/\s+$/u, "");
89
67
  const normalizePath = (filePath) => filePath.replace(/\\/g, "/");
90
68
  const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
69
+ const usage = normalizeTrailingWhitespace(fs.readFileSync(USAGE_PATH, "utf8"));
91
70
  const rulesetSchema = JSON.parse(fs.readFileSync(RULESET_SCHEMA_PATH, "utf8"));
71
+ const TOOL_RULES = normalizeTrailingWhitespace(fs.readFileSync(TOOL_RULES_PATH, "utf8"));
92
72
  const ajv = new Ajv({ allErrors: true, strict: false });
93
73
  const validateRulesetSchema = ajv.compile(rulesetSchema);
94
74
  const formatSchemaErrors = (errors) => {
@@ -108,6 +88,9 @@ const resolveFrom = (baseDir, targetPath) => {
108
88
  }
109
89
  return path.resolve(baseDir, targetPath);
110
90
  };
91
+ const ensureDir = (dirPath) => {
92
+ fs.mkdirSync(dirPath, { recursive: true });
93
+ };
111
94
  const clearCache = () => {
112
95
  if (fs.existsSync(DEFAULT_CACHE_ROOT)) {
113
96
  fs.rmSync(DEFAULT_CACHE_ROOT, { recursive: true, force: true });
@@ -270,9 +253,6 @@ const resolveRefHash = (repoUrl, ref) => {
270
253
  const [hash] = raw.split(/\s+/u);
271
254
  return hash ?? null;
272
255
  };
273
- const ensureDir = (dirPath) => {
274
- fs.mkdirSync(dirPath, { recursive: true });
275
- };
276
256
  const cloneAtRef = (repoUrl, ref, destination) => {
277
257
  execGit(["clone", "--depth", "1", "--branch", ref, repoUrl, destination]);
278
258
  };
@@ -329,6 +309,41 @@ const resolveLocalRulesRoot = (rulesetDir, source) => {
329
309
  ensureDirectoryExists(candidate);
330
310
  return candidate;
331
311
  };
312
+ const resolveWorkspaceRoot = (rulesetDir, source) => {
313
+ if (source.startsWith("github:")) {
314
+ const parsed = parseGithubSource(source);
315
+ return path.join(DEFAULT_WORKSPACE_ROOT, parsed.owner, parsed.repo);
316
+ }
317
+ return resolveFrom(rulesetDir, source);
318
+ };
319
+ const ensureWorkspaceForGithubSource = (source) => {
320
+ const parsed = parseGithubSource(source);
321
+ const workspaceRoot = path.join(DEFAULT_WORKSPACE_ROOT, parsed.owner, parsed.repo);
322
+ if (!fs.existsSync(workspaceRoot)) {
323
+ ensureDir(path.dirname(workspaceRoot));
324
+ execGit(["clone", parsed.url, workspaceRoot]);
325
+ }
326
+ if (parsed.ref !== "latest") {
327
+ execGit(["fetch", "--all"], workspaceRoot);
328
+ execGit(["checkout", parsed.ref], workspaceRoot);
329
+ }
330
+ return workspaceRoot;
331
+ };
332
+ const applyRulesFromWorkspace = (rulesetDir, source) => {
333
+ if (!source.startsWith("github:")) {
334
+ return;
335
+ }
336
+ const workspaceRoot = ensureWorkspaceForGithubSource(source);
337
+ const status = execGit(["status", "--porcelain"], workspaceRoot);
338
+ if (status) {
339
+ throw new Error(`Workspace has uncommitted changes: ${workspaceRoot}`);
340
+ }
341
+ const branch = execGit(["rev-parse", "--abbrev-ref", "HEAD"], workspaceRoot);
342
+ if (branch === "HEAD") {
343
+ throw new Error(`Workspace is in detached HEAD state: ${workspaceRoot}`);
344
+ }
345
+ execGit(["push"], workspaceRoot);
346
+ };
332
347
  const resolveRulesRoot = (rulesetDir, source, refresh) => {
333
348
  if (source.startsWith("github:")) {
334
349
  return resolveGithubRulesRoot(source, refresh);
@@ -364,38 +379,24 @@ const composeRuleset = (rulesetPath, rootDir, options) => {
364
379
  fs.writeFileSync(outputPath, output, "utf8");
365
380
  return normalizePath(path.relative(rootDir, outputPath));
366
381
  };
367
- const findRulesetFiles = (rootDir, rulesetName) => {
368
- const results = [];
369
- const pending = [rootDir];
370
- while (pending.length > 0) {
371
- const currentDir = pending.pop();
372
- if (!currentDir) {
373
- continue;
374
- }
375
- const entries = fs.readdirSync(currentDir, { withFileTypes: true });
376
- for (const entry of entries) {
377
- const entryPath = path.join(currentDir, entry.name);
378
- if (entry.isDirectory()) {
379
- if (DEFAULT_IGNORE_DIRS.has(entry.name)) {
380
- continue;
381
- }
382
- pending.push(entryPath);
383
- continue;
384
- }
385
- if (entry.isFile() && entry.name === rulesetName) {
386
- results.push(entryPath);
387
- }
388
- }
389
- }
390
- return results;
391
- };
392
382
  const getRulesetFiles = (rootDir, specificRuleset, rulesetName) => {
393
383
  if (specificRuleset) {
394
384
  const resolved = resolveFrom(rootDir, specificRuleset);
395
385
  ensureFileExists(resolved);
396
386
  return [resolved];
397
387
  }
398
- return findRulesetFiles(rootDir, rulesetName);
388
+ const defaultRuleset = path.join(rootDir, rulesetName);
389
+ if (!fs.existsSync(defaultRuleset)) {
390
+ return [];
391
+ }
392
+ return [defaultRuleset];
393
+ };
394
+ const ensureSingleRuleset = (rulesetFiles, rootDir, rulesetName) => {
395
+ if (rulesetFiles.length === 0) {
396
+ const expectedPath = normalizePath(path.join(rootDir, rulesetName));
397
+ throw new Error(`Missing ruleset file: ${expectedPath}`);
398
+ }
399
+ return rulesetFiles[0];
399
400
  };
400
401
  const main = () => {
401
402
  const args = parseArgs(process.argv.slice(2));
@@ -411,8 +412,30 @@ const main = () => {
411
412
  const rootDir = args.root ? path.resolve(args.root) : process.cwd();
412
413
  const rulesetName = args.rulesetName || DEFAULT_RULESET_NAME;
413
414
  const rulesetFiles = getRulesetFiles(rootDir, args.ruleset, rulesetName);
415
+ const command = args.command ?? "compose";
416
+ if (command === "edit-rules") {
417
+ const rulesetPath = ensureSingleRuleset(rulesetFiles, rootDir, rulesetName);
418
+ const rulesetDir = path.dirname(rulesetPath);
419
+ const ruleset = readProjectRuleset(rulesetPath);
420
+ let workspaceRoot = resolveWorkspaceRoot(rulesetDir, ruleset.source);
421
+ if (ruleset.source.startsWith("github:")) {
422
+ workspaceRoot = ensureWorkspaceForGithubSource(ruleset.source);
423
+ }
424
+ process.stdout.write(`Rules workspace: ${workspaceRoot}\n`);
425
+ return;
426
+ }
427
+ if (command === "apply-rules") {
428
+ const rulesetPath = ensureSingleRuleset(rulesetFiles, rootDir, rulesetName);
429
+ const rulesetDir = path.dirname(rulesetPath);
430
+ const ruleset = readProjectRuleset(rulesetPath);
431
+ applyRulesFromWorkspace(rulesetDir, ruleset.source);
432
+ const output = composeRuleset(rulesetPath, rootDir, { refresh: true });
433
+ process.stdout.write(`Composed AGENTS.md:\n- ${output}\n`);
434
+ return;
435
+ }
414
436
  if (rulesetFiles.length === 0) {
415
- throw new Error(`No ruleset files named ${rulesetName} found under ${rootDir}`);
437
+ const expectedPath = normalizePath(path.join(rootDir, rulesetName));
438
+ throw new Error(`Missing ruleset file: ${expectedPath}`);
416
439
  }
417
440
  const outputs = rulesetFiles
418
441
  .sort()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compose-agentsmd",
3
- "version": "2.0.1",
3
+ "version": "3.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": {
@@ -0,0 +1,4 @@
1
+ # Tool Rules (compose-agentsmd)
2
+ - Before starting any work, run `compose-agentsmd` from the project root.
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.
@@ -0,0 +1,8 @@
1
+ Usage: compose-agentsmd [edit-rules|apply-rules] [--root <path>] [--ruleset <path>] [--ruleset-name <name>] [--refresh] [--clear-cache]
2
+
3
+ Options:
4
+ --root <path> Project root directory (default: current working directory)
5
+ --ruleset <path> Only compose a single ruleset file
6
+ --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