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