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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +99 -66
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +115 -3
  4. package/api/build/blocks/blocks.d.mts +42 -0
  5. package/api/build/blocks/blocks.mjs +169 -0
  6. package/api/build/blocks/blocks.test.mjs +108 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/kit/answer.d.mts +14 -0
  10. package/api/build/kit/answer.mjs +85 -0
  11. package/api/build/kit/answer.test.mjs +38 -0
  12. package/api/build/kit/kind.d.mts +25 -0
  13. package/api/build/kit/kind.mjs +170 -0
  14. package/api/build/kit/kit.d.mts +2 -4
  15. package/api/build/kit/kit.mjs +8 -398
  16. package/api/build/kit/rank.d.mts +3 -32
  17. package/api/build/kit/rank.mjs +25 -342
  18. package/api/build/kit/rank.test.mjs +2 -1
  19. package/api/build/kit/signals.d.mts +60 -0
  20. package/api/build/kit/signals.mjs +284 -0
  21. package/api/build/kit/start.d.mts +32 -0
  22. package/api/build/kit/start.mjs +163 -0
  23. package/api/build/kit/words.d.mts +72 -0
  24. package/api/build/kit/words.mjs +202 -0
  25. package/api/build/tree.d.mts +20 -0
  26. package/api/build/tree.mjs +52 -0
  27. package/api/build/tree.test.mjs +177 -0
  28. package/api/build/tree.weights.json +1 -0
  29. package/api/component/component.mjs +16 -2
  30. package/api/component/component.test.mjs +15 -0
  31. package/api/discover/_adapter.mjs +36 -9
  32. package/api/discover/_adapter.test.mjs +42 -0
  33. package/api/docs/_adapter.mjs +17 -5
  34. package/api/docs/docs.test.mjs +7 -1
  35. package/api/doctor/doctor.mjs +86 -12
  36. package/api/doctor/doctor.test.mjs +28 -3
  37. package/api/init/init.doc.mjs +20 -6
  38. package/api/init/init.test.mjs +18 -2
  39. package/api/init/init.type.d.mts +3 -3
  40. package/api/init/init.type.mjs +3 -3
  41. package/api/init/remove/remove.mjs +17 -4
  42. package/api/init/remove/remove.test.mjs +114 -2
  43. package/api/init/run/run.mjs +69 -6
  44. package/api/integration/authoring-checks.mjs +83 -9
  45. package/api/integration/pack-check.mjs +123 -4
  46. package/api/integration/pack-check.test.mjs +87 -0
  47. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  48. package/api/integration/validate-integration-gaps.test.mjs +1964 -0
  49. package/api/integration/validate-integration.d.mts +11 -2
  50. package/api/integration/validate-integration.mjs +226 -32
  51. package/api/integration/validate-integration.type.d.mts +58 -1
  52. package/api/integration/validate-integration.type.mjs +45 -2
  53. package/api/integration/validateIntegration.doc.mjs +1 -1
  54. package/api/search/ORDERING.md +28 -0
  55. package/api/search/_adapter.d.mts +66 -2
  56. package/api/search/_adapter.mjs +118 -10
  57. package/api/search/_candidate-score.d.mts +40 -0
  58. package/api/search/_candidate-score.mjs +238 -0
  59. package/api/search/_cards.json +6098 -0
  60. package/api/search/_compound.d.mts +50 -0
  61. package/api/search/_compound.mjs +170 -0
  62. package/api/search/_doc-searcher.d.mts +4 -0
  63. package/api/search/_doc-searcher.mjs +23 -0
  64. package/api/search/_doc-searcher.test.mjs +36 -0
  65. package/api/search/_doc-searcher.weights.json +1 -0
  66. package/api/search/_doc-text.d.mts +30 -0
  67. package/api/search/_doc-text.mjs +87 -0
  68. package/api/search/_entries.d.mts +52 -0
  69. package/api/search/_entries.mjs +158 -0
  70. package/api/search/_features.d.mts +35 -0
  71. package/api/search/_features.mjs +155 -0
  72. package/api/search/_gather-components.d.mts +26 -0
  73. package/api/search/_gather-components.mjs +284 -0
  74. package/api/search/_gather-docs.d.mts +20 -0
  75. package/api/search/_gather-docs.mjs +254 -0
  76. package/api/search/_gather-hooks.d.mts +12 -0
  77. package/api/search/_gather-hooks.mjs +52 -0
  78. package/api/search/_gather-templates.d.mts +25 -0
  79. package/api/search/_gather-templates.mjs +107 -0
  80. package/api/search/_lexical.d.mts +135 -0
  81. package/api/search/_lexical.mjs +351 -0
  82. package/api/search/_main-searcher.d.mts +19 -0
  83. package/api/search/_main-searcher.mjs +115 -0
  84. package/api/search/_main-searcher.test.mjs +106 -0
  85. package/api/search/_main-searcher.weights.json +1 -0
  86. package/api/search/_module-doc.d.mts +32 -0
  87. package/api/search/_module-doc.mjs +78 -0
  88. package/api/search/_order.d.mts +32 -0
  89. package/api/search/_order.mjs +162 -0
  90. package/api/search/_order.test.mjs +72 -0
  91. package/api/search/_priority.d.mts +50 -0
  92. package/api/search/_priority.mjs +139 -0
  93. package/api/search/_query-words.d.mts +25 -0
  94. package/api/search/_query-words.mjs +92 -0
  95. package/api/search/_query.d.mts +65 -0
  96. package/api/search/_query.mjs +176 -0
  97. package/api/search/_result.d.mts +97 -0
  98. package/api/search/_result.mjs +150 -0
  99. package/api/search/_score.d.mts +60 -0
  100. package/api/search/_score.mjs +283 -0
  101. package/api/search/_synonyms.d.mts +21 -0
  102. package/api/search/_synonyms.mjs +67 -0
  103. package/api/search/_template-searcher.d.mts +4 -0
  104. package/api/search/_template-searcher.mjs +23 -0
  105. package/api/search/_template-searcher.test.mjs +40 -0
  106. package/api/search/_template-searcher.weights.json +1 -0
  107. package/api/search/_titles.d.mts +33 -0
  108. package/api/search/_titles.mjs +65 -0
  109. package/api/search/_words.d.mts +64 -0
  110. package/api/search/_words.mjs +157 -0
  111. package/api/search/search.d.mts +22 -305
  112. package/api/search/search.doc.mjs +7 -5
  113. package/api/search/search.mjs +53 -2483
  114. package/api/search/search.type.d.mts +6 -1
  115. package/api/search/search.type.mjs +6 -1
  116. package/api/search/tree.d.mts +15 -0
  117. package/api/search/tree.mjs +40 -0
  118. package/api/search/tree.test.mjs +66 -0
  119. package/api/search/tree.weights.json +1 -0
  120. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  121. package/api/upgrade/_adapter.d.mts +57 -9
  122. package/api/upgrade/_adapter.mjs +198 -28
  123. package/api/upgrade/registry/registry.d.mts +10 -4
  124. package/api/upgrade/registry/registry.mjs +33 -16
  125. package/api/upgrade/registry/registry.test.mjs +85 -0
  126. package/api/upgrade/run/run.d.mts +4 -2
  127. package/api/upgrade/run/run.mjs +13 -2
  128. package/api/upgrade/run/run.test.mjs +417 -3
  129. package/api/upgrade/upgrade.doc.mjs +10 -5
  130. package/api/upgrade/upgrade.mjs +13 -2
  131. package/api/upgrade/upgrade.test.mjs +194 -0
  132. package/api/upgrade/upgrade.type.d.mts +5 -3
  133. package/api/upgrade/upgrade.type.mjs +8 -6
  134. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  135. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  136. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  137. package/assets/docs/tree/api.doc.mjs +6 -0
  138. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  139. package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
  140. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  141. package/assets/docs/working-with-ai.doc.mjs +6 -2
  142. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  143. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  144. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  145. package/authoring/debug/parse.d.mts +1 -1
  146. package/clients/cli/bin/astryx.mjs +7 -78
  147. package/clients/cli/commands/doctor.mjs +30 -0
  148. package/clients/cli/commands/doctor.test.mjs +65 -0
  149. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  150. package/clients/cli/commands/init.doc.mjs +28 -9
  151. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  152. package/clients/cli/commands/init.mjs +2 -2
  153. package/clients/cli/commands/outside-project.test.mjs +2 -1
  154. package/clients/cli/commands/setup-nudge.test.mjs +61 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  156. package/clients/cli/commands/upgrade.mjs +9 -2
  157. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  158. package/clients/cli/index.mjs +19 -1
  159. package/clients/cli/run.mjs +108 -0
  160. package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
  161. package/foundation/agent-docs/agent-doc-state.mjs +35 -8
  162. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  163. package/foundation/agent-docs/agent-docs.mjs +1010 -99
  164. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  165. package/foundation/agent-docs/agent-docs.test.mjs +678 -35
  166. package/foundation/config/project.mjs +20 -22
  167. package/foundation/config/project.test.mjs +56 -0
  168. package/foundation/discovery/component-discovery.d.mts +37 -0
  169. package/foundation/discovery/component-discovery.mjs +80 -5
  170. package/foundation/discovery/component-loader.d.mts +1 -1
  171. package/foundation/discovery/docs-discovery.mjs +14 -1
  172. package/foundation/discovery/module-exports.d.mts +41 -0
  173. package/foundation/discovery/module-exports.mjs +253 -0
  174. package/foundation/discovery/template-adapter.mjs +18 -1
  175. package/foundation/discovery/template-needs.mjs +1 -44
  176. package/foundation/discovery/theme-discovery.mjs +147 -3
  177. package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
  178. package/foundation/doc-compiler/read.mjs +59 -1
  179. package/foundation/doc-compiler/read.test.mjs +11 -2
  180. package/foundation/fs/paths.d.mts +70 -8
  181. package/foundation/fs/paths.mjs +556 -38
  182. package/foundation/fs/paths.test.mjs +430 -54
  183. package/foundation/integrations/autolink.d.mts +2 -1
  184. package/foundation/integrations/autolink.mjs +4 -13
  185. package/foundation/integrations/computer-integrations.d.mts +6 -3
  186. package/foundation/integrations/computer-integrations.mjs +38 -9
  187. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  188. package/foundation/integrations/contribution-inventory.mjs +41 -5
  189. package/foundation/integrations/integrations.d.mts +27 -1
  190. package/foundation/integrations/integrations.mjs +61 -2
  191. package/foundation/integrations/validate-contributions.d.mts +115 -0
  192. package/foundation/integrations/validate-contributions.mjs +1168 -13
  193. package/foundation/release/handoff.d.mts +23 -4
  194. package/foundation/release/handoff.mjs +175 -8
  195. package/foundation/release/run-context.mjs +14 -0
  196. package/foundation/response/response-types.doc.mjs +1 -1
  197. package/foundation/route/route.d.mts +285 -0
  198. package/foundation/route/route.mjs +324 -0
  199. package/foundation/route/route.test.mjs +153 -0
  200. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  201. package/foundation/runtime/locked-versions.d.mts +32 -0
  202. package/foundation/runtime/locked-versions.mjs +65 -0
  203. package/foundation/runtime/package-root.d.mts +91 -0
  204. package/foundation/runtime/package-root.mjs +174 -0
  205. package/foundation/runtime/package-set.d.mts +62 -0
  206. package/foundation/runtime/package-set.mjs +130 -0
  207. package/foundation/runtime/package-set.test.mjs +432 -0
  208. package/host/door-conformance.test.mjs +274 -0
  209. package/host/index.d.ts +116 -0
  210. package/host/index.mjs +295 -0
  211. package/host/index.test.mjs +302 -0
  212. package/package.json +14 -9
@@ -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
  }
@@ -700,14 +1024,16 @@ export function injectClaudeMd(targetDir, version) {
700
1024
  }
701
1025
 
702
1026
  /**
703
- * Remove Astryx section from a file.
704
- * If the file becomes empty (only boilerplate header remains), deletes it.
1027
+ * Remove the Astryx-managed block, between its markers, from a file, leaving
1028
+ * every other line as it is. With `deleteIfEmpty`, the file is deleted when
1029
+ * nothing is left but whitespace or the exact heading init writes into a file
1030
+ * it creates; with `deleteIfBlank`, only when nothing but whitespace is left.
705
1031
  *
706
1032
  * @param {string} filePath
707
- * @param {{deleteIfEmpty?: boolean}} [options]
1033
+ * @param {{deleteIfEmpty?: boolean, deleteIfBlank?: boolean}} [options]
708
1034
  * @returns {boolean} Whether the Astryx section was found and removed
709
1035
  */
710
- export function removeXdsBlock(filePath, {deleteIfEmpty = false} = {}) {
1036
+ export function removeXdsBlock(filePath, {deleteIfEmpty = false, deleteIfBlank = false} = {}) {
711
1037
  if (!fs.existsSync(filePath)) return false;
712
1038
 
713
1039
  let content = fs.readFileSync(filePath, 'utf-8');
@@ -719,18 +1045,198 @@ export function removeXdsBlock(filePath, {deleteIfEmpty = false} = {}) {
719
1045
  const after = content.slice(block.end).trimStart();
720
1046
  content = before + (after ? '\n\n' + after : '') + '\n';
721
1047
 
722
- if (deleteIfEmpty) {
723
- const stripped = content.replace(/^#.*\n+.*guidance.*\n*/m, '').trim();
724
- if (!stripped) {
725
- fs.unlinkSync(filePath);
726
- return true;
727
- }
1048
+ const rest = content.trim();
1049
+ if (
1050
+ (deleteIfBlank && rest === '') ||
1051
+ (deleteIfEmpty && (rest === '' || CREATED_HEADING.test(rest)))
1052
+ ) {
1053
+ fs.unlinkSync(filePath);
1054
+ return true;
728
1055
  }
729
1056
 
730
1057
  fs.writeFileSync(filePath, content);
731
1058
  return true;
732
1059
  }
733
1060
 
1061
+ /**
1062
+ * The whole of a file init created, once its block is gone: the heading init
1063
+ * writes, `# <name>` and its one line, and nothing else.
1064
+ */
1065
+ const CREATED_HEADING =
1066
+ /^# [^\n]+\n\nProject-specific guidance for AI coding agents\.$/u;
1067
+
1068
+ /**
1069
+ * Whether the project's ignore rules exclude root CLAUDE.md, read with the
1070
+ * working-tree ignore reader upgrade uses, from the repository root down to
1071
+ * the project root. It decides only where the copy
1072
+ * goes (a fresh clone lacks an ignored file); the managed block is written
1073
+ * whatever the ignore rules say (INV31). An ignore file that cannot be read
1074
+ * counts as excluding root CLAUDE.md, so the copy in .claude/CLAUDE.md stays.
1075
+ * @param {string} targetDir
1076
+ * @returns {boolean}
1077
+ */
1078
+ function rootClaudeIgnored(targetDir) {
1079
+ return uncommittedHere(targetDir, CLAUDE_MD, true);
1080
+ }
1081
+
1082
+ /**
1083
+ * Whether a fresh clone lacks `rel` in `targetDir`: it is untracked and
1084
+ * ignored. Inside a repository the repository answers, with every ignore
1085
+ * source it reads (its ignore files, its local exclude file and configured
1086
+ * excludes), and a tracked file is committed whatever they say, as git and
1087
+ * Mercurial ignore only untracked files. Outside one, the project's ignore
1088
+ * files answer. `unreadable` is the answer when the repository, or an ignore
1089
+ * file, cannot answer.
1090
+ * @param {string} targetDir
1091
+ * @param {string} rel
1092
+ * @param {boolean} unreadable
1093
+ * @returns {boolean}
1094
+ */
1095
+ function uncommittedHere(targetDir, rel, unreadable) {
1096
+ const root = repositoryRootOf(targetDir);
1097
+ if (!root) return ignoredHere(targetDir, rel, unreadable);
1098
+ return untrackedIgnored(root, targetDir, rel) ?? unreadable;
1099
+ }
1100
+
1101
+ /**
1102
+ * How each repository kind lists `rel` when it is untracked and ignored:
1103
+ * `git check-ignore` exits 0 for such a file and 1 otherwise (it never
1104
+ * reports a tracked file); `status -i -n` prints it, or nothing.
1105
+ */
1106
+ const IGNORED_QUERIES = [
1107
+ {marker: '.git', command: 'git', args: ['check-ignore', '-q', '--'], by: 'exit'},
1108
+ {marker: '.sl', command: 'sl', args: ['status', '-i', '-n', '--'], by: 'output'},
1109
+ {marker: '.hg', command: 'hg', args: ['status', '-i', '-n', '--'], by: 'output'},
1110
+ ];
1111
+
1112
+ /**
1113
+ * Whether the repository at `root` holds `rel` in `targetDir` as untracked
1114
+ * and ignored; null, which keeps the copy, when it cannot answer: its tool is
1115
+ * not installed or cannot start, runs past the time limit, or exits with a
1116
+ * code other than its documented answers.
1117
+ * @param {string} root
1118
+ * @param {string} targetDir
1119
+ * @param {string} rel
1120
+ * @returns {boolean | null}
1121
+ */
1122
+ function untrackedIgnored(root, targetDir, rel) {
1123
+ const query = IGNORED_QUERIES.find(q => fs.existsSync(path.join(root, q.marker)));
1124
+ if (!query) return null;
1125
+ const result = spawnSync(query.command, [...query.args, rel], {
1126
+ cwd: targetDir,
1127
+ encoding: 'utf-8',
1128
+ stdio: ['ignore', 'pipe', 'ignore'],
1129
+ timeout: 10_000,
1130
+ windowsHide: true,
1131
+ shell: false,
1132
+ });
1133
+ if (result.error) return null;
1134
+ if (query.by === 'exit') {
1135
+ if (result.status === 0) return true;
1136
+ if (result.status === 1) return false;
1137
+ return null;
1138
+ }
1139
+ if (result.status !== 0) return null;
1140
+ return result.stdout.trim() !== '';
1141
+ }
1142
+
1143
+ /**
1144
+ * Whether the ignore files in `targetDir` exclude `rel`, read with the
1145
+ * working-tree ignore reader upgrade uses; for a folder outside any
1146
+ * repository. `unreadable` when an ignore file cannot be read.
1147
+ * @param {string} targetDir
1148
+ * @param {string} rel
1149
+ * @param {boolean} unreadable
1150
+ * @returns {boolean}
1151
+ */
1152
+ function ignoredHere(targetDir, rel, unreadable) {
1153
+ try {
1154
+ return createFileProtectionResolver(targetDir)
1155
+ .classify(path.join(targetDir, rel))
1156
+ .some(item => item.reason === 'ignored');
1157
+ } catch {
1158
+ return unreadable;
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * The repository root at or above `dir`: the nearest folder holding `.git`,
1164
+ * `.hg`, or `.sl`. Null outside a repository.
1165
+ * @param {string} dir
1166
+ * @returns {string | null}
1167
+ */
1168
+ function repositoryRootOf(dir) {
1169
+ for (let current = path.resolve(dir); ; ) {
1170
+ if (REPOSITORY_MARKERS.some(marker => fs.existsSync(path.join(current, marker)))) {
1171
+ return current;
1172
+ }
1173
+ const parent = path.dirname(current);
1174
+ if (parent === current) return null;
1175
+ current = parent;
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Whether .claude/CLAUDE.md keeps a copy beside root CLAUDE.md (INV31): while
1181
+ * a fresh clone lacks root CLAUDE.md (the project ignores it and does not
1182
+ * track it), a .claude/CLAUDE.md the project commits (tracked, or not
1183
+ * ignored) gets the copy too (DEC-2: an extra copy beats none). One the
1184
+ * project ignores and does not track follows the usual rule: its block goes
1185
+ * once root CLAUDE.md holds one. An ignore file, or tracking, that cannot be
1186
+ * read keeps the copy.
1187
+ * @param {string} targetDir
1188
+ * @returns {boolean}
1189
+ */
1190
+ function claudeDirKeepsCopy(targetDir) {
1191
+ if (!rootClaudeIgnored(targetDir)) return false;
1192
+ return !uncommittedHere(targetDir, CLAUDE_DIR_MD, false);
1193
+ }
1194
+
1195
+ /**
1196
+ * `rels` without the files that resolve outside `targetDir`, each reported
1197
+ * through `onSkipped`.
1198
+ * @param {string} targetDir
1199
+ * @param {readonly string[]} rels
1200
+ * @param {((rel: string, reason: string) => void) | undefined} onSkipped
1201
+ * @returns {Set<string>} the files skipped
1202
+ */
1203
+ function skipEscaping(targetDir, rels, onSkipped) {
1204
+ /** @type {Set<string>} */
1205
+ const skipped = new Set();
1206
+ for (const p of rels) {
1207
+ try {
1208
+ assertWithin(p, targetDir, {label: 'agent docs path'});
1209
+ } catch (err) {
1210
+ if (!(err instanceof PathSafetyError)) throw err;
1211
+ skipped.add(p);
1212
+ onSkipped?.(p, err.message);
1213
+ }
1214
+ }
1215
+ return skipped;
1216
+ }
1217
+
1218
+ /**
1219
+ * Whether root CLAUDE.md holds `block` as its managed block, so a copy in
1220
+ * .claude/CLAUDE.md may go.
1221
+ * @param {string} targetDir
1222
+ * @param {string} block
1223
+ * @returns {boolean}
1224
+ */
1225
+ function rootClaudeHolds(targetDir, block) {
1226
+ // A root CLAUDE.md the project ignores is not in a fresh clone, so it never
1227
+ // stands in for a committed copy in .claude/CLAUDE.md (an extra copy beats
1228
+ // none, DEC-2). An ignored .claude/CLAUDE.md is not in one either, so the
1229
+ // usual rule holds for it.
1230
+ if (claudeDirKeepsCopy(targetDir)) return false;
1231
+ try {
1232
+ const content = fs.readFileSync(path.join(targetDir, CLAUDE_MD), 'utf-8');
1233
+ const found = findManagedBlock(content);
1234
+ return found != null && content.slice(found.start, found.end) === block.trim();
1235
+ } catch {
1236
+ return false;
1237
+ }
1238
+ }
1239
+
734
1240
  /**
735
1241
  * Whether an agent-doc file already carries a managed-block marker.
736
1242
  * @param {string} filePath
@@ -773,24 +1279,40 @@ function assertTargetsWithin(targetDir, relPaths) {
773
1279
  }
774
1280
 
775
1281
  /**
776
- * Remove Astryx section from all known agent doc files.
1282
+ * Throw `ERR_PATH_TRAVERSAL` when an agent doc in `targetDir` that holds a
1283
+ * managed block resolves outside `targetDir`.
777
1284
  * @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
1285
  */
782
- export function removeAgentDocs(targetDir) {
1286
+ function assertRemovableWithin(targetDir, skip = new Set()) {
783
1287
  const allPaths = discoverAgentDocs(targetDir);
784
1288
  try {
785
1289
  assertTargetsWithin(
786
1290
  targetDir,
787
- allPaths.filter(p => hasRemovableBlock(path.join(targetDir, p))),
1291
+ allPaths.filter(
1292
+ p => !skip.has(p) && hasRemovableBlock(path.join(targetDir, p)),
1293
+ ),
788
1294
  );
789
1295
  } catch (err) {
790
1296
  // The code reaches the error envelope as is, so it must be registered.
791
1297
  if (!(err instanceof PathSafetyError)) throw err;
792
1298
  throw new PathSafetyError(err.message, ERROR_CODES.ERR_PATH_TRAVERSAL);
793
1299
  }
1300
+ }
1301
+
1302
+ /**
1303
+ * Remove Astryx section from all known agent doc files.
1304
+ * @param {string} targetDir
1305
+ * @param {object} [options]
1306
+ * @param {string} [options.shownFrom] - Folder the logged paths are shown
1307
+ * relative to; defaults to `targetDir`.
1308
+ * @param {Set<string>} [options.skip] - Files left as they are.
1309
+ * @returns {string[]} the project-relative files a block was removed from.
1310
+ * @throws {PathSafetyError} `ERR_PATH_TRAVERSAL` when a file it would change
1311
+ * resolves outside `targetDir`; nothing is changed.
1312
+ */
1313
+ export function removeAgentDocs(targetDir, {shownFrom = targetDir, skip = new Set()} = {}) {
1314
+ assertRemovableWithin(targetDir, skip);
1315
+ const allPaths = discoverAgentDocs(targetDir).filter(p => !skip.has(p));
794
1316
 
795
1317
  /** @type {string[]} */
796
1318
  const removedFrom = [];
@@ -799,27 +1321,374 @@ export function removeAgentDocs(targetDir) {
799
1321
  // Delete if empty for files we created (AGENTS.md, .claude/CLAUDE.md)
800
1322
  const deleteIfEmpty = p === AGENTS_MD || p === CLAUDE_DIR_MD;
801
1323
  if (removeXdsBlock(filePath, {deleteIfEmpty})) {
1324
+ // The default creates GEMINI.md holding only the import of AGENTS.md.
1325
+ if (p === GEMINI_MD && fs.readFileSync(filePath, 'utf-8').trim() === '') {
1326
+ fs.unlinkSync(filePath);
1327
+ }
802
1328
  removedFrom.push(p);
1329
+ const shown = path.relative(shownFrom, filePath).split(path.sep).join('/') || p;
803
1330
  if (!fs.existsSync(filePath)) {
804
- humanLog(`[ok] Removed empty ${p}`);
1331
+ humanLog(`[ok] Removed empty ${shown}`);
805
1332
  } else {
806
- humanLog(`[ok] Removed design system section from ${p}`);
1333
+ humanLog(`[ok] Removed design system section from ${shown}`);
807
1334
  }
808
1335
  }
809
1336
  }
810
1337
  return removedFrom;
811
1338
  }
812
1339
 
1340
+ /**
1341
+ * Remove the managed block where init writes it, the project root (see
1342
+ * {@link resolveAgentDocsRoot}), and in `cwd`, so a block an earlier version
1343
+ * wrote in that folder goes too. Each folder is checked before anything is
1344
+ * changed. Run below the project root, a project-root agent file holding a
1345
+ * block that resolves outside the project is left as it is and reported to
1346
+ * `onSkipped`, as install does there; 0.7.x touched only `cwd`. A file in
1347
+ * `cwd`, or at the root when run there, that resolves outside refuses the run.
1348
+ *
1349
+ * @param {string} cwd
1350
+ * @param {{onSkipped?: (rel: string, reason: string) => void}} [options]
1351
+ * @returns {string[]} the files a block was removed from, relative to `cwd`
1352
+ * @throws {PathSafetyError} `ERR_PATH_TRAVERSAL`; nothing is changed
1353
+ */
1354
+ export function removeProjectAgentDocs(cwd, {onSkipped} = {}) {
1355
+ const root = agentDocsTargetDir(cwd);
1356
+ const below = realDir(root) !== realDir(cwd);
1357
+ /** @type {Set<string>} */
1358
+ const rootSkip = below
1359
+ ? skipEscaping(
1360
+ root,
1361
+ discoverAgentDocs(root).filter(p => hasRemovableBlock(path.join(root, p))),
1362
+ (rel, reason) =>
1363
+ onSkipped?.(path.relative(cwd, path.join(root, rel)).split(path.sep).join('/'), reason),
1364
+ )
1365
+ : new Set();
1366
+ const dirs = below ? [root, cwd] : [cwd];
1367
+ for (const dir of dirs) assertRemovableWithin(dir, dir === root ? rootSkip : undefined);
1368
+ return dirs.flatMap(dir =>
1369
+ removeAgentDocs(dir, {shownFrom: cwd, skip: dir === root ? rootSkip : undefined}).map(rel =>
1370
+ path.relative(cwd, path.join(dir, rel)).split(path.sep).join('/'),
1371
+ ),
1372
+ );
1373
+ }
1374
+
1375
+ /**
1376
+ * Write the block into each standalone agent doc and remove a managed block
1377
+ * from each import wrapper. A refresh (`onlyReplace`) skips files without a
1378
+ * block and leaves a managed import of AGENTS.md as it is.
1379
+ *
1380
+ * @param {string} targetDir
1381
+ * @param {string[]} existing
1382
+ * @param {Map<string, Set<string>>} givenWrappers
1383
+ * @param {string} compressedIndex
1384
+ * @param {boolean} onlyReplace
1385
+ * @param {((rel: string, reason: string) => void) | undefined} onSkipped
1386
+ * @param {boolean} [refuseEscapes] whether a file 0.7.1 wrote that escapes
1387
+ * `targetDir` refuses the run
1388
+ * @returns {string[]} files written
1389
+ */
1390
+ function writeEachAgentDoc(targetDir, existing, givenWrappers, compressedIndex, onlyReplace, onSkipped, refuseEscapes = true) {
1391
+ /** @param {string} p */
1392
+ const marked = p => hasManagedMarker(path.join(targetDir, p));
1393
+ // A committed .claude/CLAUDE.md beside an ignored root CLAUDE.md keeps a
1394
+ // copy (INV31): one that already holds a block is refreshed where it is,
1395
+ // even when it imports another agent file, rather than left stale.
1396
+ const keptCopy =
1397
+ givenWrappers.has(CLAUDE_DIR_MD) && marked(CLAUDE_DIR_MD) && claudeDirKeepsCopy(targetDir);
1398
+ const wrappers = new Map(givenWrappers);
1399
+ if (keptCopy) wrappers.delete(CLAUDE_DIR_MD);
1400
+ /** @param {string} p */
1401
+ const isImport = p => {
1402
+ try {
1403
+ return isAgentsImportBlock(p, fs.readFileSync(path.join(targetDir, p), 'utf-8'));
1404
+ } catch {
1405
+ return false;
1406
+ }
1407
+ };
1408
+ // Every agent file that imports no other agent file holds a copy, GEMINI.md
1409
+ // included (INV31); a managed import of AGENTS.md is left as it is.
1410
+ const targets = existing.filter(p => !wrappers.has(p) && !isImport(p));
1411
+ const writes = targets.filter(p => !onlyReplace || marked(p));
1412
+ const strips = [...wrappers.keys()].filter(marked);
1413
+ // A file 0.7.1 did not write here (GEMINI.md, or any file when init runs
1414
+ // below the project root) that resolves outside the project is skipped and
1415
+ // reported, as in the default layout, and the rest is written. A path of a
1416
+ // file 0.7.1 wrote that escapes the project refuses the run, as in 0.7.1.
1417
+ const skipped = skipEscaping(
1418
+ targetDir,
1419
+ // A kept copy here already holds a block, which 0.7.x wrote, so its
1420
+ // escape refuses the run like any file 0.7.x wrote.
1421
+ [...writes, ...strips].filter(p => !refuseEscapes || p === GEMINI_MD),
1422
+ onSkipped,
1423
+ );
1424
+ assertTargetsWithin(
1425
+ targetDir,
1426
+ [...writes, ...strips].filter(p => !skipped.has(p)),
1427
+ );
1428
+ // A refresh skips unmarked files and a wrapper is only written when it
1429
+ // carries a block, so only the files this run writes are checked.
1430
+ /** @type {string[]} */
1431
+ const written = [];
1432
+ for (const p of targets) {
1433
+ if (skipped.has(p)) continue;
1434
+ const didWrite = injectXdsBlock(path.join(targetDir, p), compressedIndex, {onlyReplace});
1435
+ if (didWrite) written.push(p);
1436
+ }
1437
+ for (const p of wrappers.keys()) {
1438
+ if (skipped.has(p)) continue;
1439
+ // The copy in .claude/CLAUDE.md goes only once root CLAUDE.md holds it.
1440
+ if (
1441
+ p === CLAUDE_DIR_MD &&
1442
+ (skipped.has(CLAUDE_MD) || !rootClaudeHolds(targetDir, compressedIndex))
1443
+ ) {
1444
+ continue;
1445
+ }
1446
+ if (stripManagedBlock(path.join(targetDir, p), compressedIndex)) written.push(p);
1447
+ }
1448
+ return written;
1449
+ }
1450
+
1451
+ /**
1452
+ * The Claude Code file that holds the copy of the block: root CLAUDE.md, which
1453
+ * projects commit even when they ignore .claude/, else .claude/CLAUDE.md. It
1454
+ * holds the copy even when it imports AGENTS.md: Claude Code does not follow an
1455
+ * import of a file above the folder it starts in, so a session started in a
1456
+ * subfolder reads only the copy.
1457
+ *
1458
+ * @param {Set<string>} present Existing agent docs that are not AGENTS.md under
1459
+ * another name.
1460
+ * @param {Set<string>} aliases Files that are AGENTS.md under another name.
1461
+ * @returns {string | null} null when the project has no Claude file, or when
1462
+ * root CLAUDE.md is AGENTS.md under another name and so holds the block.
1463
+ */
1464
+ function claudeCopyFile(present, aliases) {
1465
+ if (aliases.has(CLAUDE_MD)) return null;
1466
+ return [CLAUDE_MD, CLAUDE_DIR_MD].find(p => present.has(p)) ?? null;
1467
+ }
1468
+
1469
+ /**
1470
+ * Plan the default layout. AGENTS.md holds the block. The Claude Code file the
1471
+ * project already has holds a copy (see {@link claudeCopyFile}), so the block
1472
+ * sits in a file the project commits; init creates no Claude file. GEMINI.md
1473
+ * imports AGENTS.md unless it already does. Every other wrapper here imports
1474
+ * AGENTS.md, so it already reaches the block.
1475
+ *
1476
+ * @param {string} targetDir
1477
+ * @param {string[]} existing
1478
+ * @param {Map<string, Set<string>>} wrappers
1479
+ * @param {Set<string>} aliases Files that are AGENTS.md under another name;
1480
+ * they are never written.
1481
+ * @returns {Map<string, 'block' | 'import' | 'strip'>}
1482
+ */
1483
+ function planDefaultAgentDocs(targetDir, existing, wrappers, aliases) {
1484
+ const present = new Set(existing.filter(p => !aliases.has(p)));
1485
+ /** @type {Map<string, 'block' | 'import' | 'strip'>} */
1486
+ const plan = new Map([[AGENTS_MD, 'block']]);
1487
+
1488
+ // Claude Code reads CLAUDE.md and .claude/CLAUDE.md, and skips AGENTS.md
1489
+ // when either exists. One Claude file holds the copy, so Claude Code reads
1490
+ // it once in a subfolder and at most twice at the root, where it also
1491
+ // follows that file's import of AGENTS.md.
1492
+ const claudeCopy = claudeCopyFile(present, aliases);
1493
+ for (const p of [CLAUDE_MD, CLAUDE_DIR_MD]) {
1494
+ if (present.has(p)) plan.set(p, p === claudeCopy ? 'block' : 'strip');
1495
+ }
1496
+
1497
+ // Gemini CLI reads GEMINI.md. Project settings can point it at AGENTS.md
1498
+ // too, but Gemini loads them only when it starts at the project root, so the
1499
+ // import is written anyway: two copies at the root beat none in a subfolder.
1500
+ // An import GEMINI.md already has, even inline, is enough.
1501
+ if (!aliases.has(GEMINI_MD)) {
1502
+ if (geminiImportsAgentsMd(targetDir)) {
1503
+ if (present.has(GEMINI_MD)) plan.set(GEMINI_MD, 'strip');
1504
+ } else {
1505
+ plan.set(GEMINI_MD, 'import');
1506
+ }
1507
+ }
1508
+
1509
+ // Every other file: a full block when standalone, none in a wrapper.
1510
+ for (const p of present) {
1511
+ if (!plan.has(p)) plan.set(p, wrappers.has(p) ? 'strip' : 'block');
1512
+ }
1513
+ return plan;
1514
+ }
1515
+
1516
+ /**
1517
+ * Write the default layout (see {@link planDefaultAgentDocs}).
1518
+ *
1519
+ * Path safety follows 0.7.0: an agent doc it would have written that resolves
1520
+ * outside `targetDir` refuses the whole run before any write. A file only this
1521
+ * layout writes, such as GEMINI.md, is skipped instead, reported through
1522
+ * `onSkipped`, and the rest is written.
1523
+ *
1524
+ * @param {string} targetDir
1525
+ * @param {string[]} existing
1526
+ * @param {Map<string, Set<string>>} wrappers
1527
+ * @param {Set<string>} aliases
1528
+ * @param {string} compressedIndex
1529
+ * @param {((rel: string, reason: string) => void) | undefined} onSkipped
1530
+ * @param {boolean} [refuseEscapes] whether a file 0.7.1 wrote that escapes
1531
+ * `targetDir` refuses the run
1532
+ * @returns {string[]} files written, in {@link AGENT_DOC_PATHS} order
1533
+ */
1534
+ function writeDefaultAgentDocs(targetDir, existing, wrappers, aliases, compressedIndex, onSkipped, refuseEscapes = true) {
1535
+ const plan = planDefaultAgentDocs(targetDir, existing, wrappers, aliases);
1536
+ // While root CLAUDE.md is one the project ignores, .claude/CLAUDE.md gets
1537
+ // the copy too, first run included: a fresh clone has only that file.
1538
+ if (plan.get(CLAUDE_DIR_MD) === 'strip' && claudeDirKeepsCopy(targetDir)) {
1539
+ plan.set(CLAUDE_DIR_MD, 'block');
1540
+ }
1541
+ // 0.7.x left a block-less import of an existing AGENTS.md alone, so a Claude
1542
+ // file of that shape that now gets the copy and resolves outside the project
1543
+ // is skipped and reported. Any other agent file 0.7.x wrote, so its escape
1544
+ // still refuses the run.
1545
+ /** @param {string} p */
1546
+ const leftAlone = p =>
1547
+ existing.includes(AGENTS_MD) &&
1548
+ wrappers.get(p)?.has(AGENTS_MD) === true &&
1549
+ !hasManagedMarker(path.join(targetDir, p));
1550
+ const foundLegacy = existing.filter(p => LEGACY_DOC_PATHS.includes(p));
1551
+ /** @param {string} p */
1552
+ const legacy = p =>
1553
+ !leftAlone(p) &&
1554
+ (foundLegacy.includes(p) || (p === AGENTS_MD && foundLegacy.length === 0));
1555
+
1556
+ /** @type {Set<string>} */
1557
+ const escaped = new Set();
1558
+ for (const [p, action] of [...plan]) {
1559
+ // A strip writes only a file that carries a block.
1560
+ if (action === 'strip' && !hasManagedMarker(path.join(targetDir, p))) {
1561
+ plan.delete(p);
1562
+ continue;
1563
+ }
1564
+ try {
1565
+ assertWithin(p, targetDir, {label: 'agent docs path'});
1566
+ } catch (err) {
1567
+ if (!(err instanceof PathSafetyError) || (refuseEscapes && legacy(p))) throw err;
1568
+ plan.delete(p);
1569
+ escaped.add(p);
1570
+ onSkipped?.(p, err.message);
1571
+ }
1572
+ }
1573
+ // An import of an AGENTS.md left as it is would point at no block, so the
1574
+ // GEMINI.md import is not written either.
1575
+ if (escaped.has(AGENTS_MD) && plan.get(GEMINI_MD) === 'import') {
1576
+ plan.delete(GEMINI_MD);
1577
+ onSkipped?.(GEMINI_MD, 'its import of AGENTS.md would point at a file left as it is');
1578
+ }
1579
+
1580
+ /** @type {string[]} */
1581
+ const written = [];
1582
+ for (const p of AGENT_DOC_PATHS) {
1583
+ const action = plan.get(p);
1584
+ if (action == null) continue;
1585
+ const filePath = path.join(targetDir, p);
1586
+ if (action === 'strip') {
1587
+ // The copy in .claude/CLAUDE.md goes only once root CLAUDE.md holds it.
1588
+ if (
1589
+ p === CLAUDE_DIR_MD &&
1590
+ (escaped.has(CLAUDE_MD) || !rootClaudeHolds(targetDir, compressedIndex))
1591
+ ) {
1592
+ continue;
1593
+ }
1594
+ if (stripManagedBlock(filePath, compressedIndex)) written.push(p);
1595
+ continue;
1596
+ }
1597
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
1598
+ injectXdsBlock(
1599
+ filePath,
1600
+ action === 'block' ? compressedIndex : renderAgentsImportBlock(p),
1601
+ {createIfMissing: true, header: CREATED_HEADERS[p] ?? ''},
1602
+ );
1603
+ written.push(p);
1604
+ }
1605
+ return written;
1606
+ }
1607
+
1608
+ /**
1609
+ * Heading of the AGENTS.md the default creates; the same text the presets
1610
+ * write, so `--remove-agents` recognizes and deletes it.
1611
+ * @type {Readonly<Record<string, string>>}
1612
+ */
1613
+ const CREATED_HEADERS = Object.freeze({
1614
+ [AGENTS_MD]: '# AGENTS.md\n\nProject-specific guidance for AI coding agents.',
1615
+ });
1616
+
1617
+ /**
1618
+ * Agent docs that are one file under several names (symlinks), resolved inside
1619
+ * or outside the project.
1620
+ * @param {string} targetDir
1621
+ * @param {string[]} existing
1622
+ * @returns {{agentsAliases: Set<string>, otherShared: boolean}} the files that
1623
+ * are AGENTS.md under another name, and whether any other two docs share a
1624
+ * file.
1625
+ */
1626
+ function findSharedAgentDocs(targetDir, existing) {
1627
+ /** @type {Map<string, string[]>} */
1628
+ const byFile = new Map();
1629
+ for (const p of existing) {
1630
+ let real;
1631
+ try {
1632
+ real = fs.realpathSync(path.join(targetDir, p));
1633
+ } catch {
1634
+ real = path.resolve(targetDir, p);
1635
+ }
1636
+ byFile.set(real, [...(byFile.get(real) ?? []), p]);
1637
+ }
1638
+ /** @type {Set<string>} */
1639
+ const agentsAliases = new Set();
1640
+ let otherShared = false;
1641
+ for (const names of byFile.values()) {
1642
+ if (names.length < 2) continue;
1643
+ if (!names.includes(AGENTS_MD)) {
1644
+ otherShared = true;
1645
+ continue;
1646
+ }
1647
+ for (const name of names) if (name !== AGENTS_MD) agentsAliases.add(name);
1648
+ }
1649
+ return {agentsAliases, otherShared};
1650
+ }
1651
+
1652
+ /**
1653
+ * Remove the managed block from a file that should not carry one, leaving the
1654
+ * rest of the file as it is. A malformed block is refused (throws) rather than
1655
+ * guessed at. `.claude/CLAUDE.md` is deleted when nothing but whitespace is
1656
+ * left.
1657
+ * @param {string} filePath
1658
+ * @param {string} compressedIndex
1659
+ * @returns {boolean} whether the file changed
1660
+ */
1661
+ function stripManagedBlock(filePath, compressedIndex) {
1662
+ const deleteIfBlank =
1663
+ path.basename(filePath) === path.basename(CLAUDE_DIR_MD) &&
1664
+ path.basename(path.dirname(filePath)) === path.dirname(CLAUDE_DIR_MD);
1665
+ if (removeXdsBlock(filePath, {deleteIfBlank})) return true;
1666
+ if (hasManagedMarker(filePath)) {
1667
+ // Preserve the existing fail-closed behavior for malformed blocks.
1668
+ injectXdsBlock(filePath, compressedIndex, {onlyReplace: true});
1669
+ }
1670
+ return false;
1671
+ }
1672
+
813
1673
  /**
814
1674
  * Programmatic entry point for installing agent docs.
815
1675
  * Used by the init command, upgrade command, and agent-docs command.
816
1676
  *
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.
1677
+ * Default strategy (no agent/paths specified), so every agent reads the block:
1678
+ * - Write the block to AGENTS.md (created when missing).
1679
+ * - Write the block to the project's Claude Code file: root CLAUDE.md when it
1680
+ * exists, else an existing .claude/CLAUDE.md, even when it imports AGENTS.md.
1681
+ * Remove a block from the other one. Create no Claude file.
1682
+ * - Write a managed import of AGENTS.md to GEMINI.md (created when missing),
1683
+ * unless GEMINI.md already imports AGENTS.md.
1684
+ * - Keep a full block in every other existing standalone file.
1685
+ * - Leave every other `@path` import wrapper untouched; if an older run
1686
+ * expanded a block into one, remove that duplicate block. When a wrapper
1687
+ * imports a file other than AGENTS.md, the project keeps its own layout:
1688
+ * every standalone file gets the block.
1689
+ * A refresh (`onlyReplace`) rewrites existing blocks in place, the Claude
1690
+ * file's copy included, leaves imports of AGENTS.md as they are, and creates
1691
+ * nothing.
823
1692
  *
824
1693
  * @param {string} targetDir
825
1694
  * @param {object} [options]
@@ -833,9 +1702,19 @@ export function removeAgentDocs(targetDir) {
833
1702
  * so an integration's topics reach the agent.
834
1703
  * @param {string} [options.renderedBlock] - Fully rendered expected block. Init
835
1704
  * and upgrade pass one shared block to every target.
1705
+ * @param {(rel: string, reason: string) => void} [options.onSkipped] - Called
1706
+ * for each file skipped because it resolves outside `targetDir` and 0.7.1
1707
+ * never wrote it: GEMINI.md, or any file when `refuseEscapes` is false.
1708
+ * @param {boolean} [options.refuseEscapes] - Whether a file 0.7.1 wrote that
1709
+ * resolves outside `targetDir` refuses the run (default). Init passes false
1710
+ * when it runs below the project root, where 0.7.1 wrote in the folder it
1711
+ * ran in instead.
836
1712
  * @returns {string[]} List of files written
837
1713
  * @throws {import('../fs/path-safety.mjs').PathSafetyError} when a file it would
838
1714
  * write resolves outside `targetDir`; nothing is written.
1715
+ * @throws {AgentDocsHomeError} when `targetDir` is the home folder, or a settings
1716
+ * folder in it outside a project, or, with `paths`, when a file's own folder
1717
+ * is; nothing is written.
839
1718
  */
840
1719
  export function installAgentDocs(
841
1720
  targetDir,
@@ -847,8 +1726,14 @@ export function installAgentDocs(
847
1726
  onlyReplace = false,
848
1727
  topics,
849
1728
  renderedBlock,
1729
+ onSkipped,
1730
+ refuseEscapes = true,
850
1731
  } = {},
851
1732
  ) {
1733
+ const explicit = paths != null && paths.length > 0;
1734
+ // Explicit paths are checked file by file below: each file's own folder
1735
+ // is what agents would read in every project.
1736
+ if (!explicit) assertAgentDocsTarget(targetDir);
852
1737
  const coreDir = findCoreDir(targetDir);
853
1738
  const version = getXdsVersion(coreDir);
854
1739
  const invocation = getCliInvocation(targetDir);
@@ -867,13 +1752,20 @@ export function installAgentDocs(
867
1752
  const written = [];
868
1753
 
869
1754
  // Explicit paths override everything
870
- if (paths && paths.length > 0) {
1755
+ if (explicit) {
871
1756
  // Path-safety: each --agent-docs-path entry must resolve inside the
872
1757
  // target directory. Reject absolute paths (silent re-rooting via
873
1758
  // path.join hides intent) and `..` traversal.
874
1759
  for (const p of paths) {
875
1760
  assertWithin(p, targetDir, {label: 'agent docs path'});
876
1761
  }
1762
+ // A file directly in the home folder, or at the top of a settings folder
1763
+ // in it, is refused, as 0.7.x never wrote there unasked; a file in a
1764
+ // plain folder of the home folder, such as ~/notes/AI.md, is written.
1765
+ // Every path is checked before anything is written.
1766
+ for (const p of paths) {
1767
+ assertAgentDocsTarget(path.dirname(path.join(targetDir, p)));
1768
+ }
877
1769
  for (const p of paths) {
878
1770
  const filePath = path.join(targetDir, p);
879
1771
  const dir = path.dirname(filePath);
@@ -892,12 +1784,43 @@ export function installAgentDocs(
892
1784
  // Agent preset
893
1785
  if (agent) {
894
1786
  const {inject, create} = resolveAgentPaths(targetDir, agent);
895
- assertTargetsWithin(targetDir, [...inject, ...create]);
896
- for (const p of inject) {
1787
+ // Claude Code reads CLAUDE.md and .claude/CLAUDE.md together: when the
1788
+ // block goes in CLAUDE.md, .claude/CLAUDE.md gets none, and a block there
1789
+ // is removed.
1790
+ const claudeRoot = inject.includes(CLAUDE_MD);
1791
+ // While root CLAUDE.md is ignored, a committed .claude/CLAUDE.md holds a
1792
+ // copy too, with `claude` and `all` alike (INV31), first run included.
1793
+ const keepDirCopy = claudeRoot && claudeDirKeepsCopy(targetDir);
1794
+ const injectAll = !claudeRoot
1795
+ ? inject
1796
+ : keepDirCopy
1797
+ ? [...new Set([...inject, CLAUDE_DIR_MD])].filter(p =>
1798
+ p !== CLAUDE_DIR_MD || fs.existsSync(path.join(targetDir, CLAUDE_DIR_MD)),
1799
+ )
1800
+ : inject.filter(p => p !== CLAUDE_DIR_MD);
1801
+ const dirHasBlock = hasManagedMarker(path.join(targetDir, CLAUDE_DIR_MD));
1802
+ const strip = claudeRoot && !keepDirCopy && dirHasBlock ? [CLAUDE_DIR_MD] : [];
1803
+ // A path that escapes the project refuses the run, as in 0.7.0; below the
1804
+ // project root, where 0.7.1 wrote nothing, it is skipped and reported. A
1805
+ // .claude/CLAUDE.md the preset adds beside an ignored root, which 0.7.1
1806
+ // did not write, is skipped and reported wherever init runs.
1807
+ // So is removing a block from .claude/CLAUDE.md with `claude`, whose
1808
+ // 0.7.1 preset wrote only root CLAUDE.md there; `all` wrote both, so its
1809
+ // escape still refuses.
1810
+ const added = injectAll.filter(p => !inject.includes(p));
1811
+ const unrefused = agent === 'claude' ? [...added, ...strip] : added;
1812
+ const escaping = refuseEscapes
1813
+ ? skipEscaping(targetDir, unrefused, onSkipped)
1814
+ : skipEscaping(targetDir, [...injectAll, ...create, ...strip], onSkipped);
1815
+ const injectHere = injectAll.filter(p => !escaping.has(p));
1816
+ const createHere = create.filter(p => !escaping.has(p));
1817
+ const stripHere = escaping.has(CLAUDE_MD) ? [] : strip.filter(p => !escaping.has(p));
1818
+ assertTargetsWithin(targetDir, [...injectHere, ...createHere, ...stripHere]);
1819
+ for (const p of injectHere) {
897
1820
  injectXdsBlock(path.join(targetDir, p), compressedIndex);
898
1821
  written.push(p);
899
1822
  }
900
- for (const p of create) {
1823
+ for (const p of createHere) {
901
1824
  const filePath = path.join(targetDir, p);
902
1825
  const dir = path.dirname(filePath);
903
1826
  if (dir !== targetDir) {
@@ -909,57 +1832,45 @@ export function installAgentDocs(
909
1832
  });
910
1833
  written.push(p);
911
1834
  }
1835
+ for (const p of stripHere) {
1836
+ if (!rootClaudeHolds(targetDir, compressedIndex)) continue;
1837
+ if (stripManagedBlock(path.join(targetDir, p), compressedIndex)) written.push(p);
1838
+ }
912
1839
  return written;
913
1840
  }
914
1841
 
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.
1842
+ // Auto-detect. A refresh rewrites each existing block where it is.
918
1843
  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;
1844
+ if (onlyReplace) {
1845
+ const wrappers = findAgentDocWrappers(readAgentDocImports(targetDir, existing));
1846
+ // The Claude file keeps its copy even when it imports AGENTS.md, so a
1847
+ // refresh rewrites that block rather than removing it.
1848
+ const {agentsAliases} = findSharedAgentDocs(targetDir, existing);
1849
+ const claudeCopy = claudeCopyFile(
1850
+ new Set(existing.filter(p => !agentsAliases.has(p))),
1851
+ agentsAliases,
1852
+ );
1853
+ if (claudeCopy != null) wrappers.delete(claudeCopy);
1854
+ // A committed .claude/CLAUDE.md kept beside an ignored root CLAUDE.md is
1855
+ // refreshed even when it imports AGENTS.md (writeEachAgentDoc).
1856
+ return writeEachAgentDoc(targetDir, existing, wrappers, compressedIndex, true, onSkipped, refuseEscapes);
949
1857
  }
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;
1858
+ // A wrapper that imports something other than AGENTS.md, or two agent docs
1859
+ // that are one file, mean the project chose its own layout: keep the
1860
+ // per-file layout then. AGENTS.md under another name is fine.
1861
+ const wrappers = findAgentDocWrappers(readAgentDocImports(targetDir, existing, [AGENTS_MD]));
1862
+ const {agentsAliases, otherShared} = findSharedAgentDocs(targetDir, existing);
1863
+ if (otherShared || [...wrappers.values()].some(targets => !targets.has(AGENTS_MD))) {
1864
+ const ownWrappers = findAgentDocWrappers(readAgentDocImports(targetDir, existing));
1865
+ return writeEachAgentDoc(targetDir, existing, ownWrappers, compressedIndex, false, onSkipped, refuseEscapes);
1866
+ }
1867
+ return writeDefaultAgentDocs(
1868
+ targetDir,
1869
+ existing,
1870
+ wrappers,
1871
+ agentsAliases,
1872
+ compressedIndex,
1873
+ onSkipped,
1874
+ refuseEscapes,
1875
+ );
965
1876
  }