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.
- package/README.md +7 -0
- package/oclif.manifest.json +129 -89
- 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 +143 -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 +127 -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 +318 -0
- package/src/services/ai-env-deployer.js +444 -0
- package/src/services/ai-env-scanner.js +242 -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 +85 -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 +1006 -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 +800 -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,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
|
+
}
|