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.
- package/README.md +72 -0
- package/oclif.manifest.json +275 -235
- package/package.json +2 -1
- package/src/commands/auth/login.js +20 -16
- package/src/commands/changelog.js +12 -12
- package/src/commands/costs/get.js +14 -24
- package/src/commands/costs/trend.js +13 -24
- package/src/commands/create/repo.js +72 -54
- package/src/commands/docs/list.js +29 -25
- package/src/commands/docs/projects.js +58 -24
- package/src/commands/docs/read.js +56 -39
- package/src/commands/docs/search.js +37 -25
- package/src/commands/doctor.js +37 -35
- package/src/commands/dotfiles/add.js +51 -39
- package/src/commands/dotfiles/setup.js +62 -33
- package/src/commands/dotfiles/status.js +18 -18
- package/src/commands/dotfiles/sync.js +62 -46
- package/src/commands/init.js +143 -132
- package/src/commands/logs/index.js +10 -16
- package/src/commands/open.js +12 -12
- package/src/commands/pipeline/logs.js +8 -11
- package/src/commands/pipeline/rerun.js +21 -16
- package/src/commands/pipeline/status.js +28 -24
- package/src/commands/pr/create.js +40 -27
- package/src/commands/pr/detail.js +9 -7
- package/src/commands/pr/review.js +18 -19
- package/src/commands/pr/status.js +27 -21
- package/src/commands/prompts/browse.js +15 -15
- package/src/commands/prompts/download.js +15 -16
- package/src/commands/prompts/install-speckit.js +11 -12
- package/src/commands/prompts/list.js +12 -12
- package/src/commands/prompts/run.js +16 -19
- package/src/commands/repo/list.js +57 -41
- package/src/commands/search.js +20 -18
- package/src/commands/security/setup.js +38 -34
- package/src/commands/sync-config-ai/index.js +257 -0
- package/src/commands/tasks/assigned.js +43 -33
- package/src/commands/tasks/list.js +43 -33
- package/src/commands/tasks/today.js +32 -30
- package/src/commands/upgrade.js +18 -17
- package/src/commands/vuln/detail.js +8 -8
- package/src/commands/vuln/scan.js +39 -20
- package/src/commands/vuln/search.js +23 -18
- package/src/commands/welcome.js +2 -2
- package/src/commands/whoami.js +19 -23
- package/src/formatters/ai-config.js +215 -0
- package/src/formatters/charts.js +6 -23
- package/src/formatters/cost.js +1 -7
- package/src/formatters/dotfiles.js +48 -19
- package/src/formatters/markdown.js +11 -6
- package/src/formatters/openapi.js +7 -9
- package/src/formatters/prompts.js +69 -78
- package/src/formatters/security.js +2 -2
- package/src/formatters/status.js +1 -1
- package/src/formatters/table.js +1 -3
- package/src/formatters/vuln.js +33 -20
- package/src/help.js +162 -164
- package/src/hooks/init.js +1 -3
- package/src/hooks/postrun.js +5 -7
- package/src/index.js +1 -1
- package/src/services/ai-config-store.js +349 -0
- package/src/services/ai-env-deployer.js +650 -0
- package/src/services/ai-env-scanner.js +983 -0
- package/src/services/audit-detector.js +2 -2
- package/src/services/audit-runner.js +40 -31
- package/src/services/auth.js +9 -9
- package/src/services/awesome-copilot.js +7 -4
- package/src/services/aws-costs.js +22 -22
- package/src/services/clickup.js +26 -26
- package/src/services/cloudwatch-logs.js +5 -9
- package/src/services/config.js +13 -13
- package/src/services/docs.js +19 -20
- package/src/services/dotfiles.js +149 -51
- package/src/services/github.js +22 -24
- package/src/services/nvd.js +21 -31
- package/src/services/platform.js +2 -2
- package/src/services/prompts.js +23 -35
- package/src/services/security.js +135 -61
- package/src/services/shell.js +4 -4
- package/src/services/skills-sh.js +3 -9
- package/src/services/speckit.js +4 -7
- package/src/services/version-check.js +10 -10
- package/src/types.js +117 -0
- package/src/utils/aws-vault.js +18 -41
- package/src/utils/banner.js +5 -7
- package/src/utils/errors.js +42 -46
- package/src/utils/frontmatter.js +4 -4
- package/src/utils/gradient.js +18 -16
- package/src/utils/open-browser.js +3 -3
- package/src/utils/tui/form.js +1184 -0
- package/src/utils/tui/modal.js +15 -14
- package/src/utils/tui/navigable-table.js +16 -16
- package/src/utils/tui/tab-tui.js +1089 -0
- package/src/utils/typewriter.js +3 -3
- package/src/utils/welcome.js +18 -21
- 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
|
+
}
|