devvami 1.4.2 → 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.
Files changed (96) hide show
  1. package/README.md +72 -0
  2. package/oclif.manifest.json +275 -235
  3. package/package.json +2 -1
  4. package/src/commands/auth/login.js +20 -16
  5. package/src/commands/changelog.js +12 -12
  6. package/src/commands/costs/get.js +14 -24
  7. package/src/commands/costs/trend.js +13 -24
  8. package/src/commands/create/repo.js +72 -54
  9. package/src/commands/docs/list.js +29 -25
  10. package/src/commands/docs/projects.js +58 -24
  11. package/src/commands/docs/read.js +56 -39
  12. package/src/commands/docs/search.js +37 -25
  13. package/src/commands/doctor.js +37 -35
  14. package/src/commands/dotfiles/add.js +51 -39
  15. package/src/commands/dotfiles/setup.js +62 -33
  16. package/src/commands/dotfiles/status.js +18 -18
  17. package/src/commands/dotfiles/sync.js +62 -46
  18. package/src/commands/init.js +143 -132
  19. package/src/commands/logs/index.js +10 -16
  20. package/src/commands/open.js +12 -12
  21. package/src/commands/pipeline/logs.js +8 -11
  22. package/src/commands/pipeline/rerun.js +21 -16
  23. package/src/commands/pipeline/status.js +28 -24
  24. package/src/commands/pr/create.js +40 -27
  25. package/src/commands/pr/detail.js +9 -7
  26. package/src/commands/pr/review.js +18 -19
  27. package/src/commands/pr/status.js +27 -21
  28. package/src/commands/prompts/browse.js +15 -15
  29. package/src/commands/prompts/download.js +15 -16
  30. package/src/commands/prompts/install-speckit.js +11 -12
  31. package/src/commands/prompts/list.js +12 -12
  32. package/src/commands/prompts/run.js +16 -19
  33. package/src/commands/repo/list.js +57 -41
  34. package/src/commands/search.js +20 -18
  35. package/src/commands/security/setup.js +38 -34
  36. package/src/commands/sync-config-ai/index.js +257 -0
  37. package/src/commands/tasks/assigned.js +43 -33
  38. package/src/commands/tasks/list.js +43 -33
  39. package/src/commands/tasks/today.js +32 -30
  40. package/src/commands/upgrade.js +18 -17
  41. package/src/commands/vuln/detail.js +8 -8
  42. package/src/commands/vuln/scan.js +39 -20
  43. package/src/commands/vuln/search.js +23 -18
  44. package/src/commands/welcome.js +2 -2
  45. package/src/commands/whoami.js +19 -23
  46. package/src/formatters/ai-config.js +215 -0
  47. package/src/formatters/charts.js +6 -23
  48. package/src/formatters/cost.js +1 -7
  49. package/src/formatters/dotfiles.js +48 -19
  50. package/src/formatters/markdown.js +11 -6
  51. package/src/formatters/openapi.js +7 -9
  52. package/src/formatters/prompts.js +69 -78
  53. package/src/formatters/security.js +2 -2
  54. package/src/formatters/status.js +1 -1
  55. package/src/formatters/table.js +1 -3
  56. package/src/formatters/vuln.js +33 -20
  57. package/src/help.js +162 -164
  58. package/src/hooks/init.js +1 -3
  59. package/src/hooks/postrun.js +5 -7
  60. package/src/index.js +1 -1
  61. package/src/services/ai-config-store.js +349 -0
  62. package/src/services/ai-env-deployer.js +650 -0
  63. package/src/services/ai-env-scanner.js +983 -0
  64. package/src/services/audit-detector.js +2 -2
  65. package/src/services/audit-runner.js +40 -31
  66. package/src/services/auth.js +9 -9
  67. package/src/services/awesome-copilot.js +7 -4
  68. package/src/services/aws-costs.js +22 -22
  69. package/src/services/clickup.js +26 -26
  70. package/src/services/cloudwatch-logs.js +5 -9
  71. package/src/services/config.js +13 -13
  72. package/src/services/docs.js +19 -20
  73. package/src/services/dotfiles.js +149 -51
  74. package/src/services/github.js +22 -24
  75. package/src/services/nvd.js +21 -31
  76. package/src/services/platform.js +2 -2
  77. package/src/services/prompts.js +23 -35
  78. package/src/services/security.js +135 -61
  79. package/src/services/shell.js +4 -4
  80. package/src/services/skills-sh.js +3 -9
  81. package/src/services/speckit.js +4 -7
  82. package/src/services/version-check.js +10 -10
  83. package/src/types.js +117 -0
  84. package/src/utils/aws-vault.js +18 -41
  85. package/src/utils/banner.js +5 -7
  86. package/src/utils/errors.js +42 -46
  87. package/src/utils/frontmatter.js +4 -4
  88. package/src/utils/gradient.js +18 -16
  89. package/src/utils/open-browser.js +3 -3
  90. package/src/utils/tui/form.js +1184 -0
  91. package/src/utils/tui/modal.js +15 -14
  92. package/src/utils/tui/navigable-table.js +16 -16
  93. package/src/utils/tui/tab-tui.js +1089 -0
  94. package/src/utils/typewriter.js +3 -3
  95. package/src/utils/welcome.js +18 -21
  96. package/src/validators/repo-name.js +2 -2
@@ -0,0 +1,983 @@
1
+ /**
2
+ * @module ai-env-scanner
3
+ * Detects AI coding environments by scanning well-known project and global config paths.
4
+ */
5
+
6
+ import {existsSync, readFileSync, readdirSync} from 'node:fs'
7
+ import {resolve, join} from 'node:path'
8
+ import {homedir} from 'node:os'
9
+ import yaml from 'js-yaml'
10
+
11
+ /** @import { CategoryType, EnvironmentId, PathStatus, CategoryCounts, DetectedEnvironment, CategoryEntry, NativeEntry, DriftInfo } from '../types.js' */
12
+
13
+ /**
14
+ * @typedef {Object} PathSpec
15
+ * @property {string} path - Relative (project) or absolute (global) path string
16
+ * @property {boolean} isJson - Whether to attempt JSON.parse after reading
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} EnvironmentDef
21
+ * @property {EnvironmentId} id
22
+ * @property {string} name - Display name
23
+ * @property {PathSpec[]} projectPaths - Paths relative to cwd
24
+ * @property {PathSpec[]} globalPaths - Absolute paths (resolved from homedir)
25
+ * @property {CategoryType[]} supportedCategories
26
+ */
27
+
28
+ /**
29
+ * All recognised AI coding environments with their detection paths and capabilities.
30
+ * @type {Readonly<EnvironmentDef[]>}
31
+ */
32
+ export const ENVIRONMENTS = Object.freeze([
33
+ {
34
+ id: /** @type {EnvironmentId} */ ('vscode-copilot'),
35
+ name: 'VS Code Copilot',
36
+ projectPaths: [
37
+ {path: '.github/copilot-instructions.md', isJson: false},
38
+ {path: '.vscode/mcp.json', isJson: true},
39
+ {path: '.github/instructions/', isJson: false},
40
+ {path: '.github/prompts/', isJson: false},
41
+ {path: '.github/agents/', isJson: false},
42
+ {path: '.github/skills/', isJson: false},
43
+ ],
44
+ globalPaths: [],
45
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']),
46
+ },
47
+ {
48
+ id: /** @type {EnvironmentId} */ ('claude-code'),
49
+ name: 'Claude Code',
50
+ projectPaths: [
51
+ {path: 'CLAUDE.md', isJson: false},
52
+ {path: '.mcp.json', isJson: true},
53
+ {path: '.claude/commands/', isJson: false},
54
+ {path: '.claude/skills/', isJson: false},
55
+ {path: '.claude/agents/', isJson: false},
56
+ {path: '.claude/rules/', isJson: false},
57
+ ],
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']),
73
+ },
74
+ {
75
+ id: /** @type {EnvironmentId} */ ('opencode'),
76
+ name: 'OpenCode',
77
+ projectPaths: [
78
+ {path: 'AGENTS.md', isJson: false},
79
+ {path: '.opencode/commands/', isJson: false},
80
+ {path: '.opencode/skills/', isJson: false},
81
+ {path: '.opencode/agents/', isJson: false},
82
+ {path: 'opencode.json', isJson: true},
83
+ {path: 'opencode.toml', isJson: false},
84
+ ],
85
+ globalPaths: [
86
+ {path: '~/.config/opencode/opencode.json', isJson: true},
87
+ {path: '~/.config/opencode/commands/', isJson: false},
88
+ {path: '~/.config/opencode/agents/', isJson: false},
89
+ {path: '~/.config/opencode/skills/', isJson: false},
90
+ ],
91
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']),
92
+ },
93
+ {
94
+ id: /** @type {EnvironmentId} */ ('gemini-cli'),
95
+ name: 'Gemini CLI',
96
+ projectPaths: [{path: 'GEMINI.md', isJson: false}],
97
+ globalPaths: [
98
+ {path: '~/.gemini/settings.json', isJson: true},
99
+ {path: '~/.gemini/commands/', isJson: false},
100
+ {path: '~/.gemini/GEMINI.md', isJson: false},
101
+ ],
102
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule']),
103
+ },
104
+ {
105
+ id: /** @type {EnvironmentId} */ ('copilot-cli'),
106
+ name: 'GitHub Copilot CLI',
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
+ ],
113
+ globalPaths: [
114
+ {path: '~/.copilot/config.json', isJson: true},
115
+ {path: '~/.copilot/mcp-config.json', isJson: true},
116
+ {path: '~/.copilot/agents/', isJson: false},
117
+ {path: '~/.copilot/skills/', isJson: false},
118
+ {path: '~/.copilot/copilot-instructions.md', isJson: false},
119
+ ],
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']),
185
+ },
186
+ ])
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
+
214
+ /**
215
+ * Resolve a path spec into an absolute path.
216
+ * Project paths are resolved relative to `cwd`; global paths have their `~/` prefix
217
+ * replaced with the actual home directory.
218
+ *
219
+ * @param {PathSpec} spec
220
+ * @param {string} cwd
221
+ * @param {boolean} isGlobal
222
+ * @returns {string}
223
+ */
224
+ function resolvePathSpec(spec, cwd, isGlobal) {
225
+ if (isGlobal) {
226
+ // Global paths are stored with a leading `~/`
227
+ return resolve(join(homedir(), spec.path.replace(/^~\//, '')))
228
+ }
229
+ return resolve(join(cwd, spec.path))
230
+ }
231
+
232
+ /**
233
+ * Build a PathStatus for one path spec.
234
+ * For JSON files that exist, attempt to parse them; failure marks the path as unreadable.
235
+ *
236
+ * @param {PathSpec} spec
237
+ * @param {string} absolutePath
238
+ * @param {string[]} unreadable - Mutable array; unreadable paths are pushed here
239
+ * @returns {PathStatus}
240
+ */
241
+ function evaluatePathSpec(spec, absolutePath, unreadable) {
242
+ const exists = existsSync(absolutePath)
243
+
244
+ if (!exists) {
245
+ return {path: absolutePath, exists: false, readable: false}
246
+ }
247
+
248
+ if (!spec.isJson) {
249
+ return {path: absolutePath, exists: true, readable: true}
250
+ }
251
+
252
+ // JSON file — try to parse
253
+ try {
254
+ JSON.parse(readFileSync(absolutePath, 'utf8'))
255
+ return {path: absolutePath, exists: true, readable: true}
256
+ } catch {
257
+ unreadable.push(absolutePath)
258
+ return {path: absolutePath, exists: true, readable: false}
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Compute the detection scope based on which path groups produced hits.
264
+ *
265
+ * @param {PathStatus[]} projectStatuses
266
+ * @param {PathStatus[]} globalStatuses
267
+ * @returns {'project'|'global'|'both'}
268
+ */
269
+ function computeScope(projectStatuses, globalStatuses) {
270
+ const hasProject = projectStatuses.some((s) => s.exists)
271
+ const hasGlobal = globalStatuses.some((s) => s.exists)
272
+
273
+ if (hasProject && hasGlobal) return 'both'
274
+ if (hasGlobal) return 'global'
275
+ return 'project'
276
+ }
277
+
278
+ /**
279
+ * Scan the filesystem for each known AI coding environment and return only those
280
+ * that were detected (i.e. at least one config path exists on disk).
281
+ *
282
+ * @param {string} [cwd] - Working directory for project-relative path resolution (defaults to process.cwd())
283
+ * @returns {DetectedEnvironment[]} Detected environments only
284
+ */
285
+ export function scanEnvironments(cwd = process.cwd()) {
286
+ /** @type {DetectedEnvironment[]} */
287
+ const detected = []
288
+
289
+ for (const env of ENVIRONMENTS) {
290
+ /** @type {string[]} */
291
+ const unreadable = []
292
+
293
+ const projectStatuses = env.projectPaths.map((spec) => {
294
+ const absPath = resolvePathSpec(spec, cwd, false)
295
+ return evaluatePathSpec(spec, absPath, unreadable)
296
+ })
297
+
298
+ const globalStatuses = env.globalPaths.map((spec) => {
299
+ const absPath = resolvePathSpec(spec, cwd, true)
300
+ return evaluatePathSpec(spec, absPath, unreadable)
301
+ })
302
+
303
+ const isDetected = [...projectStatuses, ...globalStatuses].some((s) => s.exists)
304
+
305
+ if (!isDetected) continue
306
+
307
+ detected.push({
308
+ id: env.id,
309
+ name: env.name,
310
+ detected: true,
311
+ projectPaths: projectStatuses,
312
+ globalPaths: globalStatuses,
313
+ unreadable,
314
+ supportedCategories: env.supportedCategories,
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: [],
319
+ scope: computeScope(projectStatuses, globalStatuses),
320
+ })
321
+ }
322
+
323
+ return detected
324
+ }
325
+
326
+ /**
327
+ * Filter detected environments to those that support a given category type.
328
+ *
329
+ * @param {CategoryType} type - Category type to filter by
330
+ * @param {DetectedEnvironment[]} detectedEnvs - Array of detected environments from {@link scanEnvironments}
331
+ * @returns {EnvironmentId[]} IDs of environments that support the given type
332
+ */
333
+ export function getCompatibleEnvironments(type, detectedEnvs) {
334
+ return detectedEnvs.filter((env) => env.supportedCategories.includes(type)).map((env) => env.id)
335
+ }
336
+
337
+ /**
338
+ * Count active entries from the AI config store that target a given environment,
339
+ * grouped by category type.
340
+ *
341
+ * @param {EnvironmentId} envId - Environment to count entries for
342
+ * @param {CategoryEntry[]} entries - All entries from the AI config store
343
+ * @returns {CategoryCounts} Per-category active entry counts
344
+ */
345
+ export function computeCategoryCounts(envId, entries) {
346
+ /** @type {CategoryCounts} */
347
+ const counts = {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}
348
+
349
+ for (const entry of entries) {
350
+ if (entry.active && entry.environments.includes(envId)) {
351
+ counts[entry.type] = (counts[entry.type] ?? 0) + 1
352
+ }
353
+ }
354
+
355
+ return counts
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
+ }