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
16 changes: 16 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"mcpServers": {
"homeassistant": {
"command": "uvx",
"args": [
"mcp-proxy",
"--transport=streamablehttp",
"--stateless",
"http://192.168.1.221:8123/api/mcp"
],
"env": {
"API_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiMDEwMmU0NmUxMWY0MzA2YWE3Y2I5ZjI5NWJmZGViOSIsImlhdCI6MTc3NTE1OTY2NiwiZXhwIjoyMDkwNTE5NjY2fQ._90tJ9Yl6dZu18t3jwxiz9ir7PtBP9DLd_CPsAMKZrw"
}
}
}
}
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.5.2] - 2026-04-02

### Added

- **sync-config-ai:** Dynamic MCP form — fields now adapt to transport type; stdio shows Command/Args, sse/streamable-http shows URL, irrelevant fields are hidden
- **sync-config-ai:** Env vars support — new editor field for environment variables (KEY=VALUE format) in MCP creation/editing
- **sync-config-ai:** Transport-specific validation — stdio requires Command, sse/streamable-http requires URL, env var format is validated
- **sync-config-ai:** Args changed from single text field to multi-line editor (one arg per line) to support args with spaces
- **sync-config-ai:** `hidden` field property in form system for dynamic field visibility
- **sync-config-ai:** `customValidator` support on FormState for category-specific validation
- **sync-config-ai:** `nextVisibleIndex` helper for Tab/Shift+Tab navigation over hidden fields

### Fixed

- **sync-config-ai:** Fixed params pollution — form values (name, environments, description) no longer leak into MCPParams on save
- **sync-config-ai:** Fixed type field in deployed config — stdio transport no longer writes `type: "stdio"`; most environments infer it from the presence of `command`
- **sync-config-ai:** Fixed env vars serialization — env vars are now correctly written as `Record<string, string>` objects instead of raw strings
- **sync-config-ai:** Fixed args serialization — args are now correctly written as `string[]` arrays instead of newline-joined strings
- **sync-config-ai:** Normalized legacy data — deployer now auto-converts string-format args/env from older store entries into proper arrays/objects
- **sync-config-ai:** Aligned drift detection with deployer output for stdio type omission


## [1.5.1] - 2026-04-02

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ The TUI shows 6 tabs — **Environments** (read-only detection) + one tab per ca
- **Native** — items already in each tool's config that dvmi doesn't manage yet (press `i` to import)
- **Managed** — entries you've added via dvmi; synced across all target environments automatically

MCP server forms adapt to the selected transport type: **stdio** shows Command and Args fields, **sse/streamable-http** shows a URL field, and irrelevant fields are hidden automatically. Environment variables (e.g. API keys) can be set via a dedicated editor in `KEY=VALUE` format. Args are entered one per line for proper support of arguments containing spaces. Transport-specific validation ensures stdio entries have a command and SSE/streamable-http entries have a URL before saving.


Supports 10 AI environments: VS Code Copilot, Claude Code, OpenCode, Gemini CLI, GitHub Copilot CLI, Cursor, Windsurf, Continue.dev, Zed, Amazon Q.

Key bindings: `n` create · `Enter` edit · `d` toggle active · `Del` delete · `r` reveal env vars · `i` import native · `Tab` switch section · `q` exit
Expand Down
18 changes: 18 additions & 0 deletions opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"mcp": {
"homeassistant": {
"enabled": true,
"type": "local",
"command": [
"uvx",
"mcp-proxy",
"--transport=streamablehttp",
"--stateless",
"http://192.168.1.221:8123/api/mcp"
],
"environment": {
"API_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiMDEwMmU0NmUxMWY0MzA2YWE3Y2I5ZjI5NWJmZGViOSIsImlhdCI6MTc3NTE1OTY2NiwiZXhwIjoyMDkwNTE5NjY2fQ._90tJ9Yl6dZu18t3jwxiz9ir7PtBP9DLd_CPsAMKZrw"
}
}
}
}
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.1",
"version": "1.5.2",
"author": "",
"type": "module",
"bin": {
Expand Down
51 changes: 48 additions & 3 deletions src/commands/sync-config-ai/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,49 @@ import {formatEnvironmentsTable, formatCategoriesTable, formatNativeEntriesTable
import {startTabTUI} from '../../utils/tui/tab-tui.js'
import {DvmiError} from '../../utils/errors.js'

/** @import { DetectedEnvironment, CategoryEntry } from '../../types.js' */
/** @import { DetectedEnvironment, CategoryEntry, MCPParams } from '../../types.js' */

/**
* Extract only MCPParams-relevant fields from raw form values.
* Parses args (editor newline-joined) into string[] and env vars (KEY=VALUE lines) into Record.
* @param {Record<string, unknown>} values - Raw form output from extractValues
* @returns {MCPParams}
*/
function buildMCPParams(values) {
/** @type {MCPParams} */
const params = {transport: /** @type {'stdio'|'sse'|'streamable-http'} */ (values.transport)}

if (params.transport === 'stdio') {
if (values.command) params.command = /** @type {string} */ (values.command)
// Args: editor field → newline-joined string → split into array
if (values.args && typeof values.args === 'string') {
const arr = /** @type {string} */ (values.args).split('\n').map((a) => a.trim()).filter(Boolean)
if (arr.length > 0) params.args = arr
} else if (Array.isArray(values.args) && values.args.length > 0) {
params.args = values.args
}
} else {
if (values.url) params.url = /** @type {string} */ (values.url)
}

// Env vars: editor field → newline-joined KEY=VALUE string → parse into Record.
// Env vars apply to ALL transports (e.g. API keys for remote servers too).
if (values.env && typeof values.env === 'string') {
/** @type {Record<string, string>} */
const envObj = {}
for (const line of /** @type {string} */ (values.env).split('\n')) {
const t = line.trim()
if (!t) continue
const eq = t.indexOf('=')
if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
}
if (Object.keys(envObj).length > 0) params.env = envObj
} else if (values.env && typeof values.env === 'object' && !Array.isArray(values.env)) {
params.env = /** @type {Record<string, string>} */ (values.env)
}

return params
}

export default class SyncConfigAi extends Command {
static description = 'Manage AI coding tool configurations across environments via TUI'
Expand Down Expand Up @@ -155,16 +197,19 @@ 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',
environments: action.values.environments || [],
params: action.values,
params: isMCP ? buildMCPParams(action.values) : action.values,
})
await deployEntry(created, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'edit') {
const updated = await updateEntry(action.id, {params: action.values})
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})
await deployEntry(updated, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'delete') {
Expand Down
12 changes: 7 additions & 5 deletions src/formatters/ai-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,17 @@ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) {
: chalk.green(padCell(statusText, COL_STATUS))
const scopeStr = padCell(env.scope ?? 'project', COL_SCOPE)

const mcpStr = padCell(String(env.nativeCounts?.mcp ?? 0), COL_COUNT)
const cmdStr = padCell(String(env.nativeCounts?.command ?? 0), COL_COUNT)
const total = (/** @type {string} */ type) => (env.counts?.[type] ?? 0) + (env.nativeCounts?.[type] ?? 0)
const mcpStr = padCell(String(total('mcp')), COL_COUNT)
const cmdStr = padCell(String(total('command')), COL_COUNT)
const ruleStr = env.supportedCategories.includes('rule')
? padCell(String(env.nativeCounts?.rule ?? 0), COL_COUNT)
? padCell(String(total('rule')), COL_COUNT)
: padCell('—', COL_COUNT)
const skillStr = env.supportedCategories.includes('skill')
? padCell(String(env.nativeCounts?.skill ?? 0), COL_COUNT)
? padCell(String(total('skill')), COL_COUNT)
: padCell('—', COL_COUNT)
const agentStr = env.supportedCategories.includes('agent')
? padCell(String(env.nativeCounts?.agent ?? 0), COL_COUNT)
? padCell(String(total('agent')), COL_COUNT)
: padCell('—', COL_COUNT)

lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, ruleStr, skillStr, agentStr].join(' '))
Expand All @@ -81,6 +82,7 @@ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) {
const ENV_SHORT_NAMES = {
'vscode-copilot': 'VSCode',
'claude-code': 'Claude',
'claude-desktop': 'Desktop',
opencode: 'OpenCode',
'gemini-cli': 'Gemini',
'copilot-cli': 'Copilot',
Expand Down
1 change: 1 addition & 0 deletions src/services/ai-config-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const AI_CONFIG_PATH = join(CONFIG_DIR, 'ai-config.json')
const COMPATIBILITY = {
'vscode-copilot': ['mcp', 'command', 'rule', 'skill', 'agent'],
'claude-code': ['mcp', 'command', 'rule', 'skill', 'agent'],
'claude-desktop': ['mcp'],
opencode: ['mcp', 'command', 'rule', 'skill', 'agent'],
'gemini-cli': ['mcp', 'command', 'rule'],
'copilot-cli': ['mcp', 'command', 'rule', 'skill', 'agent'],
Expand Down
88 changes: 83 additions & 5 deletions src/services/ai-env-deployer.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ const MCP_TARGETS = {
resolvePath: (cwd) => join(cwd, '.mcp.json'),
mcpKey: 'mcpServers',
},
'claude-desktop': {
resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
mcpKey: 'mcpServers',
},
opencode: {
resolvePath: (cwd) => join(cwd, 'opencode.json'),
mcpKey: 'mcpServers',
mcpKey: 'mcp',
},
'gemini-cli': {
resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'),
Expand Down Expand Up @@ -319,10 +323,83 @@ function buildMCPServerObject(params) {
const server = {}

if (params.command !== undefined) server.command = params.command
if (params.args !== undefined) server.args = params.args
if (params.env !== undefined) server.env = params.env
// Normalize args: must be string[] in the deployed JSON.
// Guard against legacy data where args was stored as a newline-joined string.
if (params.args !== undefined) {
server.args = typeof params.args === 'string'
? params.args.split('\n').map((a) => a.trim()).filter(Boolean)
: params.args
}
// Normalize env: must be Record<string,string> in the deployed JSON.
// Guard against legacy data where env was stored as a KEY=VALUE string.
if (params.env !== undefined) {
if (typeof params.env === 'string') {
/** @type {Record<string, string>} */
const envObj = {}
for (const line of params.env.split('\n')) {
const t = line.trim()
if (!t) continue
const eq = t.indexOf('=')
if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
}
if (Object.keys(envObj).length > 0) server.env = envObj
} else {
server.env = params.env
}
}
if (params.url !== undefined) server.url = params.url
if (params.transport !== undefined) server.type = params.transport
// Omit type for stdio — most environments infer it from the presence of command.
// Only write type for sse/streamable-http where it's required.
if (params.transport && params.transport !== 'stdio') server.type = params.transport

return server
}

/**
* Build an OpenCode-format MCP server object from dvmi's normalized params.
* OpenCode uses: command as array, `environment` instead of `env`,
* `type: "local"/"remote"` instead of transport strings, and `enabled` flag.
*
* @param {import('../types.js').MCPParams} params
* @returns {Record<string, unknown>}
*/
function buildOpenCodeMCPObject(params) {
/** @type {Record<string, unknown>} */
const server = {enabled: true}

const isRemote = params.transport === 'sse' || params.transport === 'streamable-http'
server.type = isRemote ? 'remote' : 'local'

if (isRemote) {
if (params.url !== undefined) server.url = params.url
} else {
const cmd = []
if (params.command !== undefined) cmd.push(params.command)
if (params.args !== undefined) {
const argsArr = typeof params.args === 'string'
? params.args.split('\n').map((a) => a.trim()).filter(Boolean)
: params.args
cmd.push(...argsArr)
}
if (cmd.length > 0) server.command = cmd
}

// Normalize env for OpenCode (uses "environment" key)
if (params.env !== undefined) {
if (typeof params.env === 'string') {
/** @type {Record<string, string>} */
const envObj = {}
for (const line of params.env.split('\n')) {
const t = line.trim()
if (!t) continue
const eq = t.indexOf('=')
if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
}
if (Object.keys(envObj).length > 0) server.environment = envObj
} else {
server.environment = params.env
}
}

return server
}
Expand Down Expand Up @@ -361,7 +438,8 @@ export async function deployMCPEntry(entry, envId, cwd) {

/** @type {Record<string, unknown>} */
const mcpKey = /** @type {any} */ (json[target.mcpKey])
mcpKey[entry.name] = buildMCPServerObject(/** @type {import('../types.js').MCPParams} */ (entry.params))
const params = /** @type {import('../types.js').MCPParams} */ (entry.params)
mcpKey[entry.name] = envId === 'opencode' ? buildOpenCodeMCPObject(params) : buildMCPServerObject(params)

if (target.isYaml) {
await writeYaml(filePath, json)
Expand Down
Loading
Loading