diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..688aee5 --- /dev/null +++ b/.mcp.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b473ba..0dcdfc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/README.md b/README.md index 074a33f..f17ffcb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..4e33859 --- /dev/null +++ b/opencode.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 66047f1..954f470 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/commands/sync-config-ai/index.js b/src/commands/sync-config-ai/index.js index 788b40a..ea9e100 100644 --- a/src/commands/sync-config-ai/index.js +++ b/src/commands/sync-config-ai/index.js @@ -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} 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} */ + 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} */ (values.env) + } + + return params +} export default class SyncConfigAi extends Command { static description = 'Manage AI coding tool configurations across environments via TUI' @@ -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') { diff --git a/src/formatters/ai-config.js b/src/formatters/ai-config.js index 7d59144..75176bd 100644 --- a/src/formatters/ai-config.js +++ b/src/formatters/ai-config.js @@ -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(' ')) @@ -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', diff --git a/src/services/ai-config-store.js b/src/services/ai-config-store.js index 972778f..c8a5204 100644 --- a/src/services/ai-config-store.js +++ b/src/services/ai-config-store.js @@ -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'], diff --git a/src/services/ai-env-deployer.js b/src/services/ai-env-deployer.js index a2b321b..9600b9f 100644 --- a/src/services/ai-env-deployer.js +++ b/src/services/ai-env-deployer.js @@ -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'), @@ -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 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} */ + 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} + */ +function buildOpenCodeMCPObject(params) { + /** @type {Record} */ + 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} */ + 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 } @@ -361,7 +438,8 @@ export async function deployMCPEntry(entry, envId, cwd) { /** @type {Record} */ 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) diff --git a/src/services/ai-env-scanner.js b/src/services/ai-env-scanner.js index 280a455..c174669 100644 --- a/src/services/ai-env-scanner.js +++ b/src/services/ai-env-scanner.js @@ -62,6 +62,15 @@ export const ENVIRONMENTS = Object.freeze([ ], supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']), }, + { + id: /** @type {EnvironmentId} */ ('claude-desktop'), + name: 'Claude Desktop', + projectPaths: [], + globalPaths: [ + {path: '~/Library/Application Support/Claude/claude_desktop_config.json', isJson: true}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp']), + }, { id: /** @type {EnvironmentId} */ ('opencode'), name: 'OpenCode', @@ -450,6 +459,55 @@ function parseMCPsFromYaml(filePath, envId, level, managedSet) { } } +/** + * Parse MCP entries from an OpenCode config file. + * OpenCode uses a different format: key is `mcp`, command is an array, + * env vars in `environment`, type is `local`/`remote`. + * @param {string} filePath + * @param {EnvironmentId} envId + * @param {'project'|'global'} level + * @param {Set} managedSet + * @returns {NativeEntry[]} + */ +function parseMCPsFromOpenCode(filePath, envId, level, managedSet) { + if (!existsSync(filePath)) return [] + try { + const raw = readFileSync(filePath, 'utf8') + const json = JSON.parse(raw) + const section = json.mcp + if (!section || typeof section !== 'object') return [] + + /** @type {NativeEntry[]} */ + const entries = [] + for (const [name, server] of Object.entries(section)) { + if (managedSet.has(managedKey(name, 'mcp'))) continue + const s = /** @type {any} */ (server) + // OpenCode format: command is an array, environment instead of env, type is local/remote + const cmdArr = Array.isArray(s.command) ? s.command : [] + const transport = s.type === 'remote' ? 'streamable-http' : 'stdio' + /** @type {NativeEntry} */ + const entry = { + name, + type: 'mcp', + environmentId: envId, + level, + sourcePath: filePath, + params: { + transport, + ...(cmdArr.length > 0 ? {command: cmdArr[0]} : {}), + ...(cmdArr.length > 1 ? {args: cmdArr.slice(1)} : {}), + ...(s.environment !== undefined ? {env: s.environment} : {}), + ...(s.url !== undefined ? {url: s.url} : {}), + }, + } + entries.push(entry) + } + return entries + } catch { + return [] + } +} + /** * Parse file-based entries (commands, rules, skills, agents) from a directory. * Each file in the directory becomes one native entry. @@ -538,9 +596,12 @@ export function parseNativeEntries(envDef, cwd, managedEntries) { result.push(...parseMCPsFromJson(join(cwd, '.mcp.json'), 'mcpServers', id, 'project', managedSet)) result.push(...parseMCPsFromJson(join(home, '.claude.json'), 'mcpServers', id, 'global', managedSet)) break + case 'claude-desktop': + result.push(...parseMCPsFromJson(join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), 'mcpServers', id, 'global', managedSet)) + break case 'opencode': - result.push(...parseMCPsFromJson(join(cwd, 'opencode.json'), 'mcpServers', id, 'project', managedSet)) - result.push(...parseMCPsFromJson(join(home, '.config', 'opencode', 'opencode.json'), 'mcpServers', id, 'global', managedSet)) + result.push(...parseMCPsFromOpenCode(join(cwd, 'opencode.json'), id, 'project', managedSet)) + result.push(...parseMCPsFromOpenCode(join(home, '.config', 'opencode', 'opencode.json'), id, 'global', managedSet)) break case 'gemini-cli': result.push(...parseMCPsFromJson(join(home, '.gemini', 'settings.json'), 'mcpServers', id, 'global', managedSet)) @@ -854,13 +915,14 @@ export function detectDrift(detectedEnvs, managedEntries, cwd = process.cwd()) { const actual = readDeployedMCPEntry(entry.name, envId, cwd) if (actual === null) continue // not deployed yet — not drift - // Build expected server object + // Build expected server object — must match what buildMCPServerObject produces. + // For stdio, type is omitted (environments infer it from command). const expected = { ...(params.command !== undefined ? {command: params.command} : {}), ...(params.args !== undefined ? {args: params.args} : {}), ...(params.env !== undefined ? {env: params.env} : {}), ...(params.url !== undefined ? {url: params.url} : {}), - ...(params.transport !== undefined ? {type: params.transport} : {}), + ...(params.transport && params.transport !== 'stdio' ? {type: params.transport} : {}), } if (JSON.stringify(expected) !== JSON.stringify(actual)) { diff --git a/src/types.js b/src/types.js index 731af53..7605ef4 100644 --- a/src/types.js +++ b/src/types.js @@ -341,7 +341,7 @@ */ /** - * @typedef {'vscode-copilot'|'claude-code'|'opencode'|'gemini-cli'|'copilot-cli'|'cursor'|'windsurf'|'continue-dev'|'zed'|'amazon-q'} EnvironmentId + * @typedef {'vscode-copilot'|'claude-code'|'claude-desktop'|'opencode'|'gemini-cli'|'copilot-cli'|'cursor'|'windsurf'|'continue-dev'|'zed'|'amazon-q'} EnvironmentId */ /** diff --git a/src/utils/tui/form.js b/src/utils/tui/form.js index f0c0206..df8c961 100644 --- a/src/utils/tui/form.js +++ b/src/utils/tui/form.js @@ -20,6 +20,7 @@ import chalk from 'chalk' * @property {boolean} required * @property {string} placeholder * @property {string} [key] - Optional override key for extractValues output + * @property {boolean} [hidden] - When true, field is skipped in rendering, navigation, validation, and extraction */ /** @@ -30,6 +31,7 @@ import chalk from 'chalk' * @property {number} selectedIndex * @property {boolean} required * @property {string} [key] + * @property {boolean} [hidden] */ /** @@ -45,6 +47,7 @@ import chalk from 'chalk' * @property {number} focusedOptionIndex * @property {boolean} required * @property {string} [key] + * @property {boolean} [hidden] */ /** @@ -56,6 +59,7 @@ import chalk from 'chalk' * @property {number} cursorCol * @property {boolean} required * @property {string} [key] + * @property {boolean} [hidden] */ /** @@ -69,6 +73,7 @@ import chalk from 'chalk' * @property {string} title * @property {'editing'|'submitted'|'cancelled'} status * @property {string|null} errorMessage + * @property {((state: FormState) => string|null)|null} [customValidator] - Optional transport-specific validator */ /** @@ -97,6 +102,25 @@ function fieldKey(field) { return field.label.toLowerCase().replace(/\s+/g, '_') } +/** + * Find the next visible (non-hidden) field index in a given direction. + * Wraps around the array. Returns current index if all fields are hidden. + * @param {Field[]} fields + * @param {number} current - Current focused index + * @param {1|-1} direction - 1 for forward, -1 for backward + * @returns {number} + */ +function nextVisibleIndex(fields, current, direction) { + const len = fields.length + let next = (current + direction + len) % len + let checked = 0 + while (fields[next]?.hidden && checked < len) { + next = (next + direction + len) % len + checked++ + } + return next +} + /** * Render the text cursor inside a string value at the given position. * Inserts a `|` character at the cursor index. @@ -263,6 +287,7 @@ export function buildFormScreen(formState, viewportHeight, termCols) { for (let i = 0; i < formState.fields.length; i++) { const field = formState.fields[i] + if (field.hidden) continue const isFocused = i === formState.focusedFieldIndex // Header line @@ -311,6 +336,7 @@ export function extractValues(formState) { const result = {} for (const field of formState.fields) { + if (field.hidden) continue const key = fieldKey(field) if (field.type === 'text') { @@ -339,6 +365,7 @@ export function extractValues(formState) { */ function validateForm(formState) { for (const field of formState.fields) { + if (field.hidden) continue if (!field.required) continue if (field.type === 'text' && field.value.trim() === '') { @@ -636,20 +663,20 @@ export function handleFormKeypress(formState, key) { return attemptSubmit(formState) } - // ── Tab: next field ─────────────────────────────────────────────────────── + // ── Tab: next field (skip hidden) ────────────────────────────────────────── if (key.name === 'tab' && !key.shift) { return { ...formState, - focusedFieldIndex: (focusedFieldIndex + 1) % fields.length, + focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1), errorMessage: null, } } - // ── Shift+Tab: previous field ───────────────────────────────────────────── + // ── Shift+Tab: previous field (skip hidden) ────────────────────────────── if (key.name === 'tab' && key.shift) { return { ...formState, - focusedFieldIndex: (focusedFieldIndex - 1 + fields.length) % fields.length, + focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, -1), errorMessage: null, } } @@ -680,9 +707,15 @@ export function handleFormKeypress(formState, key) { if (focusedField.type === 'selector') { const updated = handleSelectorFieldKey(focusedField, key) if (updated === focusedField) return formState + let newFields = replaceAt(fields, focusedFieldIndex, updated) + // Dynamic visibility: when transport selector changes, toggle field visibility + if (fieldKey(focusedField) === 'transport') { + const newTransport = updated.options[updated.selectedIndex] + newFields = updateMCPFieldVisibility(newFields, newTransport) + } return { ...formState, - fields: replaceAt(fields, focusedFieldIndex, updated), + fields: newFields, } } @@ -691,7 +724,7 @@ export function handleFormKeypress(formState, key) { if ('advanceField' in result) { return { ...formState, - focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex), + focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1), } } if (result === focusedField) return formState @@ -704,11 +737,10 @@ export function handleFormKeypress(formState, key) { if (focusedField.type === 'editor') { const result = handleEditorFieldKey(focusedField, key) if ('advanceField' in result) { - // Esc in editor cancels the form only if we treat it as a field-level escape. - // Per spec, Esc in editor moves to next field. + // Esc in editor moves to next field (skip hidden) return { ...formState, - focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex), + focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1), } } if (result === focusedField) return formState @@ -735,6 +767,13 @@ function attemptSubmit(formState) { errorMessage: `"${invalidLabel}" is required.`, } } + // Run custom validator (e.g. MCP transport-specific checks) + if (formState.customValidator) { + const customError = formState.customValidator(formState) + if (customError !== null) { + return {...formState, errorMessage: customError} + } + } return { submitted: /** @type {true} */ (true), values: extractValues(formState), @@ -760,8 +799,11 @@ function replaceAt(arr, index, value) { /** * Return form fields for creating or editing an MCP entry. * - * Fields: name (text), environments (multiselect), transport (selector), command (text), - * args (text), url (text), description (text, optional). + * Fields: name (text), environments (multiselect), transport (selector), + * command (text, stdio only), args (editor, stdio only), url (text, remote only), + * env vars (editor), description (text, optional). + * + * Fields are dynamically shown/hidden based on the selected transport. * * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type @@ -773,8 +815,10 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) { const transportOptions = ['stdio', 'sse', 'streamable-http'] const transportIndex = p ? Math.max(0, transportOptions.indexOf(p.transport)) : 0 + const transport = transportOptions[transportIndex] - return [ + /** @type {Field[]} */ + const fields = [ /** @type {TextField} */ ({ type: 'text', label: 'Name', @@ -810,14 +854,14 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) { required: false, placeholder: 'npx my-mcp-server', }), - /** @type {TextField} */ ({ - type: 'text', + /** @type {MiniEditorField} */ ({ + type: 'editor', label: 'Args', key: 'args', - value: p?.args ? p.args.join(' ') : '', - cursor: p?.args ? p.args.join(' ').length : 0, + lines: p?.args?.length > 0 ? [...p.args] : [''], + cursorLine: 0, + cursorCol: 0, required: false, - placeholder: '--port 3000 --verbose', }), /** @type {TextField} */ ({ type: 'text', @@ -828,6 +872,15 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) { required: false, placeholder: 'https://mcp.example.com', }), + /** @type {MiniEditorField} */ ({ + type: 'editor', + label: 'Env Vars', + key: 'env', + lines: p?.env ? Object.entries(p.env).map(([k, v]) => `${k}=${v}`) : [''], + cursorLine: 0, + cursorCol: 0, + required: false, + }), /** @type {TextField} */ ({ type: 'text', label: 'Description', @@ -838,6 +891,67 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) { placeholder: 'Optional description', }), ] + + return updateMCPFieldVisibility(fields, transport) +} + +/** + * Toggle visibility of MCP-specific fields based on the selected transport. + * - stdio: show Command + Args, hide URL + * - sse/streamable-http: show URL, hide Command + Args + * - Env Vars and Description are always visible + * + * @param {Field[]} fields + * @param {string} transport + * @returns {Field[]} + */ +export function updateMCPFieldVisibility(fields, transport) { + const isStdio = transport === 'stdio' + return fields.map((f) => { + const key = f.key || f.label.toLowerCase().replace(/\s+/g, '_') + if (key === 'command' || key === 'args') { + return {...f, hidden: !isStdio} + } + if (key === 'url') { + return {...f, hidden: isStdio} + } + return f + }) +} + +/** + * MCP-specific form validator. Checks that: + * - stdio transport has a non-empty command + * - sse/streamable-http transport has a non-empty URL + * - Env var lines (if any) follow KEY=VALUE format + * + * @param {FormState} formState + * @returns {string|null} Error message or null if valid + */ +export function validateMCPForm(formState) { + const values = extractValues(formState) + const transport = values.transport + + if (transport === 'stdio') { + if (!values.command || /** @type {string} */ (values.command).trim() === '') { + return 'Command is required for stdio transport' + } + } else if (transport === 'sse' || transport === 'streamable-http') { + if (!values.url || /** @type {string} */ (values.url).trim() === '') { + return 'URL is required for sse/streamable-http transport' + } + } + + if (values.env && typeof values.env === 'string') { + const lines = /** @type {string} */ (values.env).split('\n').filter((l) => l.trim() !== '') + for (const line of lines) { + if (!line.includes('=')) { + return `Invalid env var format: "${line}" (expected KEY=VALUE)` + } + } + } + + return null } /** diff --git a/src/utils/tui/tab-tui.js b/src/utils/tui/tab-tui.js index 5489463..21d7351 100644 --- a/src/utils/tui/tab-tui.js +++ b/src/utils/tui/tab-tui.js @@ -15,6 +15,7 @@ import { getRuleFormFields, getSkillFormFields, getAgentFormFields, + validateMCPForm, } from './form.js' // ────────────────────────────────────────────────────────────────────────────── @@ -435,10 +436,17 @@ export function handleCategoriesKeypress(state, key) { } // Navigation — clears env var reveal on any movement + // Cross-section: up from top of managed goes to last native; down from bottom of native goes to first managed if (key.name === 'up' || key.name === 'k') { + if (selectedIndex === 0 && section === 'managed' && nativeEntries.length > 0) { + return {...state, section: 'native', selectedIndex: nativeEntries.length - 1, revealedEntryId: null} + } return {...state, selectedIndex: Math.max(0, selectedIndex - 1), revealedEntryId: null} } if (key.name === 'down' || key.name === 'j') { + if (selectedIndex >= maxIndex && section === 'native' && entries.length > 0) { + return {...state, section: 'managed', selectedIndex: 0, revealedEntryId: null} + } return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1), revealedEntryId: null} } if (key.name === 'pageup') { @@ -690,7 +698,7 @@ export async function startTabTUI(opts) { formatEnvs, termCols, ) - hintStr = chalk.dim(' ↑↓ navigate Tab switch tabs q exit') + hintStr = chalk.dim(' ↑↓ navigate ←→ switch tabs q exit') } else { const tabKey = tabs[activeTabIndex].key const tabState = catTabStates[tabKey] @@ -704,9 +712,9 @@ export async function startTabTUI(opts) { } else { const nativeFmt = formatNative ?? formatNativeEntriesTableFallback contentLines = buildCategoriesTab(tabState, contentViewportHeight, formatCats, nativeFmt, termCols) - const sectionHint = tabState.nativeEntries.length > 0 ? ' Tab switch section' : ' Tab switch tabs' + const sectionHint = tabState.nativeEntries.length > 0 ? ' Tab section' : '' const nativeHint = tabState.section === 'native' ? ' i import' : ' n new Enter edit d toggle Del delete r reveal' - hintStr = chalk.dim(` ↑↓ navigate${nativeHint}${sectionHint} q exit`) + hintStr = chalk.dim(` ↑↓ navigate ←→ tabs${nativeHint}${sectionHint} q exit`) } } @@ -740,8 +748,16 @@ export async function startTabTUI(opts) { const listener = async (_str, key) => { if (!key) return - // Global keys - if (key.name === 'escape' || key.name === 'q') { + // ── Compute mode guards ── + const activeTabKey = tuiState.activeTabIndex > 0 ? tabs[tuiState.activeTabIndex].key : null + const activeCatState = activeTabKey ? catTabStates[activeTabKey] : null + const isInFormMode = activeCatState?.mode === 'form' + const isInDriftMode = activeCatState?.mode === 'drift' + const isInConfirmDelete = activeCatState?.mode === 'confirm-delete' + const isModalMode = isInFormMode || isInDriftMode || isInConfirmDelete + + // ── Ctrl+C: always exit ── + if (key.ctrl && key.name === 'c') { process.stdout.removeListener('resize', onResize) process.removeListener('SIGINT', sigHandler) process.removeListener('SIGTERM', sigHandler) @@ -750,7 +766,9 @@ export async function startTabTUI(opts) { resolve() return } - if (key.ctrl && key.name === 'c') { + + // ── Esc: close sub-mode first; exit TUI only from list mode ── + if (key.name === 'escape' && !isModalMode) { process.stdout.removeListener('resize', onResize) process.removeListener('SIGINT', sigHandler) process.removeListener('SIGTERM', sigHandler) @@ -759,19 +777,39 @@ export async function startTabTUI(opts) { resolve() return } + // In modal modes Esc falls through to per-tab handlers (form cancel, drift back, etc.) - // Tab switching — only when not in form/drift mode - const activeTabKey = tuiState.activeTabIndex > 0 ? tabs[tuiState.activeTabIndex].key : null - const isInFormMode = activeTabKey !== null && catTabStates[activeTabKey]?.mode === 'form' + // ── q: exit TUI only from list mode (in forms q is a regular character) ── + if (key.name === 'q' && !isModalMode) { + process.stdout.removeListener('resize', onResize) + process.removeListener('SIGINT', sigHandler) + process.removeListener('SIGTERM', sigHandler) + process.removeListener('exit', exitHandler) + cleanupTerminal() + resolve() + return + } + + // ── Left/Right arrows: tab switching (blocked in modal sub-modes) ── + if ((key.name === 'right' || key.name === 'l') && !isModalMode) { + tuiState = {...tuiState, activeTabIndex: (tuiState.activeTabIndex + 1) % tabs.length} + render() + return + } + if ((key.name === 'left' || key.name === 'h') && !isModalMode) { + tuiState = {...tuiState, activeTabIndex: (tuiState.activeTabIndex - 1 + tabs.length) % tabs.length} + render() + return + } + + // ── Tab: toggle Native/Managed section within category tabs ── if (key.name === 'tab' && !key.shift && !isInFormMode) { - // If active category tab has native entries, Tab switches section within the tab - const catState = activeTabKey ? catTabStates[activeTabKey] : null - if (catState && catState.nativeEntries && catState.nativeEntries.length > 0 && catState.mode !== 'drift') { + if (activeCatState && activeCatState.nativeEntries?.length > 0 && !isInDriftMode) { catTabStates = { ...catTabStates, [activeTabKey]: { - ...catState, - section: catState.section === 'managed' ? 'native' : 'managed', + ...activeCatState, + section: activeCatState.section === 'managed' ? 'native' : 'managed', selectedIndex: 0, revealedEntryId: null, }, @@ -779,12 +817,7 @@ export async function startTabTUI(opts) { render() return } - tuiState = { - ...tuiState, - activeTabIndex: (tuiState.activeTabIndex + 1) % tabs.length, - } - render() - return + // No native entries or Environments tab — Tab is a no-op (use ←/→) } // Delegate to active tab @@ -922,7 +955,8 @@ export async function startTabTUI(opts) { render() } } catch { - /* ignore */ + // Import failed — re-render so user sees the entry stayed in native + render() } return } @@ -989,6 +1023,7 @@ export async function startTabTUI(opts) { title: `Create ${tabLabel}`, status: 'editing', errorMessage: null, + customValidator: tabKey === 'mcp' ? validateMCPForm : null, }, }, } @@ -1023,6 +1058,7 @@ export async function startTabTUI(opts) { title: `Edit ${entry.name}`, status: 'editing', errorMessage: null, + customValidator: entry.type === 'mcp' ? validateMCPForm : null, }, }, } diff --git a/tests/unit/services/ai-env-deployer.test.js b/tests/unit/services/ai-env-deployer.test.js index 61917ef..458de64 100644 --- a/tests/unit/services/ai-env-deployer.test.js +++ b/tests/unit/services/ai-env-deployer.test.js @@ -255,14 +255,19 @@ describe('deployMCPEntry', () => { expect(json.mcpServers).toHaveProperty('claude-mcp') }) - it('handles opencode: writes to opencode.json with "mcpServers" key', async () => { + it('handles opencode: writes to opencode.json with "mcp" key in OpenCode format', async () => { const entry = makeMCPEntry({name: 'oc-mcp', environments: ['opencode']}) await deployMCPEntry(entry, 'opencode', cwd) const json = await readJson(join(cwd, 'opencode.json')) - expect(json).toHaveProperty('mcpServers') - expect(json.mcpServers).toHaveProperty('oc-mcp') + expect(json).toHaveProperty('mcp') + expect(json.mcp).toHaveProperty('oc-mcp') + // OpenCode format: command is array, environment instead of env, type is local/remote + const server = json.mcp['oc-mcp'] + expect(server).toHaveProperty('enabled', true) + expect(server).toHaveProperty('type', 'local') + expect(Array.isArray(server.command)).toBe(true) }) it('handles gemini-cli: writes to ~/.gemini/settings.json with "mcpServers" key', async () => { @@ -386,6 +391,96 @@ describe('deployMCPEntry', () => { expect(json.mcpServers).toHaveProperty('amazonq-mcp') }) + it('omits type field for stdio transport (environments infer it from command)', async () => { + const entry = makeMCPEntry({ + name: 'stdio-no-type', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', args: ['-y', 'test-pkg']}, + }) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + const server = json.mcpServers['stdio-no-type'] + expect(server).toHaveProperty('command', 'npx') + expect(server).not.toHaveProperty('type') + }) + + it('includes type field for sse transport', async () => { + const entry = makeMCPEntry({ + name: 'sse-server', + environments: ['claude-code'], + params: {transport: 'sse', url: 'https://mcp.example.com/sse'}, + }) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + const server = json.mcpServers['sse-server'] + expect(server).toHaveProperty('type', 'sse') + expect(server).toHaveProperty('url', 'https://mcp.example.com/sse') + }) + + it('includes type field for streamable-http transport', async () => { + const entry = makeMCPEntry({ + name: 'http-server', + environments: ['claude-code'], + params: {transport: 'streamable-http', url: 'https://mcp.example.com/http'}, + }) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + const server = json.mcpServers['http-server'] + expect(server).toHaveProperty('type', 'streamable-http') + expect(server).toHaveProperty('url', 'https://mcp.example.com/http') + }) + + it('deploys env vars to the server object', async () => { + const entry = makeMCPEntry({ + name: 'env-server', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', args: [], env: {API_KEY: 'abc123', SECRET: 'xyz'}}, + }) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + const server = json.mcpServers['env-server'] + expect(server.env).toEqual({API_KEY: 'abc123', SECRET: 'xyz'}) + }) + + it('normalizes legacy string args into array', async () => { + const entry = makeMCPEntry({ + name: 'legacy-args', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', args: 'mcp-proxy\n--transport=sse\nhttp://localhost:8123'}, + }) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + const server = json.mcpServers['legacy-args'] + expect(Array.isArray(server.args)).toBe(true) + expect(server.args).toEqual(['mcp-proxy', '--transport=sse', 'http://localhost:8123']) + }) + + it('normalizes legacy string env into object', async () => { + const entry = makeMCPEntry({ + name: 'legacy-env', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', env: 'API_KEY=abc123\nSECRET=xyz'}, + }) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + const server = json.mcpServers['legacy-env'] + expect(typeof server.env).toBe('object') + expect(Array.isArray(server.env)).toBe(false) + expect(server.env).toEqual({API_KEY: 'abc123', SECRET: 'xyz'}) + }) + it('is a no-op when entry type is not mcp', async () => { const entry = makeCommandEntry() diff --git a/tests/unit/services/ai-env-scanner.test.js b/tests/unit/services/ai-env-scanner.test.js index 001ae41..bd940d2 100644 --- a/tests/unit/services/ai-env-scanner.test.js +++ b/tests/unit/services/ai-env-scanner.test.js @@ -437,10 +437,11 @@ describe('scanEnvironments — 10 environments', () => { expect(result.some((e) => e.id === 'amazon-q')).toBe(true) }) - it('ENVIRONMENTS array contains all 10 environments', () => { + it('ENVIRONMENTS array contains all 11 environments', () => { const ids = ENVIRONMENTS.map((e) => e.id) expect(ids).toContain('vscode-copilot') expect(ids).toContain('claude-code') + expect(ids).toContain('claude-desktop') expect(ids).toContain('opencode') expect(ids).toContain('gemini-cli') expect(ids).toContain('copilot-cli') @@ -449,7 +450,7 @@ describe('scanEnvironments — 10 environments', () => { expect(ids).toContain('continue-dev') expect(ids).toContain('zed') expect(ids).toContain('amazon-q') - expect(ids).toHaveLength(10) + expect(ids).toHaveLength(11) }) it('cursor supportedCategories includes rule but not agent', () => { diff --git a/tests/unit/utils/tui/form.test.js b/tests/unit/utils/tui/form.test.js index 77e1558..0ea0178 100644 --- a/tests/unit/utils/tui/form.test.js +++ b/tests/unit/utils/tui/form.test.js @@ -10,6 +10,8 @@ import { getCommandFormFields, getSkillFormFields, getAgentFormFields, + updateMCPFieldVisibility, + validateMCPForm, } from '../../../../src/utils/tui/form.js' // ────────────────────────────────────────────────────────────────────────────── @@ -709,6 +711,7 @@ describe('getMCPFormFields', () => { expect(labels).toContain('Command') expect(labels).toContain('Args') expect(labels).toContain('URL') + expect(labels).toContain('Env Vars') expect(labels).toContain('Description') }) @@ -727,7 +730,7 @@ describe('getMCPFormFields', () => { it('returns correct number of fields', () => { const fields = getMCPFormFields() - expect(fields.length).toBe(7) // name, environments, transport, command, args, url, description + expect(fields.length).toBe(8) // name, environments, transport, command, args, url, env, description }) }) @@ -762,7 +765,8 @@ describe('getMCPFormFields with existing entry', () => { expect(/** @type {any} */ (urlField).value).toBe('https://mcp.example.com') const argsField = fields.find((f) => f.label === 'Args') - expect(/** @type {any} */ (argsField).value).toBe('--port 3000') + expect(/** @type {any} */ (argsField).type).toBe('editor') + expect(/** @type {any} */ (argsField).lines).toEqual(['--port', '3000']) }) }) @@ -870,3 +874,284 @@ describe('getAgentFormFields', () => { expect(/** @type {any} */ (instructionsField).lines).toEqual(['do this', 'do that']) }) }) + +// ────────────────────────────────────────────────────────────────────────────── +// Hidden fields +// ────────────────────────────────────────────────────────────────────────────── + +describe('hidden fields', () => { + it('hidden fields are skipped in extractValues', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const state = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + {type: 'text', label: 'Visible', key: 'visible', value: 'yes', cursor: 3, required: false, placeholder: ''}, + {type: 'text', label: 'Hidden', key: 'hidden_field', value: 'no', cursor: 2, required: false, placeholder: '', hidden: true}, + ], + } + const values = extractValues(state) + expect(values.visible).toBe('yes') + expect(values.hidden_field).toBeUndefined() + }) + + it('hidden fields are skipped in buildFormScreen', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const state = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + {type: 'text', label: 'Shown', key: 'shown', value: '', cursor: 0, required: false, placeholder: ''}, + {type: 'text', label: 'Invisible', key: 'invisible', value: '', cursor: 0, required: false, placeholder: '', hidden: true}, + ], + } + const lines = buildFormScreen(state, 24, 80).map(stripAnsi).join('\n') + expect(lines).toContain('Shown') + expect(lines).not.toContain('Invisible') + }) + + it('Tab skips hidden fields', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const state = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + {type: 'text', label: 'First', key: 'first', value: 'a', cursor: 1, required: false, placeholder: ''}, + {type: 'text', label: 'Middle', key: 'middle', value: 'b', cursor: 1, required: false, placeholder: '', hidden: true}, + {type: 'text', label: 'Last', key: 'last', value: 'c', cursor: 1, required: false, placeholder: ''}, + ], + } + const result = handleFormKeypress(state, namedKey('tab')) + expect(/** @type {any} */ (result).focusedFieldIndex).toBe(2) // skips index 1 + }) + + it('Shift+Tab skips hidden fields', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const state = { + title: 'Test', + focusedFieldIndex: 2, + status: 'editing', + errorMessage: null, + fields: [ + {type: 'text', label: 'First', key: 'first', value: 'a', cursor: 1, required: false, placeholder: ''}, + {type: 'text', label: 'Middle', key: 'middle', value: 'b', cursor: 1, required: false, placeholder: '', hidden: true}, + {type: 'text', label: 'Last', key: 'last', value: 'c', cursor: 1, required: false, placeholder: ''}, + ], + } + const result = handleFormKeypress(state, namedKey('tab', {shift: true})) + expect(/** @type {any} */ (result).focusedFieldIndex).toBe(0) // skips index 1 + }) + + it('hidden required fields are not validated', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const state = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + {type: 'text', label: 'Name', key: 'name', value: 'ok', cursor: 2, required: true, placeholder: ''}, + {type: 'text', label: 'URL', key: 'url', value: '', cursor: 0, required: true, placeholder: '', hidden: true}, + ], + } + // Should submit successfully because URL is hidden even though empty and required + const result = handleFormKeypress(state, namedKey('s', {ctrl: true})) + expect(result).toHaveProperty('submitted', true) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// updateMCPFieldVisibility +// ────────────────────────────────────────────────────────────────────────────── + +describe('updateMCPFieldVisibility', () => { + it('hides URL and shows Command/Args for stdio', () => { + const fields = getMCPFormFields() + const updated = updateMCPFieldVisibility(fields, 'stdio') + const commandField = updated.find((f) => f.key === 'command') + const argsField = updated.find((f) => f.key === 'args') + const urlField = updated.find((f) => f.key === 'url') + expect(commandField?.hidden).toBeFalsy() + expect(argsField?.hidden).toBeFalsy() + expect(urlField?.hidden).toBe(true) + }) + + it('hides Command/Args and shows URL for sse', () => { + const fields = getMCPFormFields() + const updated = updateMCPFieldVisibility(fields, 'sse') + const commandField = updated.find((f) => f.key === 'command') + const argsField = updated.find((f) => f.key === 'args') + const urlField = updated.find((f) => f.key === 'url') + expect(commandField?.hidden).toBe(true) + expect(argsField?.hidden).toBe(true) + expect(urlField?.hidden).toBeFalsy() + }) + + it('hides Command/Args and shows URL for streamable-http', () => { + const fields = getMCPFormFields() + const updated = updateMCPFieldVisibility(fields, 'streamable-http') + const commandField = updated.find((f) => f.key === 'command') + const argsField = updated.find((f) => f.key === 'args') + const urlField = updated.find((f) => f.key === 'url') + expect(commandField?.hidden).toBe(true) + expect(argsField?.hidden).toBe(true) + expect(urlField?.hidden).toBeFalsy() + }) + + it('keeps Env Vars and Description always visible', () => { + const fields = getMCPFormFields() + for (const transport of ['stdio', 'sse', 'streamable-http']) { + const updated = updateMCPFieldVisibility(fields, transport) + const envField = updated.find((f) => f.key === 'env') + const descField = updated.find((f) => f.key === 'description') + expect(envField?.hidden).toBeFalsy() + expect(descField?.hidden).toBeFalsy() + } + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// validateMCPForm +// ────────────────────────────────────────────────────────────────────────────── + +describe('validateMCPForm', () => { + /** + * @param {string} transport + * @param {object} [overrides] + * @returns {import('../../../../src/utils/tui/form.js').FormState} + */ + function makeMCPFormState(transport, overrides = {}) { + const fields = getMCPFormFields() + const updated = updateMCPFieldVisibility(fields, transport) + // Set the transport selector to the right index + const transportField = updated.find((f) => f.key === 'transport') + if (transportField?.type === 'selector') { + transportField.selectedIndex = transportField.options.indexOf(transport) + } + return { + title: 'Test', + focusedFieldIndex: 0, + status: /** @type {'editing'} */ ('editing'), + errorMessage: null, + fields: updated, + ...overrides, + } + } + + it('returns error when stdio has no command', () => { + const state = makeMCPFormState('stdio') + const err = validateMCPForm(state) + expect(err).toContain('Command is required') + }) + + it('returns null when stdio has a command', () => { + const state = makeMCPFormState('stdio') + const commandField = state.fields.find((f) => f.key === 'command') + if (commandField?.type === 'text') commandField.value = 'npx my-server' + const err = validateMCPForm(state) + expect(err).toBeNull() + }) + + it('returns error when sse has no URL', () => { + const state = makeMCPFormState('sse') + const err = validateMCPForm(state) + expect(err).toContain('URL is required') + }) + + it('returns null when sse has a URL', () => { + const state = makeMCPFormState('sse') + const urlField = state.fields.find((f) => f.key === 'url') + if (urlField?.type === 'text') urlField.value = 'https://mcp.example.com' + const err = validateMCPForm(state) + expect(err).toBeNull() + }) + + it('returns error for invalid env var format', () => { + const state = makeMCPFormState('stdio') + const commandField = state.fields.find((f) => f.key === 'command') + if (commandField?.type === 'text') commandField.value = 'npx server' + const envField = state.fields.find((f) => f.key === 'env') + if (envField?.type === 'editor') envField.lines = ['VALID=ok', 'INVALID_LINE'] + const err = validateMCPForm(state) + expect(err).toContain('Invalid env var format') + }) + + it('accepts valid env vars', () => { + const state = makeMCPFormState('stdio') + const commandField = state.fields.find((f) => f.key === 'command') + if (commandField?.type === 'text') commandField.value = 'npx server' + const envField = state.fields.find((f) => f.key === 'env') + if (envField?.type === 'editor') envField.lines = ['API_KEY=abc123', 'SECRET=xyz'] + const err = validateMCPForm(state) + expect(err).toBeNull() + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getMCPFormFields — dynamic visibility on create +// ────────────────────────────────────────────────────────────────────────────── + +describe('getMCPFormFields — default stdio hides URL', () => { + it('URL is hidden and Command is visible for default stdio transport', () => { + const fields = getMCPFormFields() + const urlField = fields.find((f) => f.key === 'url') + const commandField = fields.find((f) => f.key === 'command') + expect(urlField?.hidden).toBe(true) + expect(commandField?.hidden).toBeFalsy() + }) + + it('Command is hidden and URL is visible when entry has sse transport', () => { + /** @type {import('../../../../src/types.js').CategoryEntry} */ + const entry = { + id: 'test', + name: 'remote-mcp', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'sse', url: 'https://example.com'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + const fields = getMCPFormFields(entry) + const urlField = fields.find((f) => f.key === 'url') + const commandField = fields.find((f) => f.key === 'command') + expect(urlField?.hidden).toBeFalsy() + expect(commandField?.hidden).toBe(true) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getMCPFormFields — env vars pre-fill +// ────────────────────────────────────────────────────────────────────────────── + +describe('getMCPFormFields — env vars', () => { + it('pre-fills env vars from entry params', () => { + /** @type {import('../../../../src/types.js').CategoryEntry} */ + const entry = { + id: 'test', + name: 'my-mcp', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx server', env: {API_KEY: 'abc', SECRET: 'xyz'}}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + const fields = getMCPFormFields(entry) + const envField = fields.find((f) => f.key === 'env') + expect(envField?.type).toBe('editor') + expect(/** @type {any} */ (envField).lines).toEqual(['API_KEY=abc', 'SECRET=xyz']) + }) + + it('starts with empty line when no env vars', () => { + const fields = getMCPFormFields() + const envField = fields.find((f) => f.key === 'env') + expect(/** @type {any} */ (envField).lines).toEqual(['']) + }) +})