compose-agentsmd 3.5.1 → 5.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
@@ -1,6 +1,6 @@
1
1
  # Compose AGENTS.md
2
2
 
3
- This repository contains CLI tooling for composing per-project `AGENTS.md` files from modular rule sets.
3
+ This repository contains CLI tooling for composing repository-local and user-global agent instruction files from modular rule sets.
4
4
 
5
5
  ## Compatibility
6
6
 
@@ -24,7 +24,7 @@ This provides the `compose-agentsmd` command.
24
24
 
25
25
  ## Rules setup (this repository)
26
26
 
27
- The default ruleset for this repository is `agent-ruleset.json` and currently composes the `node` domain into `AGENTS.md` from the shared GitHub source.
27
+ The default ruleset for this repository is `agent-ruleset.json` and currently composes the `node` domain into repository-local instructions from the shared GitHub source.
28
28
 
29
29
  ## Compose
30
30
 
@@ -34,14 +34,22 @@ From each project root, run:
34
34
  compose-agentsmd
35
35
  ```
36
36
 
37
- 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`.
37
+ The tool reads `agent-ruleset.json` from the given root directory (default: current working directory), and writes the repository-local output file specified by the ruleset. If `output` is omitted, it defaults to `AGENTS.md`.
38
38
 
39
39
  By default, compose also writes a `CLAUDE.md` companion file containing an `@...` import pointing to the primary output file. You can disable this with `claude.enabled: false` in the ruleset.
40
40
 
41
- The tool prepends a small "Tool Rules" block to every generated `AGENTS.md` so agents know how to regenerate or update rules.
42
- Each composed rule section is also prefixed with the source file path that produced it.
41
+ By default, compose writes `rules/global` to these user-global instruction files with the same composed content:
43
42
 
44
- When the output file is `AGENTS.md`, the CLI also prints a unified diff for `AGENTS.md` when it changes (and prints `AGENTS.md unchanged.` when it does not). This works even when the project is not under git. `--quiet` and `--json` suppress this output.
43
+ - `~/.codex/AGENTS.md`
44
+ - `~/.claude/CLAUDE.md`
45
+ - `~/.gemini/GEMINI.md`
46
+ - `~/.copilot/copilot-instructions.md`
47
+
48
+ Repository-local `AGENTS.md` contains the tool rules plus only the repository-facing rules (`domains` + `extra`). Global rules are no longer embedded into each repository output.
49
+
50
+ Each composed rule section is prefixed with the source file path that produced it.
51
+
52
+ When compose changes files, the CLI prints diffs for both repository outputs and global outputs. This works even when the project is not under git. `--quiet` and `--json` suppress this output.
45
53
 
46
54
  ## Setup (init)
47
55
 
@@ -56,11 +64,11 @@ Defaults:
56
64
  - `source`: `github:owner/repo@latest`
57
65
  - `domains`: empty
58
66
  - `extra`: empty
59
- - `global`: omitted (defaults to `true`)
67
+ - `global`: omitted (defaults to `true`, meaning write user-global instruction files)
60
68
  - `claude`: `{ "enabled": true, "output": "CLAUDE.md" }`
61
69
  - `output`: `AGENTS.md`
62
70
 
63
- Use `--dry-run` to preview actions, `--force` to overwrite existing files, and `--compose` to generate `AGENTS.md` immediately.
71
+ Use `--dry-run` to preview actions, `--force` to overwrite existing repository output files, and `--compose` to generate instruction files immediately.
64
72
 
65
73
  ## Updating shared rules
66
74
 
@@ -77,7 +85,7 @@ compose-agentsmd edit-rules
77
85
  compose-agentsmd apply-rules
78
86
  ```
79
87
 
80
- `edit-rules` clones the GitHub source into the workspace (or reuses it), then prints the workspace path, rules directory, and next steps. `apply-rules` pushes the workspace (if clean) and regenerates `AGENTS.md` by refreshing the cache. If your `source` is a local path, `edit-rules` points to the local workspace and `apply-rules` skips the push.
88
+ `edit-rules` clones the GitHub source into the workspace (or reuses it), then prints the workspace path, rules directory, and next steps. `apply-rules` pushes the workspace (if clean) and regenerates repository/global instruction files by refreshing the cache. If your `source` is a local path, `edit-rules` points to the local workspace and `apply-rules` skips the push.
81
89
 
82
90
  ## Project ruleset format
83
91
 
@@ -91,12 +99,12 @@ Ruleset files accept JSON with `//` or `/* */` comments.
91
99
  "domains": ["node", "unreal"],
92
100
  // Additional local rule files to append.
93
101
  "extra": ["agent-rules-local/custom.md"],
94
- // Optional Claude Code companion output.
102
+ // Optional Claude Code repository companion output.
95
103
  "claude": {
96
104
  "enabled": true,
97
105
  "output": "CLAUDE.md"
98
106
  },
99
- // Output file name.
107
+ // Repository output file name.
100
108
  "output": "AGENTS.md"
101
109
  }
102
110
  ```
@@ -104,13 +112,18 @@ Ruleset files accept JSON with `//` or `/* */` comments.
104
112
  Ruleset keys:
105
113
 
106
114
  - `source` (required): rules source. Use `github:owner/repo@ref` or a local path.
107
- - `global` (optional): include `rules/global` (defaults to true). Omit this unless you want to disable globals.
115
+ - `global` (optional): write `rules/global` to user-global instruction files (defaults to true). Set `false` to skip global writes.
108
116
  - `domains` (optional): domain folders under `rules/domains/<domain>`.
109
117
  - `extra` (optional): additional local rule files to append.
110
- - `claude` (optional): companion settings for Claude Code.
118
+ - `budget` (optional): global-rule budget thresholds in `o200k_base` tokens.
119
+ - `budget.totalTokens` (optional): total token budget for the composed global instruction output (defaults to `4500`).
120
+ - `budget.moduleTokens` (optional): per-module token budget for each composed global rule section (defaults to `400`).
121
+ - `claude` (optional): repository companion settings for Claude Code.
111
122
  - `claude.enabled` (optional): enable/disable companion generation (defaults to `true`).
112
123
  - `claude.output` (optional): companion file path (defaults to `CLAUDE.md`).
113
- - `output` (optional): output file name (defaults to `AGENTS.md`).
124
+ - `output` (optional): repository output file name (defaults to `AGENTS.md`).
125
+
126
+ When the composed global instruction output exceeds either budget, the CLI emits a warning to `stderr`. The machine-readable `--json` output includes the tokenizer name, total token count, and any over-budget modules.
114
127
 
115
128
  ### Ruleset schema validation
116
129
 
@@ -132,11 +145,11 @@ Remote sources are cached under `~/.agentsmd/cache/<owner>/<repo>/<ref>/`. Use `
132
145
  - `--source <source>`: rules source for `init`
133
146
  - `--domains <list>`: comma-separated domains for `init`
134
147
  - `--extra <list>`: comma-separated extra rules for `init`
135
- - `--output <file>`: output filename for `init`
148
+ - `--output <file>`: repository output filename for `init`
136
149
  - `--no-domains`: initialize with no domains
137
150
  - `--no-extra`: initialize without extra rule files
138
- - `--no-global`: initialize without global rules
139
- - `--compose`: compose output file(s) after `init`
151
+ - `--no-global`: initialize without user-global rules
152
+ - `--compose`: compose repository and user-global instruction files after `init`
140
153
  - `--dry-run`: show init plan without writing files
141
154
  - `--yes`: skip init confirmation prompt
142
155
  - `--force`: overwrite existing files during init
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "title": "Compose AGENTS.md ruleset",
3
+ "title": "Compose agent instruction files ruleset",
4
4
  "type": "object",
5
5
  "additionalProperties": false,
6
6
  "required": ["source"],
@@ -10,7 +10,8 @@
10
10
  "minLength": 1
11
11
  },
12
12
  "global": {
13
- "type": "boolean"
13
+ "type": "boolean",
14
+ "description": "Write rules/global to user-global instruction files."
14
15
  },
15
16
  "output": {
16
17
  "type": "string",
@@ -47,11 +48,11 @@
47
48
  "type": "object",
48
49
  "additionalProperties": false,
49
50
  "properties": {
50
- "totalLines": {
51
+ "totalTokens": {
51
52
  "type": "integer",
52
53
  "minimum": 1
53
54
  },
54
- "moduleLines": {
55
+ "moduleTokens": {
55
56
  "type": "integer",
56
57
  "minimum": 1
57
58
  }
@@ -6,9 +6,15 @@ import { execFileSync } from "node:child_process";
6
6
  import readline from "node:readline";
7
7
  import { Ajv } from "ajv";
8
8
  import { createTwoFilesPatch } from "diff";
9
+ import { countTokens } from "gpt-tokenizer";
10
+ import { prepareGitFallbackDestination } from "./git-fallback.js";
9
11
  const DEFAULT_RULESET_NAME = "agent-ruleset.json";
10
12
  const DEFAULT_OUTPUT = "AGENTS.md";
11
13
  const DEFAULT_CLAUDE_OUTPUT = "CLAUDE.md";
14
+ const DEFAULT_CODEX_GLOBAL_OUTPUT = path.join(os.homedir(), ".codex", "AGENTS.md");
15
+ const DEFAULT_CLAUDE_GLOBAL_OUTPUT = path.join(os.homedir(), ".claude", "CLAUDE.md");
16
+ const DEFAULT_GEMINI_GLOBAL_OUTPUT = path.join(os.homedir(), ".gemini", "GEMINI.md");
17
+ const DEFAULT_COPILOT_GLOBAL_OUTPUT = path.join(os.homedir(), ".copilot", "copilot-instructions.md");
12
18
  const DEFAULT_CACHE_ROOT = path.join(os.homedir(), ".agentsmd", "cache");
13
19
  const DEFAULT_WORKSPACE_ROOT = path.join(os.homedir(), ".agentsmd", "workspace");
14
20
  const DEFAULT_INIT_SOURCE = "github:owner/repo@latest";
@@ -18,8 +24,10 @@ const RULESET_SCHEMA_PATH = new URL("../agent-ruleset.schema.json", import.meta.
18
24
  const PACKAGE_JSON_PATH = new URL("../package.json", import.meta.url);
19
25
  const TOOL_RULES_PATH = new URL("../tools/tool-rules.md", import.meta.url);
20
26
  const USAGE_PATH = new URL("../tools/usage.txt", import.meta.url);
21
- const DEFAULT_TOTAL_BUDGET = 350;
22
- const DEFAULT_MODULE_BUDGET = 30;
27
+ const BUDGET_TOKENIZER = "o200k_base";
28
+ const DEFAULT_TOTAL_BUDGET = 4500;
29
+ const DEFAULT_MODULE_BUDGET = 400;
30
+ const LINT_HEADER = "<!-- markdownlint-disable MD025 -->";
23
31
  const readValueArg = (remaining, index, flag) => {
24
32
  const value = remaining[index + 1];
25
33
  if (!value) {
@@ -182,6 +190,22 @@ const resolveFrom = (baseDir, targetPath) => {
182
190
  }
183
191
  return path.resolve(baseDir, targetPath);
184
192
  };
193
+ const isSubPath = (baseDir, targetPath) => {
194
+ const relativePath = path.relative(path.resolve(baseDir), path.resolve(targetPath));
195
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
196
+ };
197
+ const toDisplayPath = (rootDir, filePath) => {
198
+ if (isSubPath(rootDir, filePath)) {
199
+ const relativePath = path.relative(rootDir, filePath);
200
+ return normalizePath(relativePath || path.basename(filePath));
201
+ }
202
+ const homeDir = os.homedir();
203
+ if (isSubPath(homeDir, filePath)) {
204
+ const relativeToHome = normalizePath(path.relative(homeDir, filePath));
205
+ return relativeToHome ? `~/${relativeToHome}` : "~";
206
+ }
207
+ return normalizePath(path.resolve(filePath));
208
+ };
185
209
  const ensureDir = (dirPath) => {
186
210
  fs.mkdirSync(dirPath, { recursive: true });
187
211
  };
@@ -420,7 +444,7 @@ const cloneAtRef = (repoUrl, ref, destination) => {
420
444
  execGit(["clone", "--depth", "1", "--branch", ref, repoUrl, destination]);
421
445
  };
422
446
  const fetchCommit = (repoUrl, commitHash, destination) => {
423
- ensureDir(destination);
447
+ prepareGitFallbackDestination(destination);
424
448
  execGit(["init"], destination);
425
449
  execGit(["remote", "add", "origin", repoUrl], destination);
426
450
  execGit(["fetch", "--depth", "1", "origin", commitHash], destination);
@@ -530,94 +554,163 @@ const formatRuleSourcePath = (rulePath, rulesRoot, rulesetDir, source, resolvedR
530
554
  const result = normalizePath(path.relative(rulesetDir, rulePath));
531
555
  return result;
532
556
  };
557
+ const getGlobalOutputPaths = () => [
558
+ DEFAULT_CODEX_GLOBAL_OUTPUT,
559
+ DEFAULT_CLAUDE_GLOBAL_OUTPUT,
560
+ DEFAULT_GEMINI_GLOBAL_OUTPUT,
561
+ DEFAULT_COPILOT_GLOBAL_OUTPUT
562
+ ];
533
563
  const resolveOutputPaths = (rulesetDir, projectRuleset) => {
534
564
  const primaryOutputPath = resolveFrom(rulesetDir, projectRuleset.output ?? DEFAULT_OUTPUT);
535
565
  const claude = projectRuleset.claude ?? {};
536
566
  const companionEnabled = claude.enabled !== false;
537
567
  const configuredCompanionPath = resolveFrom(rulesetDir, claude.output ?? DEFAULT_CLAUDE_OUTPUT);
568
+ const globalOutputPaths = projectRuleset.global === false ? [] : getGlobalOutputPaths();
538
569
  if (!companionEnabled ||
539
570
  path.resolve(primaryOutputPath) === path.resolve(configuredCompanionPath)) {
540
- return { primaryOutputPath };
571
+ return { primaryOutputPath, globalOutputPaths };
541
572
  }
542
- return { primaryOutputPath, companionOutputPath: configuredCompanionPath };
573
+ return {
574
+ primaryOutputPath,
575
+ companionOutputPath: configuredCompanionPath,
576
+ globalOutputPaths
577
+ };
543
578
  };
544
579
  const buildClaudeCompanionContent = (primaryOutputPath, companionOutputPath) => {
545
580
  const relativeImportPath = normalizePath(path.relative(path.dirname(companionOutputPath), primaryOutputPath));
546
581
  return `@${relativeImportPath}\n`;
547
582
  };
583
+ const buildInstructionContent = (parts, includeToolRules) => {
584
+ const sections = includeToolRules ? [normalizeTrailingWhitespace(TOOL_RULES), ...parts] : parts;
585
+ if (sections.length === 0) {
586
+ return "";
587
+ }
588
+ return `${LINT_HEADER}\n${sections.join("\n\n")}\n`;
589
+ };
590
+ const countBudgetTokens = (content) => {
591
+ if (content.length === 0) {
592
+ return 0;
593
+ }
594
+ return countTokens(content);
595
+ };
596
+ const buildScopeDiff = (scope, targetPaths, desiredContent, rootDir) => {
597
+ if (targetPaths.length === 0) {
598
+ return undefined;
599
+ }
600
+ const displayTargets = targetPaths.map((filePath) => toDisplayPath(rootDir, filePath));
601
+ const changedTargetPath = targetPaths.find((filePath) => {
602
+ if (!fs.existsSync(filePath)) {
603
+ return true;
604
+ }
605
+ return fs.readFileSync(filePath, "utf8") !== desiredContent;
606
+ });
607
+ if (!changedTargetPath) {
608
+ return {
609
+ scope,
610
+ targets: displayTargets,
611
+ status: "unchanged"
612
+ };
613
+ }
614
+ const before = fs.existsSync(changedTargetPath) ? fs.readFileSync(changedTargetPath, "utf8") : "";
615
+ const displayPath = toDisplayPath(rootDir, changedTargetPath);
616
+ return {
617
+ scope,
618
+ targets: displayTargets,
619
+ status: "updated",
620
+ patch: createTwoFilesPatch(`a/${displayPath}`, `b/${displayPath}`, before, desiredContent, "", "", { context: 3 })
621
+ };
622
+ };
548
623
  const composeRuleset = (rulesetPath, rootDir, options) => {
549
624
  const rulesetDir = path.dirname(rulesetPath);
550
625
  const projectRuleset = readProjectRuleset(rulesetPath);
551
- const { primaryOutputPath, companionOutputPath } = resolveOutputPaths(rulesetDir, projectRuleset);
552
- const composedOutputPath = normalizePath(path.relative(rootDir, primaryOutputPath));
626
+ const { primaryOutputPath, companionOutputPath, globalOutputPaths } = resolveOutputPaths(rulesetDir, projectRuleset);
627
+ const composedOutputPath = toDisplayPath(rootDir, primaryOutputPath);
553
628
  const { rulesRoot, resolvedRef } = resolveRulesRoot(rulesetDir, projectRuleset.source, options.refresh ?? false);
554
629
  const globalRoot = path.join(rulesRoot, "global");
555
630
  const domainsRoot = path.join(rulesRoot, "domains");
556
- const resolvedRules = [];
557
- const seenRules = new Set();
631
+ const resolvedGlobalRules = [];
632
+ const seenGlobalRules = new Set();
633
+ const resolvedRepositoryRules = [];
634
+ const seenRepositoryRules = new Set();
558
635
  if (projectRuleset.global !== false) {
559
- addRulePaths(collectMarkdownFiles(globalRoot), resolvedRules, seenRules);
636
+ addRulePaths(collectMarkdownFiles(globalRoot), resolvedGlobalRules, seenGlobalRules);
560
637
  }
561
638
  const domains = Array.isArray(projectRuleset.domains) ? projectRuleset.domains : [];
562
639
  for (const domain of domains) {
563
640
  const domainRoot = path.resolve(domainsRoot, domain);
564
- addRulePaths(collectMarkdownFiles(domainRoot), resolvedRules, seenRules);
641
+ addRulePaths(collectMarkdownFiles(domainRoot), resolvedRepositoryRules, seenRepositoryRules);
565
642
  }
566
643
  const extraRules = Array.isArray(projectRuleset.extra) ? projectRuleset.extra : [];
567
644
  const directRulePaths = extraRules.map((rulePath) => resolveFrom(rulesetDir, rulePath));
568
- addRulePaths(directRulePaths, resolvedRules, seenRules);
569
- const totalBudget = projectRuleset.budget?.totalLines ?? DEFAULT_TOTAL_BUDGET;
570
- const moduleBudget = projectRuleset.budget?.moduleLines ?? DEFAULT_MODULE_BUDGET;
571
- const normalizedGlobalRoot = normalizePath(path.resolve(globalRoot));
572
- const globalRuleFiles = resolvedRules.filter((p) => normalizePath(p).startsWith(`${normalizedGlobalRoot}/`));
573
- const moduleLineCounts = globalRuleFiles.map((filePath) => {
574
- const content = fs.readFileSync(filePath, "utf8");
575
- return { name: path.basename(filePath), lines: content.split("\n").length };
645
+ addRulePaths(directRulePaths, resolvedRepositoryRules, seenRepositoryRules);
646
+ const totalBudget = projectRuleset.budget?.totalTokens ?? DEFAULT_TOTAL_BUDGET;
647
+ const moduleBudget = projectRuleset.budget?.moduleTokens ?? DEFAULT_MODULE_BUDGET;
648
+ const buildRuleParts = (rulePaths) => rulePaths.map((rulePath) => {
649
+ const body = normalizeTrailingWhitespace(fs.readFileSync(rulePath, "utf8"));
650
+ const sourcePath = formatRuleSourcePath(rulePath, rulesRoot, rulesetDir, projectRuleset.source, resolvedRef);
651
+ return {
652
+ name: path.basename(rulePath),
653
+ content: `Source: ${sourcePath}\n\n${body}`
654
+ };
576
655
  });
577
- const totalLines = moduleLineCounts.reduce((sum, m) => sum + m.lines, 0);
578
- const overBudgetModules = moduleLineCounts.filter((m) => m.lines > moduleBudget);
656
+ const repositoryParts = buildRuleParts(resolvedRepositoryRules);
657
+ const globalParts = buildRuleParts(resolvedGlobalRules);
658
+ const repositoryContentParts = repositoryParts.map((part) => part.content);
659
+ const globalContentParts = globalParts.map((part) => part.content);
660
+ const primaryOutputContent = buildInstructionContent(repositoryContentParts, true);
661
+ const globalOutputContent = buildInstructionContent(globalContentParts, false);
662
+ const moduleTokenCounts = globalParts.map((part) => ({
663
+ name: part.name,
664
+ tokens: countBudgetTokens(part.content)
665
+ }));
666
+ const totalTokens = countBudgetTokens(globalOutputContent);
667
+ const overBudgetModules = moduleTokenCounts.filter((module) => module.tokens > moduleBudget);
579
668
  const budgetResult = {
580
- totalLines,
669
+ tokenizer: BUDGET_TOKENIZER,
670
+ totalTokens,
581
671
  totalBudget,
582
672
  moduleBudget,
583
673
  overBudgetModules,
584
- exceeded: totalLines > totalBudget || overBudgetModules.length > 0
674
+ exceeded: totalTokens > totalBudget || overBudgetModules.length > 0
585
675
  };
586
- const parts = resolvedRules.map((rulePath) => {
587
- const body = normalizeTrailingWhitespace(fs.readFileSync(rulePath, "utf8"));
588
- const sourcePath = formatRuleSourcePath(rulePath, rulesRoot, rulesetDir, projectRuleset.source, resolvedRef);
589
- return `Source: ${sourcePath}\n\n${body}`;
590
- });
591
- const lintHeader = "<!-- markdownlint-disable MD025 -->";
592
- const toolRules = normalizeTrailingWhitespace(TOOL_RULES);
593
- const primaryOutputContent = `${lintHeader}\n${[toolRules, ...parts].join("\n\n")}\n`;
676
+ const repositoryOutputs = [toDisplayPath(rootDir, primaryOutputPath)];
677
+ const globalOutputs = globalOutputPaths.map((filePath) => toDisplayPath(rootDir, filePath));
594
678
  const composedFiles = [
595
679
  {
596
680
  absolutePath: primaryOutputPath,
597
- relativePath: composedOutputPath,
598
- content: primaryOutputContent
681
+ relativePath: toDisplayPath(rootDir, primaryOutputPath),
682
+ content: primaryOutputContent,
683
+ scope: "repository"
599
684
  }
600
685
  ];
601
686
  if (companionOutputPath) {
687
+ const companionDisplayPath = toDisplayPath(rootDir, companionOutputPath);
688
+ repositoryOutputs.push(companionDisplayPath);
602
689
  composedFiles.push({
603
690
  absolutePath: companionOutputPath,
604
- relativePath: normalizePath(path.relative(rootDir, companionOutputPath)),
605
- content: buildClaudeCompanionContent(primaryOutputPath, companionOutputPath)
691
+ relativePath: companionDisplayPath,
692
+ content: buildClaudeCompanionContent(primaryOutputPath, companionOutputPath),
693
+ scope: "repository"
606
694
  });
607
695
  }
608
- let agentsMdDiff;
609
- if (options.emitAgentsMdDiff && path.basename(primaryOutputPath) === DEFAULT_OUTPUT) {
610
- const before = fs.existsSync(primaryOutputPath)
611
- ? fs.readFileSync(primaryOutputPath, "utf8")
612
- : "";
613
- if (before === primaryOutputContent) {
614
- agentsMdDiff = { status: "unchanged" };
696
+ for (const globalOutputPath of globalOutputPaths) {
697
+ composedFiles.push({
698
+ absolutePath: globalOutputPath,
699
+ relativePath: toDisplayPath(rootDir, globalOutputPath),
700
+ content: globalOutputContent,
701
+ scope: "global"
702
+ });
703
+ }
704
+ const outputDiffs = [];
705
+ if (options.emitDiffs) {
706
+ const repositoryDiff = buildScopeDiff("repository", [primaryOutputPath], primaryOutputContent, rootDir);
707
+ if (repositoryDiff) {
708
+ repositoryDiff.targets = repositoryOutputs;
709
+ outputDiffs.push(repositoryDiff);
615
710
  }
616
- else {
617
- agentsMdDiff = {
618
- status: "updated",
619
- patch: createTwoFilesPatch(`a/${composedOutputPath}`, `b/${composedOutputPath}`, before, primaryOutputContent, "", "", { context: 3 })
620
- };
711
+ const globalDiff = buildScopeDiff("global", globalOutputPaths, globalOutputContent, rootDir);
712
+ if (globalDiff) {
713
+ outputDiffs.push(globalDiff);
621
714
  }
622
715
  }
623
716
  if (!options.dryRun) {
@@ -628,38 +721,53 @@ const composeRuleset = (rulesetPath, rootDir, options) => {
628
721
  }
629
722
  return {
630
723
  output: composedOutputPath,
631
- outputs: composedFiles.map((file) => file.relativePath),
632
- agentsMdDiff,
724
+ outputs: [...repositoryOutputs, ...globalOutputs],
725
+ repositoryOutputs,
726
+ globalOutputs,
727
+ outputDiffs,
633
728
  budgetResult
634
729
  };
635
730
  };
636
- const printAgentsMdDiffIfPresent = (result) => {
637
- if (!result.agentsMdDiff) {
638
- return;
731
+ const printOutputDiffs = (result) => {
732
+ for (const diff of result.outputDiffs) {
733
+ const scopeLabel = diff.scope === "global" ? "Global outputs" : "Repository outputs";
734
+ if (diff.status === "unchanged") {
735
+ process.stdout.write(`${scopeLabel} unchanged.\n`);
736
+ continue;
737
+ }
738
+ process.stdout.write(`${scopeLabel} updated. ACTION (agent): refresh rule recognition from the diff below.\n`);
739
+ process.stdout.write(`Targets:\n${diff.targets.map((target) => `- ${target}`).join("\n")}\n`);
740
+ process.stdout.write(`--- BEGIN ${diff.scope.toUpperCase()} DIFF ---\n`);
741
+ if (diff.patch) {
742
+ process.stdout.write(diff.patch);
743
+ if (!diff.patch.endsWith("\n")) {
744
+ process.stdout.write("\n");
745
+ }
746
+ }
747
+ process.stdout.write(`--- END ${diff.scope.toUpperCase()} DIFF ---\n`);
639
748
  }
640
- if (result.agentsMdDiff.status === "unchanged") {
641
- process.stdout.write("AGENTS.md unchanged.\n");
642
- return;
749
+ };
750
+ const formatComposedOutputs = (result) => {
751
+ const lines = ["Composed instruction files:"];
752
+ if (result.repositoryOutputs.length > 0) {
753
+ lines.push("Repository:");
754
+ lines.push(...result.repositoryOutputs.map((filePath) => `- ${filePath}`));
643
755
  }
644
- process.stdout.write("AGENTS.md updated. ACTION (agent): refresh rule recognition from the diff below.\n");
645
- process.stdout.write("--- BEGIN DIFF ---\n");
646
- if (result.agentsMdDiff.patch) {
647
- process.stdout.write(result.agentsMdDiff.patch);
648
- if (!result.agentsMdDiff.patch.endsWith("\n")) {
649
- process.stdout.write("\n");
650
- }
756
+ if (result.globalOutputs.length > 0) {
757
+ lines.push("Global:");
758
+ lines.push(...result.globalOutputs.map((filePath) => `- ${filePath}`));
651
759
  }
652
- process.stdout.write("--- END DIFF ---\n");
760
+ return `${lines.join("\n")}\n`;
653
761
  };
654
762
  const formatBudgetWarning = (result) => {
655
- const totalInfo = result.totalLines > result.totalBudget
656
- ? `: ${result.totalLines}/${result.totalBudget} lines`
763
+ const totalInfo = result.totalTokens > result.totalBudget
764
+ ? `: ${result.totalTokens}/${result.totalBudget} tokens`
657
765
  : "";
658
- const lines = [`⚠ Global rules budget exceeded${totalInfo}`];
766
+ const lines = [`⚠ Global rules budget exceeded (${result.tokenizer})${totalInfo}`];
659
767
  if (result.overBudgetModules.length > 0) {
660
- lines.push(` Over-budget modules (>${result.moduleBudget} lines):`);
768
+ lines.push(` Over-budget modules (> ${result.moduleBudget} tokens):`);
661
769
  for (const mod of result.overBudgetModules) {
662
- lines.push(` ${mod.name}: ${mod.lines} lines`);
770
+ lines.push(` ${mod.name}: ${mod.tokens} tokens`);
663
771
  }
664
772
  }
665
773
  return `${lines.join("\n")}\n`;
@@ -704,7 +812,7 @@ const formatInitRuleset = (ruleset) => {
704
812
  ` "extra": ${extraValue},`
705
813
  ];
706
814
  if (ruleset.global === false) {
707
- lines.push(" // Include rules/global from the source.");
815
+ lines.push(" // Write shared global rules to user-level instruction files.");
708
816
  lines.push(' "global": false,');
709
817
  }
710
818
  lines.push(" // Claude Code companion output settings.");
@@ -720,7 +828,7 @@ const formatInitRuleset = (ruleset) => {
720
828
  const formatPlan = (items, rootDir) => {
721
829
  const lines = items.map((item) => {
722
830
  const verb = item.action === "overwrite" ? "Overwrite" : "Create";
723
- const relative = normalizePath(path.relative(rootDir, item.path));
831
+ const relative = toDisplayPath(rootDir, item.path);
724
832
  return `- ${verb}: ${relative}`;
725
833
  });
726
834
  return `Init plan:\n${lines.join("\n")}\n`;
@@ -768,19 +876,25 @@ const initProject = async (args, rootDir, rulesetName) => {
768
876
  extraToWrite.push(extraPath);
769
877
  }
770
878
  if (args.compose) {
771
- const composedTargets = [outputPaths.primaryOutputPath];
879
+ const composedTargets = [
880
+ { path: outputPaths.primaryOutputPath, requireForce: true },
881
+ ...outputPaths.globalOutputPaths.map((outputPath) => ({
882
+ path: outputPath,
883
+ requireForce: false
884
+ }))
885
+ ];
772
886
  if (outputPaths.companionOutputPath) {
773
- composedTargets.push(outputPaths.companionOutputPath);
887
+ composedTargets.push({ path: outputPaths.companionOutputPath, requireForce: true });
774
888
  }
775
889
  for (const composedTarget of composedTargets) {
776
- if (fs.existsSync(composedTarget)) {
777
- if (!args.force) {
778
- throw new Error(`Output already exists: ${normalizePath(composedTarget)} (use --force to overwrite)`);
890
+ if (fs.existsSync(composedTarget.path)) {
891
+ if (composedTarget.requireForce && !args.force) {
892
+ throw new Error(`Output already exists: ${normalizePath(composedTarget.path)} (use --force to overwrite)`);
779
893
  }
780
- plan.push({ action: "overwrite", path: composedTarget });
894
+ plan.push({ action: "overwrite", path: composedTarget.path });
781
895
  }
782
896
  else {
783
- plan.push({ action: "create", path: composedTarget });
897
+ plan.push({ action: "create", path: composedTarget.path });
784
898
  }
785
899
  }
786
900
  }
@@ -793,7 +907,7 @@ const initProject = async (args, rootDir, rulesetName) => {
793
907
  dryRun: true,
794
908
  plan: plan.map((item) => ({
795
909
  action: item.action,
796
- path: normalizePath(path.relative(rootDir, item.path))
910
+ path: toDisplayPath(rootDir, item.path)
797
911
  }))
798
912
  }, null, 2) + "\n");
799
913
  }
@@ -813,28 +927,30 @@ const initProject = async (args, rootDir, rulesetName) => {
813
927
  if (args.compose) {
814
928
  composedOutput = composeRuleset(rulesetPath, rootDir, {
815
929
  refresh: args.refresh ?? false,
816
- emitAgentsMdDiff: !args.quiet && !args.json
930
+ emitDiffs: !args.quiet && !args.json
817
931
  });
818
932
  }
819
933
  if (args.json) {
820
934
  process.stdout.write(JSON.stringify({
821
- initialized: [normalizePath(path.relative(rootDir, rulesetPath))],
822
- localRules: extraToWrite.map((filePath) => normalizePath(path.relative(rootDir, filePath))),
935
+ initialized: [toDisplayPath(rootDir, rulesetPath)],
936
+ localRules: extraToWrite.map((filePath) => toDisplayPath(rootDir, filePath)),
823
937
  composed: composedOutput ? composedOutput.outputs : [],
938
+ repositoryOutputs: composedOutput ? composedOutput.repositoryOutputs : [],
939
+ globalOutputs: composedOutput ? composedOutput.globalOutputs : [],
824
940
  dryRun: false,
825
941
  ...(composedOutput ? { budget: composedOutput.budgetResult } : {})
826
942
  }, null, 2) + "\n");
827
943
  }
828
944
  else if (!args.quiet) {
829
- process.stdout.write(`Initialized ruleset:\n- ${normalizePath(path.relative(rootDir, rulesetPath))}\n`);
945
+ process.stdout.write(`Initialized ruleset:\n- ${toDisplayPath(rootDir, rulesetPath)}\n`);
830
946
  if (extraToWrite.length > 0) {
831
947
  process.stdout.write(`Initialized local rules:\n${extraToWrite
832
- .map((filePath) => `- ${normalizePath(path.relative(rootDir, filePath))}`)
948
+ .map((filePath) => `- ${toDisplayPath(rootDir, filePath)}`)
833
949
  .join("\n")}\n`);
834
950
  }
835
951
  if (composedOutput) {
836
- process.stdout.write(`Composed AGENTS.md:\n${composedOutput.outputs.map((filePath) => `- ${filePath}`).join("\n")}\n`);
837
- printAgentsMdDiffIfPresent(composedOutput);
952
+ process.stdout.write(formatComposedOutputs(composedOutput));
953
+ printOutputDiffs(composedOutput);
838
954
  if (composedOutput.budgetResult.exceeded) {
839
955
  process.stderr.write(formatBudgetWarning(composedOutput.budgetResult));
840
956
  }
@@ -905,7 +1021,7 @@ const main = async () => {
905
1021
  "Next steps:",
906
1022
  `- Edit rule files under: ${rulesDirectory}`,
907
1023
  "- If this source is GitHub, commit and push the workspace changes before apply-rules.",
908
- "- Run compose-agentsmd apply-rules from your project root to apply updates and regenerate AGENTS.md."
1024
+ "- Run compose-agentsmd apply-rules from your project root to apply updates and regenerate instruction files."
909
1025
  ].join("\n") + "\n");
910
1026
  return;
911
1027
  }
@@ -921,14 +1037,20 @@ const main = async () => {
921
1037
  const output = composeRuleset(rulesetPath, rootDir, {
922
1038
  refresh: true,
923
1039
  dryRun: args.dryRun,
924
- emitAgentsMdDiff: !args.quiet && !args.json
1040
+ emitDiffs: !args.quiet && !args.json
925
1041
  });
926
1042
  if (args.json) {
927
- process.stdout.write(JSON.stringify({ composed: output.outputs, dryRun: !!args.dryRun, budget: output.budgetResult }, null, 2) + "\n");
1043
+ process.stdout.write(JSON.stringify({
1044
+ composed: output.outputs,
1045
+ repositoryOutputs: output.repositoryOutputs,
1046
+ globalOutputs: output.globalOutputs,
1047
+ dryRun: !!args.dryRun,
1048
+ budget: output.budgetResult
1049
+ }, null, 2) + "\n");
928
1050
  }
929
1051
  else if (!args.quiet) {
930
- process.stdout.write(`Composed AGENTS.md:\n${output.outputs.map((filePath) => `- ${filePath}`).join("\n")}\n`);
931
- printAgentsMdDiffIfPresent(output);
1052
+ process.stdout.write(formatComposedOutputs(output));
1053
+ printOutputDiffs(output);
932
1054
  if (output.budgetResult.exceeded) {
933
1055
  process.stderr.write(formatBudgetWarning(output.budgetResult));
934
1056
  }
@@ -942,22 +1064,28 @@ const main = async () => {
942
1064
  const outputs = rulesetFiles.sort().map((rulesetPath) => composeRuleset(rulesetPath, rootDir, {
943
1065
  refresh: args.refresh,
944
1066
  dryRun: args.dryRun,
945
- emitAgentsMdDiff: !args.quiet && !args.json
1067
+ emitDiffs: !args.quiet && !args.json
946
1068
  }));
947
1069
  if (args.json) {
948
1070
  process.stdout.write(JSON.stringify({
949
1071
  composed: outputs.flatMap((result) => result.outputs),
1072
+ repositoryOutputs: outputs.flatMap((result) => result.repositoryOutputs),
1073
+ globalOutputs: outputs.flatMap((result) => result.globalOutputs),
950
1074
  dryRun: !!args.dryRun,
951
1075
  budget: outputs[0].budgetResult
952
1076
  }, null, 2) + "\n");
953
1077
  }
954
1078
  else if (!args.quiet) {
955
- process.stdout.write(`Composed AGENTS.md:\n${outputs
956
- .flatMap((result) => result.outputs)
957
- .map((filePath) => `- ${filePath}`)
958
- .join("\n")}\n`);
1079
+ process.stdout.write(formatComposedOutputs({
1080
+ output: outputs[0].output,
1081
+ outputs: outputs.flatMap((result) => result.outputs),
1082
+ repositoryOutputs: outputs.flatMap((result) => result.repositoryOutputs),
1083
+ globalOutputs: outputs.flatMap((result) => result.globalOutputs),
1084
+ outputDiffs: [],
1085
+ budgetResult: outputs[0].budgetResult
1086
+ }));
959
1087
  for (const result of outputs) {
960
- printAgentsMdDiffIfPresent(result);
1088
+ printOutputDiffs(result);
961
1089
  }
962
1090
  for (const result of outputs) {
963
1091
  if (result.budgetResult.exceeded) {
@@ -0,0 +1,7 @@
1
+ import fs from "node:fs";
2
+ export const prepareGitFallbackDestination = (destination) => {
3
+ if (fs.existsSync(destination)) {
4
+ fs.rmSync(destination, { recursive: true, force: true });
5
+ }
6
+ fs.mkdirSync(destination, { recursive: true });
7
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "compose-agentsmd",
3
- "version": "3.5.1",
4
- "description": "CLI tools for composing per-project AGENTS.md files from modular rule sets",
3
+ "version": "5.0.0",
4
+ "description": "CLI tools for composing repository-local and user-global agent instruction files from modular rule sets",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -62,10 +62,12 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "ajv": "^8.17.1",
65
- "diff": "^8.0.3"
65
+ "diff": "^8.0.3",
66
+ "gpt-tokenizer": "^3.4.0"
66
67
  },
67
68
  "overrides": {
68
- "minimatch": "^10.2.2"
69
+ "minimatch": "^10.2.4",
70
+ "rollup": "^4.59.0"
69
71
  },
70
72
  "lint-staged": {
71
73
  "**/*.ts": [
package/tools/usage.txt CHANGED
@@ -12,11 +12,11 @@ Options:
12
12
  --source <source> Rules source for init (default: github:owner/repo@latest)
13
13
  --domains <list> Comma-separated domains for init (default: none)
14
14
  --extra <list> Comma-separated extra rules for init
15
- --output <file> Output filename for init (default: AGENTS.md)
15
+ --output <file> Repository output filename for init (default: AGENTS.md)
16
16
  --no-domains Initialize with no domains
17
17
  --no-extra Initialize without extra rule files
18
- --no-global Initialize without global rules
19
- --compose Compose output file(s) after init
18
+ --no-global Initialize without user-global rules
19
+ --compose Compose repository and user-global instruction files after init
20
20
  --dry-run Show init plan without writing files
21
21
  --yes Skip init confirmation prompt
22
22
  --force Overwrite existing files during init