Skip to content

Commit 9ff5cdd

Browse files
savezclaude
andauthored
fix(sync-config-ai): dynamic MCP form, env vars, type/serialization fixes (v1.5.2) (#15)
* feat(sync-config-ai): add AI config sync TUI command (spec 007) Introduces `dvmi sync-config-ai` — an interactive full-screen TUI for managing AI coding tool configurations (MCP servers, commands, skills, agents) across VS Code Copilot, Claude Code, OpenCode, Gemini CLI, and GitHub Copilot CLI from a single place. Key capabilities: - Filesystem scan on every launch detects installed AI tools (project + global paths, including ~/.config/opencode/ for globally installed OpenCode) - 5-tab TUI: Environments (read-only) + dedicated tab per category type (MCPs | Commands | Skills | Agents) — each tab shows only its entries - Inline forms with type-specific fields and a mini text editor for multi-line content; Environments multi-select filtered to compatible tools per compatibility matrix - Full CRUD: create, edit, deactivate/activate, delete with confirmation - Entries persist in ~/.config/dvmi/ai-config.json and are deployed to target environment config files on save - Chezmoi integration: auto-syncs AI config after mutations if configured; shows setup tip in footer otherwise - --json flag for non-interactive/CI use Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(release): sync version to 1.5.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: apply consistent code style across codebase Format all source and test files with Prettier (space-free destructuring, consistent import spacing). Add project-level Claude Code slash commands under .claude/commands/ for speckit workflow integration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: exclude .claude/ from version control Add .claude/ to .gitignore and remove tracked files — project-level Claude Code commands and memory are local-only artifacts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(changelog): rewrite with accurate release history for devvami Replace stale content (old repo references, incorrect versions) with the actual release history from v1.0.0 to v1.4.2, plus an [Unreleased] section documenting the sync-config-ai feature landing in v1.5.0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(changelog): move sync-config-ai to v1.5.0 release section Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(readme): add missing commands to Commands section [skip ci] Added dotfiles, prompts, vuln, logs, security, sync-config-ai, and welcome commands that existed in src/ but were not documented in the README. Also updated the "What It Does" feature table to reflect the full feature set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync-config-ai): extend TUI to 10 envs, 5 categories, native entries, drift detection - Add 5 new AI environments: Cursor, Windsurf, Continue.dev, Zed, Amazon Q - Add Rules category (5th type) with MDC/markdown deployment support - Add native entry parsing — show unmanaged items in read-only Native section - Add drift detection with re-deploy/accept-changes resolution UI - Add env var masking for MCP entries with r to reveal, i to import native - Wire chezmoi sync after every store mutation - Bump schema to v2 with migration, expand --json output - Bump version to 1.5.1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(changelog,readme): update for v1.5.1 sync-config-ai extension - Add v1.5.1 changelog entry with new environments, categories, native entries, drift detection - Update README sync-config-ai section with 10 envs, TUI sections, key bindings, --json flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sync-config-ai): dynamic MCP form, env vars, type fix, args/env serialization (v1.5.2) - Dynamic form fields based on transport type (stdio shows Command/Args, sse/http shows URL) - Env vars editor field (KEY=VALUE per line) for MCP servers - Transport-specific validation (stdio requires Command, sse/http requires URL) - Args as multi-line editor (one per line) instead of space-separated text - Fix params pollution: name/environments/description no longer leak into MCPParams - Fix type field: omit type for stdio (environments infer from command) - Fix env/args serialization: always write as object/array, not strings - Normalize legacy string args/env from older store entries - Align drift detection with deployer output for stdio type omission Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2e7adf9 commit 9ff5cdd

16 files changed

Lines changed: 842 additions & 64 deletions

File tree

.mcp.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"mcpServers": {
3+
"homeassistant": {
4+
"command": "uvx",
5+
"args": [
6+
"mcp-proxy",
7+
"--transport=streamablehttp",
8+
"--stateless",
9+
"http://192.168.1.221:8123/api/mcp"
10+
],
11+
"env": {
12+
"API_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiMDEwMmU0NmUxMWY0MzA2YWE3Y2I5ZjI5NWJmZGViOSIsImlhdCI6MTc3NTE1OTY2NiwiZXhwIjoyMDkwNTE5NjY2fQ._90tJ9Yl6dZu18t3jwxiz9ir7PtBP9DLd_CPsAMKZrw"
13+
}
14+
}
15+
}
16+
}

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.5.2] - 2026-04-02
11+
12+
### Added
13+
14+
- **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
15+
- **sync-config-ai:** Env vars support — new editor field for environment variables (KEY=VALUE format) in MCP creation/editing
16+
- **sync-config-ai:** Transport-specific validation — stdio requires Command, sse/streamable-http requires URL, env var format is validated
17+
- **sync-config-ai:** Args changed from single text field to multi-line editor (one arg per line) to support args with spaces
18+
- **sync-config-ai:** `hidden` field property in form system for dynamic field visibility
19+
- **sync-config-ai:** `customValidator` support on FormState for category-specific validation
20+
- **sync-config-ai:** `nextVisibleIndex` helper for Tab/Shift+Tab navigation over hidden fields
21+
22+
### Fixed
23+
24+
- **sync-config-ai:** Fixed params pollution — form values (name, environments, description) no longer leak into MCPParams on save
25+
- **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`
26+
- **sync-config-ai:** Fixed env vars serialization — env vars are now correctly written as `Record<string, string>` objects instead of raw strings
27+
- **sync-config-ai:** Fixed args serialization — args are now correctly written as `string[]` arrays instead of newline-joined strings
28+
- **sync-config-ai:** Normalized legacy data — deployer now auto-converts string-format args/env from older store entries into proper arrays/objects
29+
- **sync-config-ai:** Aligned drift detection with deployer output for stdio type omission
30+
31+
1032
## [1.5.1] - 2026-04-02
1133

1234
### Added

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ The TUI shows 6 tabs — **Environments** (read-only detection) + one tab per ca
193193
- **Native** — items already in each tool's config that dvmi doesn't manage yet (press `i` to import)
194194
- **Managed** — entries you've added via dvmi; synced across all target environments automatically
195195

196+
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.
197+
198+
196199
Supports 10 AI environments: VS Code Copilot, Claude Code, OpenCode, Gemini CLI, GitHub Copilot CLI, Cursor, Windsurf, Continue.dev, Zed, Amazon Q.
197200

198201
Key bindings: `n` create · `Enter` edit · `d` toggle active · `Del` delete · `r` reveal env vars · `i` import native · `Tab` switch section · `q` exit

opencode.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"mcp": {
3+
"homeassistant": {
4+
"enabled": true,
5+
"type": "local",
6+
"command": [
7+
"uvx",
8+
"mcp-proxy",
9+
"--transport=streamablehttp",
10+
"--stateless",
11+
"http://192.168.1.221:8123/api/mcp"
12+
],
13+
"environment": {
14+
"API_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiMDEwMmU0NmUxMWY0MzA2YWE3Y2I5ZjI5NWJmZGViOSIsImlhdCI6MTc3NTE1OTY2NiwiZXhwIjoyMDkwNTE5NjY2fQ._90tJ9Yl6dZu18t3jwxiz9ir7PtBP9DLd_CPsAMKZrw"
15+
}
16+
}
17+
}
18+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "devvami",
33
"description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
4-
"version": "1.5.1",
4+
"version": "1.5.2",
55
"author": "",
66
"type": "module",
77
"bin": {

src/commands/sync-config-ai/index.js

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,49 @@ import {formatEnvironmentsTable, formatCategoriesTable, formatNativeEntriesTable
1717
import {startTabTUI} from '../../utils/tui/tab-tui.js'
1818
import {DvmiError} from '../../utils/errors.js'
1919

20-
/** @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+
}
2163

2264
export default class SyncConfigAi extends Command {
2365
static description = 'Manage AI coding tool configurations across environments via TUI'
@@ -155,16 +197,19 @@ export default class SyncConfigAi extends Command {
155197
const currentStore = await loadAIConfig()
156198

157199
if (action.type === 'create') {
200+
const isMCP = action.tabKey === 'mcp'
158201
const created = await addEntry({
159202
name: action.values.name,
160203
type: action.tabKey || 'mcp',
161204
environments: action.values.environments || [],
162-
params: action.values,
205+
params: isMCP ? buildMCPParams(action.values) : action.values,
163206
})
164207
await deployEntry(created, detectedEnvs, process.cwd())
165208
await syncAIConfigToChezmoi()
166209
} else if (action.type === 'edit') {
167-
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})
168213
await deployEntry(updated, detectedEnvs, process.cwd())
169214
await syncAIConfigToChezmoi()
170215
} else if (action.type === 'delete') {

src/formatters/ai-config.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,17 @@ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) {
5959
: chalk.green(padCell(statusText, COL_STATUS))
6060
const scopeStr = padCell(env.scope ?? 'project', COL_SCOPE)
6161

62-
const mcpStr = padCell(String(env.nativeCounts?.mcp ?? 0), COL_COUNT)
63-
const cmdStr = padCell(String(env.nativeCounts?.command ?? 0), 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)
6465
const ruleStr = env.supportedCategories.includes('rule')
65-
? padCell(String(env.nativeCounts?.rule ?? 0), COL_COUNT)
66+
? padCell(String(total('rule')), COL_COUNT)
6667
: padCell('—', COL_COUNT)
6768
const skillStr = env.supportedCategories.includes('skill')
68-
? padCell(String(env.nativeCounts?.skill ?? 0), COL_COUNT)
69+
? padCell(String(total('skill')), COL_COUNT)
6970
: padCell('—', COL_COUNT)
7071
const agentStr = env.supportedCategories.includes('agent')
71-
? padCell(String(env.nativeCounts?.agent ?? 0), COL_COUNT)
72+
? padCell(String(total('agent')), COL_COUNT)
7273
: padCell('—', COL_COUNT)
7374

7475
lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, ruleStr, skillStr, agentStr].join(' '))
@@ -81,6 +82,7 @@ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) {
8182
const ENV_SHORT_NAMES = {
8283
'vscode-copilot': 'VSCode',
8384
'claude-code': 'Claude',
85+
'claude-desktop': 'Desktop',
8486
opencode: 'OpenCode',
8587
'gemini-cli': 'Gemini',
8688
'copilot-cli': 'Copilot',

src/services/ai-config-store.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const AI_CONFIG_PATH = join(CONFIG_DIR, 'ai-config.json')
2828
const COMPATIBILITY = {
2929
'vscode-copilot': ['mcp', 'command', 'rule', 'skill', 'agent'],
3030
'claude-code': ['mcp', 'command', 'rule', 'skill', 'agent'],
31+
'claude-desktop': ['mcp'],
3132
opencode: ['mcp', 'command', 'rule', 'skill', 'agent'],
3233
'gemini-cli': ['mcp', 'command', 'rule'],
3334
'copilot-cli': ['mcp', 'command', 'rule', 'skill', 'agent'],

src/services/ai-env-deployer.js

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ const MCP_TARGETS = {
3232
resolvePath: (cwd) => join(cwd, '.mcp.json'),
3333
mcpKey: 'mcpServers',
3434
},
35+
'claude-desktop': {
36+
resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
37+
mcpKey: 'mcpServers',
38+
},
3539
opencode: {
3640
resolvePath: (cwd) => join(cwd, 'opencode.json'),
37-
mcpKey: 'mcpServers',
41+
mcpKey: 'mcp',
3842
},
3943
'gemini-cli': {
4044
resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'),
@@ -319,10 +323,83 @@ function buildMCPServerObject(params) {
319323
const server = {}
320324

321325
if (params.command !== undefined) server.command = params.command
322-
if (params.args !== undefined) server.args = params.args
323-
if (params.env !== undefined) server.env = params.env
326+
// Normalize args: must be string[] in the deployed JSON.
327+
// Guard against legacy data where args was stored as a newline-joined string.
328+
if (params.args !== undefined) {
329+
server.args = typeof params.args === 'string'
330+
? params.args.split('\n').map((a) => a.trim()).filter(Boolean)
331+
: params.args
332+
}
333+
// Normalize env: must be Record<string,string> in the deployed JSON.
334+
// Guard against legacy data where env was stored as a KEY=VALUE string.
335+
if (params.env !== undefined) {
336+
if (typeof params.env === 'string') {
337+
/** @type {Record<string, string>} */
338+
const envObj = {}
339+
for (const line of params.env.split('\n')) {
340+
const t = line.trim()
341+
if (!t) continue
342+
const eq = t.indexOf('=')
343+
if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
344+
}
345+
if (Object.keys(envObj).length > 0) server.env = envObj
346+
} else {
347+
server.env = params.env
348+
}
349+
}
324350
if (params.url !== undefined) server.url = params.url
325-
if (params.transport !== undefined) server.type = params.transport
351+
// Omit type for stdio — most environments infer it from the presence of command.
352+
// Only write type for sse/streamable-http where it's required.
353+
if (params.transport && params.transport !== 'stdio') server.type = params.transport
354+
355+
return server
356+
}
357+
358+
/**
359+
* Build an OpenCode-format MCP server object from dvmi's normalized params.
360+
* OpenCode uses: command as array, `environment` instead of `env`,
361+
* `type: "local"/"remote"` instead of transport strings, and `enabled` flag.
362+
*
363+
* @param {import('../types.js').MCPParams} params
364+
* @returns {Record<string, unknown>}
365+
*/
366+
function buildOpenCodeMCPObject(params) {
367+
/** @type {Record<string, unknown>} */
368+
const server = {enabled: true}
369+
370+
const isRemote = params.transport === 'sse' || params.transport === 'streamable-http'
371+
server.type = isRemote ? 'remote' : 'local'
372+
373+
if (isRemote) {
374+
if (params.url !== undefined) server.url = params.url
375+
} else {
376+
const cmd = []
377+
if (params.command !== undefined) cmd.push(params.command)
378+
if (params.args !== undefined) {
379+
const argsArr = typeof params.args === 'string'
380+
? params.args.split('\n').map((a) => a.trim()).filter(Boolean)
381+
: params.args
382+
cmd.push(...argsArr)
383+
}
384+
if (cmd.length > 0) server.command = cmd
385+
}
386+
387+
// Normalize env for OpenCode (uses "environment" key)
388+
if (params.env !== undefined) {
389+
if (typeof params.env === 'string') {
390+
/** @type {Record<string, string>} */
391+
const envObj = {}
392+
for (const line of params.env.split('\n')) {
393+
const t = line.trim()
394+
if (!t) continue
395+
const eq = t.indexOf('=')
396+
if (eq > 0) envObj[t.slice(0, eq)] = t.slice(eq + 1)
397+
}
398+
if (Object.keys(envObj).length > 0) server.environment = envObj
399+
} else {
400+
server.environment = params.env
401+
}
402+
}
326403

327404
return server
328405
}
@@ -361,7 +438,8 @@ export async function deployMCPEntry(entry, envId, cwd) {
361438

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

366444
if (target.isYaml) {
367445
await writeYaml(filePath, json)

0 commit comments

Comments
 (0)