@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
@@ -10,7 +10,9 @@
10
10
  *
11
11
  * Auto-detected and preset files were never checked at all, so a `CLAUDE.md`
12
12
  * or `.claude/` symlinked out of the project had the block written through
13
- * it. Every case now throws PathSafetyError before any file is touched.
13
+ * it. Every case now throws PathSafetyError before any file is touched. A file
14
+ * only the current layout writes (GEMINI.md) that links outside is skipped and
15
+ * reported instead, and the rest is written, so init keeps 0.7.0's exit code.
14
16
  *
15
17
  * `removeAgentDocs` (`init --remove-agents`) stripped or deleted a block
16
18
  * through the same kind of link and reported success. It now refuses with
@@ -21,9 +23,15 @@ import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
21
23
  import * as fs from 'node:fs';
22
24
  import * as path from 'node:path';
23
25
  import * as os from 'node:os';
24
- import {installAgentDocs, removeAgentDocs} from './agent-docs.mjs';
26
+ import {spawnSync} from 'node:child_process';
27
+ import {
28
+ installAgentDocs,
29
+ removeAgentDocs,
30
+ removeXdsBlock,
31
+ } from './agent-docs.mjs';
25
32
  import {PathSafetyError} from '../fs/path-safety.mjs';
26
33
  import {runCli} from '../../test-utils/run-cli.mjs';
34
+ import {doc as initDoc} from '../../clients/cli/commands/init.doc.mjs';
27
35
 
28
36
  const BLOCK = '<!-- ASTRYX:START -->\nmanaged\n<!-- ASTRYX:END -->';
29
37
 
@@ -64,9 +72,9 @@ describe('installAgentDocs path safety', () => {
64
72
  fs.mkdirSync(target, {recursive: true});
65
73
  fs.mkdirSync(outside, {recursive: true});
66
74
 
67
- expect(() =>
68
- installAgentDocs(target, {paths: ['../OUT.md']}),
69
- ).toThrow(PathSafetyError);
75
+ expect(() => installAgentDocs(target, {paths: ['../OUT.md']})).toThrow(
76
+ PathSafetyError,
77
+ );
70
78
 
71
79
  expect(fs.existsSync(path.join(tmpDir, 'OUT.md'))).toBe(false);
72
80
  expect(fs.readdirSync(outside)).toEqual([]);
@@ -77,9 +85,9 @@ describe('installAgentDocs path safety', () => {
77
85
  fs.mkdirSync(target, {recursive: true});
78
86
  const absPath = path.join(tmpDir, 'absolute.md');
79
87
 
80
- expect(() =>
81
- installAgentDocs(target, {paths: [absPath]}),
82
- ).toThrow(/absolute paths are not allowed/i);
88
+ expect(() => installAgentDocs(target, {paths: [absPath]})).toThrow(
89
+ /absolute paths are not allowed/i,
90
+ );
83
91
 
84
92
  expect(fs.existsSync(absPath)).toBe(false);
85
93
  });
@@ -107,6 +115,51 @@ describe('installAgentDocs never writes through a symlink out of the project', (
107
115
  /outside the project root/,
108
116
  );
109
117
  expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
118
+ expect(fs.readdirSync(project)).toEqual(['CLAUDE.md']);
119
+ });
120
+
121
+ it('skips a GEMINI.md that links outside, reports it, and writes the rest', () => {
122
+ const {project, outside} = layout();
123
+ const shared = path.join(outside, 'GEMINI.md');
124
+ fs.writeFileSync(shared, '# Shared\n');
125
+ fs.symlinkSync(shared, path.join(project, 'GEMINI.md'));
126
+ /** @type {Array<[string, string]>} */
127
+ const skipped = [];
128
+
129
+ expect(
130
+ installAgentDocs(project, {
131
+ renderedBlock: BLOCK,
132
+ onSkipped: (rel, reason) => skipped.push([rel, reason]),
133
+ }),
134
+ ).toEqual(['AGENTS.md']);
135
+ expect(skipped).toEqual([
136
+ ['GEMINI.md', expect.stringMatching(/outside the project root/)],
137
+ ]);
138
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
139
+ });
140
+
141
+ it('leaves a .claude folder that links outside alone when root CLAUDE.md holds the copy', () => {
142
+ const {project, outside} = layout();
143
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Claude\n');
144
+ fs.symlinkSync(outside, path.join(project, '.claude'));
145
+
146
+ expect(installAgentDocs(project, {renderedBlock: BLOCK})).toEqual([
147
+ 'AGENTS.md',
148
+ 'CLAUDE.md',
149
+ 'GEMINI.md',
150
+ ]);
151
+ expect(fs.readdirSync(outside)).toEqual([]);
152
+
153
+ // A CLAUDE.md in the linked folder with no block is not written either.
154
+ fs.writeFileSync(path.join(outside, 'CLAUDE.md'), '# Shared\n');
155
+ expect(installAgentDocs(project, {renderedBlock: BLOCK})).toEqual([
156
+ 'AGENTS.md',
157
+ 'CLAUDE.md',
158
+ 'GEMINI.md',
159
+ ]);
160
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
161
+ '# Shared\n',
162
+ );
110
163
  });
111
164
 
112
165
  it('refuses a preset file reached through a symlinked directory', () => {
@@ -150,7 +203,10 @@ describe('installAgentDocs never writes through a symlink out of the project', (
150
203
  it('refuses to refresh a managed block that lives outside', () => {
151
204
  const {project, outside} = layout();
152
205
  const shared = path.join(outside, 'CLAUDE.md');
153
- fs.writeFileSync(shared, `# Shared\n\n${BLOCK.replace('managed', 'old')}\n`);
206
+ fs.writeFileSync(
207
+ shared,
208
+ `# Shared\n\n${BLOCK.replace('managed', 'old')}\n`,
209
+ );
154
210
  const before = fs.readFileSync(shared, 'utf-8');
155
211
  fs.symlinkSync(shared, path.join(project, 'CLAUDE.md'));
156
212
 
@@ -197,9 +253,10 @@ describe('installAgentDocs never writes through a symlink out of the project', (
197
253
  fs.writeFileSync(path.join(project, 'AGENTS.md'), '# Agents\n');
198
254
  fs.symlinkSync('AGENTS.md', path.join(project, 'CLAUDE.md'));
199
255
 
256
+ // CLAUDE.md is AGENTS.md under another name, so it already has the block.
200
257
  expect(installAgentDocs(project, {renderedBlock: BLOCK})).toEqual([
201
258
  'AGENTS.md',
202
- 'CLAUDE.md',
259
+ 'GEMINI.md',
203
260
  ]);
204
261
  const content = fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf-8');
205
262
  expect(content.split('<!-- ASTRYX:START -->')).toHaveLength(2);
@@ -217,7 +274,10 @@ describe('init --features agents with an agent doc linked outside the project',
217
274
  fs.writeFileSync(shared, '# Shared\n');
218
275
  fs.symlinkSync(shared, path.join(project, 'CLAUDE.md'));
219
276
 
220
- const json = await runCli(['init', '--features', 'agents', '--json'], project);
277
+ const json = await runCli(
278
+ ['init', '--features', 'agents', '--json'],
279
+ project,
280
+ );
221
281
  expect(json.status).toBe(1);
222
282
  const env = JSON.parse(json.stdout);
223
283
  expect(env.type).toBe('init.run');
@@ -228,6 +288,38 @@ describe('init --features agents with an agent doc linked outside the project',
228
288
 
229
289
  expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
230
290
  }, 30_000);
291
+
292
+ it('a GEMINI.md linked outside is skipped and reported, and init exits 0', async () => {
293
+ const project = path.join(tmpDir, 'project');
294
+ const outside = path.join(tmpDir, 'outside');
295
+ fs.mkdirSync(project, {recursive: true});
296
+ fs.mkdirSync(outside, {recursive: true});
297
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
298
+ const shared = path.join(outside, 'GEMINI.md');
299
+ fs.writeFileSync(shared, '# Shared\n');
300
+ fs.symlinkSync(shared, path.join(project, 'GEMINI.md'));
301
+
302
+ const json = await runCli(
303
+ ['init', '--features', 'agents', '--json'],
304
+ project,
305
+ );
306
+ expect(json.status).toBe(0);
307
+ const env = JSON.parse(json.stdout);
308
+ expect(env.data.docsWritten).toEqual(['AGENTS.md']);
309
+ expect(env.data.docsError).toEqual({
310
+ kind: 'install-failed',
311
+ message: expect.stringMatching(
312
+ /^Left as it is: GEMINI\.md \(.*outside the project root/,
313
+ ),
314
+ });
315
+
316
+ const text = await runCli(['init', '--features', 'agents'], project);
317
+ expect(text.status).toBe(0);
318
+ expect(text.stderr).toMatch(/^Note: Left as it is: GEMINI\.md/m);
319
+ expect(text.stderr).not.toMatch(/Error:/);
320
+
321
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
322
+ }, 30_000);
231
323
  });
232
324
 
233
325
  describe('removeAgentDocs never changes a file out of the project', () => {
@@ -271,14 +363,21 @@ describe('removeAgentDocs never changes a file out of the project', () => {
271
363
  fs.writeFileSync(shared, `# Shared\n\n${BLOCK}\n`);
272
364
  fs.symlinkSync(shared, path.join(project, 'CLAUDE.md'));
273
365
 
274
- expect(thrownBy(() => removeAgentDocs(project))).toBeInstanceOf(PathSafetyError);
275
- expect(fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf-8')).toBe(agents);
366
+ expect(thrownBy(() => removeAgentDocs(project))).toBeInstanceOf(
367
+ PathSafetyError,
368
+ );
369
+ expect(fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf-8')).toBe(
370
+ agents,
371
+ );
276
372
  expect(fs.readFileSync(shared, 'utf-8')).toBe(`# Shared\n\n${BLOCK}\n`);
277
373
  });
278
374
 
279
375
  it('still strips in-project blocks beside an unmarked outside link it skips', () => {
280
376
  const {project, outside} = layout();
281
- fs.writeFileSync(path.join(project, 'AGENTS.md'), `# Agents\n\nkeep\n\n${BLOCK}\n`);
377
+ fs.writeFileSync(
378
+ path.join(project, 'AGENTS.md'),
379
+ `# Agents\n\nkeep\n\n${BLOCK}\n`,
380
+ );
282
381
  const shared = path.join(outside, 'rules');
283
382
  fs.writeFileSync(shared, 'rules\n');
284
383
  fs.symlinkSync(shared, path.join(project, '.cursorrules'));
@@ -292,7 +391,10 @@ describe('removeAgentDocs never changes a file out of the project', () => {
292
391
 
293
392
  it('strips through a symlink that stays inside the project', () => {
294
393
  const {project} = layout();
295
- fs.writeFileSync(path.join(project, 'AGENTS.md'), `# Agents\n\nkeep\n\n${BLOCK}\n`);
394
+ fs.writeFileSync(
395
+ path.join(project, 'AGENTS.md'),
396
+ `# Agents\n\nkeep\n\n${BLOCK}\n`,
397
+ );
296
398
  fs.symlinkSync('AGENTS.md', path.join(project, 'CLAUDE.md'));
297
399
 
298
400
  removeAgentDocs(project);
@@ -315,14 +417,1134 @@ describe('init --remove-agents with an agent doc linked outside the project', ()
315
417
  expect(json.status).toBe(1);
316
418
  const env = JSON.parse(json.stdout);
317
419
  expect(env).toMatchObject({apiVersion: 1, code: 'ERR_PATH_TRAVERSAL'});
318
- expect(env.error).toMatch(/"CLAUDE\.md": resolves outside the project root/);
420
+ expect(env.error).toMatch(
421
+ /"CLAUDE\.md": resolves outside the project root/,
422
+ );
319
423
  expect(env).not.toHaveProperty('data');
320
424
 
321
425
  const text = await runCli(['init', '--remove-agents'], project);
322
426
  expect(text.status).toBe(1);
323
- expect(text.stderr).toMatch(/^Error: .*"CLAUDE\.md": resolves outside the project root/m);
427
+ expect(text.stderr).toMatch(
428
+ /^Error: .*"CLAUDE\.md": resolves outside the project root/m,
429
+ );
324
430
  expect(text.stdout).not.toMatch(/removed/i);
325
431
 
326
432
  expect(fs.readFileSync(shared, 'utf-8')).toBe(content);
327
433
  }, 30_000);
328
434
  });
435
+
436
+ describe('the managed block is written whatever ignore rules or generated markers say (INV31)', () => {
437
+ /** @param {string} project @param {object} [options] */
438
+ const run = (project, options = {}) => {
439
+ /** @type {Array<[string, string]>} */
440
+ const skipped = [];
441
+ const written = installAgentDocs(project, {
442
+ renderedBlock: BLOCK,
443
+ onSkipped: (rel, reason) => skipped.push([rel, reason]),
444
+ ...options,
445
+ });
446
+ return {written, skipped};
447
+ };
448
+ const OLD_BLOCK = '<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->';
449
+
450
+ it('creates AGENTS.md and the GEMINI.md import even when ignore rules cover them', () => {
451
+ const {project} = layout();
452
+ fs.writeFileSync(
453
+ path.join(project, '.gitignore'),
454
+ 'AGENTS.md\nGEMINI.md\n',
455
+ );
456
+
457
+ const {written, skipped} = run(project);
458
+
459
+ expect(written).toEqual(['AGENTS.md', 'GEMINI.md']);
460
+ expect(skipped).toEqual([]);
461
+ expect(fs.readFileSync(path.join(project, 'GEMINI.md'), 'utf-8')).toContain(
462
+ '@./AGENTS.md',
463
+ );
464
+ });
465
+
466
+ it('adds the import to an ignored GEMINI.md and keeps its text', () => {
467
+ const {project} = layout();
468
+ fs.writeFileSync(path.join(project, '.gitignore'), 'GEMINI.md\n');
469
+ fs.writeFileSync(path.join(project, 'GEMINI.md'), '# My Gemini notes\n');
470
+
471
+ const {written, skipped} = run(project);
472
+
473
+ expect(written).toEqual(['AGENTS.md', 'GEMINI.md']);
474
+ expect(skipped).toEqual([]);
475
+ const gemini = fs.readFileSync(path.join(project, 'GEMINI.md'), 'utf-8');
476
+ expect(gemini).toMatch(/^# My Gemini notes\n/u);
477
+ expect(gemini).toContain('@./AGENTS.md');
478
+ });
479
+
480
+ it('adds the import to a GEMINI.md marked generated and keeps the marker', () => {
481
+ const {project} = layout();
482
+ fs.writeFileSync(
483
+ path.join(project, 'GEMINI.md'),
484
+ '<!-- @generated by tool -->\n# Gemini\n',
485
+ );
486
+
487
+ const {written} = run(project);
488
+
489
+ expect(written).toEqual(['AGENTS.md', 'GEMINI.md']);
490
+ const gemini = fs.readFileSync(path.join(project, 'GEMINI.md'), 'utf-8');
491
+ expect(gemini).toMatch(/^<!-- @generated by tool -->\n# Gemini\n/u);
492
+ expect(gemini).toContain('@./AGENTS.md');
493
+ });
494
+
495
+ it("writes the copy into an ignored .claude/CLAUDE.md beside the user's text", () => {
496
+ const {project} = layout();
497
+ fs.writeFileSync(path.join(project, '.gitignore'), '.claude/\n');
498
+ fs.mkdirSync(path.join(project, '.claude'));
499
+ const claude = path.join(project, '.claude', 'CLAUDE.md');
500
+ fs.writeFileSync(claude, `# Mine\n\n${OLD_BLOCK}\n\nMore.\n`);
501
+
502
+ const {written, skipped} = run(project);
503
+
504
+ expect(written).toEqual(['AGENTS.md', '.claude/CLAUDE.md', 'GEMINI.md']);
505
+ expect(skipped).toEqual([]);
506
+ expect(fs.readFileSync(claude, 'utf-8')).toBe(
507
+ `# Mine\n\n${BLOCK}\n\nMore.\n`,
508
+ );
509
+ expect(fs.readdirSync(path.join(project, '.claude'))).toEqual([
510
+ 'CLAUDE.md',
511
+ ]);
512
+ });
513
+
514
+ it('still refuses a .claude/CLAUDE.md linked outside the project (INV10)', () => {
515
+ const {project, outside} = layout();
516
+ const shared = path.join(outside, 'CLAUDE.md');
517
+ fs.writeFileSync(shared, `${OLD_BLOCK}\n`);
518
+ fs.mkdirSync(path.join(project, '.claude'));
519
+ fs.symlinkSync(shared, path.join(project, '.claude', 'CLAUDE.md'));
520
+
521
+ expect(() => run(project)).toThrow(/outside the project root/u);
522
+ expect(fs.readFileSync(shared, 'utf-8')).toBe(`${OLD_BLOCK}\n`);
523
+ });
524
+
525
+ it('--agent claude creates .claude/CLAUDE.md as its option says, even in an ignored .claude', () => {
526
+ const {project} = layout();
527
+ fs.writeFileSync(path.join(project, '.gitignore'), '.claude/\n');
528
+
529
+ const {written, skipped} = run(project, {agent: 'claude'});
530
+
531
+ expect(written).toEqual(['.claude/CLAUDE.md']);
532
+ expect(skipped).toEqual([]);
533
+ expect(
534
+ fs.readFileSync(path.join(project, '.claude', 'CLAUDE.md'), 'utf-8'),
535
+ ).toContain(BLOCK);
536
+ });
537
+
538
+ it('creates the .claude folder for --agent claude in a plain project', () => {
539
+ const {project} = layout();
540
+
541
+ expect(
542
+ installAgentDocs(project, {agent: 'claude', renderedBlock: BLOCK}),
543
+ ).toEqual(['.claude/CLAUDE.md']);
544
+ expect(fs.existsSync(path.join(project, '.claude', 'CLAUDE.md'))).toBe(
545
+ true,
546
+ );
547
+ });
548
+ });
549
+
550
+ describe('a project with its own layout (INV31)', () => {
551
+ it('gives a standalone GEMINI.md that imports no agent file a copy', () => {
552
+ const {project} = layout();
553
+ // AGENTS.md imports CLAUDE.md, so the project keeps its own layout.
554
+ fs.writeFileSync(path.join(project, 'AGENTS.md'), '@CLAUDE.md\n');
555
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Claude\n');
556
+ fs.writeFileSync(path.join(project, 'GEMINI.md'), '# Gemini\n');
557
+
558
+ const written = installAgentDocs(project, {renderedBlock: BLOCK});
559
+
560
+ expect(written).toContain('GEMINI.md');
561
+ expect(fs.readFileSync(path.join(project, 'GEMINI.md'), 'utf-8')).toContain(
562
+ BLOCK,
563
+ );
564
+ expect(fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf-8')).toBe(
565
+ '@CLAUDE.md\n',
566
+ );
567
+ });
568
+ });
569
+
570
+ describe('a GEMINI.md linked outside in its own layout, and an ignored root CLAUDE.md', () => {
571
+ it('skips a GEMINI.md linked outside in a project with its own layout, and writes the rest', async () => {
572
+ const {project, outside} = layout();
573
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
574
+ // AGENTS.md imports CLAUDE.md, so the project keeps its own layout.
575
+ fs.writeFileSync(path.join(project, 'AGENTS.md'), '@CLAUDE.md\n');
576
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Claude\n');
577
+ const shared = path.join(outside, 'GEMINI.md');
578
+ fs.writeFileSync(shared, '# Shared\n');
579
+ fs.symlinkSync(shared, path.join(project, 'GEMINI.md'));
580
+
581
+ /** @type {Array<[string, string]>} */
582
+ const skipped = [];
583
+ const written = installAgentDocs(project, {
584
+ renderedBlock: BLOCK,
585
+ onSkipped: (rel, reason) => skipped.push([rel, reason]),
586
+ });
587
+ expect(written).toEqual(['CLAUDE.md']);
588
+ expect(skipped).toEqual([
589
+ ['GEMINI.md', expect.stringMatching(/outside the project root/u)],
590
+ ]);
591
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
592
+
593
+ const text = await runCli(['init', '--features', 'agents'], project);
594
+ expect(text.status).toBe(0);
595
+ expect(text.stderr).toMatch(/^Note: Left as it is: GEMINI\.md/mu);
596
+ expect(text.stderr).not.toMatch(/Error:/u);
597
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
598
+ }, 30_000);
599
+
600
+ it('keeps, and refreshes, the committed .claude/CLAUDE.md copy while root CLAUDE.md is ignored', () => {
601
+ const {project} = layout();
602
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
603
+ fs.writeFileSync(
604
+ path.join(project, 'CLAUDE.md'),
605
+ '# CLAUDE\n\nProject-specific guidance for AI coding agents.\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n',
606
+ );
607
+ fs.mkdirSync(path.join(project, '.claude'));
608
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
609
+ fs.writeFileSync(
610
+ copy,
611
+ '# Team\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n',
612
+ );
613
+
614
+ const written = installAgentDocs(project, {renderedBlock: BLOCK});
615
+
616
+ expect(written).toEqual([
617
+ 'AGENTS.md',
618
+ 'CLAUDE.md',
619
+ '.claude/CLAUDE.md',
620
+ 'GEMINI.md',
621
+ ]);
622
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
623
+ expect(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf-8')).toContain(
624
+ BLOCK,
625
+ );
626
+
627
+ // The same with the user's own text in the ignored root CLAUDE.md.
628
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
629
+ installAgentDocs(project, {renderedBlock: BLOCK});
630
+ expect(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf-8')).toBe(
631
+ `# Mine\n\n${BLOCK}\n`,
632
+ );
633
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
634
+
635
+ // --agent claude keeps it too.
636
+ installAgentDocs(project, {agent: 'claude', renderedBlock: BLOCK});
637
+ expect(fs.readFileSync(copy, 'utf-8')).toContain(BLOCK);
638
+ });
639
+ });
640
+
641
+ describe('third astracat round: first-run copy beside an ignored root, escapes below the root', () => {
642
+ it('puts the copy in a committed .claude/CLAUDE.md on the first run while root CLAUDE.md is ignored', () => {
643
+ const {project} = layout();
644
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
645
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
646
+ fs.mkdirSync(path.join(project, '.claude'));
647
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
648
+ fs.writeFileSync(copy, '# Team\n');
649
+
650
+ const written = installAgentDocs(project, {renderedBlock: BLOCK});
651
+
652
+ expect(written).toEqual([
653
+ 'AGENTS.md',
654
+ 'CLAUDE.md',
655
+ '.claude/CLAUDE.md',
656
+ 'GEMINI.md',
657
+ ]);
658
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
659
+ });
660
+
661
+ it('gives .claude/CLAUDE.md no copy on the first run when it is ignored too', () => {
662
+ const {project} = layout();
663
+ fs.writeFileSync(
664
+ path.join(project, '.gitignore'),
665
+ '/CLAUDE.md\n.claude/\n',
666
+ );
667
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
668
+ fs.mkdirSync(path.join(project, '.claude'));
669
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
670
+ fs.writeFileSync(copy, '# Team\n');
671
+
672
+ expect(installAgentDocs(project, {renderedBlock: BLOCK})).toEqual([
673
+ 'AGENTS.md',
674
+ 'CLAUDE.md',
675
+ 'GEMINI.md',
676
+ ]);
677
+ expect(fs.readFileSync(copy, 'utf-8')).toBe('# Team\n');
678
+ });
679
+
680
+ it('--agent all writes .claude/CLAUDE.md beside an ignored root CLAUDE.md', () => {
681
+ const {project} = layout();
682
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
683
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
684
+ fs.mkdirSync(path.join(project, '.claude'));
685
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
686
+ fs.writeFileSync(copy, '# Team\n');
687
+
688
+ const written = installAgentDocs(project, {
689
+ agent: 'all',
690
+ renderedBlock: BLOCK,
691
+ });
692
+
693
+ expect(written).toEqual(
694
+ expect.arrayContaining(['CLAUDE.md', '.claude/CLAUDE.md']),
695
+ );
696
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
697
+ });
698
+
699
+ it('run from a subfolder, skips a root CLAUDE.md linked outside with a note and exits 0', async () => {
700
+ const {project, outside} = layout();
701
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
702
+ const shared = path.join(outside, 'CLAUDE.md');
703
+ fs.writeFileSync(shared, '# Shared\n');
704
+ fs.symlinkSync(shared, path.join(project, 'CLAUDE.md'));
705
+ const sub = path.join(project, 'src');
706
+ fs.mkdirSync(sub);
707
+
708
+ const json = await runCli(['init', '--features', 'agents', '--json'], sub);
709
+ expect(json.status).toBe(0);
710
+ const {data} = JSON.parse(json.stdout);
711
+ expect(data.docsWritten).toEqual(['../AGENTS.md', '../GEMINI.md']);
712
+ expect(data.docsError).toEqual({
713
+ kind: 'install-failed',
714
+ message: expect.stringMatching(
715
+ /^Left as it is: CLAUDE\.md \(.*outside the project root/u,
716
+ ),
717
+ });
718
+
719
+ const text = await runCli(['init', '--features', 'agents'], sub);
720
+ expect(text.status).toBe(0);
721
+ expect(text.stderr).toMatch(/^Note: Left as it is: CLAUDE\.md/mu);
722
+ expect(text.stderr).not.toMatch(/Error:/u);
723
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
724
+
725
+ // Run at the root, where 0.7.1 wrote it too, the escape still exits 1.
726
+ const atRoot = await runCli(['init', '--features', 'agents'], project);
727
+ expect(atRoot.status).toBe(1);
728
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
729
+ }, 60_000);
730
+ });
731
+
732
+ describe('a monorepo package whose CLAUDE.md the repository root ignores', () => {
733
+ it.skipIf(spawnSync('git', ['--version']).status !== 0)(
734
+ 'keeps and refreshes the committed .claude/CLAUDE.md copy',
735
+ () => {
736
+ const repo = path.join(tmpDir, 'repo');
737
+ const project = path.join(repo, 'apps', 'web');
738
+ fs.mkdirSync(repo, {recursive: true});
739
+ spawnSync('git', ['init', '-q'], {cwd: repo});
740
+ fs.mkdirSync(path.join(project, '.claude'), {recursive: true});
741
+ fs.writeFileSync(path.join(repo, '.gitignore'), 'apps/web/CLAUDE.md\n');
742
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"web"}\n');
743
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
744
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
745
+ fs.writeFileSync(
746
+ copy,
747
+ '# Team\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n',
748
+ );
749
+
750
+ const written = installAgentDocs(project, {renderedBlock: BLOCK});
751
+
752
+ expect(written).toEqual([
753
+ 'AGENTS.md',
754
+ 'CLAUDE.md',
755
+ '.claude/CLAUDE.md',
756
+ 'GEMINI.md',
757
+ ]);
758
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
759
+
760
+ // Without the repository-root rule, the copy moves to root CLAUDE.md.
761
+ fs.writeFileSync(path.join(repo, '.gitignore'), '');
762
+ installAgentDocs(project, {renderedBlock: BLOCK});
763
+ expect(fs.readFileSync(copy, 'utf-8')).toBe('# Team\n');
764
+ },
765
+ );
766
+ });
767
+
768
+ describe('a Mercurial monorepo whose root .hgignore excludes the package CLAUDE.md', () => {
769
+ for (const [label, rules] of [
770
+ ['glob syntax', 'syntax: glob\napps/web/CLAUDE.md\n'],
771
+ ['regexp syntax', '^apps/web/CLAUDE\\.md$\n'],
772
+ ]) {
773
+ it(`keeps the committed .claude/CLAUDE.md copy (${label})`, () => {
774
+ const repo = path.join(tmpDir, 'hgrepo');
775
+ const project = path.join(repo, 'apps', 'web');
776
+ fs.mkdirSync(path.join(repo, '.hg'), {recursive: true});
777
+ fs.mkdirSync(path.join(project, '.claude'), {recursive: true});
778
+ fs.writeFileSync(path.join(repo, '.hgignore'), rules);
779
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"web"}\n');
780
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
781
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
782
+ fs.writeFileSync(copy, '# Team\n');
783
+
784
+ const written = installAgentDocs(project, {renderedBlock: BLOCK});
785
+
786
+ expect(written).toEqual([
787
+ 'AGENTS.md',
788
+ 'CLAUDE.md',
789
+ '.claude/CLAUDE.md',
790
+ 'GEMINI.md',
791
+ ]);
792
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
793
+ });
794
+ }
795
+
796
+ /**
797
+ * Run `fn` with a search path holding only `tools`: name to the shell
798
+ * script body that stands in for it, or null for a real command found on
799
+ * the current search path. A tool left out cannot be found.
800
+ * @param {Record<string, string | null>} tools
801
+ * @param {() => void} fn
802
+ */
803
+ function withTools(tools, fn) {
804
+ const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-tools-'));
805
+ const saved = process.env.PATH;
806
+ try {
807
+ for (const [name, body] of Object.entries(tools)) {
808
+ const file = path.join(bin, name);
809
+ if (body == null) {
810
+ const real = spawnSync('sh', ['-c', `command -v ${name}`], {
811
+ encoding: 'utf-8',
812
+ }).stdout.trim();
813
+ fs.symlinkSync(real, file);
814
+ } else {
815
+ fs.writeFileSync(file, `#!/bin/sh\n${body}\n`, {mode: 0o755});
816
+ }
817
+ }
818
+ process.env.PATH = bin;
819
+ fn();
820
+ } finally {
821
+ process.env.PATH = saved;
822
+ fs.rmSync(bin, {recursive: true, force: true});
823
+ }
824
+ }
825
+
826
+ /** A Mercurial monorepo whose package keeps a committed .claude copy. */
827
+ function hgProject() {
828
+ const repo = fs.mkdtempSync(path.join(tmpDir, 'hgrepo-'));
829
+ const project = path.join(repo, 'apps', 'web');
830
+ fs.mkdirSync(path.join(repo, '.hg'), {recursive: true});
831
+ fs.mkdirSync(path.join(project, '.claude'), {recursive: true});
832
+ fs.writeFileSync(
833
+ path.join(repo, '.hgignore'),
834
+ 'syntax: glob\napps/web/CLAUDE.md\n',
835
+ );
836
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"web"}\n');
837
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
838
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
839
+ fs.writeFileSync(
840
+ copy,
841
+ '# Team\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n',
842
+ );
843
+ return {project, copy};
844
+ }
845
+
846
+ it.skipIf(process.platform === 'win32').each([
847
+ ['hg is not installed', {}],
848
+ ['hg fails', {hg: 'exit 255'}],
849
+ ['hg exits with a code it does not document', {hg: 'exit 2'}],
850
+ ])(
851
+ 'keeps the copy when the repository cannot answer: %s (fails closed)',
852
+ (_label, tools) => {
853
+ const {project, copy} = hgProject();
854
+ withTools(tools, () => installAgentDocs(project, {renderedBlock: BLOCK}));
855
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
856
+ },
857
+ );
858
+
859
+ it.skipIf(process.platform === 'win32')(
860
+ 'follows the repository when it answers: a root CLAUDE.md it does not list as ignored moves the copy',
861
+ () => {
862
+ const {project, copy} = hgProject();
863
+ // hg answers, and lists nothing as untracked and ignored.
864
+ withTools({hg: 'exit 0'}, () =>
865
+ installAgentDocs(project, {renderedBlock: BLOCK}),
866
+ );
867
+ expect(fs.readFileSync(copy, 'utf-8').trimEnd()).toBe('# Team');
868
+ },
869
+ );
870
+ });
871
+
872
+ describe('below the project root, an AGENTS.md left as it is', () => {
873
+ it('skips the GEMINI.md import of it too (no import for doctor to fail on) and names both in one note', async () => {
874
+ const {project, outside} = layout();
875
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
876
+ const shared = path.join(outside, 'AGENTS.md');
877
+ fs.writeFileSync(shared, '# Shared\n');
878
+ fs.symlinkSync(shared, path.join(project, 'AGENTS.md'));
879
+ const sub = path.join(project, 'src');
880
+ fs.mkdirSync(sub);
881
+
882
+ const json = await runCli(['init', '--features', 'agents', '--json'], sub);
883
+ expect(json.status).toBe(0);
884
+ const {data} = JSON.parse(json.stdout);
885
+ expect(data.docsWritten).toEqual([]);
886
+ expect(data.docsError.message).toMatch(
887
+ /^Left as they are: AGENTS\.md \(.*outside the project root.*\); GEMINI\.md \(its import of AGENTS\.md/u,
888
+ );
889
+ expect(fs.existsSync(path.join(project, 'GEMINI.md'))).toBe(false);
890
+
891
+ const text = await runCli(['init', '--features', 'agents'], sub);
892
+ expect(text.status).toBe(0);
893
+ expect(text.stderr.split('\n').filter(Boolean)).toEqual([
894
+ expect.stringMatching(
895
+ /^Note: Left as they are: AGENTS\.md .*; GEMINI\.md /u,
896
+ ),
897
+ ]);
898
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
899
+ }, 60_000);
900
+ });
901
+
902
+ describe('round 6: an ignored .claude/CLAUDE.md follows the usual rule; --agent claude refreshes a kept copy', () => {
903
+ const OLD = '<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->';
904
+
905
+ it('strips the block from an ignored .claude/CLAUDE.md once the ignored root CLAUDE.md holds it', () => {
906
+ const {project} = layout();
907
+ fs.writeFileSync(
908
+ path.join(project, '.gitignore'),
909
+ '/CLAUDE.md\n.claude/\n',
910
+ );
911
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
912
+ fs.mkdirSync(path.join(project, '.claude'));
913
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
914
+ fs.writeFileSync(copy, `# Team\n\n${OLD}\n`);
915
+
916
+ const written = installAgentDocs(project, {renderedBlock: BLOCK});
917
+
918
+ expect(written).toEqual([
919
+ 'AGENTS.md',
920
+ 'CLAUDE.md',
921
+ '.claude/CLAUDE.md',
922
+ 'GEMINI.md',
923
+ ]);
924
+ expect(fs.readFileSync(copy, 'utf-8').trimEnd()).toBe('# Team');
925
+ expect(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf-8')).toContain(
926
+ BLOCK,
927
+ );
928
+
929
+ // --agent claude follows the same rule.
930
+ fs.writeFileSync(copy, `# Team\n\n${OLD}\n`);
931
+ installAgentDocs(project, {agent: 'claude', renderedBlock: BLOCK});
932
+ expect(fs.readFileSync(copy, 'utf-8').trimEnd()).toBe('# Team');
933
+ });
934
+
935
+ it('keeps and refreshes a committed .claude/CLAUDE.md block with --agent claude beside an ignored root', () => {
936
+ const {project} = layout();
937
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
938
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
939
+ fs.mkdirSync(path.join(project, '.claude'));
940
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
941
+ fs.writeFileSync(copy, `# Team\n\n${OLD}\n`);
942
+
943
+ const written = installAgentDocs(project, {
944
+ agent: 'claude',
945
+ renderedBlock: BLOCK,
946
+ });
947
+
948
+ expect(written).toEqual(['CLAUDE.md', '.claude/CLAUDE.md']);
949
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
950
+ expect(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf-8')).toBe(
951
+ `# Mine\n\n${BLOCK}\n`,
952
+ );
953
+ });
954
+
955
+ it('--agent claude writes the committed .claude/CLAUDE.md copy on a first run beside an ignored root', () => {
956
+ const {project} = layout();
957
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
958
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
959
+ fs.mkdirSync(path.join(project, '.claude'));
960
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
961
+ fs.writeFileSync(copy, '# Team\n');
962
+
963
+ const written = installAgentDocs(project, {
964
+ agent: 'claude',
965
+ renderedBlock: BLOCK,
966
+ });
967
+
968
+ expect(written).toEqual(['CLAUDE.md', '.claude/CLAUDE.md']);
969
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
970
+ });
971
+
972
+ it('--agent claude creates no .claude/CLAUDE.md beside an ignored root when there is none', () => {
973
+ const {project} = layout();
974
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
975
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
976
+
977
+ expect(
978
+ installAgentDocs(project, {agent: 'claude', renderedBlock: BLOCK}),
979
+ ).toEqual(['CLAUDE.md']);
980
+ expect(fs.existsSync(path.join(project, '.claude'))).toBe(false);
981
+ });
982
+ });
983
+
984
+ describe('round 8: refreshing a committed .claude copy that imports AGENTS.md; a linked .claude with --agent claude', () => {
985
+ it('refreshes a committed .claude/CLAUDE.md that imports AGENTS.md beside an ignored root', () => {
986
+ const {project} = layout();
987
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
988
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
989
+ fs.mkdirSync(path.join(project, '.claude'));
990
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
991
+ fs.writeFileSync(copy, '@../AGENTS.md\n');
992
+ const OLD = '<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->';
993
+
994
+ installAgentDocs(project, {renderedBlock: OLD});
995
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`@../AGENTS.md\n\n${OLD}\n`);
996
+
997
+ // An upgrade's refresh, twice: the copy follows the installed release.
998
+ for (let round = 0; round < 2; round++) {
999
+ const written = installAgentDocs(project, {
1000
+ onlyReplace: true,
1001
+ renderedBlock: BLOCK,
1002
+ });
1003
+ if (round === 0) expect(written).toContain('.claude/CLAUDE.md');
1004
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(
1005
+ `@../AGENTS.md\n\n${BLOCK}\n`,
1006
+ );
1007
+ }
1008
+ });
1009
+
1010
+ it('refreshes a kept .claude/CLAUDE.md copy in a project with its own layout', () => {
1011
+ const {project} = layout();
1012
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
1013
+ // AGENTS.md imports CLAUDE.md, so the project keeps its own layout.
1014
+ fs.writeFileSync(path.join(project, 'AGENTS.md'), '@CLAUDE.md\n');
1015
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1016
+ fs.mkdirSync(path.join(project, '.claude'));
1017
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
1018
+ fs.writeFileSync(
1019
+ copy,
1020
+ '@../CLAUDE.md\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n',
1021
+ );
1022
+
1023
+ installAgentDocs(project, {renderedBlock: BLOCK});
1024
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`@../CLAUDE.md\n\n${BLOCK}\n`);
1025
+ });
1026
+
1027
+ it('skips a linked-outside block-less import of an existing AGENTS.md (the shape 0.7.x left alone), with a note, exit 0', async () => {
1028
+ const {project, outside} = layout();
1029
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
1030
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
1031
+ fs.writeFileSync(path.join(project, 'AGENTS.md'), '# Agents\n');
1032
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1033
+ fs.writeFileSync(path.join(outside, 'CLAUDE.md'), '@../AGENTS.md\n');
1034
+ fs.symlinkSync(outside, path.join(project, '.claude'), 'dir');
1035
+
1036
+ /** @type {Array<[string, string]>} */
1037
+ const skipped = [];
1038
+ const written = installAgentDocs(project, {
1039
+ renderedBlock: BLOCK,
1040
+ onSkipped: (rel, reason) => skipped.push([rel, reason]),
1041
+ });
1042
+ expect(written).toEqual(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
1043
+ expect(skipped).toEqual([
1044
+ ['.claude/CLAUDE.md', expect.stringMatching(/outside the project root/u)],
1045
+ ]);
1046
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1047
+ '@../AGENTS.md\n',
1048
+ );
1049
+
1050
+ const text = await runCli(['init', '--features', 'agents'], project);
1051
+ expect(text.status).toBe(0);
1052
+ expect(text.stderr).toMatch(/^Note: Left as it is: \.claude\/CLAUDE\.md/mu);
1053
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1054
+ '@../AGENTS.md\n',
1055
+ );
1056
+ }, 30_000);
1057
+
1058
+ for (const [label, contents, withAgents] of [
1059
+ ['plain text', '# Shared\n', true],
1060
+ ['a block', '<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n', true],
1061
+ ['an import made before AGENTS.md exists', '@../AGENTS.md\n', false],
1062
+ ]) {
1063
+ it(`still refuses a linked-outside .claude/CLAUDE.md that 0.7.x wrote (${label}): exit 1, nothing written`, async () => {
1064
+ const {project, outside} = layout();
1065
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
1066
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
1067
+ if (withAgents)
1068
+ fs.writeFileSync(path.join(project, 'AGENTS.md'), '# Agents\n');
1069
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1070
+ fs.writeFileSync(path.join(outside, 'CLAUDE.md'), contents);
1071
+ fs.symlinkSync(outside, path.join(project, '.claude'), 'dir');
1072
+ const before = fs.readdirSync(project).sort();
1073
+
1074
+ const r = await runCli(
1075
+ ['init', '--features', 'agents', '--json'],
1076
+ project,
1077
+ );
1078
+ expect(r.status).toBe(1);
1079
+ expect(JSON.parse(r.stdout).data.docsError.kind).toBe('path-safety');
1080
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1081
+ contents,
1082
+ );
1083
+ expect(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf-8')).toBe(
1084
+ '# Mine\n',
1085
+ );
1086
+ expect(fs.readdirSync(project).sort()).toEqual(before);
1087
+ }, 30_000);
1088
+ }
1089
+
1090
+ it('--agent claude skips a .claude linked outside beside an ignored root, with a note, and writes CLAUDE.md', async () => {
1091
+ const {project, outside} = layout();
1092
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
1093
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
1094
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1095
+ fs.writeFileSync(path.join(outside, 'CLAUDE.md'), '# Shared\n');
1096
+ fs.symlinkSync(outside, path.join(project, '.claude'), 'dir');
1097
+
1098
+ /** @type {Array<[string, string]>} */
1099
+ const skipped = [];
1100
+ const written = installAgentDocs(project, {
1101
+ agent: 'claude',
1102
+ renderedBlock: BLOCK,
1103
+ onSkipped: (rel, reason) => skipped.push([rel, reason]),
1104
+ });
1105
+ expect(written).toEqual(['CLAUDE.md']);
1106
+ expect(skipped).toEqual([
1107
+ ['.claude/CLAUDE.md', expect.stringMatching(/outside the project root/u)],
1108
+ ]);
1109
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1110
+ '# Shared\n',
1111
+ );
1112
+
1113
+ const text = await runCli(
1114
+ ['init', '--features', 'agents', '--agent', 'claude'],
1115
+ project,
1116
+ );
1117
+ expect(text.status).toBe(0);
1118
+ expect(text.stderr).toMatch(/^Note: Left as it is: \.claude\/CLAUDE\.md/mu);
1119
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1120
+ '# Shared\n',
1121
+ );
1122
+ }, 30_000);
1123
+ });
1124
+
1125
+ describe('--agent claude beside a committed CLAUDE.md, with .claude linked outside and holding a block', () => {
1126
+ it('writes CLAUDE.md, skips the .claude/CLAUDE.md block removal with a note, and exits 0, as 0.7.1 did', async () => {
1127
+ const {project, outside} = layout();
1128
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
1129
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1130
+ const body = '# Team\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n';
1131
+ fs.writeFileSync(path.join(outside, 'CLAUDE.md'), body);
1132
+ fs.symlinkSync(outside, path.join(project, '.claude'), 'dir');
1133
+
1134
+ const r = await runCli(
1135
+ ['init', '--features', 'agents', '--agent', 'claude', '--json'],
1136
+ project,
1137
+ );
1138
+ expect(r.status).toBe(0);
1139
+ const {data} = JSON.parse(r.stdout);
1140
+ expect(data.docsWritten).toEqual(['CLAUDE.md']);
1141
+ expect(data.docsError.message).toMatch(
1142
+ /^Left as it is: \.claude\/CLAUDE\.md \(.*outside the project root/u,
1143
+ );
1144
+ expect(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf-8')).toContain(
1145
+ BLOCK.split('\n')[0],
1146
+ );
1147
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1148
+ body,
1149
+ );
1150
+ }, 30_000);
1151
+ });
1152
+
1153
+ describe('a .claude folder linked outside the project: exit parity with 0.7.1 at the project root', () => {
1154
+ // Each row: preset, root CLAUDE.md (none, committed, or ignored), what the
1155
+ // linked .claude/CLAUDE.md holds (plain text, a block, or a block-less import
1156
+ // of AGENTS.md), and the exit (1 = the run refused, nothing written). Each
1157
+ // matches 0.7.1, checked across every preset, layout, root state, linked
1158
+ // .claude, AGENTS.md and GEMINI.md combination, except the three rows marked
1159
+ // `0.7.1: 1`: there 0.7.1 wrote the linked .claude/CLAUDE.md and refused,
1160
+ // while root CLAUDE.md now holds the copy and .claude/CLAUDE.md gets none, so
1161
+ // nothing is written outside and nothing is refused.
1162
+ const OLD = '<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->';
1163
+ const BODY = {
1164
+ plain: '# Team\n',
1165
+ block: `# Team\n\n${OLD}\n`,
1166
+ import: '@../AGENTS.md\n',
1167
+ };
1168
+ for (const [preset, root, dir, exit] of /** @type {const} */ ([
1169
+ ['default', 'none', 'plain', 1],
1170
+ ['default', 'none', 'block', 1],
1171
+ ['default', 'none', 'import', 0],
1172
+ ['default', 'committed', 'plain', 0] /* 0.7.1: 1 */,
1173
+ ['default', 'committed', 'block', 1],
1174
+ ['default', 'committed', 'import', 0],
1175
+ ['default', 'ignored', 'plain', 1],
1176
+ ['default', 'ignored', 'block', 1],
1177
+ ['default', 'ignored', 'import', 0],
1178
+ ['claude', 'none', 'plain', 1],
1179
+ ['claude', 'none', 'block', 1],
1180
+ ['claude', 'none', 'import', 1],
1181
+ ['claude', 'committed', 'plain', 0],
1182
+ ['claude', 'committed', 'block', 0],
1183
+ ['claude', 'committed', 'import', 0],
1184
+ ['claude', 'ignored', 'plain', 0],
1185
+ ['claude', 'ignored', 'block', 0],
1186
+ ['claude', 'ignored', 'import', 0],
1187
+ ['all', 'none', 'plain', 1],
1188
+ ['all', 'none', 'block', 1],
1189
+ ['all', 'none', 'import', 1],
1190
+ ['all', 'committed', 'plain', 0] /* 0.7.1: 1 */,
1191
+ ['all', 'committed', 'block', 1],
1192
+ ['all', 'committed', 'import', 0] /* 0.7.1: 1 */,
1193
+ ['all', 'ignored', 'plain', 1],
1194
+ ['all', 'ignored', 'block', 1],
1195
+ ['all', 'ignored', 'import', 1],
1196
+ ])) {
1197
+ it(`${preset}, root ${root}, .claude holds ${dir}: exit ${exit}`, () => {
1198
+ const {project, outside} = layout();
1199
+ fs.writeFileSync(path.join(project, 'AGENTS.md'), '# Agents\n');
1200
+ if (root !== 'none')
1201
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1202
+ if (root === 'ignored')
1203
+ fs.writeFileSync(path.join(project, '.gitignore'), '/CLAUDE.md\n');
1204
+ fs.writeFileSync(path.join(outside, 'CLAUDE.md'), BODY[dir]);
1205
+ fs.symlinkSync(outside, path.join(project, '.claude'), 'dir');
1206
+
1207
+ const run = () =>
1208
+ installAgentDocs(project, {
1209
+ agent: preset === 'default' ? undefined : preset,
1210
+ renderedBlock: BLOCK,
1211
+ onSkipped: () => {},
1212
+ });
1213
+ if (exit === 1) {
1214
+ expect(run).toThrow(PathSafetyError);
1215
+ expect(fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf-8')).toBe(
1216
+ '# Agents\n',
1217
+ );
1218
+ } else {
1219
+ expect(run).not.toThrow();
1220
+ }
1221
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1222
+ BODY[dir],
1223
+ );
1224
+ });
1225
+ }
1226
+ });
1227
+
1228
+ describe('a root CLAUDE.md linked outside the project that only imports AGENTS.md (0.7.1 left it alone)', () => {
1229
+ it('is skipped with a note and init exits 0 at the project root; the rest is written', async () => {
1230
+ const {project, outside} = layout();
1231
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
1232
+ fs.writeFileSync(path.join(project, 'AGENTS.md'), '# Agents\n');
1233
+ fs.writeFileSync(path.join(outside, 'CLAUDE.md'), '@AGENTS.md\n');
1234
+ fs.symlinkSync(
1235
+ path.join(outside, 'CLAUDE.md'),
1236
+ path.join(project, 'CLAUDE.md'),
1237
+ );
1238
+
1239
+ const r = await runCli(['init', '--features', 'agents', '--json'], project);
1240
+ expect(r.status).toBe(0);
1241
+ const {data} = JSON.parse(r.stdout);
1242
+ expect(data.docsWritten).toEqual(['AGENTS.md', 'GEMINI.md']);
1243
+ expect(data.docsError.message).toMatch(
1244
+ /^Left as it is: CLAUDE\.md \(.*outside the project root/u,
1245
+ );
1246
+ expect(fs.readFileSync(path.join(outside, 'CLAUDE.md'), 'utf-8')).toBe(
1247
+ '@AGENTS.md\n',
1248
+ );
1249
+ }, 30_000);
1250
+ });
1251
+
1252
+ describe('a tracked .claude/CLAUDE.md is committed, whatever the ignore files say (INV31)', () => {
1253
+ /** @param {string} cwd @param {...string} args */
1254
+ const git = (cwd, ...args) => {
1255
+ const r = spawnSync(
1256
+ 'git',
1257
+ ['-c', 'user.name=t', '-c', 'user.email=t@e', ...args],
1258
+ {
1259
+ cwd,
1260
+ encoding: 'utf-8',
1261
+ },
1262
+ );
1263
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
1264
+ };
1265
+
1266
+ it.skipIf(spawnSync('git', ['--version']).status !== 0)(
1267
+ 'keeps the copy in a tracked .claude/CLAUDE.md when an unanchored CLAUDE.md rule ignores both',
1268
+ () => {
1269
+ const {project} = layout();
1270
+ git(project, 'init', '-q');
1271
+ fs.writeFileSync(path.join(project, '.gitignore'), 'CLAUDE.md\n');
1272
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1273
+ fs.mkdirSync(path.join(project, '.claude'));
1274
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
1275
+ fs.writeFileSync(copy, '# Team\n');
1276
+ // Tracked despite the rule, as `git add -f` leaves it.
1277
+ git(project, 'add', '-f', '.gitignore', '.claude/CLAUDE.md');
1278
+ git(project, 'commit', '-q', '-m', 'team claude file');
1279
+
1280
+ expect(installAgentDocs(project, {renderedBlock: BLOCK})).toEqual([
1281
+ 'AGENTS.md',
1282
+ 'CLAUDE.md',
1283
+ '.claude/CLAUDE.md',
1284
+ 'GEMINI.md',
1285
+ ]);
1286
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
1287
+ // --agent claude keeps it too.
1288
+ installAgentDocs(project, {agent: 'claude', renderedBlock: BLOCK});
1289
+ expect(fs.readFileSync(copy, 'utf-8')).toContain(BLOCK);
1290
+ },
1291
+ );
1292
+
1293
+ it.skipIf(spawnSync('git', ['--version']).status !== 0)(
1294
+ 'strips the block from an untracked .claude/CLAUDE.md the same rule ignores',
1295
+ () => {
1296
+ const {project} = layout();
1297
+ git(project, 'init', '-q');
1298
+ fs.writeFileSync(path.join(project, '.gitignore'), 'CLAUDE.md\n');
1299
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1300
+ fs.mkdirSync(path.join(project, '.claude'));
1301
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
1302
+ fs.writeFileSync(
1303
+ copy,
1304
+ '# Team\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n',
1305
+ );
1306
+
1307
+ installAgentDocs(project, {renderedBlock: BLOCK});
1308
+ expect(fs.readFileSync(copy, 'utf-8').trimEnd()).toBe('# Team');
1309
+ },
1310
+ );
1311
+
1312
+ for (const [label, setUp] of [
1313
+ [
1314
+ "the repository's local exclude file",
1315
+ (/** @type {string} */ project) =>
1316
+ fs.appendFileSync(
1317
+ path.join(project, '.git', 'info', 'exclude'),
1318
+ '/CLAUDE.md\n',
1319
+ ),
1320
+ ],
1321
+ [
1322
+ 'a configured excludes file',
1323
+ (/** @type {string} */ project) => {
1324
+ const excludes = path.join(project, '..', 'global-excludes');
1325
+ fs.writeFileSync(excludes, 'CLAUDE.md\n');
1326
+ git(project, 'config', 'core.excludesFile', excludes);
1327
+ },
1328
+ ],
1329
+ ]) {
1330
+ it.skipIf(spawnSync('git', ['--version']).status !== 0)(
1331
+ `keeps the copy in a tracked .claude/CLAUDE.md while ${label} ignores an untracked root CLAUDE.md`,
1332
+ () => {
1333
+ const {project} = layout();
1334
+ git(project, 'init', '-q');
1335
+ setUp(project);
1336
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1337
+ fs.mkdirSync(path.join(project, '.claude'));
1338
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
1339
+ fs.writeFileSync(copy, '# Team\n');
1340
+ git(project, 'add', '-f', '.claude/CLAUDE.md');
1341
+ git(project, 'commit', '-q', '-m', 'team claude file');
1342
+
1343
+ expect(installAgentDocs(project, {renderedBlock: BLOCK})).toEqual([
1344
+ 'AGENTS.md',
1345
+ 'CLAUDE.md',
1346
+ '.claude/CLAUDE.md',
1347
+ 'GEMINI.md',
1348
+ ]);
1349
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
1350
+ },
1351
+ );
1352
+ }
1353
+
1354
+ it('keeps the copy when the repository cannot say what it ignores or tracks (fails closed)', () => {
1355
+ const {project} = layout();
1356
+ // A .git that git cannot read.
1357
+ fs.writeFileSync(path.join(project, '.git'), 'gitdir: /nowhere/.git\n');
1358
+ fs.writeFileSync(path.join(project, '.gitignore'), 'CLAUDE.md\n');
1359
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Mine\n');
1360
+ fs.mkdirSync(path.join(project, '.claude'));
1361
+ const copy = path.join(project, '.claude', 'CLAUDE.md');
1362
+ fs.writeFileSync(copy, '# Team\n');
1363
+
1364
+ installAgentDocs(project, {renderedBlock: BLOCK});
1365
+ expect(fs.readFileSync(copy, 'utf-8')).toBe(`# Team\n\n${BLOCK}\n`);
1366
+ });
1367
+ });
1368
+
1369
+ describe("init's exit-code help names every soft exit-0 case these tests pin", () => {
1370
+ const exit0 = initDoc.exitCodes.find(entry => entry.code === 0)?.when ?? '';
1371
+ it.each([
1372
+ ['the home or a settings folder', /home folder, or in a settings folder/u],
1373
+ [
1374
+ 'a GEMINI.md that resolves outside the project',
1375
+ /a GEMINI\.md that resolves outside the project/u,
1376
+ ],
1377
+ [
1378
+ 'a project-root file below the root',
1379
+ /below the project root, a project-root agent file/u,
1380
+ ],
1381
+ [
1382
+ "the project's Claude Code file that only imports an existing AGENTS.md",
1383
+ /Claude Code file \(root CLAUDE\.md, or \.claude\/CLAUDE\.md when there is no root one\)[^;]*block-less import of an existing AGENTS\.md/u,
1384
+ ],
1385
+ [
1386
+ 'beside an ignored CLAUDE.md, the copy --agent claude adds or a block-less import',
1387
+ /beside a CLAUDE\.md the project ignores, the one --agent claude adds or a block-less import of an existing AGENTS\.md/u,
1388
+ ],
1389
+ [
1390
+ '--agent claude removing a block beside a CLAUDE.md',
1391
+ /with --agent claude beside a CLAUDE\.md, one whose block it would remove/u,
1392
+ ],
1393
+ ])('%s', (_label, pattern) => {
1394
+ expect(exit0).toMatch(pattern);
1395
+ });
1396
+ });
1397
+
1398
+ describe('init exit codes and output', () => {
1399
+ it('an ignored GEMINI.md with notes: written through, exit 0, nothing on stderr', async () => {
1400
+ const {project} = layout();
1401
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
1402
+ fs.writeFileSync(path.join(project, '.gitignore'), 'GEMINI.md\n');
1403
+ fs.writeFileSync(path.join(project, 'GEMINI.md'), '# My Gemini notes\n');
1404
+
1405
+ const json = await runCli(
1406
+ ['init', '--features', 'agents', '--json'],
1407
+ project,
1408
+ );
1409
+ expect(json.status).toBe(0);
1410
+ const {data} = JSON.parse(json.stdout);
1411
+ expect(data.docsWritten).toEqual(['AGENTS.md', 'GEMINI.md']);
1412
+ expect(data.docsError).toBeNull();
1413
+
1414
+ const text = await runCli(['init', '--features', 'agents'], project);
1415
+ expect(text.status).toBe(0);
1416
+ expect(text.stderr).toBe('');
1417
+ }, 30_000);
1418
+
1419
+ it('a path that escapes the project still exits 1, as in 0.7.1', async () => {
1420
+ const {project, outside} = layout();
1421
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"app"}\n');
1422
+ const shared = path.join(outside, 'CLAUDE.md');
1423
+ fs.writeFileSync(shared, '# Shared\n');
1424
+ fs.symlinkSync(shared, path.join(project, 'CLAUDE.md'));
1425
+
1426
+ const text = await runCli(['init', '--features', 'agents'], project);
1427
+ expect(text.status).toBe(1);
1428
+ expect(fs.readFileSync(shared, 'utf-8')).toBe('# Shared\n');
1429
+ }, 30_000);
1430
+ });
1431
+
1432
+ describe("the sandbox app's postinstall stays quiet", () => {
1433
+ // apps/sandbox runs `astryx init --features agents` on every install, and its
1434
+ // .gitignore lists the files init writes there.
1435
+ const sandboxIgnore = fs.readFileSync(
1436
+ new URL('../../../../apps/sandbox/.gitignore', import.meta.url),
1437
+ 'utf-8',
1438
+ );
1439
+
1440
+ it('prints nothing on stderr on a fresh install, a second install, or with an old .claude/CLAUDE.md init wrote', async () => {
1441
+ const {project} = layout();
1442
+ fs.writeFileSync(
1443
+ path.join(project, 'package.json'),
1444
+ '{"name":"sandbox"}\n',
1445
+ );
1446
+ fs.writeFileSync(path.join(project, '.gitignore'), sandboxIgnore);
1447
+
1448
+ for (let round = 0; round < 2; round++) {
1449
+ const r = await runCli(['init', '--features', 'agents'], project);
1450
+ expect(r.status, `round ${round}`).toBe(0);
1451
+ expect(r.stderr, `round ${round}`).toBe('');
1452
+ }
1453
+
1454
+ // A dev machine where an older init created .claude/CLAUDE.md.
1455
+ fs.mkdirSync(path.join(project, '.claude'), {recursive: true});
1456
+ const claude = path.join(project, '.claude', 'CLAUDE.md');
1457
+ fs.writeFileSync(
1458
+ claude,
1459
+ '# CLAUDE\n\nProject-specific guidance for AI coding agents.\n\n<!-- ASTRYX:START -->\nold\n<!-- ASTRYX:END -->\n',
1460
+ );
1461
+ const r = await runCli(['init', '--features', 'agents'], project);
1462
+ expect(r.status).toBe(0);
1463
+ expect(r.stderr).toBe('');
1464
+ expect(fs.readFileSync(claude, 'utf-8')).not.toContain('\nold\n');
1465
+ }, 60_000);
1466
+ });
1467
+
1468
+ describe('removing the copy in .claude/CLAUDE.md', () => {
1469
+ it('removes only the managed block, keeps the user text, and only once root CLAUDE.md holds the same block', () => {
1470
+ const {project} = layout();
1471
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Claude\n');
1472
+ fs.mkdirSync(path.join(project, '.claude'));
1473
+ const dirFile = path.join(project, '.claude', 'CLAUDE.md');
1474
+ fs.writeFileSync(
1475
+ dirFile,
1476
+ `# My notes\nKeep this guidance.\n\n${BLOCK}\n\nAnd this.\n`,
1477
+ );
1478
+
1479
+ expect(installAgentDocs(project, {renderedBlock: BLOCK})).toEqual([
1480
+ 'AGENTS.md',
1481
+ 'CLAUDE.md',
1482
+ '.claude/CLAUDE.md',
1483
+ 'GEMINI.md',
1484
+ ]);
1485
+ expect(fs.readFileSync(dirFile, 'utf-8').trimEnd()).toBe(
1486
+ '# My notes\nKeep this guidance.\n\nAnd this.',
1487
+ );
1488
+ expect(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf-8')).toContain(
1489
+ BLOCK,
1490
+ );
1491
+ });
1492
+
1493
+ it('keeps the copy when root CLAUDE.md does not end up holding the same block', () => {
1494
+ const {project} = layout();
1495
+ // A root CLAUDE.md with a malformed block is refused before any write, so
1496
+ // the copy in .claude/CLAUDE.md stays where it is.
1497
+ fs.writeFileSync(
1498
+ path.join(project, 'CLAUDE.md'),
1499
+ '<!-- ASTRYX:START -->\nbroken\n',
1500
+ );
1501
+ fs.mkdirSync(path.join(project, '.claude'));
1502
+ const dirFile = path.join(project, '.claude', 'CLAUDE.md');
1503
+ fs.writeFileSync(dirFile, `${BLOCK}\n`);
1504
+
1505
+ expect(() => installAgentDocs(project, {renderedBlock: BLOCK})).toThrow(
1506
+ /Malformed/u,
1507
+ );
1508
+ expect(fs.readFileSync(dirFile, 'utf-8')).toBe(`${BLOCK}\n`);
1509
+ });
1510
+
1511
+ it('deletes .claude/CLAUDE.md only when nothing but whitespace is left', () => {
1512
+ const {project} = layout();
1513
+ fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# Claude\n');
1514
+ fs.mkdirSync(path.join(project, '.claude'));
1515
+ const dirFile = path.join(project, '.claude', 'CLAUDE.md');
1516
+ fs.writeFileSync(dirFile, `\n\n${BLOCK}\n \n`);
1517
+
1518
+ installAgentDocs(project, {renderedBlock: BLOCK});
1519
+
1520
+ expect(fs.existsSync(dirFile)).toBe(false);
1521
+ });
1522
+ });
1523
+
1524
+ describe('removeXdsBlock deletes only a file init created', () => {
1525
+ it('keeps a file whose own text mentions guidance (the 0.7.0 heading match deleted it)', () => {
1526
+ const {project} = layout();
1527
+ const file = path.join(project, 'AGENTS.md');
1528
+ fs.writeFileSync(
1529
+ file,
1530
+ `# Team rules\nOur guidance for reviewers.\n\n${BLOCK}\n`,
1531
+ );
1532
+
1533
+ expect(removeXdsBlock(file, {deleteIfEmpty: true})).toBe(true);
1534
+ expect(fs.readFileSync(file, 'utf-8')).toBe(
1535
+ '# Team rules\nOur guidance for reviewers.\n',
1536
+ );
1537
+ });
1538
+
1539
+ it('deletes a file that holds only the heading init writes', () => {
1540
+ const {project} = layout();
1541
+ const file = path.join(project, 'AGENTS.md');
1542
+ fs.writeFileSync(
1543
+ file,
1544
+ `# AGENTS.md\n\nProject-specific guidance for AI coding agents.\n\n${BLOCK}\n`,
1545
+ );
1546
+
1547
+ expect(removeXdsBlock(file, {deleteIfEmpty: true})).toBe(true);
1548
+ expect(fs.existsSync(file)).toBe(false);
1549
+ });
1550
+ });