@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4

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.
Files changed (212) hide show
  1. package/README.md +99 -66
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +115 -3
  4. package/api/build/blocks/blocks.d.mts +42 -0
  5. package/api/build/blocks/blocks.mjs +169 -0
  6. package/api/build/blocks/blocks.test.mjs +108 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/kit/answer.d.mts +14 -0
  10. package/api/build/kit/answer.mjs +85 -0
  11. package/api/build/kit/answer.test.mjs +38 -0
  12. package/api/build/kit/kind.d.mts +25 -0
  13. package/api/build/kit/kind.mjs +170 -0
  14. package/api/build/kit/kit.d.mts +2 -4
  15. package/api/build/kit/kit.mjs +8 -398
  16. package/api/build/kit/rank.d.mts +3 -32
  17. package/api/build/kit/rank.mjs +25 -342
  18. package/api/build/kit/rank.test.mjs +2 -1
  19. package/api/build/kit/signals.d.mts +60 -0
  20. package/api/build/kit/signals.mjs +284 -0
  21. package/api/build/kit/start.d.mts +32 -0
  22. package/api/build/kit/start.mjs +163 -0
  23. package/api/build/kit/words.d.mts +72 -0
  24. package/api/build/kit/words.mjs +202 -0
  25. package/api/build/tree.d.mts +20 -0
  26. package/api/build/tree.mjs +52 -0
  27. package/api/build/tree.test.mjs +177 -0
  28. package/api/build/tree.weights.json +1 -0
  29. package/api/component/component.mjs +16 -2
  30. package/api/component/component.test.mjs +15 -0
  31. package/api/discover/_adapter.mjs +36 -9
  32. package/api/discover/_adapter.test.mjs +42 -0
  33. package/api/docs/_adapter.mjs +17 -5
  34. package/api/docs/docs.test.mjs +7 -1
  35. package/api/doctor/doctor.mjs +86 -12
  36. package/api/doctor/doctor.test.mjs +28 -3
  37. package/api/init/init.doc.mjs +20 -6
  38. package/api/init/init.test.mjs +18 -2
  39. package/api/init/init.type.d.mts +3 -3
  40. package/api/init/init.type.mjs +3 -3
  41. package/api/init/remove/remove.mjs +17 -4
  42. package/api/init/remove/remove.test.mjs +114 -2
  43. package/api/init/run/run.mjs +69 -6
  44. package/api/integration/authoring-checks.mjs +83 -9
  45. package/api/integration/pack-check.mjs +123 -4
  46. package/api/integration/pack-check.test.mjs +87 -0
  47. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  48. package/api/integration/validate-integration-gaps.test.mjs +1964 -0
  49. package/api/integration/validate-integration.d.mts +11 -2
  50. package/api/integration/validate-integration.mjs +226 -32
  51. package/api/integration/validate-integration.type.d.mts +58 -1
  52. package/api/integration/validate-integration.type.mjs +45 -2
  53. package/api/integration/validateIntegration.doc.mjs +1 -1
  54. package/api/search/ORDERING.md +28 -0
  55. package/api/search/_adapter.d.mts +66 -2
  56. package/api/search/_adapter.mjs +118 -10
  57. package/api/search/_candidate-score.d.mts +40 -0
  58. package/api/search/_candidate-score.mjs +238 -0
  59. package/api/search/_cards.json +6098 -0
  60. package/api/search/_compound.d.mts +50 -0
  61. package/api/search/_compound.mjs +170 -0
  62. package/api/search/_doc-searcher.d.mts +4 -0
  63. package/api/search/_doc-searcher.mjs +23 -0
  64. package/api/search/_doc-searcher.test.mjs +36 -0
  65. package/api/search/_doc-searcher.weights.json +1 -0
  66. package/api/search/_doc-text.d.mts +30 -0
  67. package/api/search/_doc-text.mjs +87 -0
  68. package/api/search/_entries.d.mts +52 -0
  69. package/api/search/_entries.mjs +158 -0
  70. package/api/search/_features.d.mts +35 -0
  71. package/api/search/_features.mjs +155 -0
  72. package/api/search/_gather-components.d.mts +26 -0
  73. package/api/search/_gather-components.mjs +284 -0
  74. package/api/search/_gather-docs.d.mts +20 -0
  75. package/api/search/_gather-docs.mjs +254 -0
  76. package/api/search/_gather-hooks.d.mts +12 -0
  77. package/api/search/_gather-hooks.mjs +52 -0
  78. package/api/search/_gather-templates.d.mts +25 -0
  79. package/api/search/_gather-templates.mjs +107 -0
  80. package/api/search/_lexical.d.mts +135 -0
  81. package/api/search/_lexical.mjs +351 -0
  82. package/api/search/_main-searcher.d.mts +19 -0
  83. package/api/search/_main-searcher.mjs +115 -0
  84. package/api/search/_main-searcher.test.mjs +106 -0
  85. package/api/search/_main-searcher.weights.json +1 -0
  86. package/api/search/_module-doc.d.mts +32 -0
  87. package/api/search/_module-doc.mjs +78 -0
  88. package/api/search/_order.d.mts +32 -0
  89. package/api/search/_order.mjs +162 -0
  90. package/api/search/_order.test.mjs +72 -0
  91. package/api/search/_priority.d.mts +50 -0
  92. package/api/search/_priority.mjs +139 -0
  93. package/api/search/_query-words.d.mts +25 -0
  94. package/api/search/_query-words.mjs +92 -0
  95. package/api/search/_query.d.mts +65 -0
  96. package/api/search/_query.mjs +176 -0
  97. package/api/search/_result.d.mts +97 -0
  98. package/api/search/_result.mjs +150 -0
  99. package/api/search/_score.d.mts +60 -0
  100. package/api/search/_score.mjs +283 -0
  101. package/api/search/_synonyms.d.mts +21 -0
  102. package/api/search/_synonyms.mjs +67 -0
  103. package/api/search/_template-searcher.d.mts +4 -0
  104. package/api/search/_template-searcher.mjs +23 -0
  105. package/api/search/_template-searcher.test.mjs +40 -0
  106. package/api/search/_template-searcher.weights.json +1 -0
  107. package/api/search/_titles.d.mts +33 -0
  108. package/api/search/_titles.mjs +65 -0
  109. package/api/search/_words.d.mts +64 -0
  110. package/api/search/_words.mjs +157 -0
  111. package/api/search/search.d.mts +22 -305
  112. package/api/search/search.doc.mjs +7 -5
  113. package/api/search/search.mjs +53 -2483
  114. package/api/search/search.type.d.mts +6 -1
  115. package/api/search/search.type.mjs +6 -1
  116. package/api/search/tree.d.mts +15 -0
  117. package/api/search/tree.mjs +40 -0
  118. package/api/search/tree.test.mjs +66 -0
  119. package/api/search/tree.weights.json +1 -0
  120. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  121. package/api/upgrade/_adapter.d.mts +57 -9
  122. package/api/upgrade/_adapter.mjs +198 -28
  123. package/api/upgrade/registry/registry.d.mts +10 -4
  124. package/api/upgrade/registry/registry.mjs +33 -16
  125. package/api/upgrade/registry/registry.test.mjs +85 -0
  126. package/api/upgrade/run/run.d.mts +4 -2
  127. package/api/upgrade/run/run.mjs +13 -2
  128. package/api/upgrade/run/run.test.mjs +417 -3
  129. package/api/upgrade/upgrade.doc.mjs +10 -5
  130. package/api/upgrade/upgrade.mjs +13 -2
  131. package/api/upgrade/upgrade.test.mjs +194 -0
  132. package/api/upgrade/upgrade.type.d.mts +5 -3
  133. package/api/upgrade/upgrade.type.mjs +8 -6
  134. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  135. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  136. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  137. package/assets/docs/tree/api.doc.mjs +6 -0
  138. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  139. package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
  140. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  141. package/assets/docs/working-with-ai.doc.mjs +6 -2
  142. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  143. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  144. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  145. package/authoring/debug/parse.d.mts +1 -1
  146. package/clients/cli/bin/astryx.mjs +7 -78
  147. package/clients/cli/commands/doctor.mjs +30 -0
  148. package/clients/cli/commands/doctor.test.mjs +65 -0
  149. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  150. package/clients/cli/commands/init.doc.mjs +28 -9
  151. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  152. package/clients/cli/commands/init.mjs +2 -2
  153. package/clients/cli/commands/outside-project.test.mjs +2 -1
  154. package/clients/cli/commands/setup-nudge.test.mjs +61 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  156. package/clients/cli/commands/upgrade.mjs +9 -2
  157. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  158. package/clients/cli/index.mjs +19 -1
  159. package/clients/cli/run.mjs +108 -0
  160. package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
  161. package/foundation/agent-docs/agent-doc-state.mjs +35 -8
  162. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  163. package/foundation/agent-docs/agent-docs.mjs +1010 -99
  164. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  165. package/foundation/agent-docs/agent-docs.test.mjs +678 -35
  166. package/foundation/config/project.mjs +20 -22
  167. package/foundation/config/project.test.mjs +56 -0
  168. package/foundation/discovery/component-discovery.d.mts +37 -0
  169. package/foundation/discovery/component-discovery.mjs +80 -5
  170. package/foundation/discovery/component-loader.d.mts +1 -1
  171. package/foundation/discovery/docs-discovery.mjs +14 -1
  172. package/foundation/discovery/module-exports.d.mts +41 -0
  173. package/foundation/discovery/module-exports.mjs +253 -0
  174. package/foundation/discovery/template-adapter.mjs +18 -1
  175. package/foundation/discovery/template-needs.mjs +1 -44
  176. package/foundation/discovery/theme-discovery.mjs +147 -3
  177. package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
  178. package/foundation/doc-compiler/read.mjs +59 -1
  179. package/foundation/doc-compiler/read.test.mjs +11 -2
  180. package/foundation/fs/paths.d.mts +70 -8
  181. package/foundation/fs/paths.mjs +556 -38
  182. package/foundation/fs/paths.test.mjs +430 -54
  183. package/foundation/integrations/autolink.d.mts +2 -1
  184. package/foundation/integrations/autolink.mjs +4 -13
  185. package/foundation/integrations/computer-integrations.d.mts +6 -3
  186. package/foundation/integrations/computer-integrations.mjs +38 -9
  187. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  188. package/foundation/integrations/contribution-inventory.mjs +41 -5
  189. package/foundation/integrations/integrations.d.mts +27 -1
  190. package/foundation/integrations/integrations.mjs +61 -2
  191. package/foundation/integrations/validate-contributions.d.mts +115 -0
  192. package/foundation/integrations/validate-contributions.mjs +1168 -13
  193. package/foundation/release/handoff.d.mts +23 -4
  194. package/foundation/release/handoff.mjs +175 -8
  195. package/foundation/release/run-context.mjs +14 -0
  196. package/foundation/response/response-types.doc.mjs +1 -1
  197. package/foundation/route/route.d.mts +285 -0
  198. package/foundation/route/route.mjs +324 -0
  199. package/foundation/route/route.test.mjs +153 -0
  200. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  201. package/foundation/runtime/locked-versions.d.mts +32 -0
  202. package/foundation/runtime/locked-versions.mjs +65 -0
  203. package/foundation/runtime/package-root.d.mts +91 -0
  204. package/foundation/runtime/package-root.mjs +174 -0
  205. package/foundation/runtime/package-set.d.mts +62 -0
  206. package/foundation/runtime/package-set.mjs +130 -0
  207. package/foundation/runtime/package-set.test.mjs +432 -0
  208. package/host/door-conformance.test.mjs +274 -0
  209. package/host/index.d.ts +116 -0
  210. package/host/index.mjs +295 -0
  211. package/host/index.test.mjs +302 -0
  212. package/package.json +14 -9
@@ -20,10 +20,64 @@ import {
20
20
  renderAgentDocsBlock,
21
21
  inspectAgentDocs,
22
22
  isAstryxInitialized,
23
+ AgentDocsHomeError,
24
+ resolveAgentDocsRoot,
23
25
  } from './agent-docs.mjs';
24
26
 
25
27
  let tmpDir;
26
28
 
29
+ /** The managed import of AGENTS.md the default writes to GEMINI.md. */
30
+ const GEMINI_IMPORT = '<!-- ASTRYX:START -->\n@./AGENTS.md\n<!-- ASTRYX:END -->\n';
31
+ /** The heading of a .claude/CLAUDE.md the default creates. */
32
+ const CLAUDE_HEADING = '# CLAUDE\n\nProject-specific guidance for AI coding agents.\n\n';
33
+
34
+ /** Every file the default layout can write. */
35
+ const LAYOUT_FILES = ['AGENTS.md', 'CLAUDE.md', '.claude/CLAUDE.md', 'GEMINI.md'];
36
+
37
+ /**
38
+ * How many full managed blocks the files hold, by the header every block
39
+ * starts with. An import of AGENTS.md carries no header.
40
+ * @param {string} dir
41
+ * @param {string[]} files
42
+ */
43
+ function countBlocks(dir, files) {
44
+ return files
45
+ .filter(f => fs.existsSync(path.join(dir, f)))
46
+ .map(f => fs.readFileSync(path.join(dir, f), 'utf-8').split(/Astryx v\d/).length - 1)
47
+ .reduce((a, b) => a + b, 0);
48
+ }
49
+
50
+ /**
51
+ * Full blocks in the files each agent reads at the project root, as measured:
52
+ * Codex reads AGENTS.md; Copilot CLI reads AGENTS.md, CLAUDE.md and GEMINI.md
53
+ * and does not expand an import in GEMINI.md; Claude Code reads CLAUDE.md and
54
+ * .claude/CLAUDE.md, or AGENTS.md when neither exists; Gemini CLI reads
55
+ * GEMINI.md and expands its import of AGENTS.md.
56
+ * @param {string} dir
57
+ */
58
+ function blocksSeen(dir) {
59
+ const claudeFiles = ['CLAUDE.md', '.claude/CLAUDE.md'].filter(f =>
60
+ fs.existsSync(path.join(dir, f)),
61
+ );
62
+ const gemini = fs.existsSync(path.join(dir, 'GEMINI.md'))
63
+ ? fs.readFileSync(path.join(dir, 'GEMINI.md'), 'utf-8')
64
+ : null;
65
+ return {
66
+ claude: countBlocks(dir, claudeFiles.length > 0 ? claudeFiles : ['AGENTS.md']),
67
+ codex: countBlocks(dir, ['AGENTS.md']),
68
+ gemini:
69
+ gemini == null
70
+ ? 0
71
+ : countBlocks(dir, ['GEMINI.md']) +
72
+ (/(^|\s)@(\.\/)?AGENTS\.md(\s|$)/.test(gemini) ? countBlocks(dir, ['AGENTS.md']) : 0),
73
+ copilot: countBlocks(dir, ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']),
74
+ };
75
+ }
76
+ /** Every agent reads one copy. */
77
+ const ONCE_EACH = {claude: 1, codex: 1, gemini: 1, copilot: 1};
78
+ /** Copilot CLI reads AGENTS.md and a root CLAUDE.md together: the accepted duplicate. */
79
+ const COPILOT_TWICE = {claude: 1, codex: 1, gemini: 1, copilot: 2};
80
+
27
81
  beforeEach(() => {
28
82
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-agent-docs-test-'));
29
83
  });
@@ -301,7 +355,8 @@ describe('renderAgentDocsBlock', () => {
301
355
  'utf-8',
302
356
  );
303
357
  expect(firstAgents).toContain(renderedBlock);
304
- expect(firstClaude).toContain(renderedBlock);
358
+ expect(firstClaude).toBe(`# Claude\n\n${renderedBlock}\n`);
359
+ expect(fs.existsSync(path.join(projectDir, '.claude'))).toBe(false);
305
360
 
306
361
  installAgentDocs(projectDir, {renderedBlock});
307
362
  expect(fs.readFileSync(path.join(projectDir, 'AGENTS.md'), 'utf-8')).toBe(
@@ -655,24 +710,27 @@ describe('installAgentDocs', () => {
655
710
  );
656
711
  }
657
712
 
658
- it('creates AGENTS.md when no agent docs exist (tool-agnostic default)', () => {
713
+ it('creates AGENTS.md, plus the GEMINI.md import, when no agent docs exist', () => {
659
714
  setupCorePackage(tmpDir);
660
715
 
661
716
  const written = installAgentDocs(tmpDir);
662
717
 
663
- expect(written).toEqual(['AGENTS.md']);
718
+ expect(written).toEqual(['AGENTS.md', 'GEMINI.md']);
664
719
  expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(true);
665
- // Must NOT create the Claude-specific file by default.
720
+ // Claude Code reads AGENTS.md when the project has no CLAUDE.md, so no
721
+ // Claude file is created.
666
722
  expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
723
+ expect(fs.existsSync(path.join(tmpDir, 'CLAUDE.md'))).toBe(false);
667
724
  const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
668
725
  expect(content).toContain('# AGENTS.md');
669
726
  expect(content).toContain('<!-- ASTRYX:START -->');
727
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(GEMINI_IMPORT);
670
728
  });
671
729
 
672
730
  it('defaults to AGENTS.md but writes .claude/CLAUDE.md only when --agent claude is explicit', () => {
673
731
  // Default (no agent): tool-agnostic AGENTS.md, never the Claude file.
674
732
  setupCorePackage(tmpDir);
675
- expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md']);
733
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
676
734
  expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
677
735
 
678
736
  // Explicit Claude: the Claude-specific file, in a fresh project.
@@ -686,46 +744,193 @@ describe('installAgentDocs', () => {
686
744
  }
687
745
  });
688
746
 
689
- it('injects into CLAUDE.md at root when it exists', () => {
747
+ it('writes the block to AGENTS.md and to root CLAUDE.md when the project has only CLAUDE.md', () => {
748
+ // Codex reads only AGENTS.md, and Claude Code skips AGENTS.md beside a
749
+ // CLAUDE.md. The copy goes in the committed root CLAUDE.md; init creates
750
+ // no .claude/ folder.
690
751
  setupCorePackage(tmpDir);
691
752
  fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n\nProject rules.\n');
692
753
 
693
754
  const written = installAgentDocs(tmpDir);
694
755
 
695
- expect(written).toEqual(['CLAUDE.md']);
696
- expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false);
697
- const claudeContent = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
698
- expect(claudeContent).toContain('<!-- ASTRYX:START -->');
699
- expect(claudeContent).toContain('Project rules.');
756
+ expect(written).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
757
+ expect(
758
+ fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8').startsWith(
759
+ '# Claude\n\nProject rules.\n\n<!-- ASTRYX:START -->',
760
+ ),
761
+ ).toBe(true);
762
+ expect(fs.existsSync(path.join(tmpDir, '.claude'))).toBe(false);
763
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(GEMINI_IMPORT);
764
+ expect(blocksSeen(tmpDir)).toEqual(COPILOT_TWICE);
700
765
  });
701
766
 
702
- it('injects into all existing agent doc files', () => {
767
+ it('keeps the block in root CLAUDE.md when AGENTS.md and CLAUDE.md both exist', () => {
703
768
  setupCorePackage(tmpDir);
704
769
  fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n\nAgent rules.\n');
705
770
  fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n\nClaude rules.\n');
706
771
 
707
772
  const written = installAgentDocs(tmpDir);
708
773
 
709
- expect(written).toContain('AGENTS.md');
710
- expect(written).toContain('CLAUDE.md');
774
+ expect(written).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
711
775
  const agentsContent = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
712
- const claudeContent = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
776
+ expect(agentsContent).toContain('Agent rules.');
713
777
  expect(agentsContent).toContain('<!-- ASTRYX:START -->');
714
- expect(claudeContent).toContain('<!-- ASTRYX:START -->');
778
+ expect(fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8')).toContain(
779
+ 'Claude rules.',
780
+ );
781
+ expect(blocksSeen(tmpDir)).toEqual(COPILOT_TWICE);
782
+ });
783
+
784
+ it('refreshes a block an older run wrote into CLAUDE.md and adds one to AGENTS.md', () => {
785
+ setupCorePackage(tmpDir, '2.0.0');
786
+ fs.writeFileSync(
787
+ path.join(tmpDir, 'CLAUDE.md'),
788
+ '# Claude\n\nRules.\n\n<!-- ASTRYX:START -->\nAstryx v1.0.0 · old\n<!-- ASTRYX:END -->\n\nMore rules.\n',
789
+ );
790
+
791
+ const written = installAgentDocs(tmpDir);
792
+
793
+ expect(written).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
794
+ const claude = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
795
+ expect(claude).toContain('Astryx v2.0.0');
796
+ expect(claude).not.toContain('Astryx v1.0.0');
797
+ expect(claude).toContain('More rules.');
798
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toContain(
799
+ 'Astryx v2.0.0',
800
+ );
801
+ expect(blocksSeen(tmpDir)).toEqual(COPILOT_TWICE);
802
+ });
803
+
804
+ it('gives Claude Code one copy when CLAUDE.md and .claude/CLAUDE.md both exist', () => {
805
+ // The copy goes in root CLAUDE.md, which projects commit even when they
806
+ // ignore .claude/; a block an older run left in .claude/CLAUDE.md goes.
807
+ setupCorePackage(tmpDir, '2.0.0');
808
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
809
+ fs.mkdirSync(path.join(tmpDir, '.claude'));
810
+ fs.writeFileSync(
811
+ path.join(tmpDir, '.claude', 'CLAUDE.md'),
812
+ '# Local\n\n<!-- ASTRYX:START -->\nAstryx v1.0.0 · old\n<!-- ASTRYX:END -->\n',
813
+ );
814
+
815
+ const written = installAgentDocs(tmpDir);
816
+
817
+ expect(written).toEqual(['AGENTS.md', 'CLAUDE.md', '.claude/CLAUDE.md', 'GEMINI.md']);
818
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
819
+ '# Local\n',
820
+ );
821
+ expect(blocksSeen(tmpDir)).toEqual(COPILOT_TWICE);
822
+ });
823
+
824
+ it('writes the same bytes when run again', () => {
825
+ setupCorePackage(tmpDir);
826
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
827
+ installAgentDocs(tmpDir);
828
+ const files = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
829
+ const first = files.map(f => fs.readFileSync(path.join(tmpDir, f), 'utf-8'));
830
+
831
+ expect(installAgentDocs(tmpDir)).toEqual(files);
832
+ expect(files.map(f => fs.readFileSync(path.join(tmpDir, f), 'utf-8'))).toEqual(first);
833
+ });
834
+
835
+ it('adds no import when GEMINI.md already imports AGENTS.md inline', () => {
836
+ setupCorePackage(tmpDir);
837
+ const gemini = '# Gemini\n\nFollow @./AGENTS.md always.\n';
838
+ fs.writeFileSync(path.join(tmpDir, 'GEMINI.md'), gemini);
839
+
840
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md']);
841
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(gemini);
842
+ expect(blocksSeen(tmpDir)).toEqual(ONCE_EACH);
843
+ });
844
+
845
+ it('removes its own import when GEMINI.md also imports AGENTS.md', () => {
846
+ setupCorePackage(tmpDir);
847
+ fs.writeFileSync(
848
+ path.join(tmpDir, 'GEMINI.md'),
849
+ `# Gemini\n\nSee @AGENTS.md first.\n\n${GEMINI_IMPORT}`,
850
+ );
851
+
852
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
853
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(
854
+ '# Gemini\n\nSee @AGENTS.md first.\n',
855
+ );
856
+ });
857
+
858
+ it('adds the import when GEMINI.md mentions AGENTS.md only in code or not as an import', () => {
859
+ setupCorePackage(tmpDir);
860
+ // Gemini CLI follows no `@` inside backticks, none glued to a word, and
861
+ // none whose path does not name AGENTS.md exactly.
862
+ const gemini = 'Write `@./AGENTS.md` to import.\nmail@AGENTS.md\nRead @./AGENTS.md.\n';
863
+ fs.writeFileSync(path.join(tmpDir, 'GEMINI.md'), gemini);
864
+
865
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
866
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(
867
+ `${gemini}\n${GEMINI_IMPORT}`,
868
+ );
869
+ });
870
+
871
+ it.each([
872
+ ['a list', '{"context": {"fileName": ["AGENTS.md", "GEMINI.md"]}}'],
873
+ ['a name', '{"context": {"fileName": "AGENTS.md"}}'],
874
+ ['comments', '{\n // Gemini reads AGENTS.md\n "context": {/* both */ "fileName": ["AGENTS.md", "GEMINI.md"]}\n}\n'],
875
+ ['another file', '{"context": {"fileName": "GEMINI.md"}}'],
876
+ ])('writes the GEMINI.md import whatever the project Gemini settings read (%s)', (_label, settings) => {
877
+ // Gemini loads project settings only when it starts at the project root;
878
+ // started in a subfolder, the import is its only copy.
879
+ setupCorePackage(tmpDir);
880
+ fs.mkdirSync(path.join(tmpDir, '.gemini'));
881
+ fs.writeFileSync(path.join(tmpDir, '.gemini', 'settings.json'), settings);
882
+
883
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
884
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(GEMINI_IMPORT);
885
+ });
886
+
887
+ it('keeps its GEMINI.md import when the project Gemini settings read AGENTS.md', () => {
888
+ setupCorePackage(tmpDir);
889
+ fs.writeFileSync(path.join(tmpDir, 'GEMINI.md'), `# Gemini\n\n${GEMINI_IMPORT}`);
890
+ fs.mkdirSync(path.join(tmpDir, '.gemini'));
891
+ fs.writeFileSync(
892
+ path.join(tmpDir, '.gemini', 'settings.json'),
893
+ '{"context": {"fileName": ["AGENTS.md", "GEMINI.md"]}}',
894
+ );
895
+
896
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
897
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(
898
+ `# Gemini\n\n${GEMINI_IMPORT}`,
899
+ );
900
+ });
901
+
902
+ it('leaves a file that is AGENTS.md under another name alone', () => {
903
+ setupCorePackage(tmpDir);
904
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n');
905
+ fs.symlinkSync('AGENTS.md', path.join(tmpDir, 'CLAUDE.md'));
906
+
907
+ // Claude Code reads the block through CLAUDE.md, so no copy is added.
908
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
909
+ expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
910
+ expect(countBlocks(tmpDir, ['AGENTS.md'])).toBe(1);
911
+ });
912
+
913
+ it('keeps an existing GEMINI.md and adds the import to it', () => {
914
+ setupCorePackage(tmpDir);
915
+ fs.writeFileSync(path.join(tmpDir, 'GEMINI.md'), '# Gemini\n\nUse tabs.\n');
916
+
917
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
918
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(
919
+ `# Gemini\n\nUse tabs.\n\n${GEMINI_IMPORT}`,
920
+ );
715
921
  });
716
922
 
717
923
  it('preserves import wrappers while initializing standalone files', () => {
718
924
  setupCorePackage(tmpDir);
719
925
  fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n');
720
- fs.mkdirSync(path.join(tmpDir, '.claude'), {recursive: true});
721
- const wrapperPath = path.join(tmpDir, '.claude', 'CLAUDE.md');
722
- const wrapper = '@../AGENTS.md\n';
926
+ const wrapperPath = path.join(tmpDir, '.hermes.md');
927
+ const wrapper = '@AGENTS.md\n';
723
928
  fs.writeFileSync(wrapperPath, wrapper);
724
929
  fs.writeFileSync(path.join(tmpDir, '.cursorrules'), 'Cursor rules.\n');
725
930
 
726
931
  const written = installAgentDocs(tmpDir);
727
932
 
728
- expect(written).toEqual(['AGENTS.md', '.cursorrules']);
933
+ expect(written).toEqual(['AGENTS.md', 'GEMINI.md', '.cursorrules']);
729
934
  expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe(wrapper);
730
935
  expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toContain(
731
936
  '<!-- ASTRYX:START -->',
@@ -735,7 +940,7 @@ describe('installAgentDocs', () => {
735
940
  );
736
941
  });
737
942
 
738
- it('keeps cyclic imports standalone when another file exists', () => {
943
+ it('treats cyclic imports as standalone files', () => {
739
944
  setupCorePackage(tmpDir);
740
945
  fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '@CLAUDE.md\n');
741
946
  fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '@AGENTS.md\n');
@@ -743,22 +948,30 @@ describe('installAgentDocs', () => {
743
948
 
744
949
  const written = installAgentDocs(tmpDir);
745
950
 
746
- expect(written).toEqual(['AGENTS.md', 'CLAUDE.md', '.cursorrules']);
747
- for (const rel of written) {
748
- expect(fs.readFileSync(path.join(tmpDir, rel), 'utf-8')).toContain(
749
- '<!-- ASTRYX:START -->',
750
- );
951
+ expect(written).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.cursorrules']);
952
+ for (const rel of ['AGENTS.md', 'CLAUDE.md', '.cursorrules']) {
953
+ expect(countBlocks(tmpDir, [rel])).toBe(1);
751
954
  }
752
955
  });
753
956
 
957
+ it('keeps the per-file layout when a wrapper imports a file other than AGENTS.md', () => {
958
+ setupCorePackage(tmpDir);
959
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
960
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '@CLAUDE.md\n');
961
+
962
+ expect(installAgentDocs(tmpDir)).toEqual(['CLAUDE.md']);
963
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toBe('@CLAUDE.md\n');
964
+ expect(countBlocks(tmpDir, ['CLAUDE.md'])).toBe(1);
965
+ expect(fs.existsSync(path.join(tmpDir, 'GEMINI.md'))).toBe(false);
966
+ });
967
+
754
968
  it('removes a managed block previously expanded into an import wrapper', () => {
755
969
  setupCorePackage(tmpDir);
756
970
  installAgentDocs(tmpDir, {agent: 'codex'});
757
- fs.mkdirSync(path.join(tmpDir, '.claude'), {recursive: true});
758
- const wrapperPath = path.join(tmpDir, '.claude', 'CLAUDE.md');
759
- const wrapper = '@../AGENTS.md\n';
971
+ const wrapperPath = path.join(tmpDir, '.hermes.md');
972
+ const wrapper = '@AGENTS.md\n';
760
973
  fs.writeFileSync(wrapperPath, wrapper);
761
- installAgentDocs(tmpDir, {agent: 'claude'});
974
+ installAgentDocs(tmpDir, {agent: 'hermes'});
762
975
  expect(fs.readFileSync(wrapperPath, 'utf-8')).toContain(
763
976
  '<!-- ASTRYX:START -->',
764
977
  );
@@ -766,7 +979,7 @@ describe('installAgentDocs', () => {
766
979
  const written = installAgentDocs(tmpDir, {onlyReplace: true});
767
980
 
768
981
  expect(written).toContain('AGENTS.md');
769
- expect(written).toContain('.claude/CLAUDE.md');
982
+ expect(written).toContain('.hermes.md');
770
983
  expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe(wrapper);
771
984
  });
772
985
 
@@ -782,17 +995,123 @@ describe('installAgentDocs', () => {
782
995
  expect(() => installAgentDocs(tmpDir)).toThrow(/malformed|no matching/i);
783
996
  });
784
997
 
785
- it('updates existing .claude/CLAUDE.md', () => {
998
+ it('adds the block to an existing .claude/CLAUDE.md and to AGENTS.md', () => {
786
999
  setupCorePackage(tmpDir);
787
1000
  fs.mkdirSync(path.join(tmpDir, '.claude'), {recursive: true});
788
1001
  fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), '# Project\n\nExisting content.\n');
789
1002
 
790
1003
  const written = installAgentDocs(tmpDir);
791
1004
 
792
- expect(written).toEqual(['.claude/CLAUDE.md']);
1005
+ expect(written).toEqual(['AGENTS.md', '.claude/CLAUDE.md', 'GEMINI.md']);
793
1006
  const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
794
- expect(content).toContain('Existing content.');
795
- expect(content).toContain('<!-- ASTRYX:START -->');
1007
+ expect(content.startsWith('# Project\n\nExisting content.\n\n<!-- ASTRYX:START -->')).toBe(
1008
+ true,
1009
+ );
1010
+ expect(blocksSeen(tmpDir)).toEqual(ONCE_EACH);
1011
+ });
1012
+
1013
+ it('gives a root CLAUDE.md that imports AGENTS.md a copy too', () => {
1014
+ // Claude Code does not follow an import of a file above the folder it
1015
+ // starts in, so a session in a subfolder reads only the copy.
1016
+ setupCorePackage(tmpDir);
1017
+ const claude = '# Claude\n\n@AGENTS.md\n';
1018
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), claude);
1019
+
1020
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
1021
+ const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
1022
+ expect(content.startsWith(`${claude}\n<!-- ASTRYX:START -->`)).toBe(true);
1023
+ expect(countBlocks(tmpDir, ['AGENTS.md', 'CLAUDE.md'])).toBe(2);
1024
+ expect(fs.existsSync(path.join(tmpDir, '.claude'))).toBe(false);
1025
+
1026
+ // Running again keeps the copy where it is.
1027
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
1028
+ expect(fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8')).toBe(content);
1029
+ });
1030
+
1031
+ it('keeps the copy in root CLAUDE.md, not .claude/CLAUDE.md, when root CLAUDE.md imports AGENTS.md', () => {
1032
+ // Root CLAUDE.md is committed even where .claude/ is ignored, so a fresh
1033
+ // clone still gives a session in a subfolder its copy.
1034
+ setupCorePackage(tmpDir, '2.0.0');
1035
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n\n@AGENTS.md\n');
1036
+ fs.mkdirSync(path.join(tmpDir, '.claude'));
1037
+ fs.writeFileSync(
1038
+ path.join(tmpDir, '.claude', 'CLAUDE.md'),
1039
+ '# Local\n\n<!-- ASTRYX:START -->\nAstryx v1.0.0 · old\n<!-- ASTRYX:END -->\n',
1040
+ );
1041
+
1042
+ expect(installAgentDocs(tmpDir)).toEqual([
1043
+ 'AGENTS.md',
1044
+ 'CLAUDE.md',
1045
+ '.claude/CLAUDE.md',
1046
+ 'GEMINI.md',
1047
+ ]);
1048
+ expect(countBlocks(tmpDir, ['CLAUDE.md'])).toBe(1);
1049
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1050
+ '# Local\n',
1051
+ );
1052
+ });
1053
+
1054
+ it('gives a .claude/CLAUDE.md that imports AGENTS.md a copy when there is no root CLAUDE.md', () => {
1055
+ setupCorePackage(tmpDir);
1056
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n');
1057
+ fs.mkdirSync(path.join(tmpDir, '.claude'));
1058
+ fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), '@../AGENTS.md\n');
1059
+
1060
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md', '.claude/CLAUDE.md', 'GEMINI.md']);
1061
+ const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
1062
+ expect(content.startsWith('@../AGENTS.md\n\n<!-- ASTRYX:START -->')).toBe(true);
1063
+ });
1064
+
1065
+ it('onlyReplace: refreshes the copy in a Claude file that imports AGENTS.md instead of removing it', () => {
1066
+ setupCorePackage(tmpDir, '1.0.0');
1067
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n\n@AGENTS.md\n');
1068
+ installAgentDocs(tmpDir);
1069
+ setupCorePackage(tmpDir, '2.0.0');
1070
+
1071
+ expect(installAgentDocs(tmpDir, {onlyReplace: true})).toEqual(['AGENTS.md', 'CLAUDE.md']);
1072
+ const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
1073
+ expect(content).toContain('@AGENTS.md');
1074
+ expect(content).toContain('Astryx v2.0.0');
1075
+ expect(content).not.toContain('Astryx v1.0.0');
1076
+ });
1077
+
1078
+ it('--agent all after the default keeps the GEMINI.md import and one Claude copy', () => {
1079
+ setupCorePackage(tmpDir);
1080
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n');
1081
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
1082
+ fs.mkdirSync(path.join(tmpDir, '.claude'));
1083
+ fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), '# Local\n');
1084
+ installAgentDocs(tmpDir);
1085
+
1086
+ expect(installAgentDocs(tmpDir, {agent: 'all'})).toEqual(['AGENTS.md', 'CLAUDE.md']);
1087
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(GEMINI_IMPORT);
1088
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1089
+ '# Local\n',
1090
+ );
1091
+ expect(blocksSeen(tmpDir)).toEqual(COPILOT_TWICE);
1092
+ });
1093
+
1094
+ it('--agent claude writes CLAUDE.md and removes a block from .claude/CLAUDE.md', () => {
1095
+ setupCorePackage(tmpDir);
1096
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
1097
+ fs.mkdirSync(path.join(tmpDir, '.claude'));
1098
+ fs.writeFileSync(
1099
+ path.join(tmpDir, '.claude', 'CLAUDE.md'),
1100
+ '# Local\n\n<!-- ASTRYX:START -->\nAstryx v1.0.0 · old\n<!-- ASTRYX:END -->\n',
1101
+ );
1102
+ installAgentDocs(tmpDir);
1103
+
1104
+ expect(installAgentDocs(tmpDir, {agent: 'claude'})).toEqual(['CLAUDE.md']);
1105
+ expect(installAgentDocs(tmpDir, {agent: 'all'})).toEqual(['AGENTS.md', 'CLAUDE.md']);
1106
+ fs.writeFileSync(
1107
+ path.join(tmpDir, '.claude', 'CLAUDE.md'),
1108
+ '# Local\n\n<!-- ASTRYX:START -->\nAstryx v1.0.0 · old\n<!-- ASTRYX:END -->\n',
1109
+ );
1110
+ expect(installAgentDocs(tmpDir, {agent: 'claude'})).toEqual(['CLAUDE.md', '.claude/CLAUDE.md']);
1111
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1112
+ '# Local\n',
1113
+ );
1114
+ expect(blocksSeen(tmpDir).claude).toBe(1);
796
1115
  });
797
1116
 
798
1117
  it('respects --agent claude preset: finds existing CLAUDE.md', () => {
@@ -879,6 +1198,39 @@ describe('installAgentDocs', () => {
879
1198
  expect(content).toContain('Other rules.');
880
1199
  });
881
1200
 
1201
+ it('onlyReplace: refreshes both copies of the block and leaves the GEMINI.md import alone', () => {
1202
+ setupCorePackage(tmpDir, '1.0.0');
1203
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
1204
+ installAgentDocs(tmpDir);
1205
+ setupCorePackage(tmpDir, '2.0.0');
1206
+
1207
+ expect(installAgentDocs(tmpDir, {onlyReplace: true})).toEqual([
1208
+ 'AGENTS.md',
1209
+ 'CLAUDE.md',
1210
+ ]);
1211
+ for (const rel of ['AGENTS.md', 'CLAUDE.md']) {
1212
+ const content = fs.readFileSync(path.join(tmpDir, rel), 'utf-8');
1213
+ expect(content).toContain('Astryx v2.0.0');
1214
+ expect(content).not.toContain('Astryx v1.0.0');
1215
+ }
1216
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(GEMINI_IMPORT);
1217
+ expect(blocksSeen(tmpDir)).toEqual(COPILOT_TWICE);
1218
+ });
1219
+
1220
+ it('onlyReplace: refreshes a block beside an import of a missing AGENTS.md', () => {
1221
+ setupCorePackage(tmpDir, '2.0.0');
1222
+ fs.writeFileSync(
1223
+ path.join(tmpDir, 'CLAUDE.md'),
1224
+ '@AGENTS.md\n\n<!-- ASTRYX:START -->\nAstryx v1.0.0\n<!-- ASTRYX:END -->\n',
1225
+ );
1226
+
1227
+ expect(installAgentDocs(tmpDir, {onlyReplace: true})).toEqual(['CLAUDE.md']);
1228
+ expect(fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8')).toContain(
1229
+ 'Astryx v2.0.0',
1230
+ );
1231
+ expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false);
1232
+ });
1233
+
882
1234
  it('onlyReplace: does not create the default AGENTS.md when nothing exists', () => {
883
1235
  setupCorePackage(tmpDir);
884
1236
 
@@ -1084,6 +1436,25 @@ describe('inspectAgentDocs', () => {
1084
1436
  expect(res.staleFiles).toEqual(['CLAUDE.md']);
1085
1437
  });
1086
1438
 
1439
+ it('checks both copies of the block and does not count the GEMINI.md import', () => {
1440
+ const core = path.join(tmpDir, 'node_modules', '@astryxdesign', 'core');
1441
+ fs.mkdirSync(core, {recursive: true});
1442
+ fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({version: '3.0.0'}));
1443
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
1444
+ installAgentDocs(tmpDir);
1445
+
1446
+ const current = inspectAgentDocs(tmpDir, '3.0.0');
1447
+ expect(current.status).toBe('current');
1448
+ expect(current.files.map(f => f.path)).toEqual(['AGENTS.md', 'CLAUDE.md']);
1449
+ expect(inspectAgentDocs(tmpDir, '4.0.0').staleFiles).toEqual(['AGENTS.md', 'CLAUDE.md']);
1450
+
1451
+ // Without the blocks, the import alone reads as not set up.
1452
+ fs.rmSync(path.join(tmpDir, 'AGENTS.md'));
1453
+ fs.rmSync(path.join(tmpDir, 'CLAUDE.md'));
1454
+ expect(inspectAgentDocs(tmpDir, '3.0.0').status).toBe('missing');
1455
+ expect(isAstryxInitialized(tmpDir)).toBe(false);
1456
+ });
1457
+
1087
1458
  it('defaults the installed version to the core package when omitted', () => {
1088
1459
  const coreDir = path.join(tmpDir, 'node_modules', '@astryxdesign', 'core');
1089
1460
  fs.mkdirSync(coreDir, {recursive: true});
@@ -1179,3 +1550,275 @@ describe('isAstryxInitialized — resilient to malformed markers (postinstall ho
1179
1550
  expect(isAstryxInitialized(path.join(tmpDir, 'does-not-exist'))).toBe(false);
1180
1551
  });
1181
1552
  });
1553
+
1554
+ describe('removeAgentDocs after the default layout', () => {
1555
+ beforeEach(() => {
1556
+ vi.spyOn(console, 'log').mockImplementation(() => {});
1557
+ });
1558
+
1559
+ it('deletes the files the default created and keeps the project files', () => {
1560
+ const core = path.join(tmpDir, 'node_modules', '@astryxdesign', 'core');
1561
+ fs.mkdirSync(core, {recursive: true});
1562
+ fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({version: '1.0.0'}));
1563
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n');
1564
+ installAgentDocs(tmpDir);
1565
+
1566
+ expect(removeAgentDocs(tmpDir)).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
1567
+ expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false);
1568
+ expect(fs.existsSync(path.join(tmpDir, 'GEMINI.md'))).toBe(false);
1569
+ expect(fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8')).toBe('# Claude\n');
1570
+ });
1571
+
1572
+ it('keeps a GEMINI.md that has the project\'s own content', () => {
1573
+ fs.writeFileSync(
1574
+ path.join(tmpDir, 'GEMINI.md'),
1575
+ `# Gemini\n\nUse tabs.\n\n${GEMINI_IMPORT}`,
1576
+ );
1577
+
1578
+ expect(removeAgentDocs(tmpDir)).toEqual(['GEMINI.md']);
1579
+ expect(fs.readFileSync(path.join(tmpDir, 'GEMINI.md'), 'utf-8')).toBe(
1580
+ '# Gemini\n\nUse tabs.\n',
1581
+ );
1582
+ });
1583
+ });
1584
+
1585
+ describe('agent docs never go into the home folder', () => {
1586
+ const saved = {HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE};
1587
+ const personal = '# My notes\n\nAlways use tabs.\n';
1588
+
1589
+ beforeEach(() => {
1590
+ process.env.HOME = tmpDir;
1591
+ process.env.USERPROFILE = tmpDir;
1592
+ fs.mkdirSync(path.join(tmpDir, '.claude'), {recursive: true});
1593
+ fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), personal);
1594
+ });
1595
+ afterEach(() => {
1596
+ for (const [key, value] of Object.entries(saved)) {
1597
+ if (value === undefined) delete process.env[key];
1598
+ else process.env[key] = value;
1599
+ }
1600
+ });
1601
+
1602
+ it.each([
1603
+ ['the default', {}],
1604
+ ['a preset', {agent: 'claude'}],
1605
+ ['an explicit path', {paths: ['AGENTS.md']}],
1606
+ ['a refresh', {onlyReplace: true}],
1607
+ ])('refuses %s and writes nothing', (_label, options) => {
1608
+ expect(() => installAgentDocs(tmpDir, options)).toThrow(AgentDocsHomeError);
1609
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1610
+ personal,
1611
+ );
1612
+ expect(fs.readdirSync(tmpDir).sort()).toEqual(['.claude']);
1613
+ });
1614
+
1615
+ it('writes an explicit path into a plain folder of the home folder, as 0.7.x did', () => {
1616
+ expect(installAgentDocs(tmpDir, {paths: ['notes/AI.md']})).toEqual(['notes/AI.md']);
1617
+ expect(fs.readFileSync(path.join(tmpDir, 'notes', 'AI.md'), 'utf-8')).toContain(
1618
+ '<!-- ASTRYX:START -->',
1619
+ );
1620
+ });
1621
+
1622
+ it.each([
1623
+ ['a file in the home folder', ['AI.md']],
1624
+ ['a file at the top of a settings folder', ['.claude/CLAUDE.md']],
1625
+ ['one of several paths', ['notes/AI.md', 'AI.md']],
1626
+ ])('refuses an explicit path to %s, and writes nothing', (_label, paths) => {
1627
+ expect(() => installAgentDocs(tmpDir, {paths})).toThrow(AgentDocsHomeError);
1628
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1629
+ personal,
1630
+ );
1631
+ expect(fs.readdirSync(tmpDir).sort()).toEqual(['.claude']);
1632
+ });
1633
+
1634
+ it('init with an explicit path from the home folder writes it, and refuses one in the home folder', async () => {
1635
+ const {init} = await import('../../api/init/init.mjs');
1636
+ const written = await init(
1637
+ {features: ['agents'], agentDocsPath: 'notes/AI.md'},
1638
+ {cwd: tmpDir},
1639
+ );
1640
+ expect(written.data.docsWritten).toEqual(['notes/AI.md']);
1641
+ expect(written.data.docsError ?? null).toBeNull();
1642
+ const refused = await init(
1643
+ {features: ['agents'], agentDocsPath: 'AI.md'},
1644
+ {cwd: tmpDir},
1645
+ );
1646
+ expect(refused.data.docsError).toMatchObject({kind: 'install-failed'});
1647
+ expect(fs.existsSync(path.join(tmpDir, 'AI.md'))).toBe(false);
1648
+ });
1649
+
1650
+ it('refuses through a symlink to the home folder', () => {
1651
+ const link = `${tmpDir}-link`;
1652
+ fs.symlinkSync(tmpDir, link);
1653
+ try {
1654
+ expect(() => installAgentDocs(link)).toThrow(AgentDocsHomeError);
1655
+ } finally {
1656
+ fs.rmSync(link);
1657
+ }
1658
+ });
1659
+
1660
+ it('still removes a block an older run wrote there', () => {
1661
+ vi.spyOn(console, 'log').mockImplementation(() => {});
1662
+ fs.writeFileSync(
1663
+ path.join(tmpDir, '.claude', 'CLAUDE.md'),
1664
+ `${personal}\n<!-- ASTRYX:START -->\nAstryx v1.0.0\n<!-- ASTRYX:END -->\n`,
1665
+ );
1666
+
1667
+ expect(removeAgentDocs(tmpDir)).toEqual(['.claude/CLAUDE.md']);
1668
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1669
+ personal,
1670
+ );
1671
+ });
1672
+
1673
+ it.each(['.gemini', '.claude', '.codex', path.join('.gemini', 'extensions', 'x')])(
1674
+ 'refuses the settings folder ~/%s',
1675
+ folder => {
1676
+ const dir = path.join(tmpDir, folder);
1677
+ fs.mkdirSync(dir, {recursive: true});
1678
+ const before = fs.readdirSync(dir);
1679
+ expect(() => installAgentDocs(dir)).toThrow(AgentDocsHomeError);
1680
+ expect(fs.readdirSync(dir)).toEqual(before);
1681
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1682
+ personal,
1683
+ );
1684
+ },
1685
+ );
1686
+
1687
+ it('writes in a project folder inside the home folder', () => {
1688
+ const project = path.join(tmpDir, 'app');
1689
+ fs.mkdirSync(project);
1690
+ expect(installAgentDocs(project)).toEqual(['AGENTS.md', 'GEMINI.md']);
1691
+ });
1692
+
1693
+ /**
1694
+ * A git project that declares Astryx, at `rel` under the home folder, with
1695
+ * Core installed at `version`.
1696
+ * @param {string} rel
1697
+ * @param {string} version
1698
+ */
1699
+ function writeProject(rel, version) {
1700
+ const project = path.join(tmpDir, rel);
1701
+ fs.mkdirSync(path.join(project, 'src'), {recursive: true});
1702
+ fs.writeFileSync(path.join(project, '.git'), 'gitdir: /elsewhere/.git/worktrees/web\n');
1703
+ fs.writeFileSync(
1704
+ path.join(project, 'package.json'),
1705
+ JSON.stringify({name: 'web', dependencies: {'@astryxdesign/core': version}}),
1706
+ );
1707
+ const core = path.join(project, 'node_modules', '@astryxdesign', 'core');
1708
+ fs.mkdirSync(core, {recursive: true});
1709
+ fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({version}));
1710
+ return project;
1711
+ }
1712
+
1713
+ it.each([
1714
+ ['a Codex worktree', path.join('.codex', 'worktrees', 'a1b2c3', 'web')],
1715
+ ['a Cursor worktree', path.join('.cursor', 'worktrees', 'web', 'x9y8')],
1716
+ ['a project with its package.json one level in', path.join('.config', 'web')],
1717
+ ])('writes and refreshes the docs of a project inside a settings folder (%s)', (_label, rel) => {
1718
+ const project = writeProject(rel, '1.0.0');
1719
+
1720
+ expect(resolveAgentDocsRoot(path.join(project, 'src'))).toBe(project);
1721
+ expect(installAgentDocs(project)).toEqual(['AGENTS.md', 'GEMINI.md']);
1722
+
1723
+ // upgrade refreshes the block in place.
1724
+ writeProject(rel, '2.0.0');
1725
+ expect(installAgentDocs(project, {onlyReplace: true})).toEqual(['AGENTS.md']);
1726
+ expect(fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf-8')).toContain(
1727
+ 'Astryx v2.0.0',
1728
+ );
1729
+
1730
+ // Nothing reaches the settings folder or the home folder.
1731
+ const settings = path.join(tmpDir, rel.split(path.sep)[0]);
1732
+ expect(fs.readdirSync(settings)).toEqual([rel.split(path.sep)[1]]);
1733
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1734
+ personal,
1735
+ );
1736
+ });
1737
+
1738
+ it('refuses the top of a settings folder even with a package.json, and the folders in it outside a project', () => {
1739
+ // A package.json in ~/.claude (for hook scripts, say) would otherwise put the
1740
+ // block in ~/.claude/CLAUDE.md, which Claude Code reads in every project.
1741
+ fs.writeFileSync(path.join(tmpDir, '.claude', 'package.json'), '{"name":"hooks"}\n');
1742
+ fs.mkdirSync(path.join(tmpDir, '.claude', 'hooks'));
1743
+
1744
+ expect(() => installAgentDocs(path.join(tmpDir, '.claude'))).toThrow(AgentDocsHomeError);
1745
+ expect(() => installAgentDocs(path.join(tmpDir, '.claude', 'hooks'))).toThrow(
1746
+ AgentDocsHomeError,
1747
+ );
1748
+ expect(resolveAgentDocsRoot(path.join(tmpDir, '.claude', 'hooks'))).toBe(
1749
+ path.join(tmpDir, '.claude'),
1750
+ );
1751
+ expect(fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(
1752
+ personal,
1753
+ );
1754
+
1755
+ // A worktree folder whose project has no package.json is not a project.
1756
+ const bare = path.join(tmpDir, '.codex', 'worktrees', 'a1b2c3', 'notes');
1757
+ fs.mkdirSync(bare, {recursive: true});
1758
+ expect(() => installAgentDocs(bare)).toThrow(AgentDocsHomeError);
1759
+ expect(fs.readdirSync(bare)).toEqual([]);
1760
+ });
1761
+ });
1762
+
1763
+ describe('resolveAgentDocsRoot', () => {
1764
+ /** @param {string} dir @param {object} pkg */
1765
+ const writePkg = (dir, pkg) => {
1766
+ fs.mkdirSync(dir, {recursive: true});
1767
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg));
1768
+ };
1769
+ const astryx = {devDependencies: {'@astryxdesign/cli': '0.7.0'}};
1770
+
1771
+ it('finds the nearest folder whose package.json declares Astryx', () => {
1772
+ writePkg(tmpDir, astryx);
1773
+ const sub = path.join(tmpDir, 'src', 'app');
1774
+ fs.mkdirSync(sub, {recursive: true});
1775
+ expect(resolveAgentDocsRoot(sub)).toBe(tmpDir);
1776
+ expect(resolveAgentDocsRoot(tmpDir)).toBe(tmpDir);
1777
+
1778
+ // A nested package that does not declare Astryx is passed over.
1779
+ const functions = path.join(tmpDir, 'functions');
1780
+ writePkg(functions, {name: 'functions'});
1781
+ fs.mkdirSync(path.join(functions, 'src'));
1782
+ expect(resolveAgentDocsRoot(path.join(functions, 'src'))).toBe(tmpDir);
1783
+
1784
+ // A nested package that declares Astryx is its own project.
1785
+ const web = path.join(tmpDir, 'apps', 'web');
1786
+ writePkg(web, {dependencies: {'@astryxdesign/core': '0.7.0'}});
1787
+ expect(resolveAgentDocsRoot(web)).toBe(web);
1788
+ });
1789
+
1790
+ it('falls back to the nearest package.json when none declares Astryx', () => {
1791
+ writePkg(tmpDir, {name: 'app'});
1792
+ const pkg = path.join(tmpDir, 'packages', 'web');
1793
+ writePkg(pkg, {name: 'web'});
1794
+ fs.mkdirSync(path.join(pkg, 'src'));
1795
+ expect(resolveAgentDocsRoot(path.join(pkg, 'src'))).toBe(pkg);
1796
+ });
1797
+
1798
+ it('stops at a repository root, and finds no project without a package.json', () => {
1799
+ writePkg(tmpDir, astryx);
1800
+ const repo = path.join(tmpDir, 'repo');
1801
+ fs.mkdirSync(path.join(repo, '.git'), {recursive: true});
1802
+ const sub = path.join(repo, 'docs');
1803
+ fs.mkdirSync(sub);
1804
+ expect(resolveAgentDocsRoot(sub)).toBeNull();
1805
+ });
1806
+
1807
+ it('never returns the home folder', () => {
1808
+ const saved = {HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE};
1809
+ process.env.HOME = tmpDir;
1810
+ process.env.USERPROFILE = tmpDir;
1811
+ try {
1812
+ writePkg(tmpDir, astryx);
1813
+ const scratch = path.join(tmpDir, 'scratch');
1814
+ fs.mkdirSync(scratch);
1815
+ expect(resolveAgentDocsRoot(scratch)).toBeNull();
1816
+ expect(resolveAgentDocsRoot(tmpDir)).toBeNull();
1817
+ } finally {
1818
+ for (const [key, value] of Object.entries(saved)) {
1819
+ if (value === undefined) delete process.env[key];
1820
+ else process.env[key] = value;
1821
+ }
1822
+ }
1823
+ });
1824
+ });