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.
@@ -9,6 +9,7 @@ import {readFile, writeFile, mkdir, rm} from 'node:fs/promises'
9
9
  import {existsSync} from 'node:fs'
10
10
  import {join, dirname} from 'node:path'
11
11
  import {homedir} from 'node:os'
12
+ import yaml from 'js-yaml'
12
13
 
13
14
  /** @import { CategoryEntry, CategoryType, EnvironmentId, DetectedEnvironment } from '../types.js' */
14
15
 
@@ -20,7 +21,7 @@ import {homedir} from 'node:os'
20
21
  * For each environment, the target JSON file path (relative to cwd or absolute)
21
22
  * and the root key that holds the MCP server map.
22
23
  *
23
- * @type {Record<EnvironmentId, { resolvePath: (cwd: string) => string, mcpKey: string }>}
24
+ * @type {Record<EnvironmentId, { resolvePath: (cwd: string) => string, mcpKey: string, isYaml?: boolean }>}
24
25
  */
25
26
  const MCP_TARGETS = {
26
27
  'vscode-copilot': {
@@ -31,9 +32,13 @@ const MCP_TARGETS = {
31
32
  resolvePath: (cwd) => join(cwd, '.mcp.json'),
32
33
  mcpKey: 'mcpServers',
33
34
  },
35
+ 'claude-desktop': {
36
+ resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
37
+ mcpKey: 'mcpServers',
38
+ },
34
39
  opencode: {
35
40
  resolvePath: (cwd) => join(cwd, 'opencode.json'),
36
- mcpKey: 'mcpServers',
41
+ mcpKey: 'mcp',
37
42
  },
38
43
  'gemini-cli': {
39
44
  resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'),
@@ -43,6 +48,27 @@ const MCP_TARGETS = {
43
48
  resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'),
44
49
  mcpKey: 'mcpServers',
45
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
+ },
46
72
  }
47
73
 
48
74
  /**
@@ -62,6 +88,8 @@ function resolveFilePath(name, type, envId, cwd) {
62
88
  return resolveSkillPath(name, envId, cwd)
63
89
  case 'agent':
64
90
  return resolveAgentPath(name, envId, cwd)
91
+ case 'rule':
92
+ return resolveRulePath(name, envId, cwd)
65
93
  default:
66
94
  throw new Error(`Unsupported file entry type: ${type}`)
67
95
  }
@@ -86,6 +114,12 @@ function resolveCommandPath(name, envId, cwd) {
86
114
  case 'copilot-cli':
87
115
  // shared path with vscode-copilot for commands
88
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`)
89
123
  default:
90
124
  throw new Error(`Unknown environment for command: ${envId}`)
91
125
  }
@@ -108,6 +142,8 @@ function resolveSkillPath(name, envId, cwd) {
108
142
  return join(cwd, '.opencode', 'skills', `${name}.md`)
109
143
  case 'copilot-cli':
110
144
  return join(homedir(), '.copilot', 'skills', `${name}.md`)
145
+ case 'cursor':
146
+ return join(cwd, '.cursor', 'skills', `${name}.md`)
111
147
  default:
112
148
  throw new Error(`Environment "${envId}" does not support skill entries`)
113
149
  }
@@ -129,11 +165,48 @@ function resolveAgentPath(name, envId, cwd) {
129
165
  return join(cwd, '.opencode', 'agents', `${name}.md`)
130
166
  case 'copilot-cli':
131
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`)
132
172
  default:
133
173
  throw new Error(`Environment "${envId}" does not support agent entries`)
134
174
  }
135
175
  }
136
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
+
137
210
  // ──────────────────────────────────────────────────────────────────────────────
138
211
  // TOML rendering
139
212
  // ──────────────────────────────────────────────────────────────────────────────
@@ -158,6 +231,23 @@ ${safeContent}
158
231
  `
159
232
  }
160
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
+
161
251
  // ──────────────────────────────────────────────────────────────────────────────
162
252
  // JSON helpers
163
253
  // ──────────────────────────────────────────────────────────────────────────────
@@ -191,6 +281,32 @@ async function writeJson(filePath, data) {
191
281
  await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8')
192
282
  }
193
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
+
194
310
  // ──────────────────────────────────────────────────────────────────────────────
195
311
  // Build MCP server object from entry params
196
312
  // ──────────────────────────────────────────────────────────────────────────────
@@ -207,10 +323,83 @@ function buildMCPServerObject(params) {
207
323
  const server = {}
208
324
 
209
325
  if (params.command !== undefined) server.command = params.command
210
- if (params.args !== undefined) server.args = params.args
211
- if (params.env !== undefined) server.env = params.env
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
+ }
212
350
  if (params.url !== undefined) server.url = params.url
213
- if (params.transport !== undefined) server.type = params.transport
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
+ }
214
403
 
215
404
  return server
216
405
  }
@@ -241,7 +430,7 @@ export async function deployMCPEntry(entry, envId, cwd) {
241
430
  if (!target) return
242
431
 
243
432
  const filePath = target.resolvePath(cwd)
244
- const json = await readJsonOrEmpty(filePath)
433
+ const json = target.isYaml ? await readYamlOrEmpty(filePath) : await readJsonOrEmpty(filePath)
245
434
 
246
435
  if (!json[target.mcpKey] || typeof json[target.mcpKey] !== 'object') {
247
436
  json[target.mcpKey] = {}
@@ -249,9 +438,14 @@ export async function deployMCPEntry(entry, envId, cwd) {
249
438
 
250
439
  /** @type {Record<string, unknown>} */
251
440
  const mcpKey = /** @type {any} */ (json[target.mcpKey])
252
- mcpKey[entry.name] = buildMCPServerObject(/** @type {import('../types.js').MCPParams} */ (entry.params))
441
+ const params = /** @type {import('../types.js').MCPParams} */ (entry.params)
442
+ mcpKey[entry.name] = envId === 'opencode' ? buildOpenCodeMCPObject(params) : buildMCPServerObject(params)
253
443
 
254
- await writeJson(filePath, json)
444
+ if (target.isYaml) {
445
+ await writeYaml(filePath, json)
446
+ } else {
447
+ await writeJson(filePath, json)
448
+ }
255
449
  }
256
450
 
257
451
  /**
@@ -272,13 +466,17 @@ export async function undeployMCPEntry(entryName, envId, cwd) {
272
466
  const filePath = target.resolvePath(cwd)
273
467
  if (!existsSync(filePath)) return
274
468
 
275
- const json = await readJsonOrEmpty(filePath)
469
+ const json = target.isYaml ? await readYamlOrEmpty(filePath) : await readJsonOrEmpty(filePath)
276
470
 
277
471
  if (json[target.mcpKey] && typeof json[target.mcpKey] === 'object') {
278
472
  delete (/** @type {any} */ (json[target.mcpKey])[entryName])
279
473
  }
280
474
 
281
- await writeJson(filePath, json)
475
+ if (target.isYaml) {
476
+ await writeYaml(filePath, json)
477
+ } else {
478
+ await writeJson(filePath, json)
479
+ }
282
480
  }
283
481
 
284
482
  // ──────────────────────────────────────────────────────────────────────────────
@@ -317,6 +515,14 @@ export async function deployFileEntry(entry, envId, cwd) {
317
515
 
318
516
  const params = /** @type {any} */ (entry.params)
319
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
+
320
526
  // Gemini CLI commands use TOML format
321
527
  if (envId === 'gemini-cli' && entry.type === 'command') {
322
528
  const description = params.description ?? ''