devvami 1.4.2 → 1.5.0

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 +7 -0
  2. package/oclif.manifest.json +129 -89
  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 +143 -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 +127 -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 +318 -0
  62. package/src/services/ai-env-deployer.js +444 -0
  63. package/src/services/ai-env-scanner.js +242 -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 +85 -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 +1006 -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 +800 -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,444 @@
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
+
13
+ /** @import { CategoryEntry, CategoryType, EnvironmentId, DetectedEnvironment } from '../types.js' */
14
+
15
+ // ──────────────────────────────────────────────────────────────────────────────
16
+ // Path & key resolution tables
17
+ // ──────────────────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * For each environment, the target JSON file path (relative to cwd or absolute)
21
+ * and the root key that holds the MCP server map.
22
+ *
23
+ * @type {Record<EnvironmentId, { resolvePath: (cwd: string) => string, mcpKey: string }>}
24
+ */
25
+ const MCP_TARGETS = {
26
+ 'vscode-copilot': {
27
+ resolvePath: (cwd) => join(cwd, '.vscode', 'mcp.json'),
28
+ mcpKey: 'servers',
29
+ },
30
+ 'claude-code': {
31
+ resolvePath: (cwd) => join(cwd, '.mcp.json'),
32
+ mcpKey: 'mcpServers',
33
+ },
34
+ opencode: {
35
+ resolvePath: (cwd) => join(cwd, 'opencode.json'),
36
+ mcpKey: 'mcpServers',
37
+ },
38
+ 'gemini-cli': {
39
+ resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'),
40
+ mcpKey: 'mcpServers',
41
+ },
42
+ 'copilot-cli': {
43
+ resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'),
44
+ mcpKey: 'mcpServers',
45
+ },
46
+ }
47
+
48
+ /**
49
+ * Resolve the target file path for a file-based entry (command, skill, agent).
50
+ *
51
+ * @param {string} name - Entry name (used as filename base)
52
+ * @param {CategoryType} type - Category type
53
+ * @param {EnvironmentId} envId - Target environment
54
+ * @param {string} cwd - Project working directory
55
+ * @returns {string} Absolute path to write
56
+ */
57
+ function resolveFilePath(name, type, envId, cwd) {
58
+ switch (type) {
59
+ case 'command':
60
+ return resolveCommandPath(name, envId, cwd)
61
+ case 'skill':
62
+ return resolveSkillPath(name, envId, cwd)
63
+ case 'agent':
64
+ return resolveAgentPath(name, envId, cwd)
65
+ default:
66
+ throw new Error(`Unsupported file entry type: ${type}`)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @param {string} name
72
+ * @param {EnvironmentId} envId
73
+ * @param {string} cwd
74
+ * @returns {string}
75
+ */
76
+ function resolveCommandPath(name, envId, cwd) {
77
+ switch (envId) {
78
+ case 'vscode-copilot':
79
+ return join(cwd, '.github', 'prompts', `${name}.prompt.md`)
80
+ case 'claude-code':
81
+ return join(cwd, '.claude', 'commands', `${name}.md`)
82
+ case 'opencode':
83
+ return join(cwd, '.opencode', 'commands', `${name}.md`)
84
+ case 'gemini-cli':
85
+ return join(homedir(), '.gemini', 'commands', `${name}.toml`)
86
+ case 'copilot-cli':
87
+ // shared path with vscode-copilot for commands
88
+ return join(cwd, '.github', 'prompts', `${name}.prompt.md`)
89
+ default:
90
+ throw new Error(`Unknown environment for command: ${envId}`)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @param {string} name
96
+ * @param {EnvironmentId} envId
97
+ * @param {string} cwd
98
+ * @returns {string}
99
+ */
100
+ function resolveSkillPath(name, envId, cwd) {
101
+ switch (envId) {
102
+ case 'vscode-copilot':
103
+ // vscode uses a nested directory with SKILL.md inside
104
+ return join(cwd, '.github', 'skills', name, 'SKILL.md')
105
+ case 'claude-code':
106
+ return join(cwd, '.claude', 'skills', `${name}.md`)
107
+ case 'opencode':
108
+ return join(cwd, '.opencode', 'skills', `${name}.md`)
109
+ case 'copilot-cli':
110
+ return join(homedir(), '.copilot', 'skills', `${name}.md`)
111
+ default:
112
+ throw new Error(`Environment "${envId}" does not support skill entries`)
113
+ }
114
+ }
115
+
116
+ /**
117
+ * @param {string} name
118
+ * @param {EnvironmentId} envId
119
+ * @param {string} cwd
120
+ * @returns {string}
121
+ */
122
+ function resolveAgentPath(name, envId, cwd) {
123
+ switch (envId) {
124
+ case 'vscode-copilot':
125
+ return join(cwd, '.github', 'agents', `${name}.agent.md`)
126
+ case 'claude-code':
127
+ return join(cwd, '.claude', 'agents', `${name}.md`)
128
+ case 'opencode':
129
+ return join(cwd, '.opencode', 'agents', `${name}.md`)
130
+ case 'copilot-cli':
131
+ return join(homedir(), '.copilot', 'agents', `${name}.md`)
132
+ default:
133
+ throw new Error(`Environment "${envId}" does not support agent entries`)
134
+ }
135
+ }
136
+
137
+ // ──────────────────────────────────────────────────────────────────────────────
138
+ // TOML rendering
139
+ // ──────────────────────────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Render a Gemini CLI command entry as a TOML string.
143
+ * No external TOML library is used — we generate the string directly.
144
+ *
145
+ * @param {string} description - Short description of the command
146
+ * @param {string} content - Prompt text content
147
+ * @returns {string} TOML-formatted string
148
+ */
149
+ function renderGeminiToml(description, content) {
150
+ // Escape triple-quotes inside the content to prevent TOML parse errors
151
+ const safeContent = content.replace(/"""/g, '\\"\\"\\"')
152
+ return `description = ${JSON.stringify(description)}
153
+
154
+ [prompt]
155
+ text = """
156
+ ${safeContent}
157
+ """
158
+ `
159
+ }
160
+
161
+ // ──────────────────────────────────────────────────────────────────────────────
162
+ // JSON helpers
163
+ // ──────────────────────────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Read a JSON file from disk. Returns an empty object when the file is missing.
167
+ * Throws if the file exists but cannot be parsed.
168
+ *
169
+ * @param {string} filePath - Absolute path to the JSON file
170
+ * @returns {Promise<Record<string, unknown>>}
171
+ */
172
+ async function readJsonOrEmpty(filePath) {
173
+ if (!existsSync(filePath)) return {}
174
+ const raw = await readFile(filePath, 'utf8')
175
+ return JSON.parse(raw)
176
+ }
177
+
178
+ /**
179
+ * Write a value to disk as pretty-printed JSON, creating parent directories
180
+ * as needed.
181
+ *
182
+ * @param {string} filePath - Absolute path
183
+ * @param {unknown} data - Serialisable value
184
+ * @returns {Promise<void>}
185
+ */
186
+ async function writeJson(filePath, data) {
187
+ const dir = dirname(filePath)
188
+ if (!existsSync(dir)) {
189
+ await mkdir(dir, {recursive: true})
190
+ }
191
+ await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8')
192
+ }
193
+
194
+ // ──────────────────────────────────────────────────────────────────────────────
195
+ // Build MCP server object from entry params
196
+ // ──────────────────────────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Convert an MCP entry's params into the server descriptor object written into
200
+ * the target JSON file.
201
+ *
202
+ * @param {import('../types.js').MCPParams} params
203
+ * @returns {Record<string, unknown>}
204
+ */
205
+ function buildMCPServerObject(params) {
206
+ /** @type {Record<string, unknown>} */
207
+ const server = {}
208
+
209
+ 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
212
+ if (params.url !== undefined) server.url = params.url
213
+ if (params.transport !== undefined) server.type = params.transport
214
+
215
+ return server
216
+ }
217
+
218
+ // ──────────────────────────────────────────────────────────────────────────────
219
+ // Public API — MCP
220
+ // ──────────────────────────────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Deploy an MCP entry to a specific AI environment by merging it into the
224
+ * appropriate JSON config file. Creates the file (and parent directories) if
225
+ * it does not yet exist. Existing entries under other names are preserved.
226
+ *
227
+ * Skips silently when:
228
+ * - `entry` is falsy
229
+ * - `entry.type` is not `'mcp'`
230
+ * - `entry.params` is absent
231
+ *
232
+ * @param {CategoryEntry} entry - The MCP entry to deploy
233
+ * @param {EnvironmentId} envId - Target environment identifier
234
+ * @param {string} cwd - Project working directory (used for project-relative paths)
235
+ * @returns {Promise<void>}
236
+ */
237
+ export async function deployMCPEntry(entry, envId, cwd) {
238
+ if (!entry || entry.type !== 'mcp' || !entry.params) return
239
+
240
+ const target = MCP_TARGETS[envId]
241
+ if (!target) return
242
+
243
+ const filePath = target.resolvePath(cwd)
244
+ const json = await readJsonOrEmpty(filePath)
245
+
246
+ if (!json[target.mcpKey] || typeof json[target.mcpKey] !== 'object') {
247
+ json[target.mcpKey] = {}
248
+ }
249
+
250
+ /** @type {Record<string, unknown>} */
251
+ const mcpKey = /** @type {any} */ (json[target.mcpKey])
252
+ mcpKey[entry.name] = buildMCPServerObject(/** @type {import('../types.js').MCPParams} */ (entry.params))
253
+
254
+ await writeJson(filePath, json)
255
+ }
256
+
257
+ /**
258
+ * Remove an MCP entry by name from a specific AI environment's JSON config file.
259
+ * If the file does not exist the function is a no-op.
260
+ * If the MCP key becomes empty after removal, it is kept as an empty object
261
+ * (the structure is preserved).
262
+ *
263
+ * @param {string} entryName - Name of the MCP server to remove
264
+ * @param {EnvironmentId} envId - Target environment identifier
265
+ * @param {string} cwd - Project working directory
266
+ * @returns {Promise<void>}
267
+ */
268
+ export async function undeployMCPEntry(entryName, envId, cwd) {
269
+ const target = MCP_TARGETS[envId]
270
+ if (!target) return
271
+
272
+ const filePath = target.resolvePath(cwd)
273
+ if (!existsSync(filePath)) return
274
+
275
+ const json = await readJsonOrEmpty(filePath)
276
+
277
+ if (json[target.mcpKey] && typeof json[target.mcpKey] === 'object') {
278
+ delete (/** @type {any} */ (json[target.mcpKey])[entryName])
279
+ }
280
+
281
+ await writeJson(filePath, json)
282
+ }
283
+
284
+ // ──────────────────────────────────────────────────────────────────────────────
285
+ // Public API — File-based entries
286
+ // ──────────────────────────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Deploy a file-based entry (command, skill, or agent) to a specific AI
290
+ * environment. Creates parent directories as needed.
291
+ *
292
+ * For Gemini CLI commands the output is TOML; for everything else it is the raw
293
+ * markdown content stored in `entry.params.content` or `entry.params.instructions`.
294
+ *
295
+ * For VS Code Copilot skills the directory structure `{name}/SKILL.md` is
296
+ * created automatically.
297
+ *
298
+ * Skips silently when:
299
+ * - `entry` is falsy
300
+ * - `entry.type` is `'mcp'` (wrong function)
301
+ * - `entry.params` is absent
302
+ *
303
+ * @param {CategoryEntry} entry - The entry to deploy
304
+ * @param {EnvironmentId} envId - Target environment identifier
305
+ * @param {string} cwd - Project working directory
306
+ * @returns {Promise<void>}
307
+ */
308
+ export async function deployFileEntry(entry, envId, cwd) {
309
+ if (!entry || entry.type === 'mcp' || !entry.params) return
310
+
311
+ const filePath = resolveFilePath(entry.name, entry.type, envId, cwd)
312
+ const dir = dirname(filePath)
313
+
314
+ if (!existsSync(dir)) {
315
+ await mkdir(dir, {recursive: true})
316
+ }
317
+
318
+ const params = /** @type {any} */ (entry.params)
319
+
320
+ // Gemini CLI commands use TOML format
321
+ if (envId === 'gemini-cli' && entry.type === 'command') {
322
+ const description = params.description ?? ''
323
+ const content = params.content ?? ''
324
+ await writeFile(filePath, renderGeminiToml(description, content), 'utf8')
325
+ return
326
+ }
327
+
328
+ // All other file entries use markdown
329
+ const content = params.content ?? params.instructions ?? ''
330
+ await writeFile(filePath, content, 'utf8')
331
+ }
332
+
333
+ /**
334
+ * Remove a deployed file-based entry from disk. This is a no-op if the file
335
+ * does not exist.
336
+ *
337
+ * @param {string} entryName - Name of the entry (used to derive the file path)
338
+ * @param {CategoryType} type - Category type of the entry
339
+ * @param {EnvironmentId} envId - Target environment identifier
340
+ * @param {string} cwd - Project working directory
341
+ * @returns {Promise<void>}
342
+ */
343
+ export async function undeployFileEntry(entryName, type, envId, cwd) {
344
+ if (type === 'mcp') return
345
+
346
+ const filePath = resolveFilePath(entryName, type, envId, cwd)
347
+ if (!existsSync(filePath)) return
348
+
349
+ await rm(filePath, {force: true})
350
+ }
351
+
352
+ // ──────────────────────────────────────────────────────────────────────────────
353
+ // Public API — Composite helpers
354
+ // ──────────────────────────────────────────────────────────────────────────────
355
+
356
+ /**
357
+ * Deploy an entry to all of its target environments that are currently detected
358
+ * and readable.
359
+ *
360
+ * - Environments listed in `entry.environments` but absent from `detectedEnvs`
361
+ * are silently skipped.
362
+ * - Environments that are detected but have unreadable JSON config files are
363
+ * also skipped (to avoid clobbering corrupt files).
364
+ *
365
+ * @param {CategoryEntry} entry - The entry to deploy
366
+ * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine
367
+ * @param {string} cwd - Project working directory
368
+ * @returns {Promise<void>}
369
+ */
370
+ export async function deployEntry(entry, detectedEnvs, cwd) {
371
+ if (!entry) return
372
+
373
+ const detectedIds = new Set(detectedEnvs.map((e) => e.id))
374
+
375
+ for (const envId of entry.environments) {
376
+ if (!detectedIds.has(envId)) continue
377
+
378
+ const detectedEnv = detectedEnvs.find((e) => e.id === envId)
379
+ // Skip if the environment has unreadable JSON config files that correspond
380
+ // to the MCP target path (we don't want to overwrite corrupt files)
381
+ if (detectedEnv && entry.type === 'mcp') {
382
+ const target = MCP_TARGETS[envId]
383
+ if (target) {
384
+ const targetPath = target.resolvePath(cwd)
385
+ if (detectedEnv.unreadable.includes(targetPath)) continue
386
+ }
387
+ }
388
+
389
+ if (entry.type === 'mcp') {
390
+ await deployMCPEntry(entry, envId, cwd)
391
+ } else {
392
+ await deployFileEntry(entry, envId, cwd)
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Undeploy an entry from all of its target environments that are currently
399
+ * detected. This is safe to call even when `entry` is `null` or `undefined`
400
+ * (it becomes a no-op).
401
+ *
402
+ * @param {CategoryEntry | null | undefined} entry - The entry to undeploy
403
+ * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine
404
+ * @param {string} cwd - Project working directory
405
+ * @returns {Promise<void>}
406
+ */
407
+ export async function undeployEntry(entry, detectedEnvs, cwd) {
408
+ if (!entry) return
409
+
410
+ const detectedIds = new Set(detectedEnvs.map((e) => e.id))
411
+
412
+ for (const envId of entry.environments) {
413
+ if (!detectedIds.has(envId)) continue
414
+
415
+ if (entry.type === 'mcp') {
416
+ await undeployMCPEntry(entry.name, envId, cwd)
417
+ } else {
418
+ await undeployFileEntry(entry.name, entry.type, envId, cwd)
419
+ }
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Reconcile all active entries against the currently detected environments.
425
+ *
426
+ * For each active entry, every detected environment listed in
427
+ * `entry.environments` is deployed (idempotent write). Environments that are
428
+ * listed but not currently detected are left untouched — we never undeploy on
429
+ * scan because the files may have been managed by the user directly
430
+ * (FR-004d: re-activation on re-detection).
431
+ *
432
+ * Inactive entries are not touched.
433
+ *
434
+ * @param {CategoryEntry[]} entries - All managed entries from the AI config store
435
+ * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine
436
+ * @param {string} cwd - Project working directory
437
+ * @returns {Promise<void>}
438
+ */
439
+ export async function reconcileOnScan(entries, detectedEnvs, cwd) {
440
+ for (const entry of entries) {
441
+ if (!entry.active) continue
442
+ await deployEntry(entry, detectedEnvs, cwd)
443
+ }
444
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @module ai-env-scanner
3
+ * Detects AI coding environments by scanning well-known project and global config paths.
4
+ */
5
+
6
+ import {existsSync, readFileSync} from 'node:fs'
7
+ import {resolve, join} from 'node:path'
8
+ import {homedir} from 'node:os'
9
+
10
+ /** @import { CategoryType, EnvironmentId, PathStatus, CategoryCounts, DetectedEnvironment, CategoryEntry } from '../types.js' */
11
+
12
+ /**
13
+ * @typedef {Object} PathSpec
14
+ * @property {string} path - Relative (project) or absolute (global) path string
15
+ * @property {boolean} isJson - Whether to attempt JSON.parse after reading
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} EnvironmentDef
20
+ * @property {EnvironmentId} id
21
+ * @property {string} name - Display name
22
+ * @property {PathSpec[]} projectPaths - Paths relative to cwd
23
+ * @property {PathSpec[]} globalPaths - Absolute paths (resolved from homedir)
24
+ * @property {CategoryType[]} supportedCategories
25
+ */
26
+
27
+ /**
28
+ * All recognised AI coding environments with their detection paths and capabilities.
29
+ * @type {Readonly<EnvironmentDef[]>}
30
+ */
31
+ export const ENVIRONMENTS = Object.freeze([
32
+ {
33
+ id: /** @type {EnvironmentId} */ ('vscode-copilot'),
34
+ name: 'VS Code Copilot',
35
+ projectPaths: [
36
+ {path: '.github/copilot-instructions.md', isJson: false},
37
+ {path: '.vscode/mcp.json', isJson: true},
38
+ {path: '.github/instructions/', isJson: false},
39
+ {path: '.github/prompts/', isJson: false},
40
+ {path: '.github/agents/', isJson: false},
41
+ {path: '.github/skills/', isJson: false},
42
+ ],
43
+ globalPaths: [],
44
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
45
+ },
46
+ {
47
+ id: /** @type {EnvironmentId} */ ('claude-code'),
48
+ name: 'Claude Code',
49
+ projectPaths: [
50
+ {path: 'CLAUDE.md', isJson: false},
51
+ {path: '.mcp.json', isJson: true},
52
+ {path: '.claude/commands/', isJson: false},
53
+ {path: '.claude/skills/', isJson: false},
54
+ {path: '.claude/agents/', isJson: false},
55
+ {path: '.claude/rules/', isJson: false},
56
+ ],
57
+ globalPaths: [],
58
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
59
+ },
60
+ {
61
+ id: /** @type {EnvironmentId} */ ('opencode'),
62
+ name: 'OpenCode',
63
+ projectPaths: [
64
+ {path: 'AGENTS.md', isJson: false},
65
+ {path: '.opencode/commands/', isJson: false},
66
+ {path: '.opencode/skills/', isJson: false},
67
+ {path: '.opencode/agents/', isJson: false},
68
+ {path: 'opencode.json', isJson: true},
69
+ ],
70
+ globalPaths: [
71
+ {path: '~/.config/opencode/opencode.json', isJson: true},
72
+ {path: '~/.config/opencode/commands/', isJson: false},
73
+ {path: '~/.config/opencode/agents/', isJson: false},
74
+ {path: '~/.config/opencode/skills/', isJson: false},
75
+ ],
76
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
77
+ },
78
+ {
79
+ id: /** @type {EnvironmentId} */ ('gemini-cli'),
80
+ name: 'Gemini CLI',
81
+ projectPaths: [{path: 'GEMINI.md', isJson: false}],
82
+ globalPaths: [
83
+ {path: '~/.gemini/settings.json', isJson: true},
84
+ {path: '~/.gemini/commands/', isJson: false},
85
+ ],
86
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command']),
87
+ },
88
+ {
89
+ id: /** @type {EnvironmentId} */ ('copilot-cli'),
90
+ name: 'GitHub Copilot CLI',
91
+ projectPaths: [],
92
+ globalPaths: [
93
+ {path: '~/.copilot/config.json', isJson: true},
94
+ {path: '~/.copilot/mcp-config.json', isJson: true},
95
+ {path: '~/.copilot/agents/', isJson: false},
96
+ {path: '~/.copilot/skills/', isJson: false},
97
+ {path: '~/.copilot/copilot-instructions.md', isJson: false},
98
+ ],
99
+ supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']),
100
+ },
101
+ ])
102
+
103
+ /**
104
+ * Resolve a path spec into an absolute path.
105
+ * Project paths are resolved relative to `cwd`; global paths have their `~/` prefix
106
+ * replaced with the actual home directory.
107
+ *
108
+ * @param {PathSpec} spec
109
+ * @param {string} cwd
110
+ * @param {boolean} isGlobal
111
+ * @returns {string}
112
+ */
113
+ function resolvePathSpec(spec, cwd, isGlobal) {
114
+ if (isGlobal) {
115
+ // Global paths are stored with a leading `~/`
116
+ return resolve(join(homedir(), spec.path.replace(/^~\//, '')))
117
+ }
118
+ return resolve(join(cwd, spec.path))
119
+ }
120
+
121
+ /**
122
+ * Build a PathStatus for one path spec.
123
+ * For JSON files that exist, attempt to parse them; failure marks the path as unreadable.
124
+ *
125
+ * @param {PathSpec} spec
126
+ * @param {string} absolutePath
127
+ * @param {string[]} unreadable - Mutable array; unreadable paths are pushed here
128
+ * @returns {PathStatus}
129
+ */
130
+ function evaluatePathSpec(spec, absolutePath, unreadable) {
131
+ const exists = existsSync(absolutePath)
132
+
133
+ if (!exists) {
134
+ return {path: absolutePath, exists: false, readable: false}
135
+ }
136
+
137
+ if (!spec.isJson) {
138
+ return {path: absolutePath, exists: true, readable: true}
139
+ }
140
+
141
+ // JSON file — try to parse
142
+ try {
143
+ JSON.parse(readFileSync(absolutePath, 'utf8'))
144
+ return {path: absolutePath, exists: true, readable: true}
145
+ } catch {
146
+ unreadable.push(absolutePath)
147
+ return {path: absolutePath, exists: true, readable: false}
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Compute the detection scope based on which path groups produced hits.
153
+ *
154
+ * @param {PathStatus[]} projectStatuses
155
+ * @param {PathStatus[]} globalStatuses
156
+ * @returns {'project'|'global'|'both'}
157
+ */
158
+ function computeScope(projectStatuses, globalStatuses) {
159
+ const hasProject = projectStatuses.some((s) => s.exists)
160
+ const hasGlobal = globalStatuses.some((s) => s.exists)
161
+
162
+ if (hasProject && hasGlobal) return 'both'
163
+ if (hasGlobal) return 'global'
164
+ return 'project'
165
+ }
166
+
167
+ /**
168
+ * Scan the filesystem for each known AI coding environment and return only those
169
+ * that were detected (i.e. at least one config path exists on disk).
170
+ *
171
+ * @param {string} [cwd] - Working directory for project-relative path resolution (defaults to process.cwd())
172
+ * @returns {DetectedEnvironment[]} Detected environments only
173
+ */
174
+ export function scanEnvironments(cwd = process.cwd()) {
175
+ /** @type {DetectedEnvironment[]} */
176
+ const detected = []
177
+
178
+ for (const env of ENVIRONMENTS) {
179
+ /** @type {string[]} */
180
+ const unreadable = []
181
+
182
+ const projectStatuses = env.projectPaths.map((spec) => {
183
+ const absPath = resolvePathSpec(spec, cwd, false)
184
+ return evaluatePathSpec(spec, absPath, unreadable)
185
+ })
186
+
187
+ const globalStatuses = env.globalPaths.map((spec) => {
188
+ const absPath = resolvePathSpec(spec, cwd, true)
189
+ return evaluatePathSpec(spec, absPath, unreadable)
190
+ })
191
+
192
+ const isDetected = [...projectStatuses, ...globalStatuses].some((s) => s.exists)
193
+
194
+ if (!isDetected) continue
195
+
196
+ detected.push({
197
+ id: env.id,
198
+ name: env.name,
199
+ detected: true,
200
+ projectPaths: projectStatuses,
201
+ globalPaths: globalStatuses,
202
+ unreadable,
203
+ supportedCategories: env.supportedCategories,
204
+ counts: {mcp: 0, command: 0, skill: 0, agent: 0},
205
+ scope: computeScope(projectStatuses, globalStatuses),
206
+ })
207
+ }
208
+
209
+ return detected
210
+ }
211
+
212
+ /**
213
+ * Filter detected environments to those that support a given category type.
214
+ *
215
+ * @param {CategoryType} type - Category type to filter by
216
+ * @param {DetectedEnvironment[]} detectedEnvs - Array of detected environments from {@link scanEnvironments}
217
+ * @returns {EnvironmentId[]} IDs of environments that support the given type
218
+ */
219
+ export function getCompatibleEnvironments(type, detectedEnvs) {
220
+ return detectedEnvs.filter((env) => env.supportedCategories.includes(type)).map((env) => env.id)
221
+ }
222
+
223
+ /**
224
+ * Count active entries from the AI config store that target a given environment,
225
+ * grouped by category type.
226
+ *
227
+ * @param {EnvironmentId} envId - Environment to count entries for
228
+ * @param {CategoryEntry[]} entries - All entries from the AI config store
229
+ * @returns {CategoryCounts} Per-category active entry counts
230
+ */
231
+ export function computeCategoryCounts(envId, entries) {
232
+ /** @type {CategoryCounts} */
233
+ const counts = {mcp: 0, command: 0, skill: 0, agent: 0}
234
+
235
+ for (const entry of entries) {
236
+ if (entry.active && entry.environments.includes(envId)) {
237
+ counts[entry.type] = (counts[entry.type] ?? 0) + 1
238
+ }
239
+ }
240
+
241
+ return counts
242
+ }
@@ -1,5 +1,5 @@
1
- import { existsSync } from 'node:fs'
2
- import { resolve, join } from 'node:path'
1
+ import {existsSync} from 'node:fs'
2
+ import {resolve, join} from 'node:path'
3
3
 
4
4
  /** @import { PackageEcosystem } from '../types.js' */
5
5