@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.
- package/README.md +99 -66
- package/api/build/_adapter.d.mts +58 -0
- package/api/build/_adapter.mjs +115 -3
- package/api/build/blocks/blocks.d.mts +42 -0
- package/api/build/blocks/blocks.mjs +169 -0
- package/api/build/blocks/blocks.test.mjs +108 -0
- package/api/build/blocks/blocks.weights.json +1 -0
- package/api/build/build.mjs +25 -6
- package/api/build/kit/answer.d.mts +14 -0
- package/api/build/kit/answer.mjs +85 -0
- package/api/build/kit/answer.test.mjs +38 -0
- package/api/build/kit/kind.d.mts +25 -0
- package/api/build/kit/kind.mjs +170 -0
- package/api/build/kit/kit.d.mts +2 -4
- package/api/build/kit/kit.mjs +8 -398
- package/api/build/kit/rank.d.mts +3 -32
- package/api/build/kit/rank.mjs +25 -342
- package/api/build/kit/rank.test.mjs +2 -1
- package/api/build/kit/signals.d.mts +60 -0
- package/api/build/kit/signals.mjs +284 -0
- package/api/build/kit/start.d.mts +32 -0
- package/api/build/kit/start.mjs +163 -0
- package/api/build/kit/words.d.mts +72 -0
- package/api/build/kit/words.mjs +202 -0
- package/api/build/tree.d.mts +20 -0
- package/api/build/tree.mjs +52 -0
- package/api/build/tree.test.mjs +177 -0
- package/api/build/tree.weights.json +1 -0
- package/api/component/component.mjs +16 -2
- package/api/component/component.test.mjs +15 -0
- package/api/discover/_adapter.mjs +36 -9
- package/api/discover/_adapter.test.mjs +42 -0
- package/api/docs/_adapter.mjs +17 -5
- package/api/docs/docs.test.mjs +7 -1
- package/api/doctor/doctor.mjs +86 -12
- package/api/doctor/doctor.test.mjs +28 -3
- package/api/init/init.doc.mjs +20 -6
- package/api/init/init.test.mjs +18 -2
- package/api/init/init.type.d.mts +3 -3
- package/api/init/init.type.mjs +3 -3
- package/api/init/remove/remove.mjs +17 -4
- package/api/init/remove/remove.test.mjs +114 -2
- package/api/init/run/run.mjs +69 -6
- package/api/integration/authoring-checks.mjs +83 -9
- package/api/integration/pack-check.mjs +123 -4
- package/api/integration/pack-check.test.mjs +87 -0
- package/api/integration/validate-integration-case-disk.test.mjs +111 -0
- package/api/integration/validate-integration-gaps.test.mjs +1964 -0
- package/api/integration/validate-integration.d.mts +11 -2
- package/api/integration/validate-integration.mjs +226 -32
- package/api/integration/validate-integration.type.d.mts +58 -1
- package/api/integration/validate-integration.type.mjs +45 -2
- package/api/integration/validateIntegration.doc.mjs +1 -1
- package/api/search/ORDERING.md +28 -0
- package/api/search/_adapter.d.mts +66 -2
- package/api/search/_adapter.mjs +118 -10
- package/api/search/_candidate-score.d.mts +40 -0
- package/api/search/_candidate-score.mjs +238 -0
- package/api/search/_cards.json +6098 -0
- package/api/search/_compound.d.mts +50 -0
- package/api/search/_compound.mjs +170 -0
- package/api/search/_doc-searcher.d.mts +4 -0
- package/api/search/_doc-searcher.mjs +23 -0
- package/api/search/_doc-searcher.test.mjs +36 -0
- package/api/search/_doc-searcher.weights.json +1 -0
- package/api/search/_doc-text.d.mts +30 -0
- package/api/search/_doc-text.mjs +87 -0
- package/api/search/_entries.d.mts +52 -0
- package/api/search/_entries.mjs +158 -0
- package/api/search/_features.d.mts +35 -0
- package/api/search/_features.mjs +155 -0
- package/api/search/_gather-components.d.mts +26 -0
- package/api/search/_gather-components.mjs +284 -0
- package/api/search/_gather-docs.d.mts +20 -0
- package/api/search/_gather-docs.mjs +254 -0
- package/api/search/_gather-hooks.d.mts +12 -0
- package/api/search/_gather-hooks.mjs +52 -0
- package/api/search/_gather-templates.d.mts +25 -0
- package/api/search/_gather-templates.mjs +107 -0
- package/api/search/_lexical.d.mts +135 -0
- package/api/search/_lexical.mjs +351 -0
- package/api/search/_main-searcher.d.mts +19 -0
- package/api/search/_main-searcher.mjs +115 -0
- package/api/search/_main-searcher.test.mjs +106 -0
- package/api/search/_main-searcher.weights.json +1 -0
- package/api/search/_module-doc.d.mts +32 -0
- package/api/search/_module-doc.mjs +78 -0
- package/api/search/_order.d.mts +32 -0
- package/api/search/_order.mjs +162 -0
- package/api/search/_order.test.mjs +72 -0
- package/api/search/_priority.d.mts +50 -0
- package/api/search/_priority.mjs +139 -0
- package/api/search/_query-words.d.mts +25 -0
- package/api/search/_query-words.mjs +92 -0
- package/api/search/_query.d.mts +65 -0
- package/api/search/_query.mjs +176 -0
- package/api/search/_result.d.mts +97 -0
- package/api/search/_result.mjs +150 -0
- package/api/search/_score.d.mts +60 -0
- package/api/search/_score.mjs +283 -0
- package/api/search/_synonyms.d.mts +21 -0
- package/api/search/_synonyms.mjs +67 -0
- package/api/search/_template-searcher.d.mts +4 -0
- package/api/search/_template-searcher.mjs +23 -0
- package/api/search/_template-searcher.test.mjs +40 -0
- package/api/search/_template-searcher.weights.json +1 -0
- package/api/search/_titles.d.mts +33 -0
- package/api/search/_titles.mjs +65 -0
- package/api/search/_words.d.mts +64 -0
- package/api/search/_words.mjs +157 -0
- package/api/search/search.d.mts +22 -305
- package/api/search/search.doc.mjs +7 -5
- package/api/search/search.mjs +53 -2483
- package/api/search/search.type.d.mts +6 -1
- package/api/search/search.type.mjs +6 -1
- package/api/search/tree.d.mts +15 -0
- package/api/search/tree.mjs +40 -0
- package/api/search/tree.test.mjs +66 -0
- package/api/search/tree.weights.json +1 -0
- package/api/theme/build/build.packed-old-core.test.mjs +1 -0
- package/api/upgrade/_adapter.d.mts +57 -9
- package/api/upgrade/_adapter.mjs +198 -28
- package/api/upgrade/registry/registry.d.mts +10 -4
- package/api/upgrade/registry/registry.mjs +33 -16
- package/api/upgrade/registry/registry.test.mjs +85 -0
- package/api/upgrade/run/run.d.mts +4 -2
- package/api/upgrade/run/run.mjs +13 -2
- package/api/upgrade/run/run.test.mjs +417 -3
- package/api/upgrade/upgrade.doc.mjs +10 -5
- package/api/upgrade/upgrade.mjs +13 -2
- package/api/upgrade/upgrade.test.mjs +194 -0
- package/api/upgrade/upgrade.type.d.mts +5 -3
- package/api/upgrade/upgrade.type.mjs +8 -6
- package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
- package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
- package/assets/docs/is-my-code-good.doc.mjs +287 -0
- package/assets/docs/tree/api.doc.mjs +6 -0
- package/assets/docs/tree/host-entry.doc.mjs +128 -0
- package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
- package/assets/docs/tree/tokens-data.doc.mjs +2 -2
- package/assets/docs/working-with-ai.doc.mjs +6 -2
- package/assets/templates/themes/butter/butterTheme.ts +4 -2
- package/assets/templates/themes/stone/stoneTheme.ts +4 -2
- package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
- package/authoring/debug/parse.d.mts +1 -1
- package/clients/cli/bin/astryx.mjs +7 -78
- package/clients/cli/commands/doctor.mjs +30 -0
- package/clients/cli/commands/doctor.test.mjs +65 -0
- package/clients/cli/commands/init.behavior.test.mjs +230 -1
- package/clients/cli/commands/init.doc.mjs +28 -9
- package/clients/cli/commands/init.flag-help.test.mjs +1 -0
- package/clients/cli/commands/init.mjs +2 -2
- package/clients/cli/commands/outside-project.test.mjs +2 -1
- package/clients/cli/commands/setup-nudge.test.mjs +61 -0
- package/clients/cli/commands/upgrade.doc.mjs +4 -2
- package/clients/cli/commands/upgrade.mjs +9 -2
- package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
- package/clients/cli/index.mjs +19 -1
- package/clients/cli/run.mjs +108 -0
- package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
- package/foundation/agent-docs/agent-doc-state.mjs +35 -8
- package/foundation/agent-docs/agent-docs.d.mts +100 -12
- package/foundation/agent-docs/agent-docs.mjs +1010 -99
- package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
- package/foundation/agent-docs/agent-docs.test.mjs +678 -35
- package/foundation/config/project.mjs +20 -22
- package/foundation/config/project.test.mjs +56 -0
- package/foundation/discovery/component-discovery.d.mts +37 -0
- package/foundation/discovery/component-discovery.mjs +80 -5
- package/foundation/discovery/component-loader.d.mts +1 -1
- package/foundation/discovery/docs-discovery.mjs +14 -1
- package/foundation/discovery/module-exports.d.mts +41 -0
- package/foundation/discovery/module-exports.mjs +253 -0
- package/foundation/discovery/template-adapter.mjs +18 -1
- package/foundation/discovery/template-needs.mjs +1 -44
- package/foundation/discovery/theme-discovery.mjs +147 -3
- package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
- package/foundation/doc-compiler/read.mjs +59 -1
- package/foundation/doc-compiler/read.test.mjs +11 -2
- package/foundation/fs/paths.d.mts +70 -8
- package/foundation/fs/paths.mjs +556 -38
- package/foundation/fs/paths.test.mjs +430 -54
- package/foundation/integrations/autolink.d.mts +2 -1
- package/foundation/integrations/autolink.mjs +4 -13
- package/foundation/integrations/computer-integrations.d.mts +6 -3
- package/foundation/integrations/computer-integrations.mjs +38 -9
- package/foundation/integrations/contribution-inventory.d.mts +15 -20
- package/foundation/integrations/contribution-inventory.mjs +41 -5
- package/foundation/integrations/integrations.d.mts +27 -1
- package/foundation/integrations/integrations.mjs +61 -2
- package/foundation/integrations/validate-contributions.d.mts +115 -0
- package/foundation/integrations/validate-contributions.mjs +1168 -13
- package/foundation/release/handoff.d.mts +23 -4
- package/foundation/release/handoff.mjs +175 -8
- package/foundation/release/run-context.mjs +14 -0
- package/foundation/response/response-types.doc.mjs +1 -1
- package/foundation/route/route.d.mts +285 -0
- package/foundation/route/route.mjs +324 -0
- package/foundation/route/route.test.mjs +153 -0
- package/foundation/runtime/hosted-runtime.test.mjs +403 -0
- package/foundation/runtime/locked-versions.d.mts +32 -0
- package/foundation/runtime/locked-versions.mjs +65 -0
- package/foundation/runtime/package-root.d.mts +91 -0
- package/foundation/runtime/package-root.mjs +174 -0
- package/foundation/runtime/package-set.d.mts +62 -0
- package/foundation/runtime/package-set.mjs +130 -0
- package/foundation/runtime/package-set.test.mjs +432 -0
- package/host/door-conformance.test.mjs +274 -0
- package/host/index.d.ts +116 -0
- package/host/index.mjs +295 -0
- package/host/index.test.mjs +302 -0
- 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
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
|
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
|
-
//
|
|
295
|
-
|
|
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
|
|
704
|
-
*
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
1331
|
+
humanLog(`[ok] Removed empty ${shown}`);
|
|
805
1332
|
} else {
|
|
806
|
-
humanLog(`[ok] Removed design system section from ${
|
|
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
|
-
*
|
|
818
|
-
* -
|
|
819
|
-
* -
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
* -
|
|
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 (
|
|
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
|
-
|
|
896
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
//
|
|
952
|
-
// AGENTS.md
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
if (
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
}
|