@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
@@ -3,26 +3,39 @@
3
3
  /**
4
4
  * @file agent-docs — Install/update agent docs for AI coding tools
5
5
  *
6
- * Generates a CLI cheat sheet from actual command metadata and injects
7
- * it into agent doc files. Supports multiple tools with auto-detection:
6
+ * Generates a CLI cheat sheet from actual command metadata and writes it, as
7
+ * one managed block, into the files AI coding tools read.
8
8
  *
9
- * - Claude Code: CLAUDE.md (root) or .claude/CLAUDE.md
10
- * - Cursor: .cursorrules
11
- * - Codex/generic: AGENTS.md
12
- * - Muse: AGENTS.md
13
- * - Hermes Agent: .hermes.md or HERMES.md (existing), else AGENTS.md
9
+ * Default (no --agent or --agent-docs-path): every agent reads the block, in a
10
+ * file the project commits.
14
11
  *
15
- * Auto-detect: discovers existing files and updates them in place.
16
- * Default (no existing files): creates AGENTS.md (the tool-agnostic standard).
12
+ * - AGENTS.md holds the block. Most agents read it; Claude Code reads it when
13
+ * the folder has no CLAUDE.md or .claude/CLAUDE.md.
14
+ * - The Claude Code file the project already has holds a copy: root CLAUDE.md,
15
+ * else .claude/CLAUDE.md, even when it imports AGENTS.md, because Claude Code
16
+ * does not follow that import from a subfolder. Init creates no Claude file.
17
+ * - Gemini CLI reads only GEMINI.md: it gets a managed import, `@./AGENTS.md`,
18
+ * created when missing, unless GEMINI.md already imports AGENTS.md. Project
19
+ * settings that point Gemini at AGENTS.md don't replace it: Gemini loads them
20
+ * only when it starts at the project root.
21
+ * - Existing Cursor (.cursorrules) and Hermes (.hermes.md, HERMES.md) files
22
+ * keep a full block.
23
+ *
24
+ * No mode writes into the home folder, or into a settings folder in it outside a
25
+ * project: agents read the files there in every project. A project inside a
26
+ * settings folder, such as an agent worktree, is written normally.
17
27
  *
18
28
  * --agent <tool>: target a specific tool preset (claude, cursor, codex, hermes, muse, all)
19
29
  * --agent-docs-path <path>: explicit file path(s)
20
30
  */
21
31
 
32
+ import {spawnSync} from 'node:child_process';
22
33
  import * as fs from 'node:fs';
34
+ import * as os from 'node:os';
23
35
  import * as path from 'node:path';
24
36
  import {findCoreDir, CLI_ROOT} from '../fs/paths.mjs';
25
37
  import {assertWithin, PathSafetyError} from '../fs/path-safety.mjs';
38
+ import {createFileProtectionResolver} from '../fs/file-protection.mjs';
26
39
  import {getCliInvocation} from '../env/package-manager.mjs';
27
40
  import {discoverComponents} from '../discovery/component-discovery.mjs';
28
41
  import {Project} from '../config/project.mjs';
@@ -31,9 +44,11 @@ import {humanLog} from '../response/json.mjs';
31
44
  import {ERROR_CODES} from '../response/error-codes.mjs';
32
45
  import {
33
46
  AGENTS_MD,
47
+ AGENT_DOC_PATHS,
34
48
  CLAUDE_MD,
35
49
  CLAUDE_DIR_MD,
36
50
  CURSOR_RULES,
51
+ GEMINI_MD,
37
52
  HERMES_DOT_MD,
38
53
  HERMES_MD,
39
54
  MARKER_START,
@@ -80,27 +95,26 @@ function validateIntegrationLabel(value) {
80
95
  }
81
96
 
82
97
  /**
83
- * Find tool-specific files that import another detected agent doc.
84
- *
85
- * Claude's `@path` directive is a real include, so injecting the full managed
86
- * block beside it duplicates the same instructions. Only an import whose
87
- * normalized target is another known agent doc counts; prose mentions and
88
- * standalone files keep the normal initialization behavior. Import cycles have
89
- * no canonical owner, so their members also stay standalone.
98
+ * The `@path` imports of other agent docs in each agent doc. Only an import
99
+ * whose normalized target is another known agent doc counts; prose mentions do
100
+ * not. Lines inside the managed block are Astryx's own (the default writes an
101
+ * import of AGENTS.md there), so they never count as the project's import.
90
102
  *
91
103
  * @param {string} targetDir
92
104
  * @param {string[]} agentDocs
93
- * @returns {Set<string>}
105
+ * @param {string[]} [extraTargets] Import targets that count even though they
106
+ * do not exist yet (the default always writes AGENTS.md).
107
+ * @returns {Map<string, Set<string>>} each importing doc and the docs it imports.
94
108
  */
95
- function discoverAgentDocWrappers(targetDir, agentDocs) {
96
- const known = new Set(agentDocs.map(p => path.normalize(p)));
109
+ function readAgentDocImports(targetDir, agentDocs, extraTargets = []) {
110
+ const known = new Set([...agentDocs, ...extraTargets].map(p => path.normalize(p)));
97
111
  /** @type {Map<string, Set<string>>} */
98
112
  const imports = new Map();
99
113
 
100
114
  for (const rel of agentDocs) {
101
115
  let content;
102
116
  try {
103
- content = fs.readFileSync(path.join(targetDir, rel), 'utf-8');
117
+ content = withoutManagedBlock(fs.readFileSync(path.join(targetDir, rel), 'utf-8'));
104
118
  } catch {
105
119
  continue;
106
120
  }
@@ -116,7 +130,21 @@ function discoverAgentDocWrappers(targetDir, agentDocs) {
116
130
  }
117
131
  if (targets.size > 0) imports.set(rel, targets);
118
132
  }
133
+ return imports;
134
+ }
119
135
 
136
+ /**
137
+ * Find tool-specific files that import another detected agent doc.
138
+ *
139
+ * Claude's `@path` directive is a real include, so injecting the full managed
140
+ * block beside it duplicates the same instructions. Standalone files keep the
141
+ * normal initialization behavior. Import cycles have no canonical owner, so
142
+ * their members also stay standalone.
143
+ *
144
+ * @param {Map<string, Set<string>>} imports From {@link readAgentDocImports}.
145
+ * @returns {Map<string, Set<string>>} each wrapper and the agent docs it imports.
146
+ */
147
+ function findAgentDocWrappers(imports) {
120
148
  /** @param {string} start */
121
149
  const isCyclic = start => {
122
150
  /**
@@ -135,7 +163,7 @@ function discoverAgentDocWrappers(targetDir, agentDocs) {
135
163
  return visit(start, new Set([start]));
136
164
  };
137
165
 
138
- return new Set([...imports.keys()].filter(rel => !isCyclic(rel)));
166
+ return new Map([...imports].filter(([rel]) => !isCyclic(rel)));
139
167
  }
140
168
 
141
169
  /**
@@ -177,6 +205,295 @@ function findManagedBlock(content) {
177
205
  return null;
178
206
  }
179
207
 
208
+ /**
209
+ * `content` with its managed block cut out. A malformed block is left in
210
+ * place; the writers refuse it later.
211
+ * @param {string} content
212
+ * @returns {string}
213
+ */
214
+ function withoutManagedBlock(content) {
215
+ try {
216
+ const block = findManagedBlock(content);
217
+ return block ? content.slice(0, block.start) + content.slice(block.end) : content;
218
+ } catch {
219
+ return content;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The managed one-line import of AGENTS.md that the default writes, keyed by
225
+ * file. Gemini CLI reads only GEMINI.md, and expands `@./AGENTS.md` there.
226
+ *
227
+ * @type {Readonly<Record<string, string>>}
228
+ */
229
+ const AGENTS_IMPORTS = Object.freeze({
230
+ [GEMINI_MD]: '@./AGENTS.md',
231
+ });
232
+
233
+ /**
234
+ * Agent docs 0.7.0 already wrote. A path-safety refusal on one of them stays an
235
+ * error, as it was; a file only the current layout writes is skipped instead.
236
+ */
237
+ const LEGACY_DOC_PATHS = AGENT_DOC_PATHS.filter(p => p !== GEMINI_MD);
238
+
239
+ /**
240
+ * The managed block holding the import of AGENTS.md for `rel`.
241
+ * @param {string} rel A key of {@link AGENTS_IMPORTS}.
242
+ * @returns {string}
243
+ */
244
+ function renderAgentsImportBlock(rel) {
245
+ return `${MARKER_START}\n${AGENTS_IMPORTS[rel]}\n${MARKER_END}`;
246
+ }
247
+
248
+ /**
249
+ * Whether the managed block in `content` holds only an import of the root
250
+ * AGENTS.md, resolved from the folder of `rel`. Such a block names no version,
251
+ * so it stays current while it points at AGENTS.md.
252
+ * @param {string} rel Project-relative path of the file `content` came from.
253
+ * @param {string} content
254
+ * @returns {boolean}
255
+ */
256
+ function isAgentsImportBlock(rel, content) {
257
+ let block;
258
+ try {
259
+ block = findManagedBlock(content);
260
+ } catch {
261
+ return false;
262
+ }
263
+ if (block == null) return false;
264
+ const text = content.slice(block.start, block.end);
265
+ const inner = text.slice(text.indexOf('-->') + 3, text.lastIndexOf('<!--')).trim();
266
+ const match = /^@(\S+)$/.exec(inner);
267
+ if (match == null || path.isAbsolute(match[1])) return false;
268
+ return path.normalize(path.join(path.dirname(rel), match[1])) === AGENTS_MD;
269
+ }
270
+
271
+ /**
272
+ * The paths Gemini CLI imports from `content`, in order. Gemini reads `@path`
273
+ * where the `@` starts the text or follows whitespace, and the path runs to the
274
+ * next whitespace and starts with `.`, `/` or a letter. An `@` inside backticks
275
+ * (inline code or a fenced block) is not an import.
276
+ * @param {string} content
277
+ * @returns {string[]}
278
+ */
279
+ function findGeminiImports(content) {
280
+ /** @type {Array<[number, number]>} */
281
+ const code = [];
282
+ for (const match of content.matchAll(/(`+)[\s\S]*?\1/g)) {
283
+ code.push([match.index, match.index + match[0].length]);
284
+ }
285
+ /** @type {string[]} */
286
+ const found = [];
287
+ for (const match of content.matchAll(/(?<=^|[ \t\r\n])@([^ \t\r\n]+)/g)) {
288
+ if (!/^[./A-Za-z]/.test(match[1])) continue;
289
+ if (code.some(([start, end]) => match.index >= start && match.index < end)) {
290
+ continue;
291
+ }
292
+ found.push(match[1]);
293
+ }
294
+ return found;
295
+ }
296
+
297
+ /**
298
+ * Whether GEMINI.md, outside Astryx's managed block, already has Gemini CLI
299
+ * import AGENTS.md, on a line of its own or inline in a sentence.
300
+ * @param {string} targetDir
301
+ * @returns {boolean}
302
+ */
303
+ function geminiImportsAgentsMd(targetDir) {
304
+ let content;
305
+ try {
306
+ content = fs.readFileSync(path.join(targetDir, GEMINI_MD), 'utf-8');
307
+ } catch {
308
+ return false;
309
+ }
310
+ const agents = path.resolve(targetDir, AGENTS_MD);
311
+ return findGeminiImports(withoutManagedBlock(content)).some(
312
+ rel => path.resolve(targetDir, rel) === agents,
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Thrown when agent docs would be written into the home folder, or into a
318
+ * settings folder in it such as `~/.gemini` outside a project. Agents read the
319
+ * files there (for example `~/.claude/CLAUDE.md` or `~/.gemini/GEMINI.md`) in
320
+ * every project, so a project's version-pinned block never goes there. Removal
321
+ * stays allowed.
322
+ */
323
+ export class AgentDocsHomeError extends Error {
324
+ /** @param {string} dir */
325
+ constructor(dir) {
326
+ super(
327
+ `Agent docs are not written into the home folder, or into a settings folder in it outside a project (${dir}): agents read the files there in every project. Run init inside a project folder.`,
328
+ );
329
+ this.name = 'AgentDocsHomeError';
330
+ /** The refused folder. */
331
+ this.dir = dir;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * `p` resolved, through symlinks when it exists.
337
+ * @param {string} p
338
+ * @returns {string}
339
+ */
340
+ function realDir(p) {
341
+ try {
342
+ return fs.realpathSync(path.resolve(p));
343
+ } catch {
344
+ return path.resolve(p);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * `dir` relative to the home folder, compared without symlinks (and without
350
+ * case on Windows). null when there is no home folder.
351
+ * @param {string} dir
352
+ * @returns {string | null}
353
+ */
354
+ function relativeToHome(dir) {
355
+ let home;
356
+ try {
357
+ home = os.homedir();
358
+ } catch {
359
+ return null;
360
+ }
361
+ if (!home) return null;
362
+ /** @param {string} p */
363
+ const fold = p => (process.platform === 'win32' ? p.toLowerCase() : p);
364
+ return path.relative(fold(realDir(home)), fold(realDir(dir)));
365
+ }
366
+
367
+ /**
368
+ * Whether `dir` is the home folder.
369
+ * @param {string} dir
370
+ * @returns {boolean}
371
+ */
372
+ function isHomeFolder(dir) {
373
+ return relativeToHome(dir) === '';
374
+ }
375
+
376
+ /**
377
+ * Whether agent docs stay out of `dir`: the home folder, or a folder in a
378
+ * settings folder of it (a folder of the home folder whose name starts with a
379
+ * dot, such as `~/.gemini` or `~/.claude`) that belongs to no project there.
380
+ * Agents read the files at the top of a settings folder in every project, so
381
+ * the settings folder itself is never a project, even with a package.json. A
382
+ * folder below it with a package.json at or above it is a project, such as an
383
+ * agent worktree in `~/.codex/worktrees/<id>/<repo>`.
384
+ * @param {string} dir
385
+ * @returns {boolean}
386
+ */
387
+ function isHomeOrSettingsFolder(dir) {
388
+ const rel = relativeToHome(dir);
389
+ if (rel == null) return false;
390
+ if (rel === '') return true;
391
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
392
+ return false;
393
+ }
394
+ const parts = rel.split(path.sep);
395
+ if (!parts[0].startsWith('.')) return false;
396
+ // Look for a package.json from `dir` up to, not including, the settings folder.
397
+ let current = realDir(dir);
398
+ for (let depth = parts.length; depth > 1; depth--) {
399
+ if (fs.existsSync(path.join(current, 'package.json'))) return false;
400
+ current = path.dirname(current);
401
+ }
402
+ return true;
403
+ }
404
+
405
+ /**
406
+ * Throw {@link AgentDocsHomeError} when `targetDir` is the home folder, or a
407
+ * settings folder in it outside a project.
408
+ * @param {string} targetDir
409
+ */
410
+ export function assertAgentDocsTarget(targetDir) {
411
+ if (isHomeOrSettingsFolder(targetDir)) throw new AgentDocsHomeError(realDir(targetDir));
412
+ }
413
+
414
+ /** Folders that mark a repository root; the project search stops there. */
415
+ const REPOSITORY_MARKERS = ['.git', '.hg', '.sl'];
416
+
417
+ /** Dependency fields where a project declares the Astryx packages it uses. */
418
+ const DEPENDENCY_FIELDS = [
419
+ 'dependencies',
420
+ 'devDependencies',
421
+ 'peerDependencies',
422
+ 'optionalDependencies',
423
+ ];
424
+
425
+ /**
426
+ * The package.json in `dir`: its parsed content, `null` when it exists but
427
+ * does not parse, or `undefined` when there is none.
428
+ * @param {string} dir
429
+ * @returns {Record<string, unknown> | null | undefined}
430
+ */
431
+ function readPackageJson(dir) {
432
+ const file = path.join(dir, 'package.json');
433
+ if (!fs.existsSync(file)) return undefined;
434
+ try {
435
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
436
+ return parsed && typeof parsed === 'object' ? parsed : null;
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Whether a parsed package.json declares an `@astryxdesign` package.
444
+ * @param {Record<string, unknown> | null} manifest
445
+ * @returns {boolean}
446
+ */
447
+ function declaresAstryx(manifest) {
448
+ return DEPENDENCY_FIELDS.some(field => {
449
+ const deps = manifest?.[field];
450
+ return (
451
+ deps != null &&
452
+ typeof deps === 'object' &&
453
+ Object.keys(deps).some(name => name.startsWith('@astryxdesign/'))
454
+ );
455
+ });
456
+ }
457
+
458
+ /**
459
+ * The folder a run started in `cwd` writes agent docs to: the nearest folder at
460
+ * or above it whose package.json declares an `@astryxdesign` package, else the
461
+ * nearest folder with any package.json. The search stops at a repository root
462
+ * and never reaches the home folder, so a stray `~/package.json` cannot pull
463
+ * the docs there.
464
+ * @param {string} cwd
465
+ * @returns {string | null} null when no folder it searches has a package.json;
466
+ * init then writes where it runs, as before.
467
+ */
468
+ export function resolveAgentDocsRoot(cwd) {
469
+ let dir = path.resolve(cwd);
470
+ /** @type {string | null} */
471
+ let nearest = null;
472
+ for (let i = 0; i < 50 && !isHomeFolder(dir); i++) {
473
+ const manifest = readPackageJson(dir);
474
+ if (manifest !== undefined) {
475
+ if (declaresAstryx(manifest)) return dir;
476
+ nearest ??= dir;
477
+ }
478
+ if (REPOSITORY_MARKERS.some(marker => fs.existsSync(path.join(dir, marker)))) break;
479
+ const parent = path.dirname(dir);
480
+ if (parent === dir) break;
481
+ dir = parent;
482
+ }
483
+ return nearest;
484
+ }
485
+
486
+ /**
487
+ * The folder `init` run in `cwd` writes the agent docs to: the project root
488
+ * {@link resolveAgentDocsRoot} finds, else `cwd` itself. The one definition
489
+ * that init, its removal, and the per-command setup nudge all use.
490
+ * @param {string} cwd
491
+ * @returns {string}
492
+ */
493
+ export function agentDocsTargetDir(cwd) {
494
+ return resolveAgentDocsRoot(cwd) ?? cwd;
495
+ }
496
+
180
497
  /**
181
498
  * Agent tool presets — maps tool names to their file search paths.
182
499
  * Order matters: first existing file wins, last entry is the default (created if none exist).
@@ -215,6 +532,8 @@ export function parseBlockVersion(content) {
215
532
  * - `current` — every managed block already matches the installed version.
216
533
  * And the project is `missing` when no managed block exists anywhere (the repo
217
534
  * has agent-doc files without our markers, or none at all — i.e. never `init`ed).
535
+ * A managed import of AGENTS.md is not a block here: it names no version and
536
+ * stays current while AGENTS.md holds the block.
218
537
  *
219
538
  * @param {string} targetDir
220
539
  * @param {string} [installedVersion] Defaults to the installed core version.
@@ -244,6 +563,8 @@ export function inspectAgentDocs(targetDir, installedVersion, expectedBlock) {
244
563
  const hasNew = content.includes(MARKER_START);
245
564
  const hasLegacy = content.includes(LEGACY_MARKER_START);
246
565
  if (!hasNew && !hasLegacy) continue; // Not a block we manage.
566
+ // An import of AGENTS.md names no version and never needs a refresh.
567
+ if (isAgentsImportBlock(rel, content)) continue;
247
568
 
248
569
  const legacy = !hasNew && hasLegacy;
249
570
  const blockVersion = parseBlockVersion(content);
@@ -291,8 +612,11 @@ export function inspectAgentDocs(targetDir, installedVersion, expectedBlock) {
291
612
  */
292
613
  export function resolveAgentPaths(targetDir, agent) {
293
614
  if (agent === 'all') {
294
- // Inject into all existing files, create defaults for each tool
295
- const existing = discoverAgentDocs(targetDir);
615
+ // Every existing agent doc gets the block, except GEMINI.md, which keeps
616
+ // the one-line import of AGENTS.md the default writes. When CLAUDE.md is
617
+ // among them, installAgentDocs then removes the block from
618
+ // .claude/CLAUDE.md, unless the project ignores CLAUDE.md (INV31).
619
+ const existing = discoverAgentDocs(targetDir).filter(p => p !== GEMINI_MD);
296
620
  if (existing.length > 0) {
297
621
  return {inject: existing, create: []};
298
622
  }
@@ -500,7 +824,8 @@ export function generateCompressedIndex(
500
824
  const suffix = resolvedTopics.length > shown.length ? ' …' : '';
501
825
  lines.push(` docs <topic> ${shown.join(', ')}${suffix} (\`astryx docs\` lists all)`);
502
826
  }
503
- lines.push(' docs cli commands, API reference, integration authoring');
827
+ lines.push(' docs cli commands and API reference');
828
+ lines.push(' docs authoring build an integration package, and every field it holds');
504
829
  lines.push(' upgrade --from <old version> --apply run after a dependency bump');
505
830
  const appendCount = agentDocs.reduce(
506
831
  (count, contribution) => count + contribution.append.length,
@@ -700,14 +1025,16 @@ export function injectClaudeMd(targetDir, version) {
700
1025
  }
701
1026
 
702
1027
  /**
703
- * Remove Astryx section from a file.
704
- * If the file becomes empty (only boilerplate header remains), deletes it.
1028
+ * Remove the Astryx-managed block, between its markers, from a file, leaving
1029
+ * every other line as it is. With `deleteIfEmpty`, the file is deleted when
1030
+ * nothing is left but whitespace or the exact heading init writes into a file
1031
+ * it creates; with `deleteIfBlank`, only when nothing but whitespace is left.
705
1032
  *
706
1033
  * @param {string} filePath
707
- * @param {{deleteIfEmpty?: boolean}} [options]
1034
+ * @param {{deleteIfEmpty?: boolean, deleteIfBlank?: boolean}} [options]
708
1035
  * @returns {boolean} Whether the Astryx section was found and removed
709
1036
  */
710
- export function removeXdsBlock(filePath, {deleteIfEmpty = false} = {}) {
1037
+ export function removeXdsBlock(filePath, {deleteIfEmpty = false, deleteIfBlank = false} = {}) {
711
1038
  if (!fs.existsSync(filePath)) return false;
712
1039
 
713
1040
  let content = fs.readFileSync(filePath, 'utf-8');
@@ -719,18 +1046,198 @@ export function removeXdsBlock(filePath, {deleteIfEmpty = false} = {}) {
719
1046
  const after = content.slice(block.end).trimStart();
720
1047
  content = before + (after ? '\n\n' + after : '') + '\n';
721
1048
 
722
- if (deleteIfEmpty) {
723
- const stripped = content.replace(/^#.*\n+.*guidance.*\n*/m, '').trim();
724
- if (!stripped) {
725
- fs.unlinkSync(filePath);
726
- return true;
727
- }
1049
+ const rest = content.trim();
1050
+ if (
1051
+ (deleteIfBlank && rest === '') ||
1052
+ (deleteIfEmpty && (rest === '' || CREATED_HEADING.test(rest)))
1053
+ ) {
1054
+ fs.unlinkSync(filePath);
1055
+ return true;
728
1056
  }
729
1057
 
730
1058
  fs.writeFileSync(filePath, content);
731
1059
  return true;
732
1060
  }
733
1061
 
1062
+ /**
1063
+ * The whole of a file init created, once its block is gone: the heading init
1064
+ * writes, `# <name>` and its one line, and nothing else.
1065
+ */
1066
+ const CREATED_HEADING =
1067
+ /^# [^\n]+\n\nProject-specific guidance for AI coding agents\.$/u;
1068
+
1069
+ /**
1070
+ * Whether the project's ignore rules exclude root CLAUDE.md, read with the
1071
+ * working-tree ignore reader upgrade uses, from the repository root down to
1072
+ * the project root. It decides only where the copy
1073
+ * goes (a fresh clone lacks an ignored file); the managed block is written
1074
+ * whatever the ignore rules say (INV31). An ignore file that cannot be read
1075
+ * counts as excluding root CLAUDE.md, so the copy in .claude/CLAUDE.md stays.
1076
+ * @param {string} targetDir
1077
+ * @returns {boolean}
1078
+ */
1079
+ function rootClaudeIgnored(targetDir) {
1080
+ return uncommittedHere(targetDir, CLAUDE_MD, true);
1081
+ }
1082
+
1083
+ /**
1084
+ * Whether a fresh clone lacks `rel` in `targetDir`: it is untracked and
1085
+ * ignored. Inside a repository the repository answers, with every ignore
1086
+ * source it reads (its ignore files, its local exclude file and configured
1087
+ * excludes), and a tracked file is committed whatever they say, as git and
1088
+ * Mercurial ignore only untracked files. Outside one, the project's ignore
1089
+ * files answer. `unreadable` is the answer when the repository, or an ignore
1090
+ * file, cannot answer.
1091
+ * @param {string} targetDir
1092
+ * @param {string} rel
1093
+ * @param {boolean} unreadable
1094
+ * @returns {boolean}
1095
+ */
1096
+ function uncommittedHere(targetDir, rel, unreadable) {
1097
+ const root = repositoryRootOf(targetDir);
1098
+ if (!root) return ignoredHere(targetDir, rel, unreadable);
1099
+ return untrackedIgnored(root, targetDir, rel) ?? unreadable;
1100
+ }
1101
+
1102
+ /**
1103
+ * How each repository kind lists `rel` when it is untracked and ignored:
1104
+ * `git check-ignore` exits 0 for such a file and 1 otherwise (it never
1105
+ * reports a tracked file); `status -i -n` prints it, or nothing.
1106
+ */
1107
+ const IGNORED_QUERIES = [
1108
+ {marker: '.git', command: 'git', args: ['check-ignore', '-q', '--'], by: 'exit'},
1109
+ {marker: '.sl', command: 'sl', args: ['status', '-i', '-n', '--'], by: 'output'},
1110
+ {marker: '.hg', command: 'hg', args: ['status', '-i', '-n', '--'], by: 'output'},
1111
+ ];
1112
+
1113
+ /**
1114
+ * Whether the repository at `root` holds `rel` in `targetDir` as untracked
1115
+ * and ignored; null, which keeps the copy, when it cannot answer: its tool is
1116
+ * not installed or cannot start, runs past the time limit, or exits with a
1117
+ * code other than its documented answers.
1118
+ * @param {string} root
1119
+ * @param {string} targetDir
1120
+ * @param {string} rel
1121
+ * @returns {boolean | null}
1122
+ */
1123
+ function untrackedIgnored(root, targetDir, rel) {
1124
+ const query = IGNORED_QUERIES.find(q => fs.existsSync(path.join(root, q.marker)));
1125
+ if (!query) return null;
1126
+ const result = spawnSync(query.command, [...query.args, rel], {
1127
+ cwd: targetDir,
1128
+ encoding: 'utf-8',
1129
+ stdio: ['ignore', 'pipe', 'ignore'],
1130
+ timeout: 10_000,
1131
+ windowsHide: true,
1132
+ shell: false,
1133
+ });
1134
+ if (result.error) return null;
1135
+ if (query.by === 'exit') {
1136
+ if (result.status === 0) return true;
1137
+ if (result.status === 1) return false;
1138
+ return null;
1139
+ }
1140
+ if (result.status !== 0) return null;
1141
+ return result.stdout.trim() !== '';
1142
+ }
1143
+
1144
+ /**
1145
+ * Whether the ignore files in `targetDir` exclude `rel`, read with the
1146
+ * working-tree ignore reader upgrade uses; for a folder outside any
1147
+ * repository. `unreadable` when an ignore file cannot be read.
1148
+ * @param {string} targetDir
1149
+ * @param {string} rel
1150
+ * @param {boolean} unreadable
1151
+ * @returns {boolean}
1152
+ */
1153
+ function ignoredHere(targetDir, rel, unreadable) {
1154
+ try {
1155
+ return createFileProtectionResolver(targetDir)
1156
+ .classify(path.join(targetDir, rel))
1157
+ .some(item => item.reason === 'ignored');
1158
+ } catch {
1159
+ return unreadable;
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * The repository root at or above `dir`: the nearest folder holding `.git`,
1165
+ * `.hg`, or `.sl`. Null outside a repository.
1166
+ * @param {string} dir
1167
+ * @returns {string | null}
1168
+ */
1169
+ function repositoryRootOf(dir) {
1170
+ for (let current = path.resolve(dir); ; ) {
1171
+ if (REPOSITORY_MARKERS.some(marker => fs.existsSync(path.join(current, marker)))) {
1172
+ return current;
1173
+ }
1174
+ const parent = path.dirname(current);
1175
+ if (parent === current) return null;
1176
+ current = parent;
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * Whether .claude/CLAUDE.md keeps a copy beside root CLAUDE.md (INV31): while
1182
+ * a fresh clone lacks root CLAUDE.md (the project ignores it and does not
1183
+ * track it), a .claude/CLAUDE.md the project commits (tracked, or not
1184
+ * ignored) gets the copy too (DEC-2: an extra copy beats none). One the
1185
+ * project ignores and does not track follows the usual rule: its block goes
1186
+ * once root CLAUDE.md holds one. An ignore file, or tracking, that cannot be
1187
+ * read keeps the copy.
1188
+ * @param {string} targetDir
1189
+ * @returns {boolean}
1190
+ */
1191
+ function claudeDirKeepsCopy(targetDir) {
1192
+ if (!rootClaudeIgnored(targetDir)) return false;
1193
+ return !uncommittedHere(targetDir, CLAUDE_DIR_MD, false);
1194
+ }
1195
+
1196
+ /**
1197
+ * `rels` without the files that resolve outside `targetDir`, each reported
1198
+ * through `onSkipped`.
1199
+ * @param {string} targetDir
1200
+ * @param {readonly string[]} rels
1201
+ * @param {((rel: string, reason: string) => void) | undefined} onSkipped
1202
+ * @returns {Set<string>} the files skipped
1203
+ */
1204
+ function skipEscaping(targetDir, rels, onSkipped) {
1205
+ /** @type {Set<string>} */
1206
+ const skipped = new Set();
1207
+ for (const p of rels) {
1208
+ try {
1209
+ assertWithin(p, targetDir, {label: 'agent docs path'});
1210
+ } catch (err) {
1211
+ if (!(err instanceof PathSafetyError)) throw err;
1212
+ skipped.add(p);
1213
+ onSkipped?.(p, err.message);
1214
+ }
1215
+ }
1216
+ return skipped;
1217
+ }
1218
+
1219
+ /**
1220
+ * Whether root CLAUDE.md holds `block` as its managed block, so a copy in
1221
+ * .claude/CLAUDE.md may go.
1222
+ * @param {string} targetDir
1223
+ * @param {string} block
1224
+ * @returns {boolean}
1225
+ */
1226
+ function rootClaudeHolds(targetDir, block) {
1227
+ // A root CLAUDE.md the project ignores is not in a fresh clone, so it never
1228
+ // stands in for a committed copy in .claude/CLAUDE.md (an extra copy beats
1229
+ // none, DEC-2). An ignored .claude/CLAUDE.md is not in one either, so the
1230
+ // usual rule holds for it.
1231
+ if (claudeDirKeepsCopy(targetDir)) return false;
1232
+ try {
1233
+ const content = fs.readFileSync(path.join(targetDir, CLAUDE_MD), 'utf-8');
1234
+ const found = findManagedBlock(content);
1235
+ return found != null && content.slice(found.start, found.end) === block.trim();
1236
+ } catch {
1237
+ return false;
1238
+ }
1239
+ }
1240
+
734
1241
  /**
735
1242
  * Whether an agent-doc file already carries a managed-block marker.
736
1243
  * @param {string} filePath
@@ -773,24 +1280,40 @@ function assertTargetsWithin(targetDir, relPaths) {
773
1280
  }
774
1281
 
775
1282
  /**
776
- * Remove Astryx section from all known agent doc files.
1283
+ * Throw `ERR_PATH_TRAVERSAL` when an agent doc in `targetDir` that holds a
1284
+ * managed block resolves outside `targetDir`.
777
1285
  * @param {string} targetDir
778
- * @returns {string[]} the project-relative files a block was removed from.
779
- * @throws {PathSafetyError} `ERR_PATH_TRAVERSAL` when a file it would change
780
- * resolves outside `targetDir`; nothing is changed.
781
1286
  */
782
- export function removeAgentDocs(targetDir) {
1287
+ function assertRemovableWithin(targetDir, skip = new Set()) {
783
1288
  const allPaths = discoverAgentDocs(targetDir);
784
1289
  try {
785
1290
  assertTargetsWithin(
786
1291
  targetDir,
787
- allPaths.filter(p => hasRemovableBlock(path.join(targetDir, p))),
1292
+ allPaths.filter(
1293
+ p => !skip.has(p) && hasRemovableBlock(path.join(targetDir, p)),
1294
+ ),
788
1295
  );
789
1296
  } catch (err) {
790
1297
  // The code reaches the error envelope as is, so it must be registered.
791
1298
  if (!(err instanceof PathSafetyError)) throw err;
792
1299
  throw new PathSafetyError(err.message, ERROR_CODES.ERR_PATH_TRAVERSAL);
793
1300
  }
1301
+ }
1302
+
1303
+ /**
1304
+ * Remove Astryx section from all known agent doc files.
1305
+ * @param {string} targetDir
1306
+ * @param {object} [options]
1307
+ * @param {string} [options.shownFrom] - Folder the logged paths are shown
1308
+ * relative to; defaults to `targetDir`.
1309
+ * @param {Set<string>} [options.skip] - Files left as they are.
1310
+ * @returns {string[]} the project-relative files a block was removed from.
1311
+ * @throws {PathSafetyError} `ERR_PATH_TRAVERSAL` when a file it would change
1312
+ * resolves outside `targetDir`; nothing is changed.
1313
+ */
1314
+ export function removeAgentDocs(targetDir, {shownFrom = targetDir, skip = new Set()} = {}) {
1315
+ assertRemovableWithin(targetDir, skip);
1316
+ const allPaths = discoverAgentDocs(targetDir).filter(p => !skip.has(p));
794
1317
 
795
1318
  /** @type {string[]} */
796
1319
  const removedFrom = [];
@@ -799,27 +1322,374 @@ export function removeAgentDocs(targetDir) {
799
1322
  // Delete if empty for files we created (AGENTS.md, .claude/CLAUDE.md)
800
1323
  const deleteIfEmpty = p === AGENTS_MD || p === CLAUDE_DIR_MD;
801
1324
  if (removeXdsBlock(filePath, {deleteIfEmpty})) {
1325
+ // The default creates GEMINI.md holding only the import of AGENTS.md.
1326
+ if (p === GEMINI_MD && fs.readFileSync(filePath, 'utf-8').trim() === '') {
1327
+ fs.unlinkSync(filePath);
1328
+ }
802
1329
  removedFrom.push(p);
1330
+ const shown = path.relative(shownFrom, filePath).split(path.sep).join('/') || p;
803
1331
  if (!fs.existsSync(filePath)) {
804
- humanLog(`[ok] Removed empty ${p}`);
1332
+ humanLog(`[ok] Removed empty ${shown}`);
805
1333
  } else {
806
- humanLog(`[ok] Removed design system section from ${p}`);
1334
+ humanLog(`[ok] Removed design system section from ${shown}`);
807
1335
  }
808
1336
  }
809
1337
  }
810
1338
  return removedFrom;
811
1339
  }
812
1340
 
1341
+ /**
1342
+ * Remove the managed block where init writes it, the project root (see
1343
+ * {@link resolveAgentDocsRoot}), and in `cwd`, so a block an earlier version
1344
+ * wrote in that folder goes too. Each folder is checked before anything is
1345
+ * changed. Run below the project root, a project-root agent file holding a
1346
+ * block that resolves outside the project is left as it is and reported to
1347
+ * `onSkipped`, as install does there; 0.7.x touched only `cwd`. A file in
1348
+ * `cwd`, or at the root when run there, that resolves outside refuses the run.
1349
+ *
1350
+ * @param {string} cwd
1351
+ * @param {{onSkipped?: (rel: string, reason: string) => void}} [options]
1352
+ * @returns {string[]} the files a block was removed from, relative to `cwd`
1353
+ * @throws {PathSafetyError} `ERR_PATH_TRAVERSAL`; nothing is changed
1354
+ */
1355
+ export function removeProjectAgentDocs(cwd, {onSkipped} = {}) {
1356
+ const root = agentDocsTargetDir(cwd);
1357
+ const below = realDir(root) !== realDir(cwd);
1358
+ /** @type {Set<string>} */
1359
+ const rootSkip = below
1360
+ ? skipEscaping(
1361
+ root,
1362
+ discoverAgentDocs(root).filter(p => hasRemovableBlock(path.join(root, p))),
1363
+ (rel, reason) =>
1364
+ onSkipped?.(path.relative(cwd, path.join(root, rel)).split(path.sep).join('/'), reason),
1365
+ )
1366
+ : new Set();
1367
+ const dirs = below ? [root, cwd] : [cwd];
1368
+ for (const dir of dirs) assertRemovableWithin(dir, dir === root ? rootSkip : undefined);
1369
+ return dirs.flatMap(dir =>
1370
+ removeAgentDocs(dir, {shownFrom: cwd, skip: dir === root ? rootSkip : undefined}).map(rel =>
1371
+ path.relative(cwd, path.join(dir, rel)).split(path.sep).join('/'),
1372
+ ),
1373
+ );
1374
+ }
1375
+
1376
+ /**
1377
+ * Write the block into each standalone agent doc and remove a managed block
1378
+ * from each import wrapper. A refresh (`onlyReplace`) skips files without a
1379
+ * block and leaves a managed import of AGENTS.md as it is.
1380
+ *
1381
+ * @param {string} targetDir
1382
+ * @param {string[]} existing
1383
+ * @param {Map<string, Set<string>>} givenWrappers
1384
+ * @param {string} compressedIndex
1385
+ * @param {boolean} onlyReplace
1386
+ * @param {((rel: string, reason: string) => void) | undefined} onSkipped
1387
+ * @param {boolean} [refuseEscapes] whether a file 0.7.1 wrote that escapes
1388
+ * `targetDir` refuses the run
1389
+ * @returns {string[]} files written
1390
+ */
1391
+ function writeEachAgentDoc(targetDir, existing, givenWrappers, compressedIndex, onlyReplace, onSkipped, refuseEscapes = true) {
1392
+ /** @param {string} p */
1393
+ const marked = p => hasManagedMarker(path.join(targetDir, p));
1394
+ // A committed .claude/CLAUDE.md beside an ignored root CLAUDE.md keeps a
1395
+ // copy (INV31): one that already holds a block is refreshed where it is,
1396
+ // even when it imports another agent file, rather than left stale.
1397
+ const keptCopy =
1398
+ givenWrappers.has(CLAUDE_DIR_MD) && marked(CLAUDE_DIR_MD) && claudeDirKeepsCopy(targetDir);
1399
+ const wrappers = new Map(givenWrappers);
1400
+ if (keptCopy) wrappers.delete(CLAUDE_DIR_MD);
1401
+ /** @param {string} p */
1402
+ const isImport = p => {
1403
+ try {
1404
+ return isAgentsImportBlock(p, fs.readFileSync(path.join(targetDir, p), 'utf-8'));
1405
+ } catch {
1406
+ return false;
1407
+ }
1408
+ };
1409
+ // Every agent file that imports no other agent file holds a copy, GEMINI.md
1410
+ // included (INV31); a managed import of AGENTS.md is left as it is.
1411
+ const targets = existing.filter(p => !wrappers.has(p) && !isImport(p));
1412
+ const writes = targets.filter(p => !onlyReplace || marked(p));
1413
+ const strips = [...wrappers.keys()].filter(marked);
1414
+ // A file 0.7.1 did not write here (GEMINI.md, or any file when init runs
1415
+ // below the project root) that resolves outside the project is skipped and
1416
+ // reported, as in the default layout, and the rest is written. A path of a
1417
+ // file 0.7.1 wrote that escapes the project refuses the run, as in 0.7.1.
1418
+ const skipped = skipEscaping(
1419
+ targetDir,
1420
+ // A kept copy here already holds a block, which 0.7.x wrote, so its
1421
+ // escape refuses the run like any file 0.7.x wrote.
1422
+ [...writes, ...strips].filter(p => !refuseEscapes || p === GEMINI_MD),
1423
+ onSkipped,
1424
+ );
1425
+ assertTargetsWithin(
1426
+ targetDir,
1427
+ [...writes, ...strips].filter(p => !skipped.has(p)),
1428
+ );
1429
+ // A refresh skips unmarked files and a wrapper is only written when it
1430
+ // carries a block, so only the files this run writes are checked.
1431
+ /** @type {string[]} */
1432
+ const written = [];
1433
+ for (const p of targets) {
1434
+ if (skipped.has(p)) continue;
1435
+ const didWrite = injectXdsBlock(path.join(targetDir, p), compressedIndex, {onlyReplace});
1436
+ if (didWrite) written.push(p);
1437
+ }
1438
+ for (const p of wrappers.keys()) {
1439
+ if (skipped.has(p)) continue;
1440
+ // The copy in .claude/CLAUDE.md goes only once root CLAUDE.md holds it.
1441
+ if (
1442
+ p === CLAUDE_DIR_MD &&
1443
+ (skipped.has(CLAUDE_MD) || !rootClaudeHolds(targetDir, compressedIndex))
1444
+ ) {
1445
+ continue;
1446
+ }
1447
+ if (stripManagedBlock(path.join(targetDir, p), compressedIndex)) written.push(p);
1448
+ }
1449
+ return written;
1450
+ }
1451
+
1452
+ /**
1453
+ * The Claude Code file that holds the copy of the block: root CLAUDE.md, which
1454
+ * projects commit even when they ignore .claude/, else .claude/CLAUDE.md. It
1455
+ * holds the copy even when it imports AGENTS.md: Claude Code does not follow an
1456
+ * import of a file above the folder it starts in, so a session started in a
1457
+ * subfolder reads only the copy.
1458
+ *
1459
+ * @param {Set<string>} present Existing agent docs that are not AGENTS.md under
1460
+ * another name.
1461
+ * @param {Set<string>} aliases Files that are AGENTS.md under another name.
1462
+ * @returns {string | null} null when the project has no Claude file, or when
1463
+ * root CLAUDE.md is AGENTS.md under another name and so holds the block.
1464
+ */
1465
+ function claudeCopyFile(present, aliases) {
1466
+ if (aliases.has(CLAUDE_MD)) return null;
1467
+ return [CLAUDE_MD, CLAUDE_DIR_MD].find(p => present.has(p)) ?? null;
1468
+ }
1469
+
1470
+ /**
1471
+ * Plan the default layout. AGENTS.md holds the block. The Claude Code file the
1472
+ * project already has holds a copy (see {@link claudeCopyFile}), so the block
1473
+ * sits in a file the project commits; init creates no Claude file. GEMINI.md
1474
+ * imports AGENTS.md unless it already does. Every other wrapper here imports
1475
+ * AGENTS.md, so it already reaches the block.
1476
+ *
1477
+ * @param {string} targetDir
1478
+ * @param {string[]} existing
1479
+ * @param {Map<string, Set<string>>} wrappers
1480
+ * @param {Set<string>} aliases Files that are AGENTS.md under another name;
1481
+ * they are never written.
1482
+ * @returns {Map<string, 'block' | 'import' | 'strip'>}
1483
+ */
1484
+ function planDefaultAgentDocs(targetDir, existing, wrappers, aliases) {
1485
+ const present = new Set(existing.filter(p => !aliases.has(p)));
1486
+ /** @type {Map<string, 'block' | 'import' | 'strip'>} */
1487
+ const plan = new Map([[AGENTS_MD, 'block']]);
1488
+
1489
+ // Claude Code reads CLAUDE.md and .claude/CLAUDE.md, and skips AGENTS.md
1490
+ // when either exists. One Claude file holds the copy, so Claude Code reads
1491
+ // it once in a subfolder and at most twice at the root, where it also
1492
+ // follows that file's import of AGENTS.md.
1493
+ const claudeCopy = claudeCopyFile(present, aliases);
1494
+ for (const p of [CLAUDE_MD, CLAUDE_DIR_MD]) {
1495
+ if (present.has(p)) plan.set(p, p === claudeCopy ? 'block' : 'strip');
1496
+ }
1497
+
1498
+ // Gemini CLI reads GEMINI.md. Project settings can point it at AGENTS.md
1499
+ // too, but Gemini loads them only when it starts at the project root, so the
1500
+ // import is written anyway: two copies at the root beat none in a subfolder.
1501
+ // An import GEMINI.md already has, even inline, is enough.
1502
+ if (!aliases.has(GEMINI_MD)) {
1503
+ if (geminiImportsAgentsMd(targetDir)) {
1504
+ if (present.has(GEMINI_MD)) plan.set(GEMINI_MD, 'strip');
1505
+ } else {
1506
+ plan.set(GEMINI_MD, 'import');
1507
+ }
1508
+ }
1509
+
1510
+ // Every other file: a full block when standalone, none in a wrapper.
1511
+ for (const p of present) {
1512
+ if (!plan.has(p)) plan.set(p, wrappers.has(p) ? 'strip' : 'block');
1513
+ }
1514
+ return plan;
1515
+ }
1516
+
1517
+ /**
1518
+ * Write the default layout (see {@link planDefaultAgentDocs}).
1519
+ *
1520
+ * Path safety follows 0.7.0: an agent doc it would have written that resolves
1521
+ * outside `targetDir` refuses the whole run before any write. A file only this
1522
+ * layout writes, such as GEMINI.md, is skipped instead, reported through
1523
+ * `onSkipped`, and the rest is written.
1524
+ *
1525
+ * @param {string} targetDir
1526
+ * @param {string[]} existing
1527
+ * @param {Map<string, Set<string>>} wrappers
1528
+ * @param {Set<string>} aliases
1529
+ * @param {string} compressedIndex
1530
+ * @param {((rel: string, reason: string) => void) | undefined} onSkipped
1531
+ * @param {boolean} [refuseEscapes] whether a file 0.7.1 wrote that escapes
1532
+ * `targetDir` refuses the run
1533
+ * @returns {string[]} files written, in {@link AGENT_DOC_PATHS} order
1534
+ */
1535
+ function writeDefaultAgentDocs(targetDir, existing, wrappers, aliases, compressedIndex, onSkipped, refuseEscapes = true) {
1536
+ const plan = planDefaultAgentDocs(targetDir, existing, wrappers, aliases);
1537
+ // While root CLAUDE.md is one the project ignores, .claude/CLAUDE.md gets
1538
+ // the copy too, first run included: a fresh clone has only that file.
1539
+ if (plan.get(CLAUDE_DIR_MD) === 'strip' && claudeDirKeepsCopy(targetDir)) {
1540
+ plan.set(CLAUDE_DIR_MD, 'block');
1541
+ }
1542
+ // 0.7.x left a block-less import of an existing AGENTS.md alone, so a Claude
1543
+ // file of that shape that now gets the copy and resolves outside the project
1544
+ // is skipped and reported. Any other agent file 0.7.x wrote, so its escape
1545
+ // still refuses the run.
1546
+ /** @param {string} p */
1547
+ const leftAlone = p =>
1548
+ existing.includes(AGENTS_MD) &&
1549
+ wrappers.get(p)?.has(AGENTS_MD) === true &&
1550
+ !hasManagedMarker(path.join(targetDir, p));
1551
+ const foundLegacy = existing.filter(p => LEGACY_DOC_PATHS.includes(p));
1552
+ /** @param {string} p */
1553
+ const legacy = p =>
1554
+ !leftAlone(p) &&
1555
+ (foundLegacy.includes(p) || (p === AGENTS_MD && foundLegacy.length === 0));
1556
+
1557
+ /** @type {Set<string>} */
1558
+ const escaped = new Set();
1559
+ for (const [p, action] of [...plan]) {
1560
+ // A strip writes only a file that carries a block.
1561
+ if (action === 'strip' && !hasManagedMarker(path.join(targetDir, p))) {
1562
+ plan.delete(p);
1563
+ continue;
1564
+ }
1565
+ try {
1566
+ assertWithin(p, targetDir, {label: 'agent docs path'});
1567
+ } catch (err) {
1568
+ if (!(err instanceof PathSafetyError) || (refuseEscapes && legacy(p))) throw err;
1569
+ plan.delete(p);
1570
+ escaped.add(p);
1571
+ onSkipped?.(p, err.message);
1572
+ }
1573
+ }
1574
+ // An import of an AGENTS.md left as it is would point at no block, so the
1575
+ // GEMINI.md import is not written either.
1576
+ if (escaped.has(AGENTS_MD) && plan.get(GEMINI_MD) === 'import') {
1577
+ plan.delete(GEMINI_MD);
1578
+ onSkipped?.(GEMINI_MD, 'its import of AGENTS.md would point at a file left as it is');
1579
+ }
1580
+
1581
+ /** @type {string[]} */
1582
+ const written = [];
1583
+ for (const p of AGENT_DOC_PATHS) {
1584
+ const action = plan.get(p);
1585
+ if (action == null) continue;
1586
+ const filePath = path.join(targetDir, p);
1587
+ if (action === 'strip') {
1588
+ // The copy in .claude/CLAUDE.md goes only once root CLAUDE.md holds it.
1589
+ if (
1590
+ p === CLAUDE_DIR_MD &&
1591
+ (escaped.has(CLAUDE_MD) || !rootClaudeHolds(targetDir, compressedIndex))
1592
+ ) {
1593
+ continue;
1594
+ }
1595
+ if (stripManagedBlock(filePath, compressedIndex)) written.push(p);
1596
+ continue;
1597
+ }
1598
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
1599
+ injectXdsBlock(
1600
+ filePath,
1601
+ action === 'block' ? compressedIndex : renderAgentsImportBlock(p),
1602
+ {createIfMissing: true, header: CREATED_HEADERS[p] ?? ''},
1603
+ );
1604
+ written.push(p);
1605
+ }
1606
+ return written;
1607
+ }
1608
+
1609
+ /**
1610
+ * Heading of the AGENTS.md the default creates; the same text the presets
1611
+ * write, so `--remove-agents` recognizes and deletes it.
1612
+ * @type {Readonly<Record<string, string>>}
1613
+ */
1614
+ const CREATED_HEADERS = Object.freeze({
1615
+ [AGENTS_MD]: '# AGENTS.md\n\nProject-specific guidance for AI coding agents.',
1616
+ });
1617
+
1618
+ /**
1619
+ * Agent docs that are one file under several names (symlinks), resolved inside
1620
+ * or outside the project.
1621
+ * @param {string} targetDir
1622
+ * @param {string[]} existing
1623
+ * @returns {{agentsAliases: Set<string>, otherShared: boolean}} the files that
1624
+ * are AGENTS.md under another name, and whether any other two docs share a
1625
+ * file.
1626
+ */
1627
+ function findSharedAgentDocs(targetDir, existing) {
1628
+ /** @type {Map<string, string[]>} */
1629
+ const byFile = new Map();
1630
+ for (const p of existing) {
1631
+ let real;
1632
+ try {
1633
+ real = fs.realpathSync(path.join(targetDir, p));
1634
+ } catch {
1635
+ real = path.resolve(targetDir, p);
1636
+ }
1637
+ byFile.set(real, [...(byFile.get(real) ?? []), p]);
1638
+ }
1639
+ /** @type {Set<string>} */
1640
+ const agentsAliases = new Set();
1641
+ let otherShared = false;
1642
+ for (const names of byFile.values()) {
1643
+ if (names.length < 2) continue;
1644
+ if (!names.includes(AGENTS_MD)) {
1645
+ otherShared = true;
1646
+ continue;
1647
+ }
1648
+ for (const name of names) if (name !== AGENTS_MD) agentsAliases.add(name);
1649
+ }
1650
+ return {agentsAliases, otherShared};
1651
+ }
1652
+
1653
+ /**
1654
+ * Remove the managed block from a file that should not carry one, leaving the
1655
+ * rest of the file as it is. A malformed block is refused (throws) rather than
1656
+ * guessed at. `.claude/CLAUDE.md` is deleted when nothing but whitespace is
1657
+ * left.
1658
+ * @param {string} filePath
1659
+ * @param {string} compressedIndex
1660
+ * @returns {boolean} whether the file changed
1661
+ */
1662
+ function stripManagedBlock(filePath, compressedIndex) {
1663
+ const deleteIfBlank =
1664
+ path.basename(filePath) === path.basename(CLAUDE_DIR_MD) &&
1665
+ path.basename(path.dirname(filePath)) === path.dirname(CLAUDE_DIR_MD);
1666
+ if (removeXdsBlock(filePath, {deleteIfBlank})) return true;
1667
+ if (hasManagedMarker(filePath)) {
1668
+ // Preserve the existing fail-closed behavior for malformed blocks.
1669
+ injectXdsBlock(filePath, compressedIndex, {onlyReplace: true});
1670
+ }
1671
+ return false;
1672
+ }
1673
+
813
1674
  /**
814
1675
  * Programmatic entry point for installing agent docs.
815
1676
  * Used by the init command, upgrade command, and agent-docs command.
816
1677
  *
817
- * Strategy (when no agent/paths specified):
818
- * - Discover all existing agent doc files.
819
- * - Leave `@path` import wrappers untouched; if an older run expanded a block
820
- * into one, remove that duplicate block.
821
- * - Initialize or refresh every standalone file.
822
- * - If nothing exists, create AGENTS.md as the tool-agnostic default.
1678
+ * Default strategy (no agent/paths specified), so every agent reads the block:
1679
+ * - Write the block to AGENTS.md (created when missing).
1680
+ * - Write the block to the project's Claude Code file: root CLAUDE.md when it
1681
+ * exists, else an existing .claude/CLAUDE.md, even when it imports AGENTS.md.
1682
+ * Remove a block from the other one. Create no Claude file.
1683
+ * - Write a managed import of AGENTS.md to GEMINI.md (created when missing),
1684
+ * unless GEMINI.md already imports AGENTS.md.
1685
+ * - Keep a full block in every other existing standalone file.
1686
+ * - Leave every other `@path` import wrapper untouched; if an older run
1687
+ * expanded a block into one, remove that duplicate block. When a wrapper
1688
+ * imports a file other than AGENTS.md, the project keeps its own layout:
1689
+ * every standalone file gets the block.
1690
+ * A refresh (`onlyReplace`) rewrites existing blocks in place, the Claude
1691
+ * file's copy included, leaves imports of AGENTS.md as they are, and creates
1692
+ * nothing.
823
1693
  *
824
1694
  * @param {string} targetDir
825
1695
  * @param {object} [options]
@@ -833,9 +1703,19 @@ export function removeAgentDocs(targetDir) {
833
1703
  * so an integration's topics reach the agent.
834
1704
  * @param {string} [options.renderedBlock] - Fully rendered expected block. Init
835
1705
  * and upgrade pass one shared block to every target.
1706
+ * @param {(rel: string, reason: string) => void} [options.onSkipped] - Called
1707
+ * for each file skipped because it resolves outside `targetDir` and 0.7.1
1708
+ * never wrote it: GEMINI.md, or any file when `refuseEscapes` is false.
1709
+ * @param {boolean} [options.refuseEscapes] - Whether a file 0.7.1 wrote that
1710
+ * resolves outside `targetDir` refuses the run (default). Init passes false
1711
+ * when it runs below the project root, where 0.7.1 wrote in the folder it
1712
+ * ran in instead.
836
1713
  * @returns {string[]} List of files written
837
1714
  * @throws {import('../fs/path-safety.mjs').PathSafetyError} when a file it would
838
1715
  * write resolves outside `targetDir`; nothing is written.
1716
+ * @throws {AgentDocsHomeError} when `targetDir` is the home folder, or a settings
1717
+ * folder in it outside a project, or, with `paths`, when a file's own folder
1718
+ * is; nothing is written.
839
1719
  */
840
1720
  export function installAgentDocs(
841
1721
  targetDir,
@@ -847,8 +1727,14 @@ export function installAgentDocs(
847
1727
  onlyReplace = false,
848
1728
  topics,
849
1729
  renderedBlock,
1730
+ onSkipped,
1731
+ refuseEscapes = true,
850
1732
  } = {},
851
1733
  ) {
1734
+ const explicit = paths != null && paths.length > 0;
1735
+ // Explicit paths are checked file by file below: each file's own folder
1736
+ // is what agents would read in every project.
1737
+ if (!explicit) assertAgentDocsTarget(targetDir);
852
1738
  const coreDir = findCoreDir(targetDir);
853
1739
  const version = getXdsVersion(coreDir);
854
1740
  const invocation = getCliInvocation(targetDir);
@@ -867,13 +1753,20 @@ export function installAgentDocs(
867
1753
  const written = [];
868
1754
 
869
1755
  // Explicit paths override everything
870
- if (paths && paths.length > 0) {
1756
+ if (explicit) {
871
1757
  // Path-safety: each --agent-docs-path entry must resolve inside the
872
1758
  // target directory. Reject absolute paths (silent re-rooting via
873
1759
  // path.join hides intent) and `..` traversal.
874
1760
  for (const p of paths) {
875
1761
  assertWithin(p, targetDir, {label: 'agent docs path'});
876
1762
  }
1763
+ // A file directly in the home folder, or at the top of a settings folder
1764
+ // in it, is refused, as 0.7.x never wrote there unasked; a file in a
1765
+ // plain folder of the home folder, such as ~/notes/AI.md, is written.
1766
+ // Every path is checked before anything is written.
1767
+ for (const p of paths) {
1768
+ assertAgentDocsTarget(path.dirname(path.join(targetDir, p)));
1769
+ }
877
1770
  for (const p of paths) {
878
1771
  const filePath = path.join(targetDir, p);
879
1772
  const dir = path.dirname(filePath);
@@ -892,12 +1785,43 @@ export function installAgentDocs(
892
1785
  // Agent preset
893
1786
  if (agent) {
894
1787
  const {inject, create} = resolveAgentPaths(targetDir, agent);
895
- assertTargetsWithin(targetDir, [...inject, ...create]);
896
- for (const p of inject) {
1788
+ // Claude Code reads CLAUDE.md and .claude/CLAUDE.md together: when the
1789
+ // block goes in CLAUDE.md, .claude/CLAUDE.md gets none, and a block there
1790
+ // is removed.
1791
+ const claudeRoot = inject.includes(CLAUDE_MD);
1792
+ // While root CLAUDE.md is ignored, a committed .claude/CLAUDE.md holds a
1793
+ // copy too, with `claude` and `all` alike (INV31), first run included.
1794
+ const keepDirCopy = claudeRoot && claudeDirKeepsCopy(targetDir);
1795
+ const injectAll = !claudeRoot
1796
+ ? inject
1797
+ : keepDirCopy
1798
+ ? [...new Set([...inject, CLAUDE_DIR_MD])].filter(p =>
1799
+ p !== CLAUDE_DIR_MD || fs.existsSync(path.join(targetDir, CLAUDE_DIR_MD)),
1800
+ )
1801
+ : inject.filter(p => p !== CLAUDE_DIR_MD);
1802
+ const dirHasBlock = hasManagedMarker(path.join(targetDir, CLAUDE_DIR_MD));
1803
+ const strip = claudeRoot && !keepDirCopy && dirHasBlock ? [CLAUDE_DIR_MD] : [];
1804
+ // A path that escapes the project refuses the run, as in 0.7.0; below the
1805
+ // project root, where 0.7.1 wrote nothing, it is skipped and reported. A
1806
+ // .claude/CLAUDE.md the preset adds beside an ignored root, which 0.7.1
1807
+ // did not write, is skipped and reported wherever init runs.
1808
+ // So is removing a block from .claude/CLAUDE.md with `claude`, whose
1809
+ // 0.7.1 preset wrote only root CLAUDE.md there; `all` wrote both, so its
1810
+ // escape still refuses.
1811
+ const added = injectAll.filter(p => !inject.includes(p));
1812
+ const unrefused = agent === 'claude' ? [...added, ...strip] : added;
1813
+ const escaping = refuseEscapes
1814
+ ? skipEscaping(targetDir, unrefused, onSkipped)
1815
+ : skipEscaping(targetDir, [...injectAll, ...create, ...strip], onSkipped);
1816
+ const injectHere = injectAll.filter(p => !escaping.has(p));
1817
+ const createHere = create.filter(p => !escaping.has(p));
1818
+ const stripHere = escaping.has(CLAUDE_MD) ? [] : strip.filter(p => !escaping.has(p));
1819
+ assertTargetsWithin(targetDir, [...injectHere, ...createHere, ...stripHere]);
1820
+ for (const p of injectHere) {
897
1821
  injectXdsBlock(path.join(targetDir, p), compressedIndex);
898
1822
  written.push(p);
899
1823
  }
900
- for (const p of create) {
1824
+ for (const p of createHere) {
901
1825
  const filePath = path.join(targetDir, p);
902
1826
  const dir = path.dirname(filePath);
903
1827
  if (dir !== targetDir) {
@@ -909,57 +1833,45 @@ export function installAgentDocs(
909
1833
  });
910
1834
  written.push(p);
911
1835
  }
1836
+ for (const p of stripHere) {
1837
+ if (!rootClaudeHolds(targetDir, compressedIndex)) continue;
1838
+ if (stripManagedBlock(path.join(targetDir, p), compressedIndex)) written.push(p);
1839
+ }
912
1840
  return written;
913
1841
  }
914
1842
 
915
- // Auto-detect: initialize standalone files, but do not expand the managed
916
- // block beside an `@path` import of another agent doc. Remove a block from a
917
- // wrapper if an older run already duplicated it there.
1843
+ // Auto-detect. A refresh rewrites each existing block where it is.
918
1844
  const existing = discoverAgentDocs(targetDir);
919
-
920
- if (existing.length > 0) {
921
- const wrappers = discoverAgentDocWrappers(targetDir, existing);
922
- const targets = existing.filter(p => !wrappers.has(p));
923
- // A refresh skips unmarked files and a wrapper is only written when it
924
- // carries a block, so only the files this run writes are checked.
925
- /** @param {string} p */
926
- const marked = p => hasManagedMarker(path.join(targetDir, p));
927
- assertTargetsWithin(targetDir, [
928
- ...targets.filter(p => !onlyReplace || marked(p)),
929
- ...[...wrappers].filter(marked),
930
- ]);
931
-
932
- for (const p of targets) {
933
- const didWrite = injectXdsBlock(path.join(targetDir, p), compressedIndex, {onlyReplace});
934
- if (didWrite) written.push(p);
935
- }
936
- for (const p of wrappers) {
937
- const filePath = path.join(targetDir, p);
938
- if (removeXdsBlock(filePath)) {
939
- written.push(p);
940
- } else {
941
- const content = fs.readFileSync(filePath, 'utf-8');
942
- if (content.includes(MARKER_START) || content.includes(LEGACY_MARKER_START)) {
943
- // Preserve the existing fail-closed behavior for malformed blocks.
944
- injectXdsBlock(filePath, compressedIndex, {onlyReplace: true});
945
- }
946
- }
947
- }
948
- return written;
1845
+ if (onlyReplace) {
1846
+ const wrappers = findAgentDocWrappers(readAgentDocImports(targetDir, existing));
1847
+ // The Claude file keeps its copy even when it imports AGENTS.md, so a
1848
+ // refresh rewrites that block rather than removing it.
1849
+ const {agentsAliases} = findSharedAgentDocs(targetDir, existing);
1850
+ const claudeCopy = claudeCopyFile(
1851
+ new Set(existing.filter(p => !agentsAliases.has(p))),
1852
+ agentsAliases,
1853
+ );
1854
+ if (claudeCopy != null) wrappers.delete(claudeCopy);
1855
+ // A committed .claude/CLAUDE.md kept beside an ignored root CLAUDE.md is
1856
+ // refreshed even when it imports AGENTS.md (writeEachAgentDoc).
1857
+ return writeEachAgentDoc(targetDir, existing, wrappers, compressedIndex, true, onSkipped, refuseEscapes);
949
1858
  }
950
-
951
- // Nothing exists — create root AGENTS.md as the default (skip if onlyReplace).
952
- // AGENTS.md is the tool-agnostic standard (Codex/Copilot, Cursor, Muse, and
953
- // most agents read it), so it's the safe default. Claude-specific output is
954
- // opt-in via `--agent claude` (→ .claude/CLAUDE.md); `--agent all` writes both.
955
- if (onlyReplace) return written;
956
-
957
- const defaultPath = AGENTS_MD;
958
- assertTargetsWithin(targetDir, [defaultPath]);
959
- injectXdsBlock(path.join(targetDir, defaultPath), compressedIndex, {
960
- createIfMissing: true,
961
- header: `# AGENTS.md\n\nProject-specific guidance for AI coding agents.`,
962
- });
963
- written.push(defaultPath);
964
- return written;
1859
+ // A wrapper that imports something other than AGENTS.md, or two agent docs
1860
+ // that are one file, mean the project chose its own layout: keep the
1861
+ // per-file layout then. AGENTS.md under another name is fine.
1862
+ const wrappers = findAgentDocWrappers(readAgentDocImports(targetDir, existing, [AGENTS_MD]));
1863
+ const {agentsAliases, otherShared} = findSharedAgentDocs(targetDir, existing);
1864
+ if (otherShared || [...wrappers.values()].some(targets => !targets.has(AGENTS_MD))) {
1865
+ const ownWrappers = findAgentDocWrappers(readAgentDocImports(targetDir, existing));
1866
+ return writeEachAgentDoc(targetDir, existing, ownWrappers, compressedIndex, false, onSkipped, refuseEscapes);
1867
+ }
1868
+ return writeDefaultAgentDocs(
1869
+ targetDir,
1870
+ existing,
1871
+ wrappers,
1872
+ agentsAliases,
1873
+ compressedIndex,
1874
+ onSkipped,
1875
+ refuseEscapes,
1876
+ );
965
1877
  }