@dalzoubi/dev-agents-sync 2.0.6 → 2.0.8

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.
@@ -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
+ });