@dalzoubi/dev-agents-sync 2.0.7 → 2.0.9
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 +19 -1
- package/package.json +1 -1
- package/src/lockfile.mjs +1 -1
- package/src/writer.mjs +13 -1
- package/tests/codex-cross-cutting.test.mjs +411 -0
- package/tests/codex-init-check.test.mjs +823 -0
- package/tests/codex-target.test.mjs +375 -0
- package/tests/e2e/codex-readme.test.mjs +335 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/codex-init-check.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Regression guards (slice: codex-init-check) for the init + check behavior of
|
|
5
|
+
* the codex target. The behavior is delivered by the build emitter (S1) and the
|
|
6
|
+
* writer/lockfile wiring (S2): `init.mjs` passes explicit `--targets` through
|
|
7
|
+
* without an ALL_TARGETS gate, and `codex` resolves via TARGET_PREFIX +
|
|
8
|
+
* filterFileMapByTargets — so this slice required no production change and these
|
|
9
|
+
* tests lock the contract against future regressions.
|
|
10
|
+
*
|
|
11
|
+
* Contract:
|
|
12
|
+
* - `--targets codex` is an accepted, selectable value (not auto-detected).
|
|
13
|
+
* - init writes <root>/.agents/skills/<agent>/SKILL.md and <root>/AGENTS.md
|
|
14
|
+
* for all six agents, each carrying the managed marker.
|
|
15
|
+
* - init records `codex` in the lockfile targets array.
|
|
16
|
+
* - init scopes correctly: only files whose FileMap key prefix matches a
|
|
17
|
+
* selected target are written. `--targets claude` must NOT write codex files
|
|
18
|
+
* and vice versa.
|
|
19
|
+
* - init leaves a pre-existing unmanaged sibling .agents/ file untouched.
|
|
20
|
+
* - Collision: a pre-existing UNMANAGED <root>/AGENTS.md (no marker) causes
|
|
21
|
+
* refusal (exit 1); --force allows the overwrite. Same for SKILL.md.
|
|
22
|
+
* - check drift: deleting or modifying a codex file reports drift (exit 1);
|
|
23
|
+
* a clean tree is in-sync (exit 0); a non-codex sibling .agents/ file is
|
|
24
|
+
* NEVER reported as drift.
|
|
25
|
+
* - autoDetectTargets does NOT include `codex` — it is opt-in only.
|
|
26
|
+
* No filesystem signal (not even .agents/ existing) triggers codex selection.
|
|
27
|
+
*
|
|
28
|
+
* The six agent names used by the build emitter (verified from .agents/agents/):
|
|
29
|
+
* analyze, define, implement, supervise, test, validate
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
33
|
+
import assert from 'node:assert/strict';
|
|
34
|
+
import {
|
|
35
|
+
mkdtempSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
existsSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { tmpdir } from 'node:os';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
|
|
45
|
+
import { runInit } from '../src/commands/init.mjs';
|
|
46
|
+
import { runCheck } from '../src/commands/check.mjs';
|
|
47
|
+
import { writeLockfile } from '../src/lockfile.mjs';
|
|
48
|
+
import { hasMarker } from '../src/marker.mjs';
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Fixture FileMap
|
|
52
|
+
//
|
|
53
|
+
// A minimal FileMap that includes codex keys for all six agents + AGENTS.md,
|
|
54
|
+
// plus a couple of claude keys — so prefix-scoping is actually exercised.
|
|
55
|
+
//
|
|
56
|
+
// Key shape mirrors what the build emitter (S1) produces and what the writer
|
|
57
|
+
// (S2) normalizes:
|
|
58
|
+
// "codex/.agents/skills/<agent>/SKILL.md" — consumer path: .agents/skills/<agent>/SKILL.md
|
|
59
|
+
// "codex/AGENTS.md" — consumer path: AGENTS.md
|
|
60
|
+
// "claude/agents/define.md" — consumer path: .claude/agents/define.md
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const SIX_AGENTS = ['analyze', 'define', 'implement', 'supervise', 'test', 'validate'];
|
|
64
|
+
|
|
65
|
+
const MANAGED_MARKER = '<!-- managed-by: dev-agents-sync v1.0.0 -->';
|
|
66
|
+
|
|
67
|
+
function makeCodexSkillContent(agent) {
|
|
68
|
+
return `${MANAGED_MARKER}\n# ${agent} Skill\n\nYou are the ${agent} agent.\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeCodexAgentsMd() {
|
|
72
|
+
return `${MANAGED_MARKER}\n# Agent Skills\n\nThis repo contains the following agent skills.\n`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeClaudeAgentContent(agent) {
|
|
76
|
+
return `---\nname: "${agent}"\n---\n${MANAGED_MARKER}\nYou are the ${agent} agent for Claude.\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Builds a fixture FileMap containing:
|
|
81
|
+
* - codex keys for all six agents + AGENTS.md
|
|
82
|
+
* - claude keys for define + test agents
|
|
83
|
+
*
|
|
84
|
+
* This is the canonical "full" FileMap used as the fetcher response for most tests.
|
|
85
|
+
*/
|
|
86
|
+
function buildFullFixtureFileMap() {
|
|
87
|
+
const map = {};
|
|
88
|
+
for (const agent of SIX_AGENTS) {
|
|
89
|
+
map[`codex/.agents/skills/${agent}/SKILL.md`] = makeCodexSkillContent(agent);
|
|
90
|
+
}
|
|
91
|
+
map['codex/AGENTS.md'] = makeCodexAgentsMd();
|
|
92
|
+
map['claude/agents/define.md'] = makeClaudeAgentContent('define');
|
|
93
|
+
map['claude/agents/test.md'] = makeClaudeAgentContent('test');
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeFullFetcher() {
|
|
98
|
+
return async (_repo, _tag, _token) => buildFullFixtureFileMap();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0'];
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Helpers
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function makeTmpDir() {
|
|
108
|
+
return mkdtempSync(path.join(tmpdir(), 'das-codex-ic-test-'));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// 1. init --targets codex writes the codex file set
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('init --targets codex — writes codex file set', () => {
|
|
116
|
+
let consumerDir;
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
consumerDir = makeTmpDir();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// RED: `codex` is not in ALL_TARGETS in init.mjs — normalizeTargets passes it
|
|
127
|
+
// through for explicit arrays, but runInit would still call filterFileMapByTargets
|
|
128
|
+
// which looks at the prefix. Once S2 wired writer, this may partially work.
|
|
129
|
+
// However the key gate is that init accepts 'codex' as a valid target string
|
|
130
|
+
// without throwing "unknown target" — currently it passes through because
|
|
131
|
+
// normalizeTargets does NOT validate against ALL_TARGETS for explicit arrays.
|
|
132
|
+
// The actual RED will appear at writeLockfile (VALID_TARGETS) if codex is absent
|
|
133
|
+
// there — but S2 already added it. So let's verify the actual behavior.
|
|
134
|
+
it('writes SKILL.md for all six agents under <root>/.agents/skills/', async () => {
|
|
135
|
+
await runInit(consumerDir, {
|
|
136
|
+
targets: ['codex'],
|
|
137
|
+
fetcher: makeFullFetcher(),
|
|
138
|
+
availableTags: AVAILABLE_TAGS,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
for (const agent of SIX_AGENTS) {
|
|
142
|
+
const skillPath = path.join(consumerDir, '.agents', 'skills', agent, 'SKILL.md');
|
|
143
|
+
assert.ok(
|
|
144
|
+
existsSync(skillPath),
|
|
145
|
+
`SKILL.md for agent "${agent}" must be written at .agents/skills/${agent}/SKILL.md`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('writes AGENTS.md at <root>/AGENTS.md (not nested in a subdir)', async () => {
|
|
151
|
+
await runInit(consumerDir, {
|
|
152
|
+
targets: ['codex'],
|
|
153
|
+
fetcher: makeFullFetcher(),
|
|
154
|
+
availableTags: AVAILABLE_TAGS,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const agentsMdPath = path.join(consumerDir, 'AGENTS.md');
|
|
158
|
+
assert.ok(existsSync(agentsMdPath), 'AGENTS.md must be written at the consumer root');
|
|
159
|
+
// Must not be nested under a subdir like .codex/ or .agents/
|
|
160
|
+
const relative = path.relative(consumerDir, agentsMdPath);
|
|
161
|
+
assert.equal(relative, 'AGENTS.md', 'AGENTS.md must be directly under consumer root');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('each written codex file contains the managed-by marker', async () => {
|
|
165
|
+
await runInit(consumerDir, {
|
|
166
|
+
targets: ['codex'],
|
|
167
|
+
fetcher: makeFullFetcher(),
|
|
168
|
+
availableTags: AVAILABLE_TAGS,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
for (const agent of SIX_AGENTS) {
|
|
172
|
+
const skillPath = path.join(consumerDir, '.agents', 'skills', agent, 'SKILL.md');
|
|
173
|
+
const content = readFileSync(skillPath, 'utf8');
|
|
174
|
+
assert.ok(
|
|
175
|
+
hasMarker(content),
|
|
176
|
+
`SKILL.md for agent "${agent}" must contain the managed-by marker`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const agentsMdPath = path.join(consumerDir, 'AGENTS.md');
|
|
181
|
+
const content = readFileSync(agentsMdPath, 'utf8');
|
|
182
|
+
assert.ok(hasMarker(content), 'AGENTS.md must contain the managed-by marker');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('records codex in the lockfile targets', async () => {
|
|
186
|
+
await runInit(consumerDir, {
|
|
187
|
+
targets: ['codex'],
|
|
188
|
+
fetcher: makeFullFetcher(),
|
|
189
|
+
availableTags: AVAILABLE_TAGS,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const lockfilePath = path.join(consumerDir, '.dev-agents-sync.json');
|
|
193
|
+
const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8'));
|
|
194
|
+
assert.ok(
|
|
195
|
+
Array.isArray(lockfile.targets) && lockfile.targets.includes('codex'),
|
|
196
|
+
`lockfile.targets must include "codex", got: ${JSON.stringify(lockfile.targets)}`,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// 2. init scoping — codex and claude are independent targets
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
describe('init --targets scoping — codex and claude are independent', () => {
|
|
206
|
+
let consumerDir;
|
|
207
|
+
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
consumerDir = makeTmpDir();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// GREEN (expected to pass already, since filterFileMapByTargets in init.mjs
|
|
217
|
+
// scopes by prefix and `claude` prefix won't match codex keys).
|
|
218
|
+
it('--targets claude does NOT write .agents/skills/ (codex files excluded)', async () => {
|
|
219
|
+
await runInit(consumerDir, {
|
|
220
|
+
targets: ['claude'],
|
|
221
|
+
fetcher: makeFullFetcher(),
|
|
222
|
+
availableTags: AVAILABLE_TAGS,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const agentsSkillsDir = path.join(consumerDir, '.agents', 'skills');
|
|
226
|
+
assert.ok(
|
|
227
|
+
!existsSync(agentsSkillsDir),
|
|
228
|
+
'.agents/skills/ must NOT exist when only claude target is selected',
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// GREEN: same for AGENTS.md — it has prefix "codex", not "claude"
|
|
233
|
+
it('--targets claude does NOT write AGENTS.md at consumer root', async () => {
|
|
234
|
+
await runInit(consumerDir, {
|
|
235
|
+
targets: ['claude'],
|
|
236
|
+
fetcher: makeFullFetcher(),
|
|
237
|
+
availableTags: AVAILABLE_TAGS,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const agentsMdPath = path.join(consumerDir, 'AGENTS.md');
|
|
241
|
+
assert.ok(
|
|
242
|
+
!existsSync(agentsMdPath),
|
|
243
|
+
'AGENTS.md must NOT be written when only claude target is selected',
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// RED: `codex` key is blocked from filterFileMapByTargets unless 'codex' ∈ resolvedTargets.
|
|
248
|
+
// When --targets codex, claude prefix keys must be excluded.
|
|
249
|
+
it('--targets codex does NOT write .claude/ (claude files excluded)', async () => {
|
|
250
|
+
await runInit(consumerDir, {
|
|
251
|
+
targets: ['codex'],
|
|
252
|
+
fetcher: makeFullFetcher(),
|
|
253
|
+
availableTags: AVAILABLE_TAGS,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
assert.ok(
|
|
257
|
+
!existsSync(path.join(consumerDir, '.claude')),
|
|
258
|
+
'.claude/ must NOT exist when only codex target is selected',
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// RED: both targets together write both sets of files
|
|
263
|
+
it('--targets claude,codex writes both .claude/ and codex files', async () => {
|
|
264
|
+
await runInit(consumerDir, {
|
|
265
|
+
targets: ['claude', 'codex'],
|
|
266
|
+
fetcher: makeFullFetcher(),
|
|
267
|
+
availableTags: AVAILABLE_TAGS,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
assert.ok(
|
|
271
|
+
existsSync(path.join(consumerDir, '.claude')),
|
|
272
|
+
'.claude/ must exist when claude target is included',
|
|
273
|
+
);
|
|
274
|
+
assert.ok(
|
|
275
|
+
existsSync(path.join(consumerDir, '.agents', 'skills', 'define', 'SKILL.md')),
|
|
276
|
+
'.agents/skills/define/SKILL.md must exist when codex target is included',
|
|
277
|
+
);
|
|
278
|
+
assert.ok(
|
|
279
|
+
existsSync(path.join(consumerDir, 'AGENTS.md')),
|
|
280
|
+
'AGENTS.md must exist when codex target is included',
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// 3. init leaves pre-existing unmanaged sibling .agents/ files untouched
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
describe('init --targets codex — unmanaged sibling .agents/ file untouched', () => {
|
|
290
|
+
let consumerDir;
|
|
291
|
+
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
consumerDir = makeTmpDir();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
afterEach(() => {
|
|
297
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// GREEN: writeManagedFile only writes paths in the FileMap; it never deletes.
|
|
301
|
+
// There is no cleanup/uninstall logic. This test locks that no-cleanup contract.
|
|
302
|
+
it('a pre-existing .agents/skills/my-own-skill.md (no marker) remains untouched after init', async () => {
|
|
303
|
+
// Seed a custom skill file that init must never manage or delete
|
|
304
|
+
const customSkillDir = path.join(consumerDir, '.agents', 'skills');
|
|
305
|
+
mkdirSync(customSkillDir, { recursive: true });
|
|
306
|
+
const customSkillPath = path.join(customSkillDir, 'my-own-skill.md');
|
|
307
|
+
const customContent = '# My Custom Skill\nNo managed-by marker here.';
|
|
308
|
+
writeFileSync(customSkillPath, customContent, 'utf8');
|
|
309
|
+
|
|
310
|
+
await runInit(consumerDir, {
|
|
311
|
+
targets: ['codex'],
|
|
312
|
+
fetcher: makeFullFetcher(),
|
|
313
|
+
availableTags: AVAILABLE_TAGS,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
assert.ok(existsSync(customSkillPath), 'custom skill file must still exist after init');
|
|
317
|
+
const afterContent = readFileSync(customSkillPath, 'utf8');
|
|
318
|
+
assert.equal(
|
|
319
|
+
afterContent,
|
|
320
|
+
customContent,
|
|
321
|
+
'custom skill file content must be byte-identical after init (init must not touch it)',
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// GREEN: no cleanup on re-init either — the file stays if init is run twice
|
|
326
|
+
it('a pre-existing .agents/ custom file survives a second init run (no cleanup on overwrite)', async () => {
|
|
327
|
+
const customSkillDir = path.join(consumerDir, '.agents', 'skills');
|
|
328
|
+
mkdirSync(customSkillDir, { recursive: true });
|
|
329
|
+
const customSkillPath = path.join(customSkillDir, 'my-own-skill.md');
|
|
330
|
+
const customContent = '# My Custom Skill\nNo managed-by marker here.';
|
|
331
|
+
writeFileSync(customSkillPath, customContent, 'utf8');
|
|
332
|
+
|
|
333
|
+
// Run init twice
|
|
334
|
+
await runInit(consumerDir, {
|
|
335
|
+
targets: ['codex'],
|
|
336
|
+
fetcher: makeFullFetcher(),
|
|
337
|
+
availableTags: AVAILABLE_TAGS,
|
|
338
|
+
});
|
|
339
|
+
await runInit(consumerDir, {
|
|
340
|
+
targets: ['codex'],
|
|
341
|
+
force: true,
|
|
342
|
+
fetcher: makeFullFetcher(),
|
|
343
|
+
availableTags: AVAILABLE_TAGS,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
assert.ok(existsSync(customSkillPath), 'custom skill file must survive a second init run');
|
|
347
|
+
const afterContent = readFileSync(customSkillPath, 'utf8');
|
|
348
|
+
assert.equal(afterContent, customContent, 'content must be unchanged after second init run');
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// 4. Collision refusal — pre-existing unmanaged codex target files
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
describe('init --targets codex — collision refusal for unmanaged files', () => {
|
|
357
|
+
let consumerDir;
|
|
358
|
+
|
|
359
|
+
beforeEach(() => {
|
|
360
|
+
consumerDir = makeTmpDir();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
afterEach(() => {
|
|
364
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// GREEN: writeManagedFile already refuses unmarked files at any path.
|
|
368
|
+
// The collision check in init.mjs does the same. These tests pin the
|
|
369
|
+
// codex-specific collision paths are respected — not new logic, but a
|
|
370
|
+
// regression guard that the codex path flows through the existing collision
|
|
371
|
+
// code (which it will once codex files are writable after S3 init change).
|
|
372
|
+
it('refuses to overwrite an unmanaged AGENTS.md (no marker) with exit code 1', async () => {
|
|
373
|
+
// Pre-seed an unmanaged AGENTS.md at the consumer root
|
|
374
|
+
writeFileSync(path.join(consumerDir, 'AGENTS.md'), '# My custom AGENTS.md\nNo marker.', 'utf8');
|
|
375
|
+
|
|
376
|
+
let caughtError;
|
|
377
|
+
try {
|
|
378
|
+
await runInit(consumerDir, {
|
|
379
|
+
targets: ['codex'],
|
|
380
|
+
fetcher: makeFullFetcher(),
|
|
381
|
+
availableTags: AVAILABLE_TAGS,
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
caughtError = err;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
assert.ok(caughtError, 'init must throw when an unmanaged AGENTS.md collision exists');
|
|
388
|
+
assert.equal(
|
|
389
|
+
caughtError.exitCode,
|
|
390
|
+
1,
|
|
391
|
+
`collision must produce exit code 1, got: ${caughtError.exitCode}`,
|
|
392
|
+
);
|
|
393
|
+
assert.ok(
|
|
394
|
+
caughtError.message.toLowerCase().includes('unmanaged') ||
|
|
395
|
+
caughtError.message.toLowerCase().includes('marker') ||
|
|
396
|
+
caughtError.message.toLowerCase().includes('force'),
|
|
397
|
+
`collision error must mention unmanaged/marker/force, got: ${caughtError.message}`,
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('collision error for AGENTS.md names the conflicting file path', async () => {
|
|
402
|
+
writeFileSync(path.join(consumerDir, 'AGENTS.md'), '# Unmanaged', 'utf8');
|
|
403
|
+
|
|
404
|
+
let errorMessage = '';
|
|
405
|
+
try {
|
|
406
|
+
await runInit(consumerDir, {
|
|
407
|
+
targets: ['codex'],
|
|
408
|
+
fetcher: makeFullFetcher(),
|
|
409
|
+
availableTags: AVAILABLE_TAGS,
|
|
410
|
+
});
|
|
411
|
+
} catch (err) {
|
|
412
|
+
errorMessage = err.message;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
assert.ok(
|
|
416
|
+
errorMessage.includes('AGENTS.md') || errorMessage.includes('codex/AGENTS.md'),
|
|
417
|
+
`collision error must name the conflicting path, got: ${errorMessage}`,
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('refuses to overwrite an unmanaged SKILL.md with exit code 1', async () => {
|
|
422
|
+
// Pre-seed an unmanaged SKILL.md at the define skill path
|
|
423
|
+
const skillDir = path.join(consumerDir, '.agents', 'skills', 'define');
|
|
424
|
+
mkdirSync(skillDir, { recursive: true });
|
|
425
|
+
writeFileSync(path.join(skillDir, 'SKILL.md'), '# My custom define skill\nNo marker.', 'utf8');
|
|
426
|
+
|
|
427
|
+
let caughtError;
|
|
428
|
+
try {
|
|
429
|
+
await runInit(consumerDir, {
|
|
430
|
+
targets: ['codex'],
|
|
431
|
+
fetcher: makeFullFetcher(),
|
|
432
|
+
availableTags: AVAILABLE_TAGS,
|
|
433
|
+
});
|
|
434
|
+
} catch (err) {
|
|
435
|
+
caughtError = err;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
assert.ok(caughtError, 'init must throw when an unmanaged SKILL.md collision exists');
|
|
439
|
+
assert.equal(
|
|
440
|
+
caughtError.exitCode,
|
|
441
|
+
1,
|
|
442
|
+
`collision must produce exit code 1, got: ${caughtError.exitCode}`,
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('--force overwrites an unmanaged AGENTS.md', async () => {
|
|
447
|
+
const agentsMdPath = path.join(consumerDir, 'AGENTS.md');
|
|
448
|
+
writeFileSync(agentsMdPath, '# Unmanaged AGENTS.md\nNo marker.', 'utf8');
|
|
449
|
+
|
|
450
|
+
await assert.doesNotReject(
|
|
451
|
+
runInit(consumerDir, {
|
|
452
|
+
targets: ['codex'],
|
|
453
|
+
force: true,
|
|
454
|
+
fetcher: makeFullFetcher(),
|
|
455
|
+
availableTags: AVAILABLE_TAGS,
|
|
456
|
+
}),
|
|
457
|
+
'init with --force must succeed even when an unmanaged AGENTS.md exists',
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const written = readFileSync(agentsMdPath, 'utf8');
|
|
461
|
+
assert.ok(
|
|
462
|
+
hasMarker(written),
|
|
463
|
+
'AGENTS.md written with --force must now contain the managed-by marker',
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('--force overwrites an unmanaged SKILL.md', async () => {
|
|
468
|
+
const skillDir = path.join(consumerDir, '.agents', 'skills', 'define');
|
|
469
|
+
mkdirSync(skillDir, { recursive: true });
|
|
470
|
+
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
471
|
+
writeFileSync(skillPath, '# Unmanaged define skill\nNo marker.', 'utf8');
|
|
472
|
+
|
|
473
|
+
await assert.doesNotReject(
|
|
474
|
+
runInit(consumerDir, {
|
|
475
|
+
targets: ['codex'],
|
|
476
|
+
force: true,
|
|
477
|
+
fetcher: makeFullFetcher(),
|
|
478
|
+
availableTags: AVAILABLE_TAGS,
|
|
479
|
+
}),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const written = readFileSync(skillPath, 'utf8');
|
|
483
|
+
assert.ok(hasMarker(written), 'SKILL.md written with --force must contain the managed-by marker');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('overwrites a previously managed codex file (already has marker) without --force', async () => {
|
|
487
|
+
// A file that already carries the managed marker is safe to overwrite — no --force needed.
|
|
488
|
+
const agentsMdPath = path.join(consumerDir, 'AGENTS.md');
|
|
489
|
+
writeFileSync(agentsMdPath, `${MANAGED_MARKER}\n# Old AGENTS.md\n`, 'utf8');
|
|
490
|
+
|
|
491
|
+
await assert.doesNotReject(
|
|
492
|
+
runInit(consumerDir, {
|
|
493
|
+
targets: ['codex'],
|
|
494
|
+
fetcher: makeFullFetcher(),
|
|
495
|
+
availableTags: AVAILABLE_TAGS,
|
|
496
|
+
}),
|
|
497
|
+
'init must not throw when an already-managed AGENTS.md exists',
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// 5. check drift for codex target
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
describe('check — codex target drift detection', () => {
|
|
507
|
+
let consumerDir;
|
|
508
|
+
|
|
509
|
+
beforeEach(() => {
|
|
510
|
+
consumerDir = makeTmpDir();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
afterEach(() => {
|
|
514
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Sets up a consumer dir that has previously run init with the codex target.
|
|
519
|
+
* Writes the lockfile and materializes all codex files to the expected paths.
|
|
520
|
+
*/
|
|
521
|
+
function setupCodexConsumer(dir, opts = {}) {
|
|
522
|
+
const { resolvedVersion = '1.2.0' } = opts;
|
|
523
|
+
|
|
524
|
+
writeLockfile(dir, {
|
|
525
|
+
source: 'github:dalzoubi/dev-agents',
|
|
526
|
+
range: '^1',
|
|
527
|
+
resolvedVersion,
|
|
528
|
+
targets: ['codex'],
|
|
529
|
+
lastUpdated: '2026-06-07T00:00:00Z',
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Write the expected codex files so the initial state is in-sync
|
|
533
|
+
for (const agent of SIX_AGENTS) {
|
|
534
|
+
const skillDir = path.join(dir, '.agents', 'skills', agent);
|
|
535
|
+
mkdirSync(skillDir, { recursive: true });
|
|
536
|
+
writeFileSync(
|
|
537
|
+
path.join(skillDir, 'SKILL.md'),
|
|
538
|
+
makeCodexSkillContent(agent),
|
|
539
|
+
'utf8',
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
writeFileSync(path.join(dir, 'AGENTS.md'), makeCodexAgentsMd(), 'utf8');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// GREEN: check uses filterFileMapByTargets which already scopes by prefix.
|
|
546
|
+
// With `codex` in lockfile targets, codex keys from the fetcher will be
|
|
547
|
+
// included in the check. A clean tree is in-sync.
|
|
548
|
+
it('exits 0 (in-sync) when all codex files match expected content', async () => {
|
|
549
|
+
setupCodexConsumer(consumerDir);
|
|
550
|
+
|
|
551
|
+
let exitCode = 0;
|
|
552
|
+
try {
|
|
553
|
+
await runCheck(consumerDir, {
|
|
554
|
+
fetcher: makeFullFetcher(),
|
|
555
|
+
availableTags: AVAILABLE_TAGS,
|
|
556
|
+
});
|
|
557
|
+
} catch (err) {
|
|
558
|
+
exitCode = err.exitCode ?? 2;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
assert.equal(exitCode, 0, 'check must exit 0 when all codex files are in sync');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// GREEN: deletion drift is detected for any target once it's in the lockfile.
|
|
565
|
+
it('exits 1 and reports <missing> when a SKILL.md is deleted', async () => {
|
|
566
|
+
setupCodexConsumer(consumerDir);
|
|
567
|
+
|
|
568
|
+
// Delete one SKILL.md to simulate deletion drift
|
|
569
|
+
const defineSkillPath = path.join(consumerDir, '.agents', 'skills', 'define', 'SKILL.md');
|
|
570
|
+
rmSync(defineSkillPath);
|
|
571
|
+
|
|
572
|
+
let exitCode;
|
|
573
|
+
let errorMessage = '';
|
|
574
|
+
try {
|
|
575
|
+
await runCheck(consumerDir, {
|
|
576
|
+
fetcher: makeFullFetcher(),
|
|
577
|
+
availableTags: AVAILABLE_TAGS,
|
|
578
|
+
});
|
|
579
|
+
exitCode = 0;
|
|
580
|
+
} catch (err) {
|
|
581
|
+
exitCode = err.exitCode;
|
|
582
|
+
errorMessage = err.message;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
assert.equal(exitCode, 1, 'check must exit 1 when a codex SKILL.md is missing');
|
|
586
|
+
assert.ok(
|
|
587
|
+
errorMessage.includes('<missing>'),
|
|
588
|
+
`drift message must mark the file as missing, got: ${errorMessage}`,
|
|
589
|
+
);
|
|
590
|
+
assert.ok(
|
|
591
|
+
errorMessage.includes('define') || errorMessage.includes('SKILL.md'),
|
|
592
|
+
`drift message must name the offending path, got: ${errorMessage}`,
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// GREEN: content drift — file exists but content differs from expected
|
|
597
|
+
it('exits 1 when a SKILL.md has been locally modified (content drift)', async () => {
|
|
598
|
+
setupCodexConsumer(consumerDir);
|
|
599
|
+
|
|
600
|
+
const defineSkillPath = path.join(consumerDir, '.agents', 'skills', 'define', 'SKILL.md');
|
|
601
|
+
const original = readFileSync(defineSkillPath, 'utf8');
|
|
602
|
+
writeFileSync(defineSkillPath, original + '\n\n# Someone added this locally.', 'utf8');
|
|
603
|
+
|
|
604
|
+
let exitCode;
|
|
605
|
+
try {
|
|
606
|
+
await runCheck(consumerDir, {
|
|
607
|
+
fetcher: makeFullFetcher(),
|
|
608
|
+
availableTags: AVAILABLE_TAGS,
|
|
609
|
+
});
|
|
610
|
+
exitCode = 0;
|
|
611
|
+
} catch (err) {
|
|
612
|
+
exitCode = err.exitCode;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
assert.equal(exitCode, 1, 'check must exit 1 when a codex SKILL.md has drifted content');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// GREEN: drift detected when AGENTS.md is deleted
|
|
619
|
+
it('exits 1 and reports <missing> when AGENTS.md is deleted', async () => {
|
|
620
|
+
setupCodexConsumer(consumerDir);
|
|
621
|
+
|
|
622
|
+
rmSync(path.join(consumerDir, 'AGENTS.md'));
|
|
623
|
+
|
|
624
|
+
let exitCode;
|
|
625
|
+
let errorMessage = '';
|
|
626
|
+
try {
|
|
627
|
+
await runCheck(consumerDir, {
|
|
628
|
+
fetcher: makeFullFetcher(),
|
|
629
|
+
availableTags: AVAILABLE_TAGS,
|
|
630
|
+
});
|
|
631
|
+
exitCode = 0;
|
|
632
|
+
} catch (err) {
|
|
633
|
+
exitCode = err.exitCode;
|
|
634
|
+
errorMessage = err.message;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
assert.equal(exitCode, 1, 'check must exit 1 when AGENTS.md is missing');
|
|
638
|
+
assert.ok(
|
|
639
|
+
errorMessage.includes('<missing>'),
|
|
640
|
+
`drift message must mark AGENTS.md as missing, got: ${errorMessage}`,
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// GREEN: content drift for AGENTS.md
|
|
645
|
+
it('exits 1 when AGENTS.md has been locally modified (content drift)', async () => {
|
|
646
|
+
setupCodexConsumer(consumerDir);
|
|
647
|
+
|
|
648
|
+
const agentsMdPath = path.join(consumerDir, 'AGENTS.md');
|
|
649
|
+
const original = readFileSync(agentsMdPath, 'utf8');
|
|
650
|
+
writeFileSync(agentsMdPath, original + '\n\n# Extra section added locally.', 'utf8');
|
|
651
|
+
|
|
652
|
+
let exitCode;
|
|
653
|
+
try {
|
|
654
|
+
await runCheck(consumerDir, {
|
|
655
|
+
fetcher: makeFullFetcher(),
|
|
656
|
+
availableTags: AVAILABLE_TAGS,
|
|
657
|
+
});
|
|
658
|
+
exitCode = 0;
|
|
659
|
+
} catch (err) {
|
|
660
|
+
exitCode = err.exitCode;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
assert.equal(exitCode, 1, 'check must exit 1 when AGENTS.md content has drifted');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// GREEN (critical contract): a non-codex sibling .agents/ file is NEVER reported
|
|
667
|
+
// as drift. check only looks at paths in the scoped FileMap — unmanaged siblings
|
|
668
|
+
// are invisible to it.
|
|
669
|
+
it('does NOT report drift for an unmanaged .agents/ sibling file', async () => {
|
|
670
|
+
setupCodexConsumer(consumerDir);
|
|
671
|
+
|
|
672
|
+
// Seed a sibling file that is not in the codex FileMap
|
|
673
|
+
const siblingPath = path.join(consumerDir, '.agents', 'skills', 'my-custom-skill.md');
|
|
674
|
+
writeFileSync(siblingPath, '# My own custom skill — not in FileMap', 'utf8');
|
|
675
|
+
|
|
676
|
+
let exitCode = 0;
|
|
677
|
+
try {
|
|
678
|
+
await runCheck(consumerDir, {
|
|
679
|
+
fetcher: makeFullFetcher(),
|
|
680
|
+
availableTags: AVAILABLE_TAGS,
|
|
681
|
+
});
|
|
682
|
+
} catch (err) {
|
|
683
|
+
exitCode = err.exitCode ?? 2;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
assert.equal(
|
|
687
|
+
exitCode,
|
|
688
|
+
0,
|
|
689
|
+
'check must exit 0 and must NOT report an unmanaged .agents/ sibling as drift',
|
|
690
|
+
);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// GREEN: check only checks the targets recorded in the lockfile.
|
|
694
|
+
// With targets: ['codex'], claude files are invisible to check.
|
|
695
|
+
it('does NOT report drift for unwritten .claude/ files when lockfile targets is only codex', async () => {
|
|
696
|
+
setupCodexConsumer(consumerDir);
|
|
697
|
+
// .claude/ does not exist — and it shouldn't be flagged as drift
|
|
698
|
+
|
|
699
|
+
let exitCode = 0;
|
|
700
|
+
try {
|
|
701
|
+
await runCheck(consumerDir, {
|
|
702
|
+
fetcher: makeFullFetcher(),
|
|
703
|
+
availableTags: AVAILABLE_TAGS,
|
|
704
|
+
});
|
|
705
|
+
} catch (err) {
|
|
706
|
+
exitCode = err.exitCode ?? 2;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
assert.equal(
|
|
710
|
+
exitCode,
|
|
711
|
+
0,
|
|
712
|
+
'check must NOT report missing .claude/ files when targets is only ["codex"]',
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
// 6. Opt-in only — autoDetectTargets must never include codex
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
describe('autoDetectTargets — codex is never auto-selected', () => {
|
|
722
|
+
let consumerDir;
|
|
723
|
+
|
|
724
|
+
beforeEach(() => {
|
|
725
|
+
consumerDir = makeTmpDir();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
afterEach(() => {
|
|
729
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// GREEN: .agents/ is a shared directory (consumer may own it for their own skills).
|
|
733
|
+
// Its presence must NOT be interpreted as a codex opt-in signal.
|
|
734
|
+
it('auto-detect does NOT select codex even when .agents/ exists in the consumer repo', async () => {
|
|
735
|
+
// Pre-create a .agents/ directory — this must NOT trigger codex selection
|
|
736
|
+
mkdirSync(path.join(consumerDir, '.agents', 'skills'), { recursive: true });
|
|
737
|
+
writeFileSync(
|
|
738
|
+
path.join(consumerDir, '.agents', 'skills', 'my-skill.md'),
|
|
739
|
+
'# My skill',
|
|
740
|
+
'utf8',
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
// Run init in auto mode (targets: 'auto') — should not select codex
|
|
744
|
+
await runInit(consumerDir, {
|
|
745
|
+
targets: 'auto',
|
|
746
|
+
fetcher: makeFullFetcher(),
|
|
747
|
+
availableTags: AVAILABLE_TAGS,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const lockfilePath = path.join(consumerDir, '.dev-agents-sync.json');
|
|
751
|
+
const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8'));
|
|
752
|
+
assert.ok(
|
|
753
|
+
!lockfile.targets.includes('codex'),
|
|
754
|
+
`auto-detect must not include "codex" when only .agents/ exists, got: ${JSON.stringify(lockfile.targets)}`,
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// GREEN: neither .claude nor .cursor, neither .codex — auto falls back to ALL_TARGETS
|
|
759
|
+
// (currently ['claude','cursor']). Codex must not be in ALL_TARGETS either.
|
|
760
|
+
it('auto-detect fallback (no .claude/ or .cursor/) does NOT include codex', async () => {
|
|
761
|
+
// Empty consumer dir — no IDE dirs at all
|
|
762
|
+
await runInit(consumerDir, {
|
|
763
|
+
targets: 'auto',
|
|
764
|
+
fetcher: makeFullFetcher(),
|
|
765
|
+
availableTags: AVAILABLE_TAGS,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const lockfilePath = path.join(consumerDir, '.dev-agents-sync.json');
|
|
769
|
+
const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8'));
|
|
770
|
+
assert.ok(
|
|
771
|
+
!lockfile.targets.includes('codex'),
|
|
772
|
+
`auto-detect fallback must not include "codex", got: ${JSON.stringify(lockfile.targets)}`,
|
|
773
|
+
);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// GREEN: .claude/ exists → auto picks claude only; still no codex
|
|
777
|
+
it('auto-detect picks only claude when .claude/ exists — still no codex', async () => {
|
|
778
|
+
mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
|
|
779
|
+
mkdirSync(path.join(consumerDir, '.agents', 'skills'), { recursive: true });
|
|
780
|
+
|
|
781
|
+
await runInit(consumerDir, {
|
|
782
|
+
targets: 'auto',
|
|
783
|
+
fetcher: makeFullFetcher(),
|
|
784
|
+
availableTags: AVAILABLE_TAGS,
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const lockfilePath = path.join(consumerDir, '.dev-agents-sync.json');
|
|
788
|
+
const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8'));
|
|
789
|
+
assert.deepEqual(
|
|
790
|
+
lockfile.targets,
|
|
791
|
+
['claude'],
|
|
792
|
+
`auto-detect must pick only claude when .claude/ exists, got: ${JSON.stringify(lockfile.targets)}`,
|
|
793
|
+
);
|
|
794
|
+
assert.ok(
|
|
795
|
+
!lockfile.targets.includes('codex'),
|
|
796
|
+
'auto-detect must not include codex even when .agents/ also exists',
|
|
797
|
+
);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// GREEN: explicit --targets codex works (user opted in) — different from auto
|
|
801
|
+
it('explicit --targets codex is accepted (opt-in path, not auto)', async () => {
|
|
802
|
+
let caughtError;
|
|
803
|
+
try {
|
|
804
|
+
await runInit(consumerDir, {
|
|
805
|
+
targets: ['codex'],
|
|
806
|
+
fetcher: makeFullFetcher(),
|
|
807
|
+
availableTags: AVAILABLE_TAGS,
|
|
808
|
+
});
|
|
809
|
+
} catch (err) {
|
|
810
|
+
caughtError = err;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Must not throw a "no targets" or "unknown target" error
|
|
814
|
+
assert.equal(
|
|
815
|
+
caughtError,
|
|
816
|
+
undefined,
|
|
817
|
+
`--targets codex (explicit opt-in) must not throw, got: ${caughtError?.message}`,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
const lockfilePath = path.join(consumerDir, '.dev-agents-sync.json');
|
|
821
|
+
assert.ok(existsSync(lockfilePath), 'lockfile must be created for explicit --targets codex');
|
|
822
|
+
});
|
|
823
|
+
});
|