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.
- package/README.md +65 -0
- package/oclif.manifest.json +249 -249
- package/package.json +1 -1
- package/src/commands/sync-config-ai/index.js +124 -10
- package/src/formatters/ai-config.js +100 -12
- package/src/services/ai-config-store.js +43 -12
- package/src/services/ai-env-deployer.js +216 -10
- package/src/services/ai-env-scanner.js +752 -11
- package/src/types.js +35 -3
- package/src/utils/tui/form.js +195 -17
- package/src/utils/tui/tab-tui.js +353 -64
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {Command, Flags} from '@oclif/core'
|
|
2
2
|
import ora from 'ora'
|
|
3
3
|
|
|
4
|
-
import {scanEnvironments, computeCategoryCounts} from '../../services/ai-env-scanner.js'
|
|
4
|
+
import {scanEnvironments, computeCategoryCounts, parseNativeEntries, detectDrift, ENVIRONMENTS} from '../../services/ai-env-scanner.js'
|
|
5
5
|
import {
|
|
6
6
|
loadAIConfig,
|
|
7
7
|
addEntry,
|
|
@@ -9,14 +9,57 @@ import {
|
|
|
9
9
|
deactivateEntry,
|
|
10
10
|
activateEntry,
|
|
11
11
|
deleteEntry,
|
|
12
|
+
syncAIConfigToChezmoi,
|
|
12
13
|
} from '../../services/ai-config-store.js'
|
|
13
14
|
import {deployEntry, undeployEntry, reconcileOnScan} from '../../services/ai-env-deployer.js'
|
|
14
15
|
import {loadConfig} from '../../services/config.js'
|
|
15
|
-
import {formatEnvironmentsTable, formatCategoriesTable} from '../../formatters/ai-config.js'
|
|
16
|
+
import {formatEnvironmentsTable, formatCategoriesTable, formatNativeEntriesTable} from '../../formatters/ai-config.js'
|
|
16
17
|
import {startTabTUI} from '../../utils/tui/tab-tui.js'
|
|
17
18
|
import {DvmiError} from '../../utils/errors.js'
|
|
18
19
|
|
|
19
|
-
/** @import { DetectedEnvironment, CategoryEntry } from '../../types.js' */
|
|
20
|
+
/** @import { DetectedEnvironment, CategoryEntry, MCPParams } from '../../types.js' */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract only MCPParams-relevant fields from raw form values.
|
|
24
|
+
* Parses args (editor newline-joined) into string[] and env vars (KEY=VALUE lines) into Record.
|
|
25
|
+
* @param {Record<string, unknown>} values - Raw form output from extractValues
|
|
26
|
+
* @returns {MCPParams}
|
|
27
|
+
*/
|
|
28
|
+
function buildMCPParams(values) {
|
|
29
|
+
/** @type {MCPParams} */
|
|
30
|
+
const params = {transport: /** @type {'stdio'|'sse'|'streamable-http'} */ (values.transport)}
|
|
31
|
+
|
|
32
|
+
if (params.transport === 'stdio') {
|
|
33
|
+
if (values.command) params.command = /** @type {string} */ (values.command)
|
|
34
|
+
// Args: editor field → newline-joined string → split into array
|
|
35
|
+
if (values.args && typeof values.args === 'string') {
|
|
36
|
+
const arr = /** @type {string} */ (values.args).split('\n').map((a) => a.trim()).filter(Boolean)
|
|
37
|
+
if (arr.length > 0) params.args = arr
|
|
38
|
+
} else if (Array.isArray(values.args) && values.args.length > 0) {
|
|
39
|
+
params.args = values.args
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
if (values.url) params.url = /** @type {string} */ (values.url)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Env vars: editor field → newline-joined KEY=VALUE string → parse into Record.
|
|
46
|
+
// Env vars apply to ALL transports (e.g. API keys for remote servers too).
|
|
47
|
+
if (values.env && typeof values.env === 'string') {
|
|
48
|
+
/** @type {Record<string, string>} */
|
|
49
|
+
const envObj = {}
|
|
50
|
+
for (const line of /** @type {string} */ (values.env).split('\n')) {
|
|
51
|
+
const t = line.trim()
|
|
52
|
+
if (!t) continue
|
|
53
|
+
const eq = t.indexOf('=')
|
|
54
|
+
if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(envObj).length > 0) params.env = envObj
|
|
57
|
+
} else if (values.env && typeof values.env === 'object' && !Array.isArray(values.env)) {
|
|
58
|
+
params.env = /** @type {Record<string, string>} */ (values.env)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return params
|
|
62
|
+
}
|
|
20
63
|
|
|
21
64
|
export default class SyncConfigAi extends Command {
|
|
22
65
|
static description = 'Manage AI coding tool configurations across environments via TUI'
|
|
@@ -75,17 +118,57 @@ export default class SyncConfigAi extends Command {
|
|
|
75
118
|
env.counts = computeCategoryCounts(env.id, store.entries)
|
|
76
119
|
}
|
|
77
120
|
|
|
121
|
+
// ── Parse native entries and populate nativeCounts ───────────────────────
|
|
122
|
+
const envDefMap = new Map(ENVIRONMENTS.map((e) => [e.id, e]))
|
|
123
|
+
for (const env of detectedEnvs) {
|
|
124
|
+
const envDef = envDefMap.get(env.id)
|
|
125
|
+
if (!envDef) continue
|
|
126
|
+
const natives = parseNativeEntries(envDef, process.cwd(), store.entries)
|
|
127
|
+
env.nativeEntries = natives
|
|
128
|
+
// Aggregate native counts per category
|
|
129
|
+
env.nativeCounts = {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}
|
|
130
|
+
for (const ne of natives) {
|
|
131
|
+
env.nativeCounts[ne.type] = (env.nativeCounts[ne.type] ?? 0) + 1
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Detect drift for managed entries ────────────────────────────────────
|
|
136
|
+
const driftInfos = detectDrift(detectedEnvs, store.entries, process.cwd())
|
|
137
|
+
for (const env of detectedEnvs) {
|
|
138
|
+
env.driftedEntries = driftInfos.filter((d) => d.environmentId === env.id)
|
|
139
|
+
}
|
|
140
|
+
|
|
78
141
|
spinner?.stop()
|
|
79
142
|
|
|
80
143
|
// ── JSON mode ────────────────────────────────────────────────────────────
|
|
81
144
|
if (isJson) {
|
|
145
|
+
if (detectedEnvs.length === 0) {
|
|
146
|
+
this.exit(2)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Collect all native entries grouped by type
|
|
150
|
+
const allNatives = detectedEnvs.flatMap((e) => e.nativeEntries ?? [])
|
|
151
|
+
|
|
152
|
+
// Build drifted set for quick lookup
|
|
153
|
+
const driftedIds = new Set(driftInfos.map((d) => d.entryId))
|
|
154
|
+
|
|
82
155
|
const categories = {
|
|
83
|
-
mcp: store.entries.filter((e) => e.type === 'mcp'),
|
|
84
|
-
command: store.entries.filter((e) => e.type === 'command'),
|
|
85
|
-
|
|
86
|
-
|
|
156
|
+
mcp: store.entries.filter((e) => e.type === 'mcp').map((e) => ({...e, drifted: driftedIds.has(e.id)})),
|
|
157
|
+
command: store.entries.filter((e) => e.type === 'command').map((e) => ({...e, drifted: driftedIds.has(e.id)})),
|
|
158
|
+
rule: store.entries.filter((e) => e.type === 'rule').map((e) => ({...e, drifted: driftedIds.has(e.id)})),
|
|
159
|
+
skill: store.entries.filter((e) => e.type === 'skill').map((e) => ({...e, drifted: driftedIds.has(e.id)})),
|
|
160
|
+
agent: store.entries.filter((e) => e.type === 'agent').map((e) => ({...e, drifted: driftedIds.has(e.id)})),
|
|
87
161
|
}
|
|
88
|
-
|
|
162
|
+
|
|
163
|
+
const nativeEntries = {
|
|
164
|
+
mcp: allNatives.filter((e) => e.type === 'mcp'),
|
|
165
|
+
command: allNatives.filter((e) => e.type === 'command'),
|
|
166
|
+
rule: allNatives.filter((e) => e.type === 'rule'),
|
|
167
|
+
skill: allNatives.filter((e) => e.type === 'skill'),
|
|
168
|
+
agent: allNatives.filter((e) => e.type === 'agent'),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {environments: detectedEnvs, categories, nativeEntries}
|
|
89
172
|
}
|
|
90
173
|
|
|
91
174
|
// ── Check chezmoi config ─────────────────────────────────────────────────
|
|
@@ -104,6 +187,7 @@ export default class SyncConfigAi extends Command {
|
|
|
104
187
|
chezmoiEnabled,
|
|
105
188
|
formatEnvs: formatEnvironmentsTable,
|
|
106
189
|
formatCats: formatCategoriesTable,
|
|
190
|
+
formatNative: formatNativeEntriesTable,
|
|
107
191
|
refreshEntries: async () => {
|
|
108
192
|
const s = await loadAIConfig()
|
|
109
193
|
return s.entries
|
|
@@ -113,16 +197,21 @@ export default class SyncConfigAi extends Command {
|
|
|
113
197
|
const currentStore = await loadAIConfig()
|
|
114
198
|
|
|
115
199
|
if (action.type === 'create') {
|
|
200
|
+
const isMCP = action.tabKey === 'mcp'
|
|
116
201
|
const created = await addEntry({
|
|
117
202
|
name: action.values.name,
|
|
118
203
|
type: action.tabKey || 'mcp',
|
|
119
204
|
environments: action.values.environments || [],
|
|
120
|
-
params: action.values,
|
|
205
|
+
params: isMCP ? buildMCPParams(action.values) : action.values,
|
|
121
206
|
})
|
|
122
207
|
await deployEntry(created, detectedEnvs, process.cwd())
|
|
208
|
+
await syncAIConfigToChezmoi()
|
|
123
209
|
} else if (action.type === 'edit') {
|
|
124
|
-
const
|
|
210
|
+
const entry = currentStore.entries.find((e) => e.id === action.id)
|
|
211
|
+
const isMCP = entry?.type === 'mcp'
|
|
212
|
+
const updated = await updateEntry(action.id, {params: isMCP ? buildMCPParams(action.values) : action.values})
|
|
125
213
|
await deployEntry(updated, detectedEnvs, process.cwd())
|
|
214
|
+
await syncAIConfigToChezmoi()
|
|
126
215
|
} else if (action.type === 'delete') {
|
|
127
216
|
await deleteEntry(action.id)
|
|
128
217
|
await undeployEntry(
|
|
@@ -130,12 +219,37 @@ export default class SyncConfigAi extends Command {
|
|
|
130
219
|
detectedEnvs,
|
|
131
220
|
process.cwd(),
|
|
132
221
|
)
|
|
222
|
+
await syncAIConfigToChezmoi()
|
|
133
223
|
} else if (action.type === 'deactivate') {
|
|
134
224
|
const entry = await deactivateEntry(action.id)
|
|
135
225
|
await undeployEntry(entry, detectedEnvs, process.cwd())
|
|
226
|
+
await syncAIConfigToChezmoi()
|
|
136
227
|
} else if (action.type === 'activate') {
|
|
137
228
|
const entry = await activateEntry(action.id)
|
|
138
229
|
await deployEntry(entry, detectedEnvs, process.cwd())
|
|
230
|
+
await syncAIConfigToChezmoi()
|
|
231
|
+
} else if (action.type === 'import-native') {
|
|
232
|
+
// T017: Import native entry into dvmi-managed sync
|
|
233
|
+
const ne = action.nativeEntry
|
|
234
|
+
const created = await addEntry({
|
|
235
|
+
name: ne.name,
|
|
236
|
+
type: ne.type,
|
|
237
|
+
environments: [ne.environmentId],
|
|
238
|
+
params: ne.params,
|
|
239
|
+
})
|
|
240
|
+
await deployEntry(created, detectedEnvs, process.cwd())
|
|
241
|
+
await syncAIConfigToChezmoi()
|
|
242
|
+
} else if (action.type === 'redeploy') {
|
|
243
|
+
// T018: Re-deploy managed entry to overwrite drifted file
|
|
244
|
+
const entry = currentStore.entries.find((e) => e.id === action.id)
|
|
245
|
+
if (entry) await deployEntry(entry, detectedEnvs, process.cwd())
|
|
246
|
+
} else if (action.type === 'accept-drift') {
|
|
247
|
+
// T018: Accept drift — update store params from the actual file state
|
|
248
|
+
const drift = driftInfos.find((d) => d.entryId === action.id)
|
|
249
|
+
if (drift) {
|
|
250
|
+
await updateEntry(action.id, {params: drift.actual})
|
|
251
|
+
await syncAIConfigToChezmoi()
|
|
252
|
+
}
|
|
139
253
|
}
|
|
140
254
|
},
|
|
141
255
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
|
|
3
|
-
/** @import { DetectedEnvironment, CategoryEntry } from '../types.js' */
|
|
3
|
+
/** @import { DetectedEnvironment, CategoryEntry, NativeEntry } from '../types.js' */
|
|
4
4
|
|
|
5
5
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
6
6
|
// Internal helpers
|
|
@@ -41,11 +41,12 @@ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) {
|
|
|
41
41
|
chalk.bold.white(padCell('Scope', COL_SCOPE)),
|
|
42
42
|
chalk.bold.white(padCell('MCPs', COL_COUNT)),
|
|
43
43
|
chalk.bold.white(padCell('Commands', COL_COUNT)),
|
|
44
|
+
chalk.bold.white(padCell('Rules', COL_COUNT)),
|
|
44
45
|
chalk.bold.white(padCell('Skills', COL_COUNT)),
|
|
45
46
|
chalk.bold.white(padCell('Agents', COL_COUNT)),
|
|
46
47
|
]
|
|
47
48
|
|
|
48
|
-
const dividerWidth = COL_ENV + COL_STATUS + COL_SCOPE + COL_COUNT *
|
|
49
|
+
const dividerWidth = COL_ENV + COL_STATUS + COL_SCOPE + COL_COUNT * 5 + 7 * 2
|
|
49
50
|
const lines = []
|
|
50
51
|
lines.push(headerParts.join(' '))
|
|
51
52
|
lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth))))
|
|
@@ -58,34 +59,119 @@ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) {
|
|
|
58
59
|
: chalk.green(padCell(statusText, COL_STATUS))
|
|
59
60
|
const scopeStr = padCell(env.scope ?? 'project', COL_SCOPE)
|
|
60
61
|
|
|
61
|
-
const
|
|
62
|
-
const
|
|
62
|
+
const total = (/** @type {string} */ type) => (env.counts?.[type] ?? 0) + (env.nativeCounts?.[type] ?? 0)
|
|
63
|
+
const mcpStr = padCell(String(total('mcp')), COL_COUNT)
|
|
64
|
+
const cmdStr = padCell(String(total('command')), COL_COUNT)
|
|
65
|
+
const ruleStr = env.supportedCategories.includes('rule')
|
|
66
|
+
? padCell(String(total('rule')), COL_COUNT)
|
|
67
|
+
: padCell('—', COL_COUNT)
|
|
63
68
|
const skillStr = env.supportedCategories.includes('skill')
|
|
64
|
-
? padCell(String(
|
|
69
|
+
? padCell(String(total('skill')), COL_COUNT)
|
|
65
70
|
: padCell('—', COL_COUNT)
|
|
66
71
|
const agentStr = env.supportedCategories.includes('agent')
|
|
67
|
-
? padCell(String(
|
|
72
|
+
? padCell(String(total('agent')), COL_COUNT)
|
|
68
73
|
: padCell('—', COL_COUNT)
|
|
69
74
|
|
|
70
|
-
lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, skillStr, agentStr].join(' '))
|
|
75
|
+
lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, ruleStr, skillStr, agentStr].join(' '))
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
return lines
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
77
|
-
// Categories table formatter
|
|
78
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
81
|
/** @type {Record<string, string>} */
|
|
81
82
|
const ENV_SHORT_NAMES = {
|
|
82
83
|
'vscode-copilot': 'VSCode',
|
|
83
84
|
'claude-code': 'Claude',
|
|
85
|
+
'claude-desktop': 'Desktop',
|
|
84
86
|
opencode: 'OpenCode',
|
|
85
87
|
'gemini-cli': 'Gemini',
|
|
86
88
|
'copilot-cli': 'Copilot',
|
|
89
|
+
cursor: 'Cursor',
|
|
90
|
+
windsurf: 'Windsurf',
|
|
91
|
+
'continue-dev': 'Continue',
|
|
92
|
+
zed: 'Zed',
|
|
93
|
+
'amazon-q': 'Amazon Q',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Mask an environment variable value for display.
|
|
98
|
+
* Shows first 6 characters followed by ***.
|
|
99
|
+
* @param {string} value
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function maskEnvVarValue(value) {
|
|
103
|
+
if (!value || value.length <= 6) return '***'
|
|
104
|
+
return value.slice(0, 6) + '***'
|
|
87
105
|
}
|
|
88
106
|
|
|
107
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
// Native entries table formatter
|
|
109
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Format native entries as a table for display in a category tab's Native section.
|
|
113
|
+
* @param {NativeEntry[]} entries
|
|
114
|
+
* @param {number} [termCols]
|
|
115
|
+
* @returns {string[]}
|
|
116
|
+
*/
|
|
117
|
+
export function formatNativeEntriesTable(entries, termCols = 120) {
|
|
118
|
+
const COL_NAME = 24
|
|
119
|
+
const COL_ENV = 16
|
|
120
|
+
const COL_LEVEL = 8
|
|
121
|
+
const COL_CONFIG = 36
|
|
122
|
+
|
|
123
|
+
const headerParts = [
|
|
124
|
+
chalk.bold.white(padCell('Name', COL_NAME)),
|
|
125
|
+
chalk.bold.white(padCell('Environment', COL_ENV)),
|
|
126
|
+
chalk.bold.white(padCell('Level', COL_LEVEL)),
|
|
127
|
+
chalk.bold.white(padCell('Config', COL_CONFIG)),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
const dividerWidth = COL_NAME + COL_ENV + COL_LEVEL + COL_CONFIG + 3 * 2
|
|
131
|
+
const lines = []
|
|
132
|
+
lines.push(headerParts.join(' '))
|
|
133
|
+
lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth))))
|
|
134
|
+
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const envShort = ENV_SHORT_NAMES[entry.environmentId] ?? entry.environmentId
|
|
137
|
+
const levelStr = padCell(entry.level, COL_LEVEL)
|
|
138
|
+
|
|
139
|
+
// Build config summary
|
|
140
|
+
const params = /** @type {any} */ (entry.params ?? {})
|
|
141
|
+
let configSummary = ''
|
|
142
|
+
if (entry.type === 'mcp') {
|
|
143
|
+
if (params.command) {
|
|
144
|
+
const args = Array.isArray(params.args) ? params.args.slice(0, 2).join(' ') : ''
|
|
145
|
+
configSummary = [params.command, args].filter(Boolean).join(' ')
|
|
146
|
+
} else if (params.url) {
|
|
147
|
+
configSummary = params.url
|
|
148
|
+
}
|
|
149
|
+
// Mask env vars
|
|
150
|
+
if (params.env && Object.keys(params.env).length > 0) {
|
|
151
|
+
const maskedVars = Object.keys(params.env)
|
|
152
|
+
.map((k) => `${k}=${maskEnvVarValue(params.env[k])}`)
|
|
153
|
+
.join(', ')
|
|
154
|
+
configSummary = configSummary ? `${configSummary} [${maskedVars}]` : maskedVars
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
configSummary = params.description ?? params.content?.slice(0, 30) ?? ''
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
lines.push([
|
|
161
|
+
padCell(entry.name, COL_NAME),
|
|
162
|
+
padCell(envShort, COL_ENV),
|
|
163
|
+
levelStr,
|
|
164
|
+
padCell(configSummary, COL_CONFIG),
|
|
165
|
+
].join(' '))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lines
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
// Categories table formatter
|
|
173
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
89
175
|
/**
|
|
90
176
|
* Format a list of category entries as a table string for display in the TUI.
|
|
91
177
|
* Columns: Name, Type, Status, Environments
|
|
@@ -113,7 +199,9 @@ export function formatCategoriesTable(entries, termCols = 120) {
|
|
|
113
199
|
|
|
114
200
|
for (const entry of entries) {
|
|
115
201
|
const statusStr = entry.active
|
|
116
|
-
?
|
|
202
|
+
? (/** @type {any} */ (entry)).drifted
|
|
203
|
+
? chalk.yellow(padCell('⚠ Drifted', COL_STATUS))
|
|
204
|
+
: chalk.green(padCell('Active', COL_STATUS))
|
|
117
205
|
: chalk.dim(padCell('Inactive', COL_STATUS))
|
|
118
206
|
|
|
119
207
|
const envNames = entry.environments.map((id) => ENV_SHORT_NAMES[id] ?? id).join(', ')
|
|
@@ -8,7 +8,7 @@ import {DvmiError} from '../utils/errors.js'
|
|
|
8
8
|
import {exec} from './shell.js'
|
|
9
9
|
import {loadConfig} from './config.js'
|
|
10
10
|
|
|
11
|
-
/** @import { AIConfigStore, CategoryEntry, CategoryType, EnvironmentId, MCPParams, CommandParams, SkillParams, AgentParams } from '../types.js' */
|
|
11
|
+
/** @import { AIConfigStore, CategoryEntry, CategoryType, EnvironmentId, MCPParams, CommandParams, RuleParams, SkillParams, AgentParams } from '../types.js' */
|
|
12
12
|
|
|
13
13
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
14
14
|
// Path resolution
|
|
@@ -26,11 +26,17 @@ export const AI_CONFIG_PATH = join(CONFIG_DIR, 'ai-config.json')
|
|
|
26
26
|
|
|
27
27
|
/** @type {Record<EnvironmentId, CategoryType[]>} */
|
|
28
28
|
const COMPATIBILITY = {
|
|
29
|
-
'vscode-copilot': ['mcp', 'command', 'skill', 'agent'],
|
|
30
|
-
'claude-code': ['mcp', 'command', 'skill', 'agent'],
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'
|
|
29
|
+
'vscode-copilot': ['mcp', 'command', 'rule', 'skill', 'agent'],
|
|
30
|
+
'claude-code': ['mcp', 'command', 'rule', 'skill', 'agent'],
|
|
31
|
+
'claude-desktop': ['mcp'],
|
|
32
|
+
opencode: ['mcp', 'command', 'rule', 'skill', 'agent'],
|
|
33
|
+
'gemini-cli': ['mcp', 'command', 'rule'],
|
|
34
|
+
'copilot-cli': ['mcp', 'command', 'rule', 'skill', 'agent'],
|
|
35
|
+
cursor: ['mcp', 'command', 'rule', 'skill'],
|
|
36
|
+
windsurf: ['mcp', 'command', 'rule'],
|
|
37
|
+
'continue-dev': ['mcp', 'command', 'rule', 'agent'],
|
|
38
|
+
zed: ['mcp', 'rule'],
|
|
39
|
+
'amazon-q': ['mcp', 'rule', 'agent'],
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/** All known environment IDs. */
|
|
@@ -45,7 +51,20 @@ const UNSAFE_CHARS = /[/\\:*?"<>|]/
|
|
|
45
51
|
|
|
46
52
|
/** @returns {AIConfigStore} */
|
|
47
53
|
function defaultStore() {
|
|
48
|
-
return {version:
|
|
54
|
+
return {version: 2, entries: []}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Migrate an AI config store to the current schema version.
|
|
59
|
+
* v1 → v2 is a no-op data migration; it only bumps the version field.
|
|
60
|
+
* @param {AIConfigStore} store
|
|
61
|
+
* @returns {AIConfigStore}
|
|
62
|
+
*/
|
|
63
|
+
function migrateStore(store) {
|
|
64
|
+
if (store.version === 1) {
|
|
65
|
+
return {...store, version: 2}
|
|
66
|
+
}
|
|
67
|
+
return store
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -93,6 +112,20 @@ function validateEnvironments(environments, type) {
|
|
|
93
112
|
}
|
|
94
113
|
}
|
|
95
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Assert that rule params contain a non-empty string `content` field.
|
|
117
|
+
* @param {RuleParams} params
|
|
118
|
+
* @returns {void}
|
|
119
|
+
*/
|
|
120
|
+
function validateRuleParams(params) {
|
|
121
|
+
if (!params || typeof params.content !== 'string' || params.content.trim() === '') {
|
|
122
|
+
throw new DvmiError(
|
|
123
|
+
'Rule entry requires a non-empty "content" string',
|
|
124
|
+
'Provide the rule content, e.g. { content: "Always use TypeScript" }',
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
97
130
|
// Core I/O
|
|
98
131
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -108,10 +141,7 @@ export async function loadAIConfig(configPath = process.env.DVMI_AI_CONFIG_PATH
|
|
|
108
141
|
try {
|
|
109
142
|
const raw = await readFile(configPath, 'utf8')
|
|
110
143
|
const parsed = JSON.parse(raw)
|
|
111
|
-
return {
|
|
112
|
-
version: parsed.version ?? 1,
|
|
113
|
-
entries: Array.isArray(parsed.entries) ? parsed.entries : [],
|
|
114
|
-
}
|
|
144
|
+
return migrateStore({version: parsed.version ?? 1, entries: Array.isArray(parsed.entries) ? parsed.entries : []})
|
|
115
145
|
} catch {
|
|
116
146
|
return defaultStore()
|
|
117
147
|
}
|
|
@@ -139,7 +169,7 @@ export async function saveAIConfig(store, configPath = process.env.DVMI_AI_CONFI
|
|
|
139
169
|
|
|
140
170
|
/**
|
|
141
171
|
* Add a new entry to the AI config store.
|
|
142
|
-
* @param {{ name: string, type: CategoryType, environments: EnvironmentId[], params: MCPParams|CommandParams|SkillParams|AgentParams }} entryData
|
|
172
|
+
* @param {{ name: string, type: CategoryType, environments: EnvironmentId[], params: MCPParams|CommandParams|RuleParams|SkillParams|AgentParams }} entryData
|
|
143
173
|
* @param {string} [configPath]
|
|
144
174
|
* @returns {Promise<CategoryEntry>}
|
|
145
175
|
*/
|
|
@@ -148,6 +178,7 @@ export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFI
|
|
|
148
178
|
|
|
149
179
|
validateName(name)
|
|
150
180
|
validateEnvironments(environments, type)
|
|
181
|
+
if (type === 'rule') validateRuleParams(/** @type {RuleParams} */ (params))
|
|
151
182
|
|
|
152
183
|
const store = await loadAIConfig(configPath)
|
|
153
184
|
|