Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "devvami",
"description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
"version": "1.5.2",
"version": "1.6.0",
"author": "",
"type": "module",
"bin": {
Expand Down
51 changes: 39 additions & 12 deletions src/commands/sync-config-ai/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
deactivateEntry,
activateEntry,
deleteEntry,
syncAIConfigToChezmoi,
} from '../../services/ai-config-store.js'
import {deployEntry, undeployEntry, reconcileOnScan} from '../../services/ai-env-deployer.js'
import {loadConfig} from '../../services/config.js'
Expand Down Expand Up @@ -58,9 +57,38 @@ function buildMCPParams(values) {
params.env = /** @type {Record<string, string>} */ (values.env)
}

// Persist description in the store (not deployed to env files)
if (values.description && typeof values.description === 'string' && values.description.trim()) {
params.description = values.description.trim()
}

return params
}

/**
* Build clean params for any entry type from raw form values.
* Strips form-only fields (name, environments, scope) to avoid polluting stored params.
* @param {string} type - Category type
* @param {Record<string, unknown>} values - Raw form output from extractValues
* @returns {object}
*/
function buildEntryParams(type, values) {
if (type === 'mcp') return buildMCPParams(values)
if (type === 'command') {
return {content: values.content, ...(values.description ? {description: values.description} : {})}
}
if (type === 'rule') {
return {content: values.content, ...(values.description ? {description: values.description} : {})}
}
if (type === 'skill') {
return {content: values.content, ...(values.description ? {description: values.description} : {})}
}
if (type === 'agent') {
return {instructions: values.instructions, ...(values.description ? {description: values.description} : {})}
}
return values
}

export default class SyncConfigAi extends Command {
static description = 'Manage AI coding tool configurations across environments via TUI'

Expand Down Expand Up @@ -197,48 +225,48 @@ export default class SyncConfigAi extends Command {
const currentStore = await loadAIConfig()

if (action.type === 'create') {
const isMCP = action.tabKey === 'mcp'
const created = await addEntry({
name: action.values.name,
type: action.tabKey || 'mcp',
scope: action.values.scope || 'project',
environments: action.values.environments || [],
params: isMCP ? buildMCPParams(action.values) : action.values,
params: buildEntryParams(action.tabKey, action.values),
})
await deployEntry(created, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'edit') {
const entry = currentStore.entries.find((e) => e.id === action.id)
const isMCP = entry?.type === 'mcp'
const updated = await updateEntry(action.id, {params: isMCP ? buildMCPParams(action.values) : action.values})
if (!entry) return
const updated = await updateEntry(action.id, {
name: action.values.name,
environments: action.values.environments || [],
scope: action.values.scope || entry.scope || 'project',
params: buildEntryParams(entry.type, action.values),
})
await deployEntry(updated, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'delete') {
await deleteEntry(action.id)
await undeployEntry(
currentStore.entries.find((e) => e.id === action.id),
detectedEnvs,
process.cwd(),
)
await syncAIConfigToChezmoi()
} else if (action.type === 'deactivate') {
const entry = await deactivateEntry(action.id)
await undeployEntry(entry, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'activate') {
const entry = await activateEntry(action.id)
await deployEntry(entry, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'import-native') {
// T017: Import native entry into dvmi-managed sync
const ne = action.nativeEntry
const created = await addEntry({
name: ne.name,
type: ne.type,
scope: ne.level,
environments: [ne.environmentId],
params: ne.params,
})
await deployEntry(created, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'redeploy') {
// T018: Re-deploy managed entry to overwrite drifted file
const entry = currentStore.entries.find((e) => e.id === action.id)
Expand All @@ -248,7 +276,6 @@ export default class SyncConfigAi extends Command {
const drift = driftInfos.find((d) => d.entryId === action.id)
if (drift) {
await updateEntry(action.id, {params: drift.actual})
await syncAIConfigToChezmoi()
}
}
},
Expand Down
20 changes: 14 additions & 6 deletions src/services/ai-config-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,26 @@ const UNSAFE_CHARS = /[/\\:*?"<>|]/

/** @returns {AIConfigStore} */
function defaultStore() {
return {version: 2, entries: []}
return {version: 3, entries: []}
}

/**
* Migrate an AI config store to the current schema version.
* v1 → v2 is a no-op data migration; it only bumps the version field.
* v2 → v3 adds `scope: 'project'` to every entry.
* @param {AIConfigStore} store
* @returns {AIConfigStore}
*/
function migrateStore(store) {
if (store.version === 1) {
return {...store, version: 2}
let migrated = store
if (migrated.version <= 2) {
migrated = {
...migrated,
version: 3,
entries: migrated.entries.map((e) => ({...e, scope: e.scope || 'project'})),
}
}
return store
return migrated
}

// ──────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -174,7 +180,7 @@ export async function saveAIConfig(store, configPath = process.env.DVMI_AI_CONFI
* @returns {Promise<CategoryEntry>}
*/
export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) {
const {name, type, environments, params} = entryData
const {name, type, scope = 'project', environments, params} = entryData

validateName(name)
validateEnvironments(environments, type)
Expand All @@ -197,6 +203,7 @@ export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFI
name,
type,
active: true,
scope,
environments,
params,
createdAt: now,
Expand All @@ -212,7 +219,7 @@ export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFI
/**
* Update an existing entry by id.
* @param {string} id - UUID of the entry to update
* @param {{ name?: string, environments?: EnvironmentId[], params?: MCPParams|CommandParams|SkillParams|AgentParams, active?: boolean }} changes
* @param {{ name?: string, scope?: 'project'|'global', environments?: EnvironmentId[], params?: MCPParams|CommandParams|SkillParams|AgentParams, active?: boolean }} changes
* @param {string} [configPath]
* @returns {Promise<CategoryEntry>}
*/
Expand Down Expand Up @@ -252,6 +259,7 @@ export async function updateEntry(id, changes, configPath = process.env.DVMI_AI_
const updated = {
...existing,
...(changes.name !== undefined ? {name: changes.name} : {}),
...(changes.scope !== undefined ? {scope: changes.scope} : {}),
...(changes.environments !== undefined ? {environments: changes.environments} : {}),
...(changes.params !== undefined ? {params: changes.params} : {}),
...(changes.active !== undefined ? {active: changes.active} : {}),
Expand Down
71 changes: 39 additions & 32 deletions src/services/ai-env-deployer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,59 +18,63 @@ import yaml from 'js-yaml'
// ──────────────────────────────────────────────────────────────────────────────

/**
* For each environment, the target JSON file path (relative to cwd or absolute)
* and the root key that holds the MCP server map.
*
* @type {Record<EnvironmentId, { resolvePath: (cwd: string) => string, mcpKey: string, isYaml?: boolean }>}
* @typedef {{ resolvePath: (cwd: string) => string, mcpKey: string, isYaml?: boolean }} MCPTargetConfig
* @type {Record<EnvironmentId, { project?: MCPTargetConfig, global?: MCPTargetConfig }>}
*/
const MCP_TARGETS = {
'vscode-copilot': {
resolvePath: (cwd) => join(cwd, '.vscode', 'mcp.json'),
mcpKey: 'servers',
project: {resolvePath: (cwd) => join(cwd, '.vscode', 'mcp.json'), mcpKey: 'servers'},
},
'claude-code': {
resolvePath: (cwd) => join(cwd, '.mcp.json'),
mcpKey: 'mcpServers',
project: {resolvePath: (cwd) => join(cwd, '.mcp.json'), mcpKey: 'mcpServers'},
global: {resolvePath: (_cwd) => join(homedir(), '.claude.json'), mcpKey: 'mcpServers'},
},
'claude-desktop': {
resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
mcpKey: 'mcpServers',
global: {resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), mcpKey: 'mcpServers'},
},
opencode: {
resolvePath: (cwd) => join(cwd, 'opencode.json'),
mcpKey: 'mcp',
project: {resolvePath: (cwd) => join(cwd, 'opencode.json'), mcpKey: 'mcp'},
global: {resolvePath: (_cwd) => join(homedir(), '.config', 'opencode', 'opencode.json'), mcpKey: 'mcp'},
},
'gemini-cli': {
resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'),
mcpKey: 'mcpServers',
global: {resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'), mcpKey: 'mcpServers'},
},
'copilot-cli': {
resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'),
mcpKey: 'mcpServers',
global: {resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'), mcpKey: 'mcpServers'},
},
cursor: {
resolvePath: (cwd) => join(cwd, '.cursor', 'mcp.json'),
mcpKey: 'mcpServers',
project: {resolvePath: (cwd) => join(cwd, '.cursor', 'mcp.json'), mcpKey: 'mcpServers'},
global: {resolvePath: (_cwd) => join(homedir(), '.cursor', 'mcp.json'), mcpKey: 'mcpServers'},
},
windsurf: {
resolvePath: (_cwd) => join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
mcpKey: 'mcpServers',
global: {resolvePath: (_cwd) => join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), mcpKey: 'mcpServers'},
},
'continue-dev': {
resolvePath: (_cwd) => join(homedir(), '.continue', 'config.yaml'),
mcpKey: 'mcpServers',
isYaml: true,
project: {resolvePath: (cwd) => join(cwd, '.continue', 'config.yaml'), mcpKey: 'mcpServers', isYaml: true},
global: {resolvePath: (_cwd) => join(homedir(), '.continue', 'config.yaml'), mcpKey: 'mcpServers', isYaml: true},
},
zed: {
resolvePath: (_cwd) => join(homedir(), '.config', 'zed', 'settings.json'),
mcpKey: 'context_servers',
global: {resolvePath: (_cwd) => join(homedir(), '.config', 'zed', 'settings.json'), mcpKey: 'context_servers'},
},
'amazon-q': {
resolvePath: (cwd) => join(cwd, '.amazonq', 'mcp.json'),
mcpKey: 'mcpServers',
project: {resolvePath: (cwd) => join(cwd, '.amazonq', 'mcp.json'), mcpKey: 'mcpServers'},
global: {resolvePath: (_cwd) => join(homedir(), '.aws', 'amazonq', 'mcp.json'), mcpKey: 'mcpServers'},
},
}

/**
* Resolve the target config for an MCP entry based on scope.
* Falls back to the available scope if the chosen one is not available.
* @param {EnvironmentId} envId
* @param {'project'|'global'} scope
* @returns {MCPTargetConfig|null}
*/
function resolveTarget(envId, scope) {
const target = MCP_TARGETS[envId]
if (!target) return null
return target[scope] || target.project || target.global || null
}

/**
* Resolve the target file path for a file-based entry (command, skill, agent).
*
Expand Down Expand Up @@ -426,7 +430,8 @@ function buildOpenCodeMCPObject(params) {
export async function deployMCPEntry(entry, envId, cwd) {
if (!entry || entry.type !== 'mcp' || !entry.params) return

const target = MCP_TARGETS[envId]
const scope = entry.scope || 'project'
const target = resolveTarget(envId, scope)
if (!target) return

const filePath = target.resolvePath(cwd)
Expand Down Expand Up @@ -457,10 +462,11 @@ export async function deployMCPEntry(entry, envId, cwd) {
* @param {string} entryName - Name of the MCP server to remove
* @param {EnvironmentId} envId - Target environment identifier
* @param {string} cwd - Project working directory
* @param {'project'|'global'} [scope='project'] - Scope to resolve the target config file
* @returns {Promise<void>}
*/
export async function undeployMCPEntry(entryName, envId, cwd) {
const target = MCP_TARGETS[envId]
export async function undeployMCPEntry(entryName, envId, cwd, scope = 'project') {
const target = resolveTarget(envId, scope)
if (!target) return

const filePath = target.resolvePath(cwd)
Expand Down Expand Up @@ -585,7 +591,8 @@ export async function deployEntry(entry, detectedEnvs, cwd) {
// Skip if the environment has unreadable JSON config files that correspond
// to the MCP target path (we don't want to overwrite corrupt files)
if (detectedEnv && entry.type === 'mcp') {
const target = MCP_TARGETS[envId]
const scope = entry.scope || 'project'
const target = resolveTarget(envId, scope)
if (target) {
const targetPath = target.resolvePath(cwd)
if (detectedEnv.unreadable.includes(targetPath)) continue
Expand Down Expand Up @@ -619,7 +626,7 @@ export async function undeployEntry(entry, detectedEnvs, cwd) {
if (!detectedIds.has(envId)) continue

if (entry.type === 'mcp') {
await undeployMCPEntry(entry.name, envId, cwd)
await undeployMCPEntry(entry.name, envId, cwd, entry.scope || 'project')
} else {
await undeployFileEntry(entry.name, entry.type, envId, cwd)
}
Expand Down
15 changes: 8 additions & 7 deletions src/services/ai-env-scanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -776,9 +776,10 @@ export function parseNativeEntries(envDef, cwd, managedEntries) {
* @param {string} entryName
* @param {EnvironmentId} envId
* @param {string} cwd
* @param {'project'|'global'} [scope='project']
* @returns {object|null}
*/
function readDeployedMCPEntry(entryName, envId, cwd) {
function readDeployedMCPEntry(entryName, envId, cwd, scope = 'project') {
const home = homedir()

/** @type {string|null} */
Expand All @@ -789,15 +790,15 @@ function readDeployedMCPEntry(entryName, envId, cwd) {

switch (envId) {
case 'vscode-copilot': filePath = join(cwd, '.vscode', 'mcp.json'); mcpKey = 'servers'; break
case 'claude-code': filePath = join(cwd, '.mcp.json'); break
case 'opencode': filePath = join(cwd, 'opencode.json'); break
case 'claude-code': filePath = scope === 'global' ? join(home, '.claude.json') : join(cwd, '.mcp.json'); break
case 'opencode': filePath = scope === 'global' ? join(home, '.config', 'opencode', 'opencode.json') : join(cwd, 'opencode.json'); break
case 'gemini-cli': filePath = join(home, '.gemini', 'settings.json'); break
case 'copilot-cli': filePath = join(home, '.copilot', 'mcp-config.json'); break
case 'cursor': filePath = join(cwd, '.cursor', 'mcp.json'); break
case 'cursor': filePath = scope === 'global' ? join(home, '.cursor', 'mcp.json') : join(cwd, '.cursor', 'mcp.json'); break
case 'windsurf': filePath = join(home, '.codeium', 'windsurf', 'mcp_config.json'); break
case 'continue-dev': filePath = join(cwd, '.continue', 'config.yaml'); isYaml = true; break
case 'continue-dev': filePath = scope === 'global' ? join(home, '.continue', 'config.yaml') : join(cwd, '.continue', 'config.yaml'); isYaml = true; break
case 'zed': filePath = join(home, '.config', 'zed', 'settings.json'); mcpKey = 'context_servers'; break
case 'amazon-q': filePath = join(cwd, '.amazonq', 'mcp.json'); break
case 'amazon-q': filePath = scope === 'global' ? join(home, '.aws', 'amazonq', 'mcp.json') : join(cwd, '.amazonq', 'mcp.json'); break
default: return null
}

Expand Down Expand Up @@ -912,7 +913,7 @@ export function detectDrift(detectedEnvs, managedEntries, cwd = process.cwd()) {
const params = /** @type {any} */ (entry.params)

if (entry.type === 'mcp') {
const actual = readDeployedMCPEntry(entry.name, envId, cwd)
const actual = readDeployedMCPEntry(entry.name, envId, cwd, entry.scope || 'project')
if (actual === null) continue // not deployed yet — not drift

// Build expected server object — must match what buildMCPServerObject produces.
Expand Down
2 changes: 1 addition & 1 deletion src/services/nvd.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {DvmiError} from '../utils/errors.js'

/** @import { CveSearchResult, CveDetail } from '../types.js' */

const NVD_BASE_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0'
const NVD_BASE_URL = process.env.NVD_BASE_URL || 'https://services.nvd.nist.gov/rest/json/cves/2.0'

/** NVD attribution required in all interactive output. */
export const NVD_ATTRIBUTION = 'This product uses data from the NVD API but is not endorsed or certified by the NVD.'
Expand Down
2 changes: 2 additions & 0 deletions src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@
* @property {string[]} [args] - Command arguments
* @property {Record<string, string>} [env] - Environment variables
* @property {string} [url] - Server URL (required for sse/streamable-http transport)
* @property {string} [description] - Human-readable description (stored in config, not deployed to env files)
*/

/**
Expand Down Expand Up @@ -383,6 +384,7 @@
* @property {string} name - Unique within its type; used as filename/key when deploying
* @property {CategoryType} type - Category type
* @property {boolean} active - true = deployed to environments, false = removed but kept in store
* @property {'project'|'global'} scope - Where to deploy: project-level or global-level config files
* @property {EnvironmentId[]} environments - Target environments for deployment
* @property {MCPParams|CommandParams|RuleParams|SkillParams|AgentParams} params - Type-specific parameters
* @property {string} createdAt - ISO 8601 timestamp
Expand Down
Loading
Loading