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.
- package/README.md +65 -0
- package/oclif.manifest.json +249 -249
- package/package.json +1 -1
- package/src/commands/sync-config-ai/index.js +124 -10
- package/src/formatters/ai-config.js +100 -12
- package/src/services/ai-config-store.js +43 -12
- package/src/services/ai-env-deployer.js +216 -10
- package/src/services/ai-env-scanner.js +752 -11
- package/src/types.js +35 -3
- package/src/utils/tui/form.js +195 -17
- package/src/utils/tui/tab-tui.js +353 -64
|
@@ -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
|
-
|
|
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
|
+
}
|