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,650 @@
1
+ /**
2
+ * @module ai-env-deployer
3
+ * Translates dvmi's abstract CategoryEntry objects into actual filesystem writes
4
+ * (JSON mutations for MCP servers, markdown/TOML files for commands, skills, and agents)
5
+ * for each supported AI coding environment.
6
+ */
7
+
8
+ import {readFile, writeFile, mkdir, rm} from 'node:fs/promises'
9
+ import {existsSync} from 'node:fs'
10
+ import {join, dirname} from 'node:path'
11
+ import {homedir} from 'node:os'
12
+ import yaml from 'js-yaml'
13
+
14
+ /** @import { CategoryEntry, CategoryType, EnvironmentId, DetectedEnvironment } from '../types.js' */
15
+
16
+ // ──────────────────────────────────────────────────────────────────────────────
17
+ // Path & key resolution tables
18
+ // ──────────────────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * For each environment, the target JSON file path (relative to cwd or absolute)
22
+ * and the root key that holds the MCP server map.
23
+ *
24
+ * @type {Record<EnvironmentId, { resolvePath: (cwd: string) => string, mcpKey: string, isYaml?: boolean }>}
25
+ */
26
+ const MCP_TARGETS = {
27
+ 'vscode-copilot': {
28
+ resolvePath: (cwd) => join(cwd, '.vscode', 'mcp.json'),
29
+ mcpKey: 'servers',
30
+ },
31
+ 'claude-code': {
32
+ resolvePath: (cwd) => join(cwd, '.mcp.json'),
33
+ mcpKey: 'mcpServers',
34
+ },
35
+ 'claude-desktop': {
36
+ resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
37
+ mcpKey: 'mcpServers',
38
+ },
39
+ opencode: {
40
+ resolvePath: (cwd) => join(cwd, 'opencode.json'),
41
+ mcpKey: 'mcp',
42
+ },
43
+ 'gemini-cli': {
44
+ resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'),
45
+ mcpKey: 'mcpServers',
46
+ },
47
+ 'copilot-cli': {
48
+ resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'),
49
+ mcpKey: 'mcpServers',
50
+ },
51
+ cursor: {
52
+ resolvePath: (cwd) => join(cwd, '.cursor', 'mcp.json'),
53
+ mcpKey: 'mcpServers',
54
+ },
55
+ windsurf: {
56
+ resolvePath: (_cwd) => join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
57
+ mcpKey: 'mcpServers',
58
+ },
59
+ 'continue-dev': {
60
+ resolvePath: (_cwd) => join(homedir(), '.continue', 'config.yaml'),
61
+ mcpKey: 'mcpServers',
62
+ isYaml: true,
63
+ },
64
+ zed: {
65
+ resolvePath: (_cwd) => join(homedir(), '.config', 'zed', 'settings.json'),
66
+ mcpKey: 'context_servers',
67
+ },
68
+ 'amazon-q': {
69
+ resolvePath: (cwd) => join(cwd, '.amazonq', 'mcp.json'),
70
+ mcpKey: 'mcpServers',
71
+ },
72
+ }
73
+
74
+ /**
75
+ * Resolve the target file path for a file-based entry (command, skill, agent).
76
+ *
77
+ * @param {string} name - Entry name (used as filename base)
78
+ * @param {CategoryType} type - Category type
79
+ * @param {EnvironmentId} envId - Target environment
80
+ * @param {string} cwd - Project working directory
81
+ * @returns {string} Absolute path to write
82
+ */
83
+ function resolveFilePath(name, type, envId, cwd) {
84
+ switch (type) {
85
+ case 'command':
86
+ return resolveCommandPath(name, envId, cwd)
87
+ case 'skill':
88
+ return resolveSkillPath(name, envId, cwd)
89
+ case 'agent':
90
+ return resolveAgentPath(name, envId, cwd)
91
+ case 'rule':
92
+ return resolveRulePath(name, envId, cwd)
93
+ default:
94
+ throw new Error(`Unsupported file entry type: ${type}`)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * @param {string} name
100
+ * @param {EnvironmentId} envId
101
+ * @param {string} cwd
102
+ * @returns {string}
103
+ */
104
+ function resolveCommandPath(name, envId, cwd) {
105
+ switch (envId) {
106
+ case 'vscode-copilot':
107
+ return join(cwd, '.github', 'prompts', `${name}.prompt.md`)
108
+ case 'claude-code':
109
+ return join(cwd, '.claude', 'commands', `${name}.md`)
110
+ case 'opencode':
111
+ return join(cwd, '.opencode', 'commands', `${name}.md`)
112
+ case 'gemini-cli':
113
+ return join(homedir(), '.gemini', 'commands', `${name}.toml`)
114
+ case 'copilot-cli':
115
+ // shared path with vscode-copilot for commands
116
+ return join(cwd, '.github', 'prompts', `${name}.prompt.md`)
117
+ case 'cursor':
118
+ return join(cwd, '.cursor', 'commands', `${name}.md`)
119
+ case 'windsurf':
120
+ return join(cwd, '.windsurf', 'workflows', `${name}.md`)
121
+ case 'continue-dev':
122
+ return join(cwd, '.continue', 'prompts', `${name}.md`)
123
+ default:
124
+ throw new Error(`Unknown environment for command: ${envId}`)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @param {string} name
130
+ * @param {EnvironmentId} envId
131
+ * @param {string} cwd
132
+ * @returns {string}
133
+ */
134
+ function resolveSkillPath(name, envId, cwd) {
135
+ switch (envId) {
136
+ case 'vscode-copilot':
137
+ // vscode uses a nested directory with SKILL.md inside
138
+ return join(cwd, '.github', 'skills', name, 'SKILL.md')
139
+ case 'claude-code':
140
+ return join(cwd, '.claude', 'skills', `${name}.md`)
141
+ case 'opencode':
142
+ return join(cwd, '.opencode', 'skills', `${name}.md`)
143
+ case 'copilot-cli':
144
+ return join(homedir(), '.copilot', 'skills', `${name}.md`)
145
+ case 'cursor':
146
+ return join(cwd, '.cursor', 'skills', `${name}.md`)
147
+ default:
148
+ throw new Error(`Environment "${envId}" does not support skill entries`)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * @param {string} name
154
+ * @param {EnvironmentId} envId
155
+ * @param {string} cwd
156
+ * @returns {string}
157
+ */
158
+ function resolveAgentPath(name, envId, cwd) {
159
+ switch (envId) {
160
+ case 'vscode-copilot':
161
+ return join(cwd, '.github', 'agents', `${name}.agent.md`)
162
+ case 'claude-code':
163
+ return join(cwd, '.claude', 'agents', `${name}.md`)
164
+ case 'opencode':
165
+ return join(cwd, '.opencode', 'agents', `${name}.md`)
166
+ case 'copilot-cli':
167
+ return join(homedir(), '.copilot', 'agents', `${name}.md`)
168
+ case 'continue-dev':
169
+ return join(cwd, '.continue', 'agents', `${name}.md`)
170
+ case 'amazon-q':
171
+ return join(homedir(), '.aws', 'amazonq', 'cli-agents', `${name}.json`)
172
+ default:
173
+ throw new Error(`Environment "${envId}" does not support agent entries`)
174
+ }
175
+ }
176
+
177
+ /**
178
+ * @param {string} name
179
+ * @param {EnvironmentId} envId
180
+ * @param {string} cwd
181
+ * @returns {string}
182
+ */
183
+ function resolveRulePath(name, envId, cwd) {
184
+ switch (envId) {
185
+ case 'vscode-copilot':
186
+ return join(cwd, '.github', 'instructions', `${name}.md`)
187
+ case 'claude-code':
188
+ return join(cwd, '.claude', 'rules', `${name}.md`)
189
+ case 'opencode':
190
+ return join(cwd, 'AGENTS.md')
191
+ case 'gemini-cli':
192
+ return join(cwd, 'GEMINI.md')
193
+ case 'copilot-cli':
194
+ return join(cwd, '.github', 'copilot-instructions.md')
195
+ case 'cursor':
196
+ return join(cwd, '.cursor', 'rules', `${name}.mdc`)
197
+ case 'windsurf':
198
+ return join(cwd, '.windsurf', 'rules', `${name}.md`)
199
+ case 'continue-dev':
200
+ return join(cwd, '.continue', 'rules', `${name}.md`)
201
+ case 'zed':
202
+ return join(cwd, '.rules')
203
+ case 'amazon-q':
204
+ return join(cwd, '.amazonq', 'rules', `${name}.md`)
205
+ default:
206
+ throw new Error(`Environment "${envId}" does not support rule entries`)
207
+ }
208
+ }
209
+
210
+ // ──────────────────────────────────────────────────────────────────────────────
211
+ // TOML rendering
212
+ // ──────────────────────────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Render a Gemini CLI command entry as a TOML string.
216
+ * No external TOML library is used — we generate the string directly.
217
+ *
218
+ * @param {string} description - Short description of the command
219
+ * @param {string} content - Prompt text content
220
+ * @returns {string} TOML-formatted string
221
+ */
222
+ function renderGeminiToml(description, content) {
223
+ // Escape triple-quotes inside the content to prevent TOML parse errors
224
+ const safeContent = content.replace(/"""/g, '\\"\\"\\"')
225
+ return `description = ${JSON.stringify(description)}
226
+
227
+ [prompt]
228
+ text = """
229
+ ${safeContent}
230
+ """
231
+ `
232
+ }
233
+
234
+ /**
235
+ * Render a Cursor rule as MDC (Markdown with YAML frontmatter).
236
+ * @param {string} name - Rule name (used as identifier)
237
+ * @param {string} description - Short description
238
+ * @param {string} content - Rule content
239
+ * @returns {string}
240
+ */
241
+ function renderCursorMDC(name, description, content) {
242
+ return `---
243
+ description: ${description || name}
244
+ globs:
245
+ alwaysApply: false
246
+ ---
247
+ ${content}
248
+ `
249
+ }
250
+
251
+ // ──────────────────────────────────────────────────────────────────────────────
252
+ // JSON helpers
253
+ // ──────────────────────────────────────────────────────────────────────────────
254
+
255
+ /**
256
+ * Read a JSON file from disk. Returns an empty object when the file is missing.
257
+ * Throws if the file exists but cannot be parsed.
258
+ *
259
+ * @param {string} filePath - Absolute path to the JSON file
260
+ * @returns {Promise<Record<string, unknown>>}
261
+ */
262
+ async function readJsonOrEmpty(filePath) {
263
+ if (!existsSync(filePath)) return {}
264
+ const raw = await readFile(filePath, 'utf8')
265
+ return JSON.parse(raw)
266
+ }
267
+
268
+ /**
269
+ * Write a value to disk as pretty-printed JSON, creating parent directories
270
+ * as needed.
271
+ *
272
+ * @param {string} filePath - Absolute path
273
+ * @param {unknown} data - Serialisable value
274
+ * @returns {Promise<void>}
275
+ */
276
+ async function writeJson(filePath, data) {
277
+ const dir = dirname(filePath)
278
+ if (!existsSync(dir)) {
279
+ await mkdir(dir, {recursive: true})
280
+ }
281
+ await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8')
282
+ }
283
+
284
+ /**
285
+ * Read a YAML file from disk. Returns an empty object when the file is missing.
286
+ * Throws if the file exists but cannot be parsed.
287
+ * @param {string} filePath
288
+ * @returns {Promise<Record<string, unknown>>}
289
+ */
290
+ async function readYamlOrEmpty(filePath) {
291
+ if (!existsSync(filePath)) return {}
292
+ const raw = await readFile(filePath, 'utf8')
293
+ return /** @type {Record<string, unknown>} */ (yaml.load(raw) ?? {})
294
+ }
295
+
296
+ /**
297
+ * Write a value to disk as YAML, creating parent directories as needed.
298
+ * @param {string} filePath
299
+ * @param {unknown} data
300
+ * @returns {Promise<void>}
301
+ */
302
+ async function writeYaml(filePath, data) {
303
+ const dir = dirname(filePath)
304
+ if (!existsSync(dir)) {
305
+ await mkdir(dir, {recursive: true})
306
+ }
307
+ await writeFile(filePath, yaml.dump(data, {lineWidth: -1}), 'utf8')
308
+ }
309
+
310
+ // ──────────────────────────────────────────────────────────────────────────────
311
+ // Build MCP server object from entry params
312
+ // ──────────────────────────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Convert an MCP entry's params into the server descriptor object written into
316
+ * the target JSON file.
317
+ *
318
+ * @param {import('../types.js').MCPParams} params
319
+ * @returns {Record<string, unknown>}
320
+ */
321
+ function buildMCPServerObject(params) {
322
+ /** @type {Record<string, unknown>} */
323
+ const server = {}
324
+
325
+ if (params.command !== undefined) server.command = params.command
326
+ // Normalize args: must be string[] in the deployed JSON.
327
+ // Guard against legacy data where args was stored as a newline-joined string.
328
+ if (params.args !== undefined) {
329
+ server.args = typeof params.args === 'string'
330
+ ? params.args.split('\n').map((a) => a.trim()).filter(Boolean)
331
+ : params.args
332
+ }
333
+ // Normalize env: must be Record<string,string> in the deployed JSON.
334
+ // Guard against legacy data where env was stored as a KEY=VALUE string.
335
+ if (params.env !== undefined) {
336
+ if (typeof params.env === 'string') {
337
+ /** @type {Record<string, string>} */
338
+ const envObj = {}
339
+ for (const line of params.env.split('\n')) {
340
+ const t = line.trim()
341
+ if (!t) continue
342
+ const eq = t.indexOf('=')
343
+ if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
344
+ }
345
+ if (Object.keys(envObj).length > 0) server.env = envObj
346
+ } else {
347
+ server.env = params.env
348
+ }
349
+ }
350
+ if (params.url !== undefined) server.url = params.url
351
+ // Omit type for stdio — most environments infer it from the presence of command.
352
+ // Only write type for sse/streamable-http where it's required.
353
+ if (params.transport && params.transport !== 'stdio') server.type = params.transport
354
+
355
+ return server
356
+ }
357
+
358
+ /**
359
+ * Build an OpenCode-format MCP server object from dvmi's normalized params.
360
+ * OpenCode uses: command as array, `environment` instead of `env`,
361
+ * `type: "local"/"remote"` instead of transport strings, and `enabled` flag.
362
+ *
363
+ * @param {import('../types.js').MCPParams} params
364
+ * @returns {Record<string, unknown>}
365
+ */
366
+ function buildOpenCodeMCPObject(params) {
367
+ /** @type {Record<string, unknown>} */
368
+ const server = {enabled: true}
369
+
370
+ const isRemote = params.transport === 'sse' || params.transport === 'streamable-http'
371
+ server.type = isRemote ? 'remote' : 'local'
372
+
373
+ if (isRemote) {
374
+ if (params.url !== undefined) server.url = params.url
375
+ } else {
376
+ const cmd = []
377
+ if (params.command !== undefined) cmd.push(params.command)
378
+ if (params.args !== undefined) {
379
+ const argsArr = typeof params.args === 'string'
380
+ ? params.args.split('\n').map((a) => a.trim()).filter(Boolean)
381
+ : params.args
382
+ cmd.push(...argsArr)
383
+ }
384
+ if (cmd.length > 0) server.command = cmd
385
+ }
386
+
387
+ // Normalize env for OpenCode (uses "environment" key)
388
+ if (params.env !== undefined) {
389
+ if (typeof params.env === 'string') {
390
+ /** @type {Record<string, string>} */
391
+ const envObj = {}
392
+ for (const line of params.env.split('\n')) {
393
+ const t = line.trim()
394
+ if (!t) continue
395
+ const eq = t.indexOf('=')
396
+ if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
397
+ }
398
+ if (Object.keys(envObj).length > 0) server.environment = envObj
399
+ } else {
400
+ server.environment = params.env
401
+ }
402
+ }
403
+
404
+ return server
405
+ }
406
+
407
+ // ──────────────────────────────────────────────────────────────────────────────
408
+ // Public API — MCP
409
+ // ──────────────────────────────────────────────────────────────────────────────
410
+
411
+ /**
412
+ * Deploy an MCP entry to a specific AI environment by merging it into the
413
+ * appropriate JSON config file. Creates the file (and parent directories) if
414
+ * it does not yet exist. Existing entries under other names are preserved.
415
+ *
416
+ * Skips silently when:
417
+ * - `entry` is falsy
418
+ * - `entry.type` is not `'mcp'`
419
+ * - `entry.params` is absent
420
+ *
421
+ * @param {CategoryEntry} entry - The MCP entry to deploy
422
+ * @param {EnvironmentId} envId - Target environment identifier
423
+ * @param {string} cwd - Project working directory (used for project-relative paths)
424
+ * @returns {Promise<void>}
425
+ */
426
+ export async function deployMCPEntry(entry, envId, cwd) {
427
+ if (!entry || entry.type !== 'mcp' || !entry.params) return
428
+
429
+ const target = MCP_TARGETS[envId]
430
+ if (!target) return
431
+
432
+ const filePath = target.resolvePath(cwd)
433
+ const json = target.isYaml ? await readYamlOrEmpty(filePath) : await readJsonOrEmpty(filePath)
434
+
435
+ if (!json[target.mcpKey] || typeof json[target.mcpKey] !== 'object') {
436
+ json[target.mcpKey] = {}
437
+ }
438
+
439
+ /** @type {Record<string, unknown>} */
440
+ const mcpKey = /** @type {any} */ (json[target.mcpKey])
441
+ const params = /** @type {import('../types.js').MCPParams} */ (entry.params)
442
+ mcpKey[entry.name] = envId === 'opencode' ? buildOpenCodeMCPObject(params) : buildMCPServerObject(params)
443
+
444
+ if (target.isYaml) {
445
+ await writeYaml(filePath, json)
446
+ } else {
447
+ await writeJson(filePath, json)
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Remove an MCP entry by name from a specific AI environment's JSON config file.
453
+ * If the file does not exist the function is a no-op.
454
+ * If the MCP key becomes empty after removal, it is kept as an empty object
455
+ * (the structure is preserved).
456
+ *
457
+ * @param {string} entryName - Name of the MCP server to remove
458
+ * @param {EnvironmentId} envId - Target environment identifier
459
+ * @param {string} cwd - Project working directory
460
+ * @returns {Promise<void>}
461
+ */
462
+ export async function undeployMCPEntry(entryName, envId, cwd) {
463
+ const target = MCP_TARGETS[envId]
464
+ if (!target) return
465
+
466
+ const filePath = target.resolvePath(cwd)
467
+ if (!existsSync(filePath)) return
468
+
469
+ const json = target.isYaml ? await readYamlOrEmpty(filePath) : await readJsonOrEmpty(filePath)
470
+
471
+ if (json[target.mcpKey] && typeof json[target.mcpKey] === 'object') {
472
+ delete (/** @type {any} */ (json[target.mcpKey])[entryName])
473
+ }
474
+
475
+ if (target.isYaml) {
476
+ await writeYaml(filePath, json)
477
+ } else {
478
+ await writeJson(filePath, json)
479
+ }
480
+ }
481
+
482
+ // ──────────────────────────────────────────────────────────────────────────────
483
+ // Public API — File-based entries
484
+ // ──────────────────────────────────────────────────────────────────────────────
485
+
486
+ /**
487
+ * Deploy a file-based entry (command, skill, or agent) to a specific AI
488
+ * environment. Creates parent directories as needed.
489
+ *
490
+ * For Gemini CLI commands the output is TOML; for everything else it is the raw
491
+ * markdown content stored in `entry.params.content` or `entry.params.instructions`.
492
+ *
493
+ * For VS Code Copilot skills the directory structure `{name}/SKILL.md` is
494
+ * created automatically.
495
+ *
496
+ * Skips silently when:
497
+ * - `entry` is falsy
498
+ * - `entry.type` is `'mcp'` (wrong function)
499
+ * - `entry.params` is absent
500
+ *
501
+ * @param {CategoryEntry} entry - The entry to deploy
502
+ * @param {EnvironmentId} envId - Target environment identifier
503
+ * @param {string} cwd - Project working directory
504
+ * @returns {Promise<void>}
505
+ */
506
+ export async function deployFileEntry(entry, envId, cwd) {
507
+ if (!entry || entry.type === 'mcp' || !entry.params) return
508
+
509
+ const filePath = resolveFilePath(entry.name, entry.type, envId, cwd)
510
+ const dir = dirname(filePath)
511
+
512
+ if (!existsSync(dir)) {
513
+ await mkdir(dir, {recursive: true})
514
+ }
515
+
516
+ const params = /** @type {any} */ (entry.params)
517
+
518
+ // Cursor rules use MDC format (Markdown with YAML frontmatter)
519
+ if (envId === 'cursor' && entry.type === 'rule') {
520
+ const description = params.description ?? ''
521
+ const content = params.content ?? ''
522
+ await writeFile(filePath, renderCursorMDC(entry.name, description, content), 'utf8')
523
+ return
524
+ }
525
+
526
+ // Gemini CLI commands use TOML format
527
+ if (envId === 'gemini-cli' && entry.type === 'command') {
528
+ const description = params.description ?? ''
529
+ const content = params.content ?? ''
530
+ await writeFile(filePath, renderGeminiToml(description, content), 'utf8')
531
+ return
532
+ }
533
+
534
+ // All other file entries use markdown
535
+ const content = params.content ?? params.instructions ?? ''
536
+ await writeFile(filePath, content, 'utf8')
537
+ }
538
+
539
+ /**
540
+ * Remove a deployed file-based entry from disk. This is a no-op if the file
541
+ * does not exist.
542
+ *
543
+ * @param {string} entryName - Name of the entry (used to derive the file path)
544
+ * @param {CategoryType} type - Category type of the entry
545
+ * @param {EnvironmentId} envId - Target environment identifier
546
+ * @param {string} cwd - Project working directory
547
+ * @returns {Promise<void>}
548
+ */
549
+ export async function undeployFileEntry(entryName, type, envId, cwd) {
550
+ if (type === 'mcp') return
551
+
552
+ const filePath = resolveFilePath(entryName, type, envId, cwd)
553
+ if (!existsSync(filePath)) return
554
+
555
+ await rm(filePath, {force: true})
556
+ }
557
+
558
+ // ──────────────────────────────────────────────────────────────────────────────
559
+ // Public API — Composite helpers
560
+ // ──────────────────────────────────────────────────────────────────────────────
561
+
562
+ /**
563
+ * Deploy an entry to all of its target environments that are currently detected
564
+ * and readable.
565
+ *
566
+ * - Environments listed in `entry.environments` but absent from `detectedEnvs`
567
+ * are silently skipped.
568
+ * - Environments that are detected but have unreadable JSON config files are
569
+ * also skipped (to avoid clobbering corrupt files).
570
+ *
571
+ * @param {CategoryEntry} entry - The entry to deploy
572
+ * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine
573
+ * @param {string} cwd - Project working directory
574
+ * @returns {Promise<void>}
575
+ */
576
+ export async function deployEntry(entry, detectedEnvs, cwd) {
577
+ if (!entry) return
578
+
579
+ const detectedIds = new Set(detectedEnvs.map((e) => e.id))
580
+
581
+ for (const envId of entry.environments) {
582
+ if (!detectedIds.has(envId)) continue
583
+
584
+ const detectedEnv = detectedEnvs.find((e) => e.id === envId)
585
+ // Skip if the environment has unreadable JSON config files that correspond
586
+ // to the MCP target path (we don't want to overwrite corrupt files)
587
+ if (detectedEnv && entry.type === 'mcp') {
588
+ const target = MCP_TARGETS[envId]
589
+ if (target) {
590
+ const targetPath = target.resolvePath(cwd)
591
+ if (detectedEnv.unreadable.includes(targetPath)) continue
592
+ }
593
+ }
594
+
595
+ if (entry.type === 'mcp') {
596
+ await deployMCPEntry(entry, envId, cwd)
597
+ } else {
598
+ await deployFileEntry(entry, envId, cwd)
599
+ }
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Undeploy an entry from all of its target environments that are currently
605
+ * detected. This is safe to call even when `entry` is `null` or `undefined`
606
+ * (it becomes a no-op).
607
+ *
608
+ * @param {CategoryEntry | null | undefined} entry - The entry to undeploy
609
+ * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine
610
+ * @param {string} cwd - Project working directory
611
+ * @returns {Promise<void>}
612
+ */
613
+ export async function undeployEntry(entry, detectedEnvs, cwd) {
614
+ if (!entry) return
615
+
616
+ const detectedIds = new Set(detectedEnvs.map((e) => e.id))
617
+
618
+ for (const envId of entry.environments) {
619
+ if (!detectedIds.has(envId)) continue
620
+
621
+ if (entry.type === 'mcp') {
622
+ await undeployMCPEntry(entry.name, envId, cwd)
623
+ } else {
624
+ await undeployFileEntry(entry.name, entry.type, envId, cwd)
625
+ }
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Reconcile all active entries against the currently detected environments.
631
+ *
632
+ * For each active entry, every detected environment listed in
633
+ * `entry.environments` is deployed (idempotent write). Environments that are
634
+ * listed but not currently detected are left untouched — we never undeploy on
635
+ * scan because the files may have been managed by the user directly
636
+ * (FR-004d: re-activation on re-detection).
637
+ *
638
+ * Inactive entries are not touched.
639
+ *
640
+ * @param {CategoryEntry[]} entries - All managed entries from the AI config store
641
+ * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine
642
+ * @param {string} cwd - Project working directory
643
+ * @returns {Promise<void>}
644
+ */
645
+ export async function reconcileOnScan(entries, detectedEnvs, cwd) {
646
+ for (const entry of entries) {
647
+ if (!entry.active) continue
648
+ await deployEntry(entry, detectedEnvs, cwd)
649
+ }
650
+ }