devvami 1.5.0 → 1.5.1

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.
@@ -3,11 +3,12 @@
3
3
  * Detects AI coding environments by scanning well-known project and global config paths.
4
4
  */
5
5
 
6
- import {existsSync, readFileSync} from 'node:fs'
6
+ import {existsSync, readFileSync, readdirSync} from 'node:fs'
7
7
  import {resolve, join} from 'node:path'
8
8
  import {homedir} from 'node:os'
9
+ import yaml from 'js-yaml'
9
10
 
10
- /** @import { CategoryType, EnvironmentId, PathStatus, CategoryCounts, DetectedEnvironment, CategoryEntry } from '../types.js' */
11
+ /** @import { CategoryType, EnvironmentId, PathStatus, CategoryCounts, DetectedEnvironment, CategoryEntry, NativeEntry, DriftInfo } from '../types.js' */
11
12
 
12
13
  /**
13
14
  * @typedef {Object} PathSpec
@@ -41,7 +42,7 @@ export const ENVIRONMENTS = Object.freeze([
41
42
  {path: '.github/skills/', isJson: false},
42
43
  ],
43
44
  globalPaths: [],
44
- supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
45
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']),
45
46
  },
46
47
  {
47
48
  id: /** @type {EnvironmentId} */ ('claude-code'),
@@ -54,8 +55,21 @@ export const ENVIRONMENTS = Object.freeze([
54
55
  {path: '.claude/agents/', isJson: false},
55
56
  {path: '.claude/rules/', isJson: false},
56
57
  ],
57
- globalPaths: [],
58
- supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
58
+ globalPaths: [
59
+ {path: '~/.claude.json', isJson: true},
60
+ {path: '~/.claude/commands/', isJson: false},
61
+ {path: '~/.claude/agents/', isJson: false},
62
+ ],
63
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']),
64
+ },
65
+ {
66
+ id: /** @type {EnvironmentId} */ ('claude-desktop'),
67
+ name: 'Claude Desktop',
68
+ projectPaths: [],
69
+ globalPaths: [
70
+ {path: '~/Library/Application Support/Claude/claude_desktop_config.json', isJson: true},
71
+ ],
72
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp']),
59
73
  },
60
74
  {
61
75
  id: /** @type {EnvironmentId} */ ('opencode'),
@@ -66,6 +80,7 @@ export const ENVIRONMENTS = Object.freeze([
66
80
  {path: '.opencode/skills/', isJson: false},
67
81
  {path: '.opencode/agents/', isJson: false},
68
82
  {path: 'opencode.json', isJson: true},
83
+ {path: 'opencode.toml', isJson: false},
69
84
  ],
70
85
  globalPaths: [
71
86
  {path: '~/.config/opencode/opencode.json', isJson: true},
@@ -73,7 +88,7 @@ export const ENVIRONMENTS = Object.freeze([
73
88
  {path: '~/.config/opencode/agents/', isJson: false},
74
89
  {path: '~/.config/opencode/skills/', isJson: false},
75
90
  ],
76
- supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
91
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']),
77
92
  },
78
93
  {
79
94
  id: /** @type {EnvironmentId} */ ('gemini-cli'),
@@ -82,13 +97,19 @@ export const ENVIRONMENTS = Object.freeze([
82
97
  globalPaths: [
83
98
  {path: '~/.gemini/settings.json', isJson: true},
84
99
  {path: '~/.gemini/commands/', isJson: false},
100
+ {path: '~/.gemini/GEMINI.md', isJson: false},
85
101
  ],
86
- supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command']),
102
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule']),
87
103
  },
88
104
  {
89
105
  id: /** @type {EnvironmentId} */ ('copilot-cli'),
90
106
  name: 'GitHub Copilot CLI',
91
- projectPaths: [],
107
+ projectPaths: [
108
+ {path: '.github/copilot-instructions.md', isJson: false},
109
+ {path: '.github/prompts/', isJson: false},
110
+ {path: '.github/agents/', isJson: false},
111
+ {path: '.github/skills/', isJson: false},
112
+ ],
92
113
  globalPaths: [
93
114
  {path: '~/.copilot/config.json', isJson: true},
94
115
  {path: '~/.copilot/mcp-config.json', isJson: true},
@@ -96,10 +117,100 @@ export const ENVIRONMENTS = Object.freeze([
96
117
  {path: '~/.copilot/skills/', isJson: false},
97
118
  {path: '~/.copilot/copilot-instructions.md', isJson: false},
98
119
  ],
99
- supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
120
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']),
121
+ },
122
+ {
123
+ id: /** @type {EnvironmentId} */ ('cursor'),
124
+ name: 'Cursor',
125
+ projectPaths: [
126
+ {path: '.cursor/mcp.json', isJson: true},
127
+ {path: '.cursor/commands/', isJson: false},
128
+ {path: '.cursor/rules/', isJson: false},
129
+ {path: '.cursor/skills/', isJson: false},
130
+ ],
131
+ globalPaths: [
132
+ {path: '~/.cursor/mcp.json', isJson: true},
133
+ ],
134
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill']),
135
+ },
136
+ {
137
+ id: /** @type {EnvironmentId} */ ('windsurf'),
138
+ name: 'Windsurf',
139
+ projectPaths: [
140
+ {path: '.windsurf/workflows/', isJson: false},
141
+ {path: '.windsurf/rules/', isJson: false},
142
+ ],
143
+ globalPaths: [
144
+ {path: '~/.codeium/windsurf/mcp_config.json', isJson: true},
145
+ ],
146
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule']),
147
+ },
148
+ {
149
+ id: /** @type {EnvironmentId} */ ('continue-dev'),
150
+ name: 'Continue.dev',
151
+ projectPaths: [
152
+ {path: '.continue/config.yaml', isJson: false},
153
+ {path: '.continue/prompts/', isJson: false},
154
+ {path: '.continue/rules/', isJson: false},
155
+ {path: '.continue/agents/', isJson: false},
156
+ ],
157
+ globalPaths: [
158
+ {path: '~/.continue/config.yaml', isJson: false},
159
+ ],
160
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'agent']),
161
+ },
162
+ {
163
+ id: /** @type {EnvironmentId} */ ('zed'),
164
+ name: 'Zed',
165
+ projectPaths: [
166
+ {path: '.rules', isJson: false},
167
+ ],
168
+ globalPaths: [
169
+ {path: '~/.config/zed/settings.json', isJson: true},
170
+ ],
171
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'rule']),
172
+ },
173
+ {
174
+ id: /** @type {EnvironmentId} */ ('amazon-q'),
175
+ name: 'Amazon Q Developer',
176
+ projectPaths: [
177
+ {path: '.amazonq/mcp.json', isJson: true},
178
+ {path: '.amazonq/rules/', isJson: false},
179
+ {path: '.amazonq/cli-agents/', isJson: false},
180
+ ],
181
+ globalPaths: [
182
+ {path: '~/.aws/amazonq/mcp.json', isJson: true},
183
+ ],
184
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'rule', 'agent']),
100
185
  },
101
186
  ])
102
187
 
188
+ /** @type {Record<import('../types.js').EnvironmentId, import('../types.js').CategoryType[]>} */
189
+ export const COMPATIBILITY = {
190
+ 'vscode-copilot': ['mcp', 'command', 'rule', 'skill', 'agent'],
191
+ 'claude-code': ['mcp', 'command', 'rule', 'skill', 'agent'],
192
+ 'opencode': ['mcp', 'command', 'rule', 'skill', 'agent'],
193
+ 'gemini-cli': ['mcp', 'command', 'rule'],
194
+ 'copilot-cli': ['mcp', 'command', 'rule', 'skill', 'agent'],
195
+ 'cursor': ['mcp', 'command', 'rule', 'skill'],
196
+ 'windsurf': ['mcp', 'command', 'rule'],
197
+ 'continue-dev': ['mcp', 'command', 'rule', 'agent'],
198
+ 'zed': ['mcp', 'rule'],
199
+ 'amazon-q': ['mcp', 'rule', 'agent'],
200
+ }
201
+
202
+ /**
203
+ * Groups of environments that share the same config file for a given path.
204
+ * Used for auto-grouping in the form's environment multi-select.
205
+ * @type {Record<string, import('../types.js').EnvironmentId[]>}
206
+ */
207
+ export const SHARED_PATHS = {
208
+ '.mcp.json': ['claude-code', 'copilot-cli'],
209
+ '.github/prompts/': ['vscode-copilot', 'copilot-cli'],
210
+ '.github/copilot-instructions.md': ['vscode-copilot', 'copilot-cli'],
211
+ '.github/agents/': ['vscode-copilot', 'copilot-cli'],
212
+ }
213
+
103
214
  /**
104
215
  * Resolve a path spec into an absolute path.
105
216
  * Project paths are resolved relative to `cwd`; global paths have their `~/` prefix
@@ -201,7 +312,10 @@ export function scanEnvironments(cwd = process.cwd()) {
201
312
  globalPaths: globalStatuses,
202
313
  unreadable,
203
314
  supportedCategories: env.supportedCategories,
204
- counts: {mcp: 0, command: 0, skill: 0, agent: 0},
315
+ counts: {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0},
316
+ nativeCounts: {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0},
317
+ nativeEntries: [],
318
+ driftedEntries: [],
205
319
  scope: computeScope(projectStatuses, globalStatuses),
206
320
  })
207
321
  }
@@ -230,7 +344,7 @@ export function getCompatibleEnvironments(type, detectedEnvs) {
230
344
  */
231
345
  export function computeCategoryCounts(envId, entries) {
232
346
  /** @type {CategoryCounts} */
233
- const counts = {mcp: 0, command: 0, skill: 0, agent: 0}
347
+ const counts = {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}
234
348
 
235
349
  for (const entry of entries) {
236
350
  if (entry.active && entry.environments.includes(envId)) {
@@ -240,3 +354,630 @@ export function computeCategoryCounts(envId, entries) {
240
354
 
241
355
  return counts
242
356
  }
357
+
358
+ // ──────────────────────────────────────────────────────────────────────────────
359
+ // Native entry parsing (T006)
360
+ // ──────────────────────────────────────────────────────────────────────────────
361
+
362
+ /**
363
+ * Build a Set key for matching managed entries by name+type.
364
+ * @param {string} name
365
+ * @param {CategoryType} type
366
+ * @returns {string}
367
+ */
368
+ function managedKey(name, type) {
369
+ return `${type}:${name}`
370
+ }
371
+
372
+ /**
373
+ * Parse MCP entries from a JSON config file.
374
+ * @param {string} filePath - Absolute path to the JSON file
375
+ * @param {string} mcpKey - The key in the JSON that holds the MCP map (e.g. 'mcpServers', 'servers', 'context_servers')
376
+ * @param {EnvironmentId} envId
377
+ * @param {'project'|'global'} level
378
+ * @param {Set<string>} managedSet - Set of 'type:name' keys to exclude
379
+ * @returns {NativeEntry[]}
380
+ */
381
+ function parseMCPsFromJson(filePath, mcpKey, envId, level, managedSet) {
382
+ if (!existsSync(filePath)) return []
383
+ try {
384
+ const raw = readFileSync(filePath, 'utf8')
385
+ const json = JSON.parse(raw)
386
+ const section = json[mcpKey]
387
+ if (!section || typeof section !== 'object') return []
388
+
389
+ /** @type {NativeEntry[]} */
390
+ const entries = []
391
+ for (const [name, server] of Object.entries(section)) {
392
+ if (managedSet.has(managedKey(name, 'mcp'))) continue
393
+ const s = /** @type {any} */ (server)
394
+ /** @type {NativeEntry} */
395
+ const entry = {
396
+ name,
397
+ type: 'mcp',
398
+ environmentId: envId,
399
+ level,
400
+ sourcePath: filePath,
401
+ params: {
402
+ transport: s.type ?? 'stdio',
403
+ ...(s.command !== undefined ? {command: s.command} : {}),
404
+ ...(s.args !== undefined ? {args: s.args} : {}),
405
+ ...(s.env !== undefined ? {env: s.env} : {}),
406
+ ...(s.url !== undefined ? {url: s.url} : {}),
407
+ },
408
+ }
409
+ entries.push(entry)
410
+ }
411
+ return entries
412
+ } catch {
413
+ return []
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Parse MCP entries from a YAML config file (Continue.dev format).
419
+ * @param {string} filePath
420
+ * @param {EnvironmentId} envId
421
+ * @param {'project'|'global'} level
422
+ * @param {Set<string>} managedSet
423
+ * @returns {NativeEntry[]}
424
+ */
425
+ function parseMCPsFromYaml(filePath, envId, level, managedSet) {
426
+ if (!existsSync(filePath)) return []
427
+ try {
428
+ const raw = readFileSync(filePath, 'utf8')
429
+ const parsed = /** @type {any} */ (yaml.load(raw) ?? {})
430
+ const section = parsed.mcpServers
431
+ if (!Array.isArray(section) && (typeof section !== 'object' || section === null)) return []
432
+
433
+ /** @type {NativeEntry[]} */
434
+ const entries = []
435
+ const servers = Array.isArray(section) ? section : Object.entries(section).map(([k, v]) => ({name: k, .../** @type {any} */ (v)}))
436
+ for (const server of servers) {
437
+ const name = server.name ?? server.id ?? String(server)
438
+ if (!name || managedSet.has(managedKey(name, 'mcp'))) continue
439
+ /** @type {NativeEntry} */
440
+ const entry = {
441
+ name,
442
+ type: 'mcp',
443
+ environmentId: envId,
444
+ level,
445
+ sourcePath: filePath,
446
+ params: {
447
+ transport: server.transport ?? 'stdio',
448
+ ...(server.command !== undefined ? {command: server.command} : {}),
449
+ ...(server.args !== undefined ? {args: server.args} : {}),
450
+ ...(server.env !== undefined ? {env: server.env} : {}),
451
+ ...(server.url !== undefined ? {url: server.url} : {}),
452
+ },
453
+ }
454
+ entries.push(entry)
455
+ }
456
+ return entries
457
+ } catch {
458
+ return []
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Parse MCP entries from an OpenCode config file.
464
+ * OpenCode uses a different format: key is `mcp`, command is an array,
465
+ * env vars in `environment`, type is `local`/`remote`.
466
+ * @param {string} filePath
467
+ * @param {EnvironmentId} envId
468
+ * @param {'project'|'global'} level
469
+ * @param {Set<string>} managedSet
470
+ * @returns {NativeEntry[]}
471
+ */
472
+ function parseMCPsFromOpenCode(filePath, envId, level, managedSet) {
473
+ if (!existsSync(filePath)) return []
474
+ try {
475
+ const raw = readFileSync(filePath, 'utf8')
476
+ const json = JSON.parse(raw)
477
+ const section = json.mcp
478
+ if (!section || typeof section !== 'object') return []
479
+
480
+ /** @type {NativeEntry[]} */
481
+ const entries = []
482
+ for (const [name, server] of Object.entries(section)) {
483
+ if (managedSet.has(managedKey(name, 'mcp'))) continue
484
+ const s = /** @type {any} */ (server)
485
+ // OpenCode format: command is an array, environment instead of env, type is local/remote
486
+ const cmdArr = Array.isArray(s.command) ? s.command : []
487
+ const transport = s.type === 'remote' ? 'streamable-http' : 'stdio'
488
+ /** @type {NativeEntry} */
489
+ const entry = {
490
+ name,
491
+ type: 'mcp',
492
+ environmentId: envId,
493
+ level,
494
+ sourcePath: filePath,
495
+ params: {
496
+ transport,
497
+ ...(cmdArr.length > 0 ? {command: cmdArr[0]} : {}),
498
+ ...(cmdArr.length > 1 ? {args: cmdArr.slice(1)} : {}),
499
+ ...(s.environment !== undefined ? {env: s.environment} : {}),
500
+ ...(s.url !== undefined ? {url: s.url} : {}),
501
+ },
502
+ }
503
+ entries.push(entry)
504
+ }
505
+ return entries
506
+ } catch {
507
+ return []
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Parse file-based entries (commands, rules, skills, agents) from a directory.
513
+ * Each file in the directory becomes one native entry.
514
+ * @param {string} dirPath - Absolute path to the directory
515
+ * @param {EnvironmentId} envId
516
+ * @param {CategoryType} type
517
+ * @param {'project'|'global'} level
518
+ * @param {Set<string>} managedSet
519
+ * @param {RegExp} [filePattern] - Only include files matching this pattern (default: all files)
520
+ * @returns {NativeEntry[]}
521
+ */
522
+ function parseEntriesFromDir(dirPath, envId, type, level, managedSet, filePattern = /.*/) {
523
+ if (!existsSync(dirPath)) return []
524
+ try {
525
+ const files = readdirSync(dirPath, {withFileTypes: true})
526
+ /** @type {NativeEntry[]} */
527
+ const entries = []
528
+ for (const dirent of files) {
529
+ if (!dirent.isFile()) continue
530
+ if (!filePattern.test(dirent.name)) continue
531
+ // Strip extension to get the entry name
532
+ const name = dirent.name.replace(/\.[^.]+$/, '')
533
+ if (!name || managedSet.has(managedKey(name, type))) continue
534
+ entries.push({
535
+ name,
536
+ type,
537
+ environmentId: envId,
538
+ level,
539
+ sourcePath: join(dirPath, dirent.name),
540
+ params: {},
541
+ })
542
+ }
543
+ return entries
544
+ } catch {
545
+ return []
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Parse a single-file entry (e.g. CLAUDE.md, GEMINI.md, .rules).
551
+ * @param {string} filePath - Absolute path to the file
552
+ * @param {string} name - Entry name to use
553
+ * @param {EnvironmentId} envId
554
+ * @param {CategoryType} type
555
+ * @param {'project'|'global'} level
556
+ * @param {Set<string>} managedSet
557
+ * @returns {NativeEntry[]}
558
+ */
559
+ function parseSingleFileEntry(filePath, name, envId, type, level, managedSet) {
560
+ if (!existsSync(filePath)) return []
561
+ if (managedSet.has(managedKey(name, type))) return []
562
+ return [{name, type, environmentId: envId, level, sourcePath: filePath, params: {}}]
563
+ }
564
+
565
+ /**
566
+ * Parse all native entries for a single detected environment.
567
+ * Returns items that exist in the environment's config files but are NOT managed by dvmi.
568
+ * Managed entries are matched by name+type and excluded.
569
+ *
570
+ * @param {EnvironmentDef} envDef - The environment definition (from ENVIRONMENTS)
571
+ * @param {string} cwd - Project working directory
572
+ * @param {CategoryEntry[]} managedEntries - All entries from the AI config store
573
+ * @returns {NativeEntry[]}
574
+ */
575
+ export function parseNativeEntries(envDef, cwd, managedEntries) {
576
+ const home = homedir()
577
+
578
+ // Build a Set of 'type:name' strings for managed entries targeting this environment
579
+ const managedSet = new Set(
580
+ managedEntries
581
+ .filter((e) => e.environments.includes(envDef.id))
582
+ .map((e) => managedKey(e.name, e.type)),
583
+ )
584
+
585
+ /** @type {NativeEntry[]} */
586
+ const result = []
587
+
588
+ const id = envDef.id
589
+
590
+ // ── MCPs ──
591
+ switch (id) {
592
+ case 'vscode-copilot':
593
+ result.push(...parseMCPsFromJson(join(cwd, '.vscode', 'mcp.json'), 'servers', id, 'project', managedSet))
594
+ break
595
+ case 'claude-code':
596
+ result.push(...parseMCPsFromJson(join(cwd, '.mcp.json'), 'mcpServers', id, 'project', managedSet))
597
+ result.push(...parseMCPsFromJson(join(home, '.claude.json'), 'mcpServers', id, 'global', managedSet))
598
+ break
599
+ case 'claude-desktop':
600
+ result.push(...parseMCPsFromJson(join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), 'mcpServers', id, 'global', managedSet))
601
+ break
602
+ case 'opencode':
603
+ result.push(...parseMCPsFromOpenCode(join(cwd, 'opencode.json'), id, 'project', managedSet))
604
+ result.push(...parseMCPsFromOpenCode(join(home, '.config', 'opencode', 'opencode.json'), id, 'global', managedSet))
605
+ break
606
+ case 'gemini-cli':
607
+ result.push(...parseMCPsFromJson(join(home, '.gemini', 'settings.json'), 'mcpServers', id, 'global', managedSet))
608
+ break
609
+ case 'copilot-cli':
610
+ result.push(...parseMCPsFromJson(join(home, '.copilot', 'mcp-config.json'), 'mcpServers', id, 'global', managedSet))
611
+ break
612
+ case 'cursor':
613
+ result.push(...parseMCPsFromJson(join(cwd, '.cursor', 'mcp.json'), 'mcpServers', id, 'project', managedSet))
614
+ result.push(...parseMCPsFromJson(join(home, '.cursor', 'mcp.json'), 'mcpServers', id, 'global', managedSet))
615
+ break
616
+ case 'windsurf':
617
+ result.push(...parseMCPsFromJson(join(home, '.codeium', 'windsurf', 'mcp_config.json'), 'mcpServers', id, 'global', managedSet))
618
+ break
619
+ case 'continue-dev':
620
+ result.push(...parseMCPsFromYaml(join(cwd, '.continue', 'config.yaml'), id, 'project', managedSet))
621
+ result.push(...parseMCPsFromYaml(join(home, '.continue', 'config.yaml'), id, 'global', managedSet))
622
+ break
623
+ case 'zed':
624
+ result.push(...parseMCPsFromJson(join(home, '.config', 'zed', 'settings.json'), 'context_servers', id, 'global', managedSet))
625
+ break
626
+ case 'amazon-q':
627
+ result.push(...parseMCPsFromJson(join(cwd, '.amazonq', 'mcp.json'), 'mcpServers', id, 'project', managedSet))
628
+ result.push(...parseMCPsFromJson(join(home, '.aws', 'amazonq', 'mcp.json'), 'mcpServers', id, 'global', managedSet))
629
+ break
630
+ default:
631
+ break
632
+ }
633
+
634
+ // ── Commands ──
635
+ if (envDef.supportedCategories.includes('command')) {
636
+ switch (id) {
637
+ case 'vscode-copilot':
638
+ case 'copilot-cli':
639
+ result.push(...parseEntriesFromDir(join(cwd, '.github', 'prompts'), id, 'command', 'project', managedSet, /\.prompt\.md$/))
640
+ break
641
+ case 'claude-code':
642
+ result.push(...parseEntriesFromDir(join(cwd, '.claude', 'commands'), id, 'command', 'project', managedSet, /\.md$/))
643
+ break
644
+ case 'opencode':
645
+ result.push(...parseEntriesFromDir(join(cwd, '.opencode', 'commands'), id, 'command', 'project', managedSet, /\.md$/))
646
+ break
647
+ case 'gemini-cli':
648
+ result.push(...parseEntriesFromDir(join(home, '.gemini', 'commands'), id, 'command', 'global', managedSet, /\.toml$/))
649
+ break
650
+ case 'cursor':
651
+ result.push(...parseEntriesFromDir(join(cwd, '.cursor', 'commands'), id, 'command', 'project', managedSet, /\.md$/))
652
+ break
653
+ case 'windsurf':
654
+ result.push(...parseEntriesFromDir(join(cwd, '.windsurf', 'workflows'), id, 'command', 'project', managedSet, /\.md$/))
655
+ break
656
+ case 'continue-dev':
657
+ result.push(...parseEntriesFromDir(join(cwd, '.continue', 'prompts'), id, 'command', 'project', managedSet, /\.md$/))
658
+ break
659
+ default:
660
+ break
661
+ }
662
+ }
663
+
664
+ // ── Rules ──
665
+ if (envDef.supportedCategories.includes('rule')) {
666
+ switch (id) {
667
+ case 'vscode-copilot':
668
+ result.push(...parseSingleFileEntry(join(cwd, '.github', 'copilot-instructions.md'), 'copilot-instructions', id, 'rule', 'project', managedSet))
669
+ result.push(...parseEntriesFromDir(join(cwd, '.github', 'instructions'), id, 'rule', 'project', managedSet, /\.md$/))
670
+ break
671
+ case 'claude-code':
672
+ result.push(...parseSingleFileEntry(join(cwd, 'CLAUDE.md'), 'CLAUDE', id, 'rule', 'project', managedSet))
673
+ result.push(...parseEntriesFromDir(join(cwd, '.claude', 'rules'), id, 'rule', 'project', managedSet, /\.md$/))
674
+ break
675
+ case 'opencode':
676
+ result.push(...parseSingleFileEntry(join(cwd, 'AGENTS.md'), 'AGENTS', id, 'rule', 'project', managedSet))
677
+ break
678
+ case 'gemini-cli':
679
+ result.push(...parseSingleFileEntry(join(cwd, 'GEMINI.md'), 'GEMINI', id, 'rule', 'project', managedSet))
680
+ result.push(...parseSingleFileEntry(join(home, '.gemini', 'GEMINI.md'), 'GEMINI', id, 'rule', 'global', managedSet))
681
+ break
682
+ case 'copilot-cli':
683
+ result.push(...parseSingleFileEntry(join(cwd, '.github', 'copilot-instructions.md'), 'copilot-instructions', id, 'rule', 'project', managedSet))
684
+ break
685
+ case 'cursor':
686
+ result.push(...parseEntriesFromDir(join(cwd, '.cursor', 'rules'), id, 'rule', 'project', managedSet, /\.mdc$/))
687
+ break
688
+ case 'windsurf':
689
+ result.push(...parseEntriesFromDir(join(cwd, '.windsurf', 'rules'), id, 'rule', 'project', managedSet, /\.md$/))
690
+ break
691
+ case 'continue-dev':
692
+ result.push(...parseEntriesFromDir(join(cwd, '.continue', 'rules'), id, 'rule', 'project', managedSet, /\.md$/))
693
+ break
694
+ case 'zed':
695
+ result.push(...parseSingleFileEntry(join(cwd, '.rules'), '.rules', id, 'rule', 'project', managedSet))
696
+ break
697
+ case 'amazon-q':
698
+ result.push(...parseEntriesFromDir(join(cwd, '.amazonq', 'rules'), id, 'rule', 'project', managedSet, /\.md$/))
699
+ break
700
+ default:
701
+ break
702
+ }
703
+ }
704
+
705
+ // ── Skills ──
706
+ if (envDef.supportedCategories.includes('skill')) {
707
+ switch (id) {
708
+ case 'vscode-copilot': {
709
+ // Skills are directories: .github/skills/<name>/SKILL.md
710
+ const skillsDir = join(cwd, '.github', 'skills')
711
+ if (existsSync(skillsDir)) {
712
+ try {
713
+ for (const dirent of readdirSync(skillsDir, {withFileTypes: true})) {
714
+ if (!dirent.isDirectory()) continue
715
+ const name = dirent.name
716
+ if (managedSet.has(managedKey(name, 'skill'))) continue
717
+ result.push({name, type: 'skill', environmentId: id, level: 'project', sourcePath: join(skillsDir, name, 'SKILL.md'), params: {}})
718
+ }
719
+ } catch { /* ignore */ }
720
+ }
721
+ break
722
+ }
723
+ case 'claude-code':
724
+ result.push(...parseEntriesFromDir(join(cwd, '.claude', 'skills'), id, 'skill', 'project', managedSet, /\.md$/))
725
+ break
726
+ case 'opencode':
727
+ result.push(...parseEntriesFromDir(join(cwd, '.opencode', 'skills'), id, 'skill', 'project', managedSet, /\.md$/))
728
+ break
729
+ case 'copilot-cli':
730
+ result.push(...parseEntriesFromDir(join(home, '.copilot', 'skills'), id, 'skill', 'global', managedSet, /\.md$/))
731
+ break
732
+ case 'cursor':
733
+ result.push(...parseEntriesFromDir(join(cwd, '.cursor', 'skills'), id, 'skill', 'project', managedSet, /\.md$/))
734
+ break
735
+ default:
736
+ break
737
+ }
738
+ }
739
+
740
+ // ── Agents ──
741
+ if (envDef.supportedCategories.includes('agent')) {
742
+ switch (id) {
743
+ case 'vscode-copilot':
744
+ result.push(...parseEntriesFromDir(join(cwd, '.github', 'agents'), id, 'agent', 'project', managedSet, /\.agent\.md$/))
745
+ break
746
+ case 'claude-code':
747
+ result.push(...parseEntriesFromDir(join(cwd, '.claude', 'agents'), id, 'agent', 'project', managedSet, /\.md$/))
748
+ break
749
+ case 'opencode':
750
+ result.push(...parseEntriesFromDir(join(cwd, '.opencode', 'agents'), id, 'agent', 'project', managedSet, /\.md$/))
751
+ break
752
+ case 'copilot-cli':
753
+ result.push(...parseEntriesFromDir(join(home, '.copilot', 'agents'), id, 'agent', 'global', managedSet, /\.md$/))
754
+ break
755
+ case 'continue-dev':
756
+ result.push(...parseEntriesFromDir(join(cwd, '.continue', 'agents'), id, 'agent', 'project', managedSet, /\.md$/))
757
+ break
758
+ case 'amazon-q':
759
+ result.push(...parseEntriesFromDir(join(home, '.aws', 'amazonq', 'cli-agents'), id, 'agent', 'global', managedSet, /\.json$/))
760
+ break
761
+ default:
762
+ break
763
+ }
764
+ }
765
+
766
+ return result
767
+ }
768
+
769
+ // ──────────────────────────────────────────────────────────────────────────────
770
+ // Drift detection (T007)
771
+ // ──────────────────────────────────────────────────────────────────────────────
772
+
773
+ /**
774
+ * Read the current value of an MCP entry from an environment's config file.
775
+ * Returns null if the entry is not found or the file does not exist.
776
+ * @param {string} entryName
777
+ * @param {EnvironmentId} envId
778
+ * @param {string} cwd
779
+ * @returns {object|null}
780
+ */
781
+ function readDeployedMCPEntry(entryName, envId, cwd) {
782
+ const home = homedir()
783
+
784
+ /** @type {string|null} */
785
+ let filePath = null
786
+ /** @type {string} */
787
+ let mcpKey = 'mcpServers'
788
+ let isYaml = false
789
+
790
+ switch (envId) {
791
+ case 'vscode-copilot': filePath = join(cwd, '.vscode', 'mcp.json'); mcpKey = 'servers'; break
792
+ case 'claude-code': filePath = join(cwd, '.mcp.json'); break
793
+ case 'opencode': filePath = join(cwd, 'opencode.json'); break
794
+ case 'gemini-cli': filePath = join(home, '.gemini', 'settings.json'); break
795
+ case 'copilot-cli': filePath = join(home, '.copilot', 'mcp-config.json'); break
796
+ case 'cursor': filePath = join(cwd, '.cursor', 'mcp.json'); break
797
+ case 'windsurf': filePath = join(home, '.codeium', 'windsurf', 'mcp_config.json'); break
798
+ case 'continue-dev': filePath = join(cwd, '.continue', 'config.yaml'); isYaml = true; break
799
+ case 'zed': filePath = join(home, '.config', 'zed', 'settings.json'); mcpKey = 'context_servers'; break
800
+ case 'amazon-q': filePath = join(cwd, '.amazonq', 'mcp.json'); break
801
+ default: return null
802
+ }
803
+
804
+ if (!filePath || !existsSync(filePath)) return null
805
+
806
+ try {
807
+ const raw = readFileSync(filePath, 'utf8')
808
+ if (isYaml) {
809
+ const parsed = /** @type {any} */ (yaml.load(raw) ?? {})
810
+ const section = parsed[mcpKey]
811
+ if (!section) return null
812
+ if (Array.isArray(section)) {
813
+ const found = section.find((s) => /** @type {any} */ (s).name === entryName)
814
+ return found ?? null
815
+ }
816
+ return section[entryName] ?? null
817
+ }
818
+ const json = JSON.parse(raw)
819
+ return json[mcpKey]?.[entryName] ?? null
820
+ } catch {
821
+ return null
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Read the current content of a file-based entry from an environment's config.
827
+ * Returns null if the file does not exist.
828
+ * @param {string} entryName
829
+ * @param {import('../types.js').CategoryType} type
830
+ * @param {EnvironmentId} envId
831
+ * @param {string} cwd
832
+ * @returns {string|null}
833
+ */
834
+ function readDeployedFileEntry(entryName, type, envId, cwd) {
835
+ const home = homedir()
836
+
837
+ /** @type {string|null} */
838
+ let filePath = null
839
+
840
+ if (type === 'command') {
841
+ switch (envId) {
842
+ case 'vscode-copilot': case 'copilot-cli': filePath = join(cwd, '.github', 'prompts', `${entryName}.prompt.md`); break
843
+ case 'claude-code': filePath = join(cwd, '.claude', 'commands', `${entryName}.md`); break
844
+ case 'opencode': filePath = join(cwd, '.opencode', 'commands', `${entryName}.md`); break
845
+ case 'gemini-cli': filePath = join(home, '.gemini', 'commands', `${entryName}.toml`); break
846
+ case 'cursor': filePath = join(cwd, '.cursor', 'commands', `${entryName}.md`); break
847
+ case 'windsurf': filePath = join(cwd, '.windsurf', 'workflows', `${entryName}.md`); break
848
+ case 'continue-dev': filePath = join(cwd, '.continue', 'prompts', `${entryName}.md`); break
849
+ default: return null
850
+ }
851
+ } else if (type === 'rule') {
852
+ switch (envId) {
853
+ case 'vscode-copilot': filePath = join(cwd, '.github', 'instructions', `${entryName}.md`); break
854
+ case 'claude-code': filePath = join(cwd, '.claude', 'rules', `${entryName}.md`); break
855
+ case 'cursor': filePath = join(cwd, '.cursor', 'rules', `${entryName}.mdc`); break
856
+ case 'windsurf': filePath = join(cwd, '.windsurf', 'rules', `${entryName}.md`); break
857
+ case 'continue-dev': filePath = join(cwd, '.continue', 'rules', `${entryName}.md`); break
858
+ case 'amazon-q': filePath = join(cwd, '.amazonq', 'rules', `${entryName}.md`); break
859
+ default: return null
860
+ }
861
+ } else if (type === 'skill') {
862
+ switch (envId) {
863
+ case 'vscode-copilot': filePath = join(cwd, '.github', 'skills', entryName, 'SKILL.md'); break
864
+ case 'claude-code': filePath = join(cwd, '.claude', 'skills', `${entryName}.md`); break
865
+ case 'opencode': filePath = join(cwd, '.opencode', 'skills', `${entryName}.md`); break
866
+ case 'copilot-cli': filePath = join(home, '.copilot', 'skills', `${entryName}.md`); break
867
+ case 'cursor': filePath = join(cwd, '.cursor', 'skills', `${entryName}.md`); break
868
+ default: return null
869
+ }
870
+ } else if (type === 'agent') {
871
+ switch (envId) {
872
+ case 'vscode-copilot': filePath = join(cwd, '.github', 'agents', `${entryName}.agent.md`); break
873
+ case 'claude-code': filePath = join(cwd, '.claude', 'agents', `${entryName}.md`); break
874
+ case 'opencode': filePath = join(cwd, '.opencode', 'agents', `${entryName}.md`); break
875
+ case 'copilot-cli': filePath = join(home, '.copilot', 'agents', `${entryName}.md`); break
876
+ case 'continue-dev': filePath = join(cwd, '.continue', 'agents', `${entryName}.md`); break
877
+ case 'amazon-q': filePath = join(home, '.aws', 'amazonq', 'cli-agents', `${entryName}.json`); break
878
+ default: return null
879
+ }
880
+ }
881
+
882
+ if (!filePath || !existsSync(filePath)) return null
883
+
884
+ try {
885
+ return readFileSync(filePath, 'utf8')
886
+ } catch {
887
+ return null
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Detect drift between managed entry expected state and actual file content.
893
+ * For each active managed entry deployed to a detected environment, compares
894
+ * the stored params against what is actually in the file.
895
+ *
896
+ * @param {DetectedEnvironment[]} detectedEnvs
897
+ * @param {CategoryEntry[]} managedEntries
898
+ * @param {string} [cwd]
899
+ * @returns {DriftInfo[]}
900
+ */
901
+ export function detectDrift(detectedEnvs, managedEntries, cwd = process.cwd()) {
902
+ const detectedIds = new Set(detectedEnvs.map((e) => e.id))
903
+ /** @type {DriftInfo[]} */
904
+ const drifted = []
905
+
906
+ for (const entry of managedEntries) {
907
+ if (!entry.active) continue
908
+
909
+ for (const envId of entry.environments) {
910
+ if (!detectedIds.has(envId)) continue
911
+
912
+ const params = /** @type {any} */ (entry.params)
913
+
914
+ if (entry.type === 'mcp') {
915
+ const actual = readDeployedMCPEntry(entry.name, envId, cwd)
916
+ if (actual === null) continue // not deployed yet — not drift
917
+
918
+ // Build expected server object — must match what buildMCPServerObject produces.
919
+ // For stdio, type is omitted (environments infer it from command).
920
+ const expected = {
921
+ ...(params.command !== undefined ? {command: params.command} : {}),
922
+ ...(params.args !== undefined ? {args: params.args} : {}),
923
+ ...(params.env !== undefined ? {env: params.env} : {}),
924
+ ...(params.url !== undefined ? {url: params.url} : {}),
925
+ ...(params.transport && params.transport !== 'stdio' ? {type: params.transport} : {}),
926
+ }
927
+
928
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) {
929
+ drifted.push({entryId: entry.id, environmentId: envId, expected, actual})
930
+ }
931
+ } else {
932
+ const actual = readDeployedFileEntry(entry.name, entry.type, envId, cwd)
933
+ if (actual === null) continue
934
+
935
+ const expectedContent = params.content ?? params.instructions ?? ''
936
+ if (expectedContent !== actual) {
937
+ drifted.push({entryId: entry.id, environmentId: envId, expected: {content: expectedContent}, actual: {content: actual}})
938
+ }
939
+ }
940
+ }
941
+ }
942
+
943
+ return drifted
944
+ }
945
+
946
+ // ──────────────────────────────────────────────────────────────────────────────
947
+ // Shared config path grouping (T008)
948
+ // ──────────────────────────────────────────────────────────────────────────────
949
+
950
+ /**
951
+ * Return groups of environment IDs that share the same config file for a given
952
+ * category type. Each group is an array of EnvironmentIds. Groups with only one
953
+ * environment are excluded.
954
+ *
955
+ * Used in the form's environment multi-select to auto-select related environments.
956
+ *
957
+ * @param {CategoryType} categoryType
958
+ * @returns {EnvironmentId[][]}
959
+ */
960
+ export function getSharedPathGroups(categoryType) {
961
+ if (categoryType === 'mcp') {
962
+ return [
963
+ ['claude-code', 'copilot-cli'], // share .mcp.json
964
+ ]
965
+ }
966
+ if (categoryType === 'command') {
967
+ return [
968
+ ['vscode-copilot', 'copilot-cli'], // share .github/prompts/
969
+ ]
970
+ }
971
+ if (categoryType === 'rule') {
972
+ return [
973
+ ['vscode-copilot', 'copilot-cli'], // share .github/copilot-instructions.md
974
+ ]
975
+ }
976
+ if (categoryType === 'agent') {
977
+ return [
978
+ ['vscode-copilot', 'copilot-cli'], // share .github/agents/
979
+ ]
980
+ }
981
+ // skills, unknown types: no shared paths
982
+ return []
983
+ }