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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "devvami",
3
3
  "description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
4
- "version": "1.5.0",
4
+ "version": "1.5.1",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -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
- skill: store.entries.filter((e) => e.type === 'skill'),
86
- agent: store.entries.filter((e) => e.type === 'agent'),
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
- return {environments: detectedEnvs, categories}
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 updated = await updateEntry(action.id, {params: action.values})
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 * 4 + 6 * 2
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 mcpStr = padCell(String(env.counts.mcp), COL_COUNT)
62
- const cmdStr = padCell(String(env.counts.command), COL_COUNT)
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(env.counts.skill), COL_COUNT)
69
+ ? padCell(String(total('skill')), COL_COUNT)
65
70
  : padCell('—', COL_COUNT)
66
71
  const agentStr = env.supportedCategories.includes('agent')
67
- ? padCell(String(env.counts.agent), COL_COUNT)
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
- ? chalk.green(padCell('Active', COL_STATUS))
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
- opencode: ['mcp', 'command', 'skill', 'agent'],
32
- 'gemini-cli': ['mcp', 'command'],
33
- 'copilot-cli': ['mcp', 'command', 'skill', 'agent'],
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: 1, entries: []}
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