@astryxdesign/cli 0.7.2 → 0.7.3-canary.0bf23bd

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