@dalzoubi/dev-agents-sync 1.0.0

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,18 @@
1
+ ---
2
+ name: "define"
3
+ description: "Planning and analysis agent for spec-driven development."
4
+ model: "opus"
5
+ tools: [Read, Grep, Glob, WebFetch, WebSearch, Agent, TodoWrite, Write]
6
+ ---
7
+ <!-- managed-by: dev-agents-sync v1.0.0 -->
8
+ You are the **Define** agent: planning and analysis for spec-driven development.
9
+
10
+ ## Your posture
11
+
12
+ Senior product-minded engineer. You do not write code. You produce a spec that is concrete enough for the Implement agent to execute.
13
+
14
+ ## Hard rules
15
+
16
+ - **Read-only on source code.** You may only write the final spec markdown.
17
+ - **One question at a time.** Never ask multi-part questions.
18
+ - Honor the project's `CLAUDE.md` at the repo root.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: "test"
3
+ description: "Test and quality agent. Creates unit, integration, and regression tests."
4
+ model: "sonnet"
5
+ tools: [Read, Edit, Write, Grep, Glob, Bash, TodoWrite, Agent]
6
+ ---
7
+ <!-- managed-by: dev-agents-sync v1.0.0 -->
8
+ You are the **Test** agent: test creation and bug reproduction.
9
+
10
+ ## Your posture
11
+
12
+ Quality-minded engineer. You write tests that fail for the right reason, pass for the right reason.
13
+
14
+ ## Hard rules
15
+
16
+ - **Edit test files only.**
17
+ - Do not fix production code — that is the Implement agent's job.
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: "preflight"
3
+ description: "Pre-task orientation command. Run at the start of any session."
4
+ tools: [Read, Grep, Glob]
5
+ ---
6
+ <!-- managed-by: dev-agents-sync v1.0.0 -->
7
+ Read CLAUDE.md and any AGENTS.md files. Orient yourself to the codebase before taking any action.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: "Planning and analysis agent for spec-driven development."
3
+ globs: []
4
+ alwaysApply: false
5
+ ---
6
+ <!-- managed-by: dev-agents-sync v1.0.0 -->
7
+ You are the **Define** agent: planning and analysis for spec-driven development.
8
+
9
+ ## Your posture
10
+
11
+ Senior product-minded engineer. You do not write code.
12
+
13
+ ## Hard rules
14
+
15
+ - **Read-only on source code.**
16
+ - **One question at a time.**
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: "Test and quality agent. Creates unit, integration, and regression tests."
3
+ globs: []
4
+ alwaysApply: false
5
+ ---
6
+ <!-- managed-by: dev-agents-sync v1.0.0 -->
7
+ You are the **Test** agent: test creation and bug reproduction.
8
+
9
+ ## Your posture
10
+
11
+ Quality-minded engineer. You write tests that fail for the right reason.
12
+
13
+ ## Hard rules
14
+
15
+ - **Edit test files only.**
@@ -0,0 +1,514 @@
1
+ /**
2
+ * tests/init.test.mjs
3
+ *
4
+ * Tests for the `init` subcommand.
5
+ *
6
+ * Strategy: inject a fixture-backed fetcher that returns the files from
7
+ * tests/fixtures/release-v1.0.0/ so no real GitHub calls are made.
8
+ *
9
+ * The fixture fetcher is a function:
10
+ * async (repo, tag, token) => FileMap
11
+ *
12
+ * where FileMap is: { [relativePath: string]: string (file content) }
13
+ *
14
+ * All tests use isolated tmp dirs so they never touch the real repo.
15
+ */
16
+
17
+ import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import {
20
+ mkdtempSync,
21
+ mkdirSync,
22
+ writeFileSync,
23
+ readFileSync,
24
+ existsSync,
25
+ rmSync,
26
+ readdirSync,
27
+ } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ import { runInit } from '../src/commands/init.mjs';
33
+ import { hasMarker } from '../src/marker.mjs';
34
+ import { readLockfile } from '../src/lockfile.mjs';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Fixture fetcher
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
42
+
43
+ /**
44
+ * Builds a FileMap from the fixture directory for a specific set of targets.
45
+ * Returns an object keyed by paths relative to the fixture root.
46
+ */
47
+ function buildFixtureFileMap(targets = ['claude', 'cursor']) {
48
+ const map = {};
49
+ for (const target of targets) {
50
+ const targetDir = path.join(FIXTURE_DIR, target);
51
+ if (!existsSync(targetDir)) continue;
52
+ collectFiles(targetDir, targetDir, map);
53
+ }
54
+ return map;
55
+ }
56
+
57
+ function collectFiles(baseDir, currentDir, map) {
58
+ const entries = readdirSync(currentDir, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ const full = path.join(currentDir, entry.name);
61
+ if (entry.isDirectory()) {
62
+ collectFiles(baseDir, full, map);
63
+ } else {
64
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
65
+ map[rel] = readFileSync(full, 'utf8');
66
+ }
67
+ }
68
+ }
69
+
70
+ /** Creates an injectable fixture fetcher for specific targets. */
71
+ function makeFixtureFetcher(availableTags = ['v1.0.0', 'v1.1.0', 'v1.2.0']) {
72
+ return async (repo, tag) => {
73
+ // Simulate returning the fixture dist contents as a FileMap
74
+ // The target directories are returned regardless of which tag was requested
75
+ // (fixture is our stand-in for all tags).
76
+ return buildFixtureFileMap(['claude', 'cursor']);
77
+ };
78
+ }
79
+
80
+ /** Available tags list for resolving ranges in tests. */
81
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Helpers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function makeTmpDir() {
88
+ return mkdtempSync(path.join(tmpdir(), 'das-init-test-'));
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Clean repo — basic init
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe('init — clean repo', () => {
96
+ let consumerDir;
97
+
98
+ beforeEach(() => {
99
+ consumerDir = makeTmpDir();
100
+ });
101
+
102
+ afterEach(() => {
103
+ rmSync(consumerDir, { recursive: true, force: true });
104
+ });
105
+
106
+ it('creates .dev-agents-sync.json with default range "^1"', async () => {
107
+ await runInit(consumerDir, {
108
+ fetcher: makeFixtureFetcher(),
109
+ availableTags: AVAILABLE_TAGS,
110
+ });
111
+
112
+ const lockfile = readLockfile(consumerDir);
113
+ assert.equal(lockfile.range, '^1');
114
+ });
115
+
116
+ it('creates .dev-agents-sync.json with resolved version', async () => {
117
+ await runInit(consumerDir, {
118
+ fetcher: makeFixtureFetcher(),
119
+ availableTags: AVAILABLE_TAGS,
120
+ });
121
+
122
+ const lockfile = readLockfile(consumerDir);
123
+ // resolvedVersion must be the highest matching ^1 tag (1.2.0)
124
+ assert.equal(lockfile.resolvedVersion, '1.2.0');
125
+ });
126
+
127
+ it('creates .dev-agents-sync.json with source field', async () => {
128
+ await runInit(consumerDir, {
129
+ fetcher: makeFixtureFetcher(),
130
+ availableTags: AVAILABLE_TAGS,
131
+ });
132
+
133
+ const lockfile = readLockfile(consumerDir);
134
+ assert.ok(lockfile.source, 'source field must be present');
135
+ });
136
+
137
+ it('writes managed files to .claude/ when targets includes claude', async () => {
138
+ await runInit(consumerDir, {
139
+ targets: ['claude'],
140
+ fetcher: makeFixtureFetcher(),
141
+ availableTags: AVAILABLE_TAGS,
142
+ });
143
+
144
+ assert.ok(existsSync(path.join(consumerDir, '.claude')));
145
+ });
146
+
147
+ it('all written .claude files have the managed-by marker', async () => {
148
+ await runInit(consumerDir, {
149
+ targets: ['claude'],
150
+ fetcher: makeFixtureFetcher(),
151
+ availableTags: AVAILABLE_TAGS,
152
+ });
153
+
154
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
155
+ if (existsSync(agentsDir)) {
156
+ const files = readdirSync(agentsDir);
157
+ for (const file of files) {
158
+ const content = readFileSync(path.join(agentsDir, file), 'utf8');
159
+ assert.ok(hasMarker(content), `file ${file} missing managed-by marker`);
160
+ }
161
+ }
162
+ });
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // --targets flag
167
+ // ---------------------------------------------------------------------------
168
+
169
+ describe('init — --targets flag', () => {
170
+ let consumerDir;
171
+
172
+ beforeEach(() => {
173
+ consumerDir = makeTmpDir();
174
+ });
175
+
176
+ afterEach(() => {
177
+ rmSync(consumerDir, { recursive: true, force: true });
178
+ });
179
+
180
+ it('--targets claude writes only .claude/, no .cursor/', async () => {
181
+ await runInit(consumerDir, {
182
+ targets: ['claude'],
183
+ fetcher: makeFixtureFetcher(),
184
+ availableTags: AVAILABLE_TAGS,
185
+ });
186
+
187
+ assert.ok(existsSync(path.join(consumerDir, '.claude')));
188
+ assert.ok(!existsSync(path.join(consumerDir, '.cursor')));
189
+ });
190
+
191
+ it('--targets claude records targets: ["claude"] in lockfile', async () => {
192
+ await runInit(consumerDir, {
193
+ targets: ['claude'],
194
+ fetcher: makeFixtureFetcher(),
195
+ availableTags: AVAILABLE_TAGS,
196
+ });
197
+
198
+ const lockfile = readLockfile(consumerDir);
199
+ assert.deepEqual(lockfile.targets, ['claude']);
200
+ });
201
+
202
+ it('--targets cursor writes only .cursor/rules/, no .claude/', async () => {
203
+ await runInit(consumerDir, {
204
+ targets: ['cursor'],
205
+ fetcher: makeFixtureFetcher(),
206
+ availableTags: AVAILABLE_TAGS,
207
+ });
208
+
209
+ assert.ok(existsSync(path.join(consumerDir, '.cursor')));
210
+ assert.ok(!existsSync(path.join(consumerDir, '.claude')));
211
+ });
212
+
213
+ it('--targets cursor records targets: ["cursor"] in lockfile', async () => {
214
+ await runInit(consumerDir, {
215
+ targets: ['cursor'],
216
+ fetcher: makeFixtureFetcher(),
217
+ availableTags: AVAILABLE_TAGS,
218
+ });
219
+
220
+ const lockfile = readLockfile(consumerDir);
221
+ assert.deepEqual(lockfile.targets, ['cursor']);
222
+ });
223
+
224
+ it('--targets claude,cursor writes both directories', async () => {
225
+ await runInit(consumerDir, {
226
+ targets: ['claude', 'cursor'],
227
+ fetcher: makeFixtureFetcher(),
228
+ availableTags: AVAILABLE_TAGS,
229
+ });
230
+
231
+ assert.ok(existsSync(path.join(consumerDir, '.claude')));
232
+ assert.ok(existsSync(path.join(consumerDir, '.cursor')));
233
+ });
234
+ });
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Auto-target detection
238
+ // ---------------------------------------------------------------------------
239
+
240
+ describe('init — auto-target detection', () => {
241
+ let consumerDir;
242
+
243
+ beforeEach(() => {
244
+ consumerDir = makeTmpDir();
245
+ });
246
+
247
+ afterEach(() => {
248
+ rmSync(consumerDir, { recursive: true, force: true });
249
+ });
250
+
251
+ it('auto: picks claude only when .claude/ exists and .cursor/ does not', async () => {
252
+ mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
253
+
254
+ await runInit(consumerDir, {
255
+ targets: 'auto',
256
+ fetcher: makeFixtureFetcher(),
257
+ availableTags: AVAILABLE_TAGS,
258
+ });
259
+
260
+ const lockfile = readLockfile(consumerDir);
261
+ assert.deepEqual(lockfile.targets, ['claude']);
262
+ assert.ok(!existsSync(path.join(consumerDir, '.cursor', 'rules')));
263
+ });
264
+
265
+ it('auto: picks cursor only when .cursor/ exists and .claude/ does not', async () => {
266
+ mkdirSync(path.join(consumerDir, '.cursor'), { recursive: true });
267
+
268
+ await runInit(consumerDir, {
269
+ targets: 'auto',
270
+ fetcher: makeFixtureFetcher(),
271
+ availableTags: AVAILABLE_TAGS,
272
+ });
273
+
274
+ const lockfile = readLockfile(consumerDir);
275
+ assert.deepEqual(lockfile.targets, ['cursor']);
276
+ assert.ok(!existsSync(path.join(consumerDir, '.claude', 'agents')));
277
+ });
278
+
279
+ it('auto: picks both when neither .claude/ nor .cursor/ exists yet', async () => {
280
+ // No pre-existing directories
281
+ await runInit(consumerDir, {
282
+ targets: 'auto',
283
+ fetcher: makeFixtureFetcher(),
284
+ availableTags: AVAILABLE_TAGS,
285
+ });
286
+
287
+ const lockfile = readLockfile(consumerDir);
288
+ // Both should be recorded (the spec says "install both if neither directory exists")
289
+ assert.ok(
290
+ lockfile.targets.includes('claude') && lockfile.targets.includes('cursor'),
291
+ `expected both targets, got: ${JSON.stringify(lockfile.targets)}`,
292
+ );
293
+ });
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // --dry-run
298
+ // ---------------------------------------------------------------------------
299
+
300
+ describe('init — --dry-run', () => {
301
+ let consumerDir;
302
+
303
+ beforeEach(() => {
304
+ consumerDir = makeTmpDir();
305
+ });
306
+
307
+ afterEach(() => {
308
+ rmSync(consumerDir, { recursive: true, force: true });
309
+ });
310
+
311
+ it('does not write any files to disk in dry-run mode', async () => {
312
+ const output = await runInit(consumerDir, {
313
+ targets: ['claude', 'cursor'],
314
+ dryRun: true,
315
+ fetcher: makeFixtureFetcher(),
316
+ availableTags: AVAILABLE_TAGS,
317
+ });
318
+
319
+ // Lockfile should NOT be created
320
+ assert.ok(
321
+ !existsSync(path.join(consumerDir, '.dev-agents-sync.json')),
322
+ 'lockfile must not be created in dry-run mode',
323
+ );
324
+ // No .claude/ or .cursor/ should be created
325
+ assert.ok(!existsSync(path.join(consumerDir, '.claude')));
326
+ assert.ok(!existsSync(path.join(consumerDir, '.cursor')));
327
+ });
328
+
329
+ it('returns planned writes in dry-run mode', async () => {
330
+ const result = await runInit(consumerDir, {
331
+ targets: ['claude'],
332
+ dryRun: true,
333
+ fetcher: makeFixtureFetcher(),
334
+ availableTags: AVAILABLE_TAGS,
335
+ });
336
+
337
+ // The result should describe what would be written
338
+ assert.ok(result, 'dry-run must return a result object');
339
+ assert.ok(
340
+ result.plannedWrites !== undefined || result.files !== undefined || Array.isArray(result),
341
+ 'dry-run result must include planned file list',
342
+ );
343
+ });
344
+ });
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Unmanaged collision
348
+ // ---------------------------------------------------------------------------
349
+
350
+ describe('init — unmanaged collision', () => {
351
+ let consumerDir;
352
+
353
+ beforeEach(() => {
354
+ consumerDir = makeTmpDir();
355
+ });
356
+
357
+ afterEach(() => {
358
+ rmSync(consumerDir, { recursive: true, force: true });
359
+ });
360
+
361
+ it('refuses to overwrite an unmanaged file (no marker), exit 1', async () => {
362
+ // Pre-create a file at a path that init would write
363
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
364
+ mkdirSync(agentsDir, { recursive: true });
365
+ writeFileSync(path.join(agentsDir, 'define.md'), '# My custom define\nNo marker here.', 'utf8');
366
+
367
+ let exitCode;
368
+ try {
369
+ await runInit(consumerDir, {
370
+ targets: ['claude'],
371
+ fetcher: makeFixtureFetcher(),
372
+ availableTags: AVAILABLE_TAGS,
373
+ });
374
+ exitCode = 0;
375
+ } catch (err) {
376
+ exitCode = err.exitCode;
377
+ }
378
+
379
+ assert.equal(exitCode, 1, 'expected exit code 1 for unmanaged collision');
380
+ });
381
+
382
+ it('collision error message names the conflicting path', async () => {
383
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
384
+ mkdirSync(agentsDir, { recursive: true });
385
+ writeFileSync(path.join(agentsDir, 'define.md'), '# Custom content', 'utf8');
386
+
387
+ let errorMessage = '';
388
+ try {
389
+ await runInit(consumerDir, {
390
+ targets: ['claude'],
391
+ fetcher: makeFixtureFetcher(),
392
+ availableTags: AVAILABLE_TAGS,
393
+ });
394
+ } catch (err) {
395
+ errorMessage = err.message;
396
+ }
397
+
398
+ assert.ok(
399
+ errorMessage.includes('define.md') || errorMessage.includes('.claude'),
400
+ `error message must name the conflicting path, got: ${errorMessage}`,
401
+ );
402
+ });
403
+
404
+ it('--force overwrites the unmanaged file', async () => {
405
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
406
+ mkdirSync(agentsDir, { recursive: true });
407
+ const originalContent = '# My custom define\nNo marker here.';
408
+ writeFileSync(path.join(agentsDir, 'define.md'), originalContent, 'utf8');
409
+
410
+ await runInit(consumerDir, {
411
+ targets: ['claude'],
412
+ force: true,
413
+ fetcher: makeFixtureFetcher(),
414
+ availableTags: AVAILABLE_TAGS,
415
+ });
416
+
417
+ // File should now have the marker
418
+ const written = readFileSync(path.join(agentsDir, 'define.md'), 'utf8');
419
+ assert.ok(hasMarker(written), 'file should now have the managed-by marker after --force');
420
+ });
421
+
422
+ it('overwrites a file that already has the marker (managed file, no --force needed)', async () => {
423
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
424
+ mkdirSync(agentsDir, { recursive: true });
425
+ // Pre-create a managed file (with marker)
426
+ writeFileSync(
427
+ path.join(agentsDir, 'define.md'),
428
+ '---\nname: "define"\n---\n<!-- managed-by: dev-agents-sync v0.9.0 -->\nOld content.',
429
+ 'utf8',
430
+ );
431
+
432
+ // Should not throw — managed files are safe to overwrite
433
+ await assert.doesNotReject(
434
+ runInit(consumerDir, {
435
+ targets: ['claude'],
436
+ fetcher: makeFixtureFetcher(),
437
+ availableTags: AVAILABLE_TAGS,
438
+ }),
439
+ );
440
+ });
441
+ });
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Non-interactive by default
445
+ // ---------------------------------------------------------------------------
446
+
447
+ describe('init — non-interactive default', () => {
448
+ let consumerDir;
449
+
450
+ beforeEach(() => {
451
+ consumerDir = makeTmpDir();
452
+ });
453
+
454
+ afterEach(() => {
455
+ rmSync(consumerDir, { recursive: true, force: true });
456
+ });
457
+
458
+ it('completes without prompting when called programmatically (--yes implicit)', async () => {
459
+ // If init tries to prompt interactively this would hang — it must not.
460
+ // We set a short timeout to detect hangs.
461
+ await assert.doesNotReject(
462
+ Promise.race([
463
+ runInit(consumerDir, {
464
+ targets: ['claude'],
465
+ fetcher: makeFixtureFetcher(),
466
+ availableTags: AVAILABLE_TAGS,
467
+ }),
468
+ new Promise((_, reject) =>
469
+ setTimeout(() => reject(new Error('init timed out — likely prompting interactively')), 3000),
470
+ ),
471
+ ]),
472
+ );
473
+ });
474
+ });
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Custom range
478
+ // ---------------------------------------------------------------------------
479
+
480
+ describe('init — custom --range', () => {
481
+ let consumerDir;
482
+
483
+ beforeEach(() => {
484
+ consumerDir = makeTmpDir();
485
+ });
486
+
487
+ afterEach(() => {
488
+ rmSync(consumerDir, { recursive: true, force: true });
489
+ });
490
+
491
+ it('records the custom range in the lockfile', async () => {
492
+ await runInit(consumerDir, {
493
+ targets: ['claude'],
494
+ range: '~1.1',
495
+ fetcher: makeFixtureFetcher(),
496
+ availableTags: AVAILABLE_TAGS,
497
+ });
498
+
499
+ const lockfile = readLockfile(consumerDir);
500
+ assert.equal(lockfile.range, '~1.1');
501
+ });
502
+
503
+ it('resolves to the latest patch for a tilde range', async () => {
504
+ await runInit(consumerDir, {
505
+ targets: ['claude'],
506
+ range: '~1.0',
507
+ fetcher: makeFixtureFetcher(),
508
+ availableTags: AVAILABLE_TAGS,
509
+ });
510
+
511
+ const lockfile = readLockfile(consumerDir);
512
+ assert.equal(lockfile.resolvedVersion, '1.0.0');
513
+ });
514
+ });