@automagik/genie 4.260409.1 → 4.260409.2

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.
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { discoverExternalAgents, importAgents } from '../lib/discovery.js';
@@ -94,9 +94,12 @@ describe('discoverExternalAgents()', () => {
94
94
  // ─── importAgents ────────────────────────────────────────────────────────────
95
95
 
96
96
  describe('importAgents()', () => {
97
- test('creates symlinks in agents/ directory', () => {
97
+ test('moves agent into agents/ directory (physical, not symlink)', () => {
98
98
  mkWorkspace(testDir);
99
99
  mkAgent(testDir, 'services/auth');
100
+ // Add a hidden folder to verify dotfiles are preserved
101
+ mkdirSync(join(testDir, 'services', 'auth', '.claude'), { recursive: true });
102
+ writeFileSync(join(testDir, 'services', 'auth', '.claude', 'settings.json'), '{}');
100
103
 
101
104
  const agents = [
102
105
  {
@@ -113,10 +116,16 @@ describe('importAgents()', () => {
113
116
  expect(result.skipped).toEqual([]);
114
117
  expect(result.errors).toEqual([]);
115
118
 
116
- const linkPath = join(testDir, 'agents', 'auth');
117
- expect(existsSync(linkPath)).toBe(true);
118
- expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
119
- expect(readlinkSync(linkPath)).toBe(join(testDir, 'services', 'auth'));
119
+ const destPath = join(testDir, 'agents', 'auth');
120
+ expect(existsSync(destPath)).toBe(true);
121
+ expect(lstatSync(destPath).isDirectory()).toBe(true);
122
+ expect(lstatSync(destPath).isSymbolicLink()).toBe(false);
123
+ // Files were moved
124
+ expect(readFileSync(join(destPath, 'AGENTS.md'), 'utf-8')).toContain('# Agent');
125
+ // Hidden folders preserved
126
+ expect(existsSync(join(destPath, '.claude', 'settings.json'))).toBe(true);
127
+ // Source removed
128
+ expect(existsSync(join(testDir, 'services', 'auth'))).toBe(false);
120
129
  });
121
130
 
122
131
  test('resolves name collisions with numeric suffix', () => {
@@ -139,9 +148,10 @@ describe('importAgents()', () => {
139
148
  expect(result.imported).toEqual(['auth-2']);
140
149
  expect(result.errors).toEqual([]);
141
150
 
142
- const linkPath = join(testDir, 'agents', 'auth-2');
143
- expect(existsSync(linkPath)).toBe(true);
144
- expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
151
+ const destPath = join(testDir, 'agents', 'auth-2');
152
+ expect(existsSync(destPath)).toBe(true);
153
+ expect(lstatSync(destPath).isDirectory()).toBe(true);
154
+ expect(lstatSync(destPath).isSymbolicLink()).toBe(false);
145
155
  });
146
156
 
147
157
  test('creates agents/ directory if it does not exist', () => {
@@ -0,0 +1,498 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ readlinkSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join, relative } from 'node:path';
15
+ import {
16
+ type MigrationJournalEntry,
17
+ executeMigration,
18
+ hasDirtyWorkingTree,
19
+ isInsideSeparateGitRepo,
20
+ planMigration,
21
+ recalculateInternalSymlinks,
22
+ rollbackMigration,
23
+ } from '../lib/migrate.js';
24
+
25
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
26
+
27
+ let testDir: string;
28
+
29
+ beforeEach(() => {
30
+ testDir = join(tmpdir(), `genie-migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
31
+ mkdirSync(testDir, { recursive: true });
32
+ });
33
+
34
+ afterEach(() => {
35
+ rmSync(testDir, { recursive: true, force: true });
36
+ });
37
+
38
+ /** Create a minimal workspace with .genie/ and agents/ dirs. */
39
+ function mkWorkspace(root: string): void {
40
+ mkdirSync(join(root, '.genie'), { recursive: true });
41
+ mkdirSync(join(root, 'agents'), { recursive: true });
42
+ }
43
+
44
+ /** Create an agent directory with AGENTS.md. */
45
+ function mkAgent(dir: string): void {
46
+ mkdirSync(dir, { recursive: true });
47
+ writeFileSync(join(dir, 'AGENTS.md'), '---\nname: test\n---\n# Agent\n');
48
+ }
49
+
50
+ /** Create a symlink in agents/ pointing to an external directory (relative). */
51
+ function mkSymlink(workspaceRoot: string, agentName: string, targetPath: string): string {
52
+ const linkPath = join(workspaceRoot, 'agents', agentName);
53
+ const relTarget = relative(join(workspaceRoot, 'agents'), targetPath);
54
+ symlinkSync(relTarget, linkPath);
55
+ return linkPath;
56
+ }
57
+
58
+ /** Read the migration journal from a workspace. */
59
+ function readJournal(workspaceRoot: string): MigrationJournalEntry[] {
60
+ const path = join(workspaceRoot, '.genie', 'migration-journal.json');
61
+ if (!existsSync(path)) return [];
62
+ return JSON.parse(readFileSync(path, 'utf-8'));
63
+ }
64
+
65
+ // ─── planMigration ──────────────────────────────────────────────────────────
66
+
67
+ describe('planMigration()', () => {
68
+ test('finds symlinked agents and returns plans', () => {
69
+ mkWorkspace(testDir);
70
+ const extDir = join(testDir, 'services', 'auth');
71
+ mkAgent(extDir);
72
+ mkSymlink(testDir, 'auth', extDir);
73
+
74
+ const plans = planMigration(testDir);
75
+
76
+ expect(plans.length).toBe(1);
77
+ expect(plans[0].agent).toBe('auth');
78
+ expect(plans[0].from).toBe(extDir);
79
+ expect(plans[0].to).toBe(join(testDir, 'agents', 'auth'));
80
+ });
81
+
82
+ test('skips physical directories (not symlinks)', () => {
83
+ mkWorkspace(testDir);
84
+ // Create a physical agent dir directly in agents/
85
+ mkAgent(join(testDir, 'agents', 'physical'));
86
+
87
+ const plans = planMigration(testDir);
88
+
89
+ expect(plans).toEqual([]);
90
+ });
91
+
92
+ test('returns empty array when no symlinks exist', () => {
93
+ mkWorkspace(testDir);
94
+
95
+ const plans = planMigration(testDir);
96
+
97
+ expect(plans).toEqual([]);
98
+ });
99
+
100
+ test('returns empty array when agents/ does not exist', () => {
101
+ mkdirSync(join(testDir, '.genie'), { recursive: true });
102
+ // No agents/ dir
103
+
104
+ const plans = planMigration(testDir);
105
+
106
+ expect(plans).toEqual([]);
107
+ });
108
+
109
+ test('detects cross-repo risk', () => {
110
+ mkWorkspace(testDir);
111
+ // Create a fake separate git repo
112
+ const externalRepo = join(testDir, 'external-repo');
113
+ mkAgent(join(externalRepo, 'agent-x'));
114
+ mkdirSync(join(externalRepo, '.git'), { recursive: true });
115
+ // Create a .git in the workspace root too
116
+ mkdirSync(join(testDir, '.git'), { recursive: true });
117
+
118
+ mkSymlink(testDir, 'agent-x', join(externalRepo, 'agent-x'));
119
+
120
+ const plans = planMigration(testDir);
121
+
122
+ expect(plans.length).toBe(1);
123
+ expect(plans[0].risks).toContain('Cross-repo agent');
124
+ });
125
+ });
126
+
127
+ // ─── executeMigration ───────────────────────────────────────────────────────
128
+
129
+ describe('executeMigration()', () => {
130
+ test('converts symlink to physical directory via copy', () => {
131
+ mkWorkspace(testDir);
132
+ const extDir = join(testDir, 'services', 'billing');
133
+ mkAgent(extDir);
134
+ writeFileSync(join(extDir, 'config.json'), '{"key":"value"}');
135
+ mkSymlink(testDir, 'billing', extDir);
136
+
137
+ const plans = planMigration(testDir);
138
+ // Force copy method for this test
139
+ const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
140
+ const result = executeMigration(testDir, plan, { noGit: true });
141
+
142
+ expect(result.migrated).toEqual(['billing']);
143
+ expect(result.skipped).toEqual([]);
144
+ expect(result.errors).toEqual([]);
145
+
146
+ // Verify the destination is now a physical directory (not a symlink)
147
+ const destPath = join(testDir, 'agents', 'billing');
148
+ expect(existsSync(destPath)).toBe(true);
149
+ expect(lstatSync(destPath).isSymbolicLink()).toBe(false);
150
+ expect(lstatSync(destPath).isDirectory()).toBe(true);
151
+
152
+ // Verify files were copied
153
+ expect(existsSync(join(destPath, 'AGENTS.md'))).toBe(true);
154
+ expect(existsSync(join(destPath, 'config.json'))).toBe(true);
155
+ expect(readFileSync(join(destPath, 'config.json'), 'utf-8')).toBe('{"key":"value"}');
156
+ });
157
+
158
+ test('writes journal entries', () => {
159
+ mkWorkspace(testDir);
160
+ const extDir = join(testDir, 'services', 'auth');
161
+ mkAgent(extDir);
162
+ mkSymlink(testDir, 'auth', extDir);
163
+
164
+ const plans = planMigration(testDir);
165
+ const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
166
+ const result = executeMigration(testDir, plan, { noGit: true });
167
+
168
+ expect(result.migrated).toEqual(['auth']);
169
+
170
+ const journal = readJournal(testDir);
171
+ expect(journal.length).toBe(1);
172
+ expect(journal[0].agent).toBe('auth');
173
+ expect(journal[0].batchId).toBe(result.batchId);
174
+ expect(journal[0].method).toBe('copy');
175
+ expect(journal[0].from).toBe(extDir);
176
+ expect(journal[0].to).toBe(join(testDir, 'agents', 'auth'));
177
+ // Timestamp is ISO 8601
178
+ expect(new Date(journal[0].timestamp).toISOString()).toBe(journal[0].timestamp);
179
+ });
180
+
181
+ test('skips cross-repo agents without force', () => {
182
+ mkWorkspace(testDir);
183
+ const externalRepo = join(testDir, 'external-repo');
184
+ mkAgent(join(externalRepo, 'agent-x'));
185
+ mkdirSync(join(externalRepo, '.git'), { recursive: true });
186
+ mkdirSync(join(testDir, '.git'), { recursive: true });
187
+ mkSymlink(testDir, 'agent-x', join(externalRepo, 'agent-x'));
188
+
189
+ const plans = planMigration(testDir);
190
+ expect(plans[0].risks).toContain('Cross-repo agent');
191
+
192
+ const result = executeMigration(testDir, plans, { noGit: true });
193
+
194
+ expect(result.skipped).toEqual(['agent-x']);
195
+ expect(result.migrated).toEqual([]);
196
+ // Symlink should still exist
197
+ expect(lstatSync(join(testDir, 'agents', 'agent-x')).isSymbolicLink()).toBe(true);
198
+ });
199
+
200
+ test('migrates cross-repo agents with force', () => {
201
+ mkWorkspace(testDir);
202
+ const externalRepo = join(testDir, 'external-repo');
203
+ mkAgent(join(externalRepo, 'agent-x'));
204
+ mkdirSync(join(externalRepo, '.git'), { recursive: true });
205
+ mkdirSync(join(testDir, '.git'), { recursive: true });
206
+ mkSymlink(testDir, 'agent-x', join(externalRepo, 'agent-x'));
207
+
208
+ const plans = planMigration(testDir);
209
+ const result = executeMigration(testDir, plans, { force: true, noGit: true });
210
+
211
+ expect(result.migrated).toEqual(['agent-x']);
212
+ expect(result.skipped).toEqual([]);
213
+ expect(lstatSync(join(testDir, 'agents', 'agent-x')).isDirectory()).toBe(true);
214
+ expect(lstatSync(join(testDir, 'agents', 'agent-x')).isSymbolicLink()).toBe(false);
215
+ });
216
+
217
+ test('aborts on dirty source repos', () => {
218
+ mkWorkspace(testDir);
219
+ const extDir = join(testDir, 'services', 'dirty');
220
+ mkAgent(extDir);
221
+ mkSymlink(testDir, 'dirty', extDir);
222
+
223
+ // Create plan with "Uncommitted changes" risk manually
224
+ const plan = [
225
+ {
226
+ agent: 'dirty',
227
+ from: extDir,
228
+ to: join(testDir, 'agents', 'dirty'),
229
+ method: 'copy' as const,
230
+ risks: ['Uncommitted changes'],
231
+ },
232
+ ];
233
+
234
+ const result = executeMigration(testDir, plan, { noGit: true });
235
+
236
+ expect(result.errors.length).toBe(1);
237
+ expect(result.errors[0].agent).toBe('dirty');
238
+ expect(result.errors[0].error).toContain('Uncommitted changes');
239
+ expect(result.migrated).toEqual([]);
240
+ // Symlink should still exist
241
+ expect(lstatSync(join(testDir, 'agents', 'dirty')).isSymbolicLink()).toBe(true);
242
+ });
243
+
244
+ test('recalculates internal relative symlinks', () => {
245
+ mkWorkspace(testDir);
246
+ // Create an agent with an internal relative symlink
247
+ const extDir = join(testDir, 'services', 'linked');
248
+ mkAgent(extDir);
249
+
250
+ // Create a shared dir and a symlink from the agent to it
251
+ const sharedDir = join(testDir, 'services', 'shared');
252
+ mkdirSync(sharedDir, { recursive: true });
253
+ writeFileSync(join(sharedDir, 'utils.ts'), 'export const x = 1;');
254
+ // Relative symlink: services/linked/shared-link -> ../shared
255
+ symlinkSync('../shared', join(extDir, 'shared-link'));
256
+
257
+ mkSymlink(testDir, 'linked', extDir);
258
+
259
+ const plans = planMigration(testDir);
260
+ const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
261
+ const result = executeMigration(testDir, plan, { noGit: true });
262
+
263
+ expect(result.migrated).toEqual(['linked']);
264
+
265
+ // The internal symlink should be recalculated
266
+ const newSharedLink = join(testDir, 'agents', 'linked', 'shared-link');
267
+ expect(existsSync(newSharedLink)).toBe(true);
268
+ expect(lstatSync(newSharedLink).isSymbolicLink()).toBe(true);
269
+ // The target should resolve to the same shared dir
270
+ const linkTarget = readlinkSync(newSharedLink);
271
+ // From agents/linked/shared-link, the relative path to services/shared is ../../services/shared
272
+ expect(linkTarget).toBe('../../services/shared');
273
+ });
274
+ });
275
+
276
+ // ─── rollbackMigration ──────────────────────────────────────────────────────
277
+
278
+ describe('rollbackMigration()', () => {
279
+ test('reverses moves from journal', () => {
280
+ mkWorkspace(testDir);
281
+ const extDir = join(testDir, 'services', 'auth');
282
+ mkAgent(extDir);
283
+ mkSymlink(testDir, 'auth', extDir);
284
+
285
+ // Execute migration first
286
+ const plans = planMigration(testDir);
287
+ const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
288
+ const migrateResult = executeMigration(testDir, plan, { noGit: true });
289
+ expect(migrateResult.migrated).toEqual(['auth']);
290
+
291
+ // Verify it's a physical dir now
292
+ expect(lstatSync(join(testDir, 'agents', 'auth')).isSymbolicLink()).toBe(false);
293
+
294
+ // Source was removed during migration
295
+ expect(existsSync(extDir)).toBe(false);
296
+
297
+ // Rollback
298
+ const rollbackResult = rollbackMigration(testDir);
299
+
300
+ expect(rollbackResult.rolledBack).toEqual(['auth']);
301
+ expect(rollbackResult.errors).toEqual([]);
302
+
303
+ // Source should be restored
304
+ expect(existsSync(extDir)).toBe(true);
305
+ expect(existsSync(join(extDir, 'AGENTS.md'))).toBe(true);
306
+
307
+ // agents/auth should be a symlink again
308
+ expect(lstatSync(join(testDir, 'agents', 'auth')).isSymbolicLink()).toBe(true);
309
+ // Relative symlink
310
+ const linkTarget = readlinkSync(join(testDir, 'agents', 'auth'));
311
+ expect(linkTarget).toBe('../services/auth');
312
+
313
+ // Journal should be empty after rollback
314
+ const journal = readJournal(testDir);
315
+ expect(journal).toEqual([]);
316
+ });
317
+
318
+ test('returns empty result when journal is empty', () => {
319
+ mkWorkspace(testDir);
320
+
321
+ const result = rollbackMigration(testDir);
322
+
323
+ expect(result.rolledBack).toEqual([]);
324
+ expect(result.errors).toEqual([]);
325
+ });
326
+
327
+ test('only rolls back the most recent batch', () => {
328
+ mkWorkspace(testDir);
329
+
330
+ // Write a fake journal with two batches
331
+ const journalEntries: MigrationJournalEntry[] = [
332
+ {
333
+ agent: 'old-agent',
334
+ from: '/tmp/old',
335
+ to: '/tmp/old-dest',
336
+ timestamp: '2025-01-01T00:00:00.000Z',
337
+ method: 'copy',
338
+ batchId: 'batch-old',
339
+ },
340
+ {
341
+ agent: 'new-agent',
342
+ from: join(testDir, 'services', 'new'),
343
+ to: join(testDir, 'agents', 'new'),
344
+ timestamp: '2025-06-01T00:00:00.000Z',
345
+ method: 'copy',
346
+ batchId: 'batch-new',
347
+ },
348
+ ];
349
+ writeFileSync(join(testDir, '.genie', 'migration-journal.json'), JSON.stringify(journalEntries));
350
+
351
+ // Create the physical dir that would be rolled back
352
+ mkAgent(join(testDir, 'agents', 'new'));
353
+
354
+ const result = rollbackMigration(testDir);
355
+
356
+ expect(result.rolledBack).toEqual(['new-agent']);
357
+
358
+ // Old batch should remain in journal
359
+ const remaining = readJournal(testDir);
360
+ expect(remaining.length).toBe(1);
361
+ expect(remaining[0].batchId).toBe('batch-old');
362
+ });
363
+ });
364
+
365
+ // ─── recalculateInternalSymlinks ────────────────────────────────────────────
366
+
367
+ describe('recalculateInternalSymlinks()', () => {
368
+ test('fixes relative symlinks when directory moves', () => {
369
+ // Setup: dir at "old/" contains a symlink "link" -> "../target"
370
+ const oldDir = join(testDir, 'old');
371
+ const newDir = join(testDir, 'new', 'nested');
372
+ const targetDir = join(testDir, 'target');
373
+
374
+ mkdirSync(oldDir, { recursive: true });
375
+ mkdirSync(targetDir, { recursive: true });
376
+ writeFileSync(join(targetDir, 'file.txt'), 'content');
377
+
378
+ // Create a relative symlink in oldDir
379
+ symlinkSync('../target', join(oldDir, 'link'));
380
+
381
+ // Verify the old symlink works
382
+ expect(existsSync(join(oldDir, 'link', 'file.txt'))).toBe(true);
383
+
384
+ // "Move" oldDir to newDir by manual copy that preserves symlinks
385
+ mkdirSync(newDir, { recursive: true });
386
+ // Manually copy: file + symlink
387
+ writeFileSync(join(newDir, 'placeholder'), ''); // ensure dir exists
388
+ rmSync(join(newDir, 'placeholder'));
389
+ for (const entry of readdirSync(oldDir)) {
390
+ const srcPath = join(oldDir, entry);
391
+ const destPath = join(newDir, entry);
392
+ const stat = lstatSync(srcPath);
393
+ if (stat.isSymbolicLink()) {
394
+ symlinkSync(readlinkSync(srcPath), destPath);
395
+ } else {
396
+ writeFileSync(destPath, readFileSync(srcPath));
397
+ }
398
+ }
399
+
400
+ // The symlink in newDir is now broken (../target doesn't exist from new/nested/)
401
+ // Recalculate
402
+ recalculateInternalSymlinks(newDir, oldDir, newDir);
403
+
404
+ // The symlink should now point to ../../target (from new/nested/ to target/)
405
+ const newLinkTarget = readlinkSync(join(newDir, 'link'));
406
+ expect(newLinkTarget).toBe('../../target');
407
+ expect(existsSync(join(newDir, 'link', 'file.txt'))).toBe(true);
408
+ });
409
+
410
+ test('leaves absolute symlinks unchanged', () => {
411
+ const dir = join(testDir, 'mydir');
412
+ const absTarget = join(testDir, 'abs-target');
413
+ mkdirSync(dir, { recursive: true });
414
+ mkdirSync(absTarget, { recursive: true });
415
+
416
+ symlinkSync(absTarget, join(dir, 'abs-link'));
417
+
418
+ recalculateInternalSymlinks(dir, dir, dir);
419
+
420
+ expect(readlinkSync(join(dir, 'abs-link'))).toBe(absTarget);
421
+ });
422
+ });
423
+
424
+ // ─── isInsideSeparateGitRepo ────────────────────────────────────────────────
425
+
426
+ describe('isInsideSeparateGitRepo()', () => {
427
+ test('returns true when path is in a different git repo', () => {
428
+ // Workspace git root
429
+ mkdirSync(join(testDir, '.git'), { recursive: true });
430
+ // External git root
431
+ const externalRoot = join(testDir, 'external');
432
+ mkdirSync(join(externalRoot, '.git'), { recursive: true });
433
+ const agentDir = join(externalRoot, 'agents', 'bot');
434
+ mkdirSync(agentDir, { recursive: true });
435
+
436
+ expect(isInsideSeparateGitRepo(agentDir, testDir)).toBe(true);
437
+ });
438
+
439
+ test('returns false when path is in the same git repo', () => {
440
+ mkdirSync(join(testDir, '.git'), { recursive: true });
441
+ const agentDir = join(testDir, 'services', 'auth');
442
+ mkdirSync(agentDir, { recursive: true });
443
+
444
+ expect(isInsideSeparateGitRepo(agentDir, testDir)).toBe(false);
445
+ });
446
+
447
+ test('returns false when no .git directory exists', () => {
448
+ const agentDir = join(testDir, 'services', 'auth');
449
+ mkdirSync(agentDir, { recursive: true });
450
+
451
+ expect(isInsideSeparateGitRepo(agentDir, testDir)).toBe(false);
452
+ });
453
+ });
454
+
455
+ // ─── hasDirtyWorkingTree ────────────────────────────────────────────────────
456
+
457
+ describe('hasDirtyWorkingTree()', () => {
458
+ test('returns false for non-git directory', () => {
459
+ const dir = join(testDir, 'not-a-repo');
460
+ mkdirSync(dir, { recursive: true });
461
+
462
+ expect(hasDirtyWorkingTree(dir)).toBe(false);
463
+ });
464
+
465
+ test('returns false for clean git repo', () => {
466
+ const dir = join(testDir, 'clean-repo');
467
+ mkdirSync(dir, { recursive: true });
468
+
469
+ // Initialize a real git repo
470
+ const { execSync } = require('node:child_process');
471
+ execSync('git init', { cwd: dir, stdio: 'pipe' });
472
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
473
+ execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
474
+ writeFileSync(join(dir, 'file.txt'), 'content');
475
+ execSync('git add .', { cwd: dir, stdio: 'pipe' });
476
+ execSync('git commit -m "init"', { cwd: dir, stdio: 'pipe' });
477
+
478
+ expect(hasDirtyWorkingTree(dir)).toBe(false);
479
+ });
480
+
481
+ test('returns true for dirty git repo', () => {
482
+ const dir = join(testDir, 'dirty-repo');
483
+ mkdirSync(dir, { recursive: true });
484
+
485
+ const { execSync } = require('node:child_process');
486
+ execSync('git init', { cwd: dir, stdio: 'pipe' });
487
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
488
+ execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
489
+ writeFileSync(join(dir, 'file.txt'), 'content');
490
+ execSync('git add .', { cwd: dir, stdio: 'pipe' });
491
+ execSync('git commit -m "init"', { cwd: dir, stdio: 'pipe' });
492
+
493
+ // Make it dirty
494
+ writeFileSync(join(dir, 'dirty.txt'), 'uncommitted');
495
+
496
+ expect(hasDirtyWorkingTree(dir)).toBe(true);
497
+ });
498
+ });
@@ -10,7 +10,7 @@
10
10
  * - Pending queue (feeds discovered agents into the pending list)
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
13
+ import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
14
14
  import { join, relative } from 'node:path';
15
15
  import { scanForAgentsAll } from './tree-scanner.js';
16
16
  import { scanAgents } from './workspace.js';
@@ -81,9 +81,10 @@ export async function discoverExternalAgents(workspaceRoot: string): Promise<Dis
81
81
  // ============================================================================
82
82
 
83
83
  /**
84
- * Import discovered agents into the canonical agents/ directory via symlink.
84
+ * Import discovered agents into the canonical agents/ directory by moving them.
85
85
  *
86
- * For each agent, creates a symlink: {root}/agents/{name} -> {agent.path}
86
+ * For each agent, physically moves the directory: {agent.path} -> {root}/agents/{name}
87
+ * Preserves all contents including hidden folders (.claude, .genie, .git).
87
88
  * If the name collides, appends a suffix derived from the relative path.
88
89
  */
89
90
  export function importAgents(workspaceRoot: string, agents: DiscoveredAgent[]): ImportResult {
@@ -93,17 +94,17 @@ export function importAgents(workspaceRoot: string, agents: DiscoveredAgent[]):
93
94
  const result: ImportResult = { imported: [], skipped: [], errors: [] };
94
95
 
95
96
  for (const agent of agents) {
96
- const linkName = resolveUniqueName(agentsDir, agent.name);
97
- const linkPath = join(agentsDir, linkName);
97
+ const destName = resolveUniqueName(agentsDir, agent.name);
98
+ const destPath = join(agentsDir, destName);
98
99
 
99
- if (existsSync(linkPath)) {
100
+ if (existsSync(destPath)) {
100
101
  result.skipped.push(agent.name);
101
102
  continue;
102
103
  }
103
104
 
104
105
  try {
105
- symlinkSync(agent.path, linkPath);
106
- result.imported.push(linkName);
106
+ moveDirectory(agent.path, destPath);
107
+ result.imported.push(destName);
107
108
  } catch (err) {
108
109
  result.errors.push({
109
110
  name: agent.name,
@@ -115,6 +116,20 @@ export function importAgents(workspaceRoot: string, agents: DiscoveredAgent[]):
115
116
  return result;
116
117
  }
117
118
 
119
+ /**
120
+ * Move a directory, trying atomic rename first, falling back to copy+remove
121
+ * for cross-filesystem moves. Preserves all contents including dotfiles.
122
+ */
123
+ function moveDirectory(src: string, dest: string): void {
124
+ try {
125
+ renameSync(src, dest);
126
+ } catch {
127
+ // Cross-filesystem — copy then remove
128
+ cpSync(src, dest, { recursive: true });
129
+ rmSync(src, { recursive: true, force: true });
130
+ }
131
+ }
132
+
118
133
  /**
119
134
  * Resolve a unique agent name within the agents directory.
120
135
  * If `name` already exists, appends `-2`, `-3`, etc.