diff --git a/CHANGELOG.md b/CHANGELOG.md index e56c198..7b473ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1] - 2026-04-02 + +### Added + +- **sync-config-ai:** 5 new AI environments — Cursor, Windsurf, Continue.dev, Zed, Amazon Q (10 total) +- **sync-config-ai:** Rules category (5th type) with MDC format for Cursor, markdown for others +- **sync-config-ai:** Native entries — read-only section showing unmanaged items already in each environment's config +- **sync-config-ai:** Drift detection — ⚠ indicator on managed entries that diverged from file state; Enter opens resolution view with re-deploy / accept-changes actions +- **sync-config-ai:** Env var masking — MCP env vars masked by default (`first6chars***`); press `r` to reveal +- **sync-config-ai:** Import to sync — press `i` on a native entry to bring it into dvmi management +- **sync-config-ai:** Tab key switches between Native and Managed sections within each category tab +- **sync-config-ai:** Chezmoi auto-sync after every create / edit / delete / activate / deactivate +- **sync-config-ai:** Schema v2 for `ai-config.json` with automatic v1 → v2 migration + +### Changed + +- **sync-config-ai:** `--json` output now includes `nativeEntries` (grouped by type), `rule` category, and `drifted` boolean per entry +- **sync-config-ai:** Format translation extended — YAML merge for Continue.dev, TOML templates for Gemini CLI commands, MDC (YAML frontmatter + content) for Cursor rules + ## [1.5.0] - 2026-04-01 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 518c44e..0d1cd58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,10 @@ # devvami Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-04-01 +Auto-generated from all feature plans. Last updated: 2026-04-02 ## Active Technologies +- JavaScript (ESM, `.js`) with JSDoc — Node.js >= 24 + `@oclif/core` v4, `chalk` v5, `ora` v8, `execa` v9, `js-yaml` v4 (already installed) — zero new dependencies (007-sync-ai-config-tui) +- JSON file at `~/.config/dvmi/ai-config.json` (same pattern as `config.json`) (007-sync-ai-config-tui) - JavaScript (ESM, `.js`) with JSDoc — Node.js >= 24 + `@oclif/core` v4, `chalk` v5, `ora` v8, `execa` v9 — zero new TUI dependencies (007-sync-ai-config-tui) @@ -22,6 +24,7 @@ npm test && npm run lint JavaScript (ESM, `.js`) with JSDoc — Node.js >= 24: Follow standard conventions ## Recent Changes +- 007-sync-ai-config-tui: Added JavaScript (ESM, `.js`) with JSDoc — Node.js >= 24 + `@oclif/core` v4, `chalk` v5, `ora` v8, `execa` v9, `js-yaml` v4 (already installed) — zero new dependencies - 007-sync-ai-config-tui: Added JavaScript (ESM, `.js`) with JSDoc — Node.js >= 24 + `@oclif/core` v4, `chalk` v5, `ora` v8, `execa` v9 — zero new TUI dependencies diff --git a/README.md b/README.md index e6badb5..074a33f 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,19 @@ dvmi security setup # Interactive wizard to set up credential protection tools ### AI Config ```bash -dvmi sync-config-ai # Manage AI tool configurations across environments via TUI +dvmi sync-config-ai # Manage AI tool configurations across environments via TUI +dvmi sync-config-ai --json # Output current state as structured JSON (CI / scripting) ``` +The TUI shows 6 tabs — **Environments** (read-only detection) + one tab per category (**MCPs**, **Commands**, **Rules**, **Skills**, **Agents**). Each category tab has two sections: + +- **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 + +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 + ### Other ```bash diff --git a/package.json b/package.json index 8f2d1a6..66047f1 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.0", + "version": "1.5.1", "author": "", "type": "module", "bin": { diff --git a/src/commands/sync-config-ai/index.js b/src/commands/sync-config-ai/index.js index e69a05d..788b40a 100644 --- a/src/commands/sync-config-ai/index.js +++ b/src/commands/sync-config-ai/index.js @@ -1,7 +1,7 @@ import {Command, Flags} from '@oclif/core' import ora from 'ora' -import {scanEnvironments, computeCategoryCounts} from '../../services/ai-env-scanner.js' +import {scanEnvironments, computeCategoryCounts, parseNativeEntries, detectDrift, ENVIRONMENTS} from '../../services/ai-env-scanner.js' import { loadAIConfig, addEntry, @@ -9,10 +9,11 @@ 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' -import {formatEnvironmentsTable, formatCategoriesTable} from '../../formatters/ai-config.js' +import {formatEnvironmentsTable, formatCategoriesTable, formatNativeEntriesTable} from '../../formatters/ai-config.js' import {startTabTUI} from '../../utils/tui/tab-tui.js' import {DvmiError} from '../../utils/errors.js' @@ -75,17 +76,57 @@ export default class SyncConfigAi extends Command { env.counts = computeCategoryCounts(env.id, store.entries) } + // ── Parse native entries and populate nativeCounts ─────────────────────── + const envDefMap = new Map(ENVIRONMENTS.map((e) => [e.id, e])) + for (const env of detectedEnvs) { + const envDef = envDefMap.get(env.id) + if (!envDef) continue + const natives = parseNativeEntries(envDef, process.cwd(), store.entries) + env.nativeEntries = natives + // Aggregate native counts per category + env.nativeCounts = {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0} + for (const ne of natives) { + env.nativeCounts[ne.type] = (env.nativeCounts[ne.type] ?? 0) + 1 + } + } + + // ── Detect drift for managed entries ──────────────────────────────────── + const driftInfos = detectDrift(detectedEnvs, store.entries, process.cwd()) + for (const env of detectedEnvs) { + env.driftedEntries = driftInfos.filter((d) => d.environmentId === env.id) + } + spinner?.stop() // ── JSON mode ──────────────────────────────────────────────────────────── if (isJson) { + if (detectedEnvs.length === 0) { + this.exit(2) + } + + // Collect all native entries grouped by type + const allNatives = detectedEnvs.flatMap((e) => e.nativeEntries ?? []) + + // Build drifted set for quick lookup + const driftedIds = new Set(driftInfos.map((d) => d.entryId)) + const categories = { - mcp: store.entries.filter((e) => e.type === 'mcp'), - command: store.entries.filter((e) => e.type === 'command'), - skill: store.entries.filter((e) => e.type === 'skill'), - agent: store.entries.filter((e) => e.type === 'agent'), + mcp: store.entries.filter((e) => e.type === 'mcp').map((e) => ({...e, drifted: driftedIds.has(e.id)})), + command: store.entries.filter((e) => e.type === 'command').map((e) => ({...e, drifted: driftedIds.has(e.id)})), + rule: store.entries.filter((e) => e.type === 'rule').map((e) => ({...e, drifted: driftedIds.has(e.id)})), + skill: store.entries.filter((e) => e.type === 'skill').map((e) => ({...e, drifted: driftedIds.has(e.id)})), + agent: store.entries.filter((e) => e.type === 'agent').map((e) => ({...e, drifted: driftedIds.has(e.id)})), + } + + const nativeEntries = { + mcp: allNatives.filter((e) => e.type === 'mcp'), + command: allNatives.filter((e) => e.type === 'command'), + rule: allNatives.filter((e) => e.type === 'rule'), + skill: allNatives.filter((e) => e.type === 'skill'), + agent: allNatives.filter((e) => e.type === 'agent'), } - return {environments: detectedEnvs, categories} + + return {environments: detectedEnvs, categories, nativeEntries} } // ── Check chezmoi config ───────────────────────────────────────────────── @@ -104,6 +145,7 @@ export default class SyncConfigAi extends Command { chezmoiEnabled, formatEnvs: formatEnvironmentsTable, formatCats: formatCategoriesTable, + formatNative: formatNativeEntriesTable, refreshEntries: async () => { const s = await loadAIConfig() return s.entries @@ -120,9 +162,11 @@ export default class SyncConfigAi extends Command { params: action.values, }) await deployEntry(created, detectedEnvs, process.cwd()) + await syncAIConfigToChezmoi() } else if (action.type === 'edit') { const updated = await updateEntry(action.id, {params: action.values}) await deployEntry(updated, detectedEnvs, process.cwd()) + await syncAIConfigToChezmoi() } else if (action.type === 'delete') { await deleteEntry(action.id) await undeployEntry( @@ -130,12 +174,37 @@ export default class SyncConfigAi extends Command { 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, + 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) + if (entry) await deployEntry(entry, detectedEnvs, process.cwd()) + } else if (action.type === 'accept-drift') { + // T018: Accept drift — update store params from the actual file state + const drift = driftInfos.find((d) => d.entryId === action.id) + if (drift) { + await updateEntry(action.id, {params: drift.actual}) + await syncAIConfigToChezmoi() + } } }, }) diff --git a/src/formatters/ai-config.js b/src/formatters/ai-config.js index de68414..7d59144 100644 --- a/src/formatters/ai-config.js +++ b/src/formatters/ai-config.js @@ -1,6 +1,6 @@ import chalk from 'chalk' -/** @import { DetectedEnvironment, CategoryEntry } from '../types.js' */ +/** @import { DetectedEnvironment, CategoryEntry, NativeEntry } from '../types.js' */ // ────────────────────────────────────────────────────────────────────────────── // Internal helpers @@ -41,11 +41,12 @@ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) { chalk.bold.white(padCell('Scope', COL_SCOPE)), chalk.bold.white(padCell('MCPs', COL_COUNT)), chalk.bold.white(padCell('Commands', COL_COUNT)), + chalk.bold.white(padCell('Rules', COL_COUNT)), chalk.bold.white(padCell('Skills', COL_COUNT)), chalk.bold.white(padCell('Agents', COL_COUNT)), ] - const dividerWidth = COL_ENV + COL_STATUS + COL_SCOPE + COL_COUNT * 4 + 6 * 2 + const dividerWidth = COL_ENV + COL_STATUS + COL_SCOPE + COL_COUNT * 5 + 7 * 2 const lines = [] lines.push(headerParts.join(' ')) lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth)))) @@ -58,25 +59,24 @@ 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.counts.mcp), COL_COUNT) - const cmdStr = padCell(String(env.counts.command), COL_COUNT) + const mcpStr = padCell(String(env.nativeCounts?.mcp ?? 0), COL_COUNT) + const cmdStr = padCell(String(env.nativeCounts?.command ?? 0), COL_COUNT) + const ruleStr = env.supportedCategories.includes('rule') + ? padCell(String(env.nativeCounts?.rule ?? 0), COL_COUNT) + : padCell('—', COL_COUNT) const skillStr = env.supportedCategories.includes('skill') - ? padCell(String(env.counts.skill), COL_COUNT) + ? padCell(String(env.nativeCounts?.skill ?? 0), COL_COUNT) : padCell('—', COL_COUNT) const agentStr = env.supportedCategories.includes('agent') - ? padCell(String(env.counts.agent), COL_COUNT) + ? padCell(String(env.nativeCounts?.agent ?? 0), COL_COUNT) : padCell('—', COL_COUNT) - lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, skillStr, agentStr].join(' ')) + lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, ruleStr, skillStr, agentStr].join(' ')) } return lines } -// ────────────────────────────────────────────────────────────────────────────── -// Categories table formatter -// ────────────────────────────────────────────────────────────────────────────── - /** @type {Record} */ const ENV_SHORT_NAMES = { 'vscode-copilot': 'VSCode', @@ -84,8 +84,92 @@ const ENV_SHORT_NAMES = { opencode: 'OpenCode', 'gemini-cli': 'Gemini', 'copilot-cli': 'Copilot', + cursor: 'Cursor', + windsurf: 'Windsurf', + 'continue-dev': 'Continue', + zed: 'Zed', + 'amazon-q': 'Amazon Q', +} + +/** + * Mask an environment variable value for display. + * Shows first 6 characters followed by ***. + * @param {string} value + * @returns {string} + */ +export function maskEnvVarValue(value) { + if (!value || value.length <= 6) return '***' + return value.slice(0, 6) + '***' } +// ────────────────────────────────────────────────────────────────────────────── +// Native entries table formatter +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Format native entries as a table for display in a category tab's Native section. + * @param {NativeEntry[]} entries + * @param {number} [termCols] + * @returns {string[]} + */ +export function formatNativeEntriesTable(entries, termCols = 120) { + const COL_NAME = 24 + const COL_ENV = 16 + const COL_LEVEL = 8 + const COL_CONFIG = 36 + + const headerParts = [ + chalk.bold.white(padCell('Name', COL_NAME)), + chalk.bold.white(padCell('Environment', COL_ENV)), + chalk.bold.white(padCell('Level', COL_LEVEL)), + chalk.bold.white(padCell('Config', COL_CONFIG)), + ] + + const dividerWidth = COL_NAME + COL_ENV + COL_LEVEL + COL_CONFIG + 3 * 2 + const lines = [] + lines.push(headerParts.join(' ')) + lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth)))) + + for (const entry of entries) { + const envShort = ENV_SHORT_NAMES[entry.environmentId] ?? entry.environmentId + const levelStr = padCell(entry.level, COL_LEVEL) + + // Build config summary + const params = /** @type {any} */ (entry.params ?? {}) + let configSummary = '' + if (entry.type === 'mcp') { + if (params.command) { + const args = Array.isArray(params.args) ? params.args.slice(0, 2).join(' ') : '' + configSummary = [params.command, args].filter(Boolean).join(' ') + } else if (params.url) { + configSummary = params.url + } + // Mask env vars + if (params.env && Object.keys(params.env).length > 0) { + const maskedVars = Object.keys(params.env) + .map((k) => `${k}=${maskEnvVarValue(params.env[k])}`) + .join(', ') + configSummary = configSummary ? `${configSummary} [${maskedVars}]` : maskedVars + } + } else { + configSummary = params.description ?? params.content?.slice(0, 30) ?? '' + } + + lines.push([ + padCell(entry.name, COL_NAME), + padCell(envShort, COL_ENV), + levelStr, + padCell(configSummary, COL_CONFIG), + ].join(' ')) + } + + return lines +} + +// ────────────────────────────────────────────────────────────────────────────── +// Categories table formatter +// ────────────────────────────────────────────────────────────────────────────── + /** * Format a list of category entries as a table string for display in the TUI. * Columns: Name, Type, Status, Environments @@ -113,7 +197,9 @@ export function formatCategoriesTable(entries, termCols = 120) { for (const entry of entries) { const statusStr = entry.active - ? chalk.green(padCell('Active', COL_STATUS)) + ? (/** @type {any} */ (entry)).drifted + ? chalk.yellow(padCell('⚠ Drifted', COL_STATUS)) + : chalk.green(padCell('Active', COL_STATUS)) : chalk.dim(padCell('Inactive', COL_STATUS)) const envNames = entry.environments.map((id) => ENV_SHORT_NAMES[id] ?? id).join(', ') diff --git a/src/services/ai-config-store.js b/src/services/ai-config-store.js index 1875146..972778f 100644 --- a/src/services/ai-config-store.js +++ b/src/services/ai-config-store.js @@ -8,7 +8,7 @@ import {DvmiError} from '../utils/errors.js' import {exec} from './shell.js' import {loadConfig} from './config.js' -/** @import { AIConfigStore, CategoryEntry, CategoryType, EnvironmentId, MCPParams, CommandParams, SkillParams, AgentParams } from '../types.js' */ +/** @import { AIConfigStore, CategoryEntry, CategoryType, EnvironmentId, MCPParams, CommandParams, RuleParams, SkillParams, AgentParams } from '../types.js' */ // ────────────────────────────────────────────────────────────────────────────── // Path resolution @@ -26,11 +26,16 @@ export const AI_CONFIG_PATH = join(CONFIG_DIR, 'ai-config.json') /** @type {Record} */ const COMPATIBILITY = { - 'vscode-copilot': ['mcp', 'command', 'skill', 'agent'], - 'claude-code': ['mcp', 'command', 'skill', 'agent'], - opencode: ['mcp', 'command', 'skill', 'agent'], - 'gemini-cli': ['mcp', 'command'], - 'copilot-cli': ['mcp', 'command', 'skill', 'agent'], + 'vscode-copilot': ['mcp', 'command', 'rule', 'skill', 'agent'], + 'claude-code': ['mcp', 'command', 'rule', 'skill', 'agent'], + opencode: ['mcp', 'command', 'rule', 'skill', 'agent'], + 'gemini-cli': ['mcp', 'command', 'rule'], + 'copilot-cli': ['mcp', 'command', 'rule', 'skill', 'agent'], + cursor: ['mcp', 'command', 'rule', 'skill'], + windsurf: ['mcp', 'command', 'rule'], + 'continue-dev': ['mcp', 'command', 'rule', 'agent'], + zed: ['mcp', 'rule'], + 'amazon-q': ['mcp', 'rule', 'agent'], } /** All known environment IDs. */ @@ -45,7 +50,20 @@ const UNSAFE_CHARS = /[/\\:*?"<>|]/ /** @returns {AIConfigStore} */ function defaultStore() { - return {version: 1, entries: []} + return {version: 2, 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. + * @param {AIConfigStore} store + * @returns {AIConfigStore} + */ +function migrateStore(store) { + if (store.version === 1) { + return {...store, version: 2} + } + return store } // ────────────────────────────────────────────────────────────────────────────── @@ -93,6 +111,20 @@ function validateEnvironments(environments, type) { } } +/** + * Assert that rule params contain a non-empty string `content` field. + * @param {RuleParams} params + * @returns {void} + */ +function validateRuleParams(params) { + if (!params || typeof params.content !== 'string' || params.content.trim() === '') { + throw new DvmiError( + 'Rule entry requires a non-empty "content" string', + 'Provide the rule content, e.g. { content: "Always use TypeScript" }', + ) + } +} + // ────────────────────────────────────────────────────────────────────────────── // Core I/O // ────────────────────────────────────────────────────────────────────────────── @@ -108,10 +140,7 @@ export async function loadAIConfig(configPath = process.env.DVMI_AI_CONFIG_PATH try { const raw = await readFile(configPath, 'utf8') const parsed = JSON.parse(raw) - return { - version: parsed.version ?? 1, - entries: Array.isArray(parsed.entries) ? parsed.entries : [], - } + return migrateStore({version: parsed.version ?? 1, entries: Array.isArray(parsed.entries) ? parsed.entries : []}) } catch { return defaultStore() } @@ -139,7 +168,7 @@ export async function saveAIConfig(store, configPath = process.env.DVMI_AI_CONFI /** * Add a new entry to the AI config store. - * @param {{ name: string, type: CategoryType, environments: EnvironmentId[], params: MCPParams|CommandParams|SkillParams|AgentParams }} entryData + * @param {{ name: string, type: CategoryType, environments: EnvironmentId[], params: MCPParams|CommandParams|RuleParams|SkillParams|AgentParams }} entryData * @param {string} [configPath] * @returns {Promise} */ @@ -148,6 +177,7 @@ export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFI validateName(name) validateEnvironments(environments, type) + if (type === 'rule') validateRuleParams(/** @type {RuleParams} */ (params)) const store = await loadAIConfig(configPath) diff --git a/src/services/ai-env-deployer.js b/src/services/ai-env-deployer.js index d01b9f2..a2b321b 100644 --- a/src/services/ai-env-deployer.js +++ b/src/services/ai-env-deployer.js @@ -9,6 +9,7 @@ import {readFile, writeFile, mkdir, rm} from 'node:fs/promises' import {existsSync} from 'node:fs' import {join, dirname} from 'node:path' import {homedir} from 'node:os' +import yaml from 'js-yaml' /** @import { CategoryEntry, CategoryType, EnvironmentId, DetectedEnvironment } from '../types.js' */ @@ -20,7 +21,7 @@ import {homedir} from 'node:os' * 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 string, mcpKey: string }>} + * @type {Record string, mcpKey: string, isYaml?: boolean }>} */ const MCP_TARGETS = { 'vscode-copilot': { @@ -43,6 +44,27 @@ const MCP_TARGETS = { resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'), mcpKey: 'mcpServers', }, + cursor: { + resolvePath: (cwd) => join(cwd, '.cursor', 'mcp.json'), + mcpKey: 'mcpServers', + }, + windsurf: { + resolvePath: (_cwd) => join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), + mcpKey: 'mcpServers', + }, + 'continue-dev': { + resolvePath: (_cwd) => join(homedir(), '.continue', 'config.yaml'), + mcpKey: 'mcpServers', + isYaml: true, + }, + zed: { + resolvePath: (_cwd) => join(homedir(), '.config', 'zed', 'settings.json'), + mcpKey: 'context_servers', + }, + 'amazon-q': { + resolvePath: (cwd) => join(cwd, '.amazonq', 'mcp.json'), + mcpKey: 'mcpServers', + }, } /** @@ -62,6 +84,8 @@ function resolveFilePath(name, type, envId, cwd) { return resolveSkillPath(name, envId, cwd) case 'agent': return resolveAgentPath(name, envId, cwd) + case 'rule': + return resolveRulePath(name, envId, cwd) default: throw new Error(`Unsupported file entry type: ${type}`) } @@ -86,6 +110,12 @@ function resolveCommandPath(name, envId, cwd) { case 'copilot-cli': // shared path with vscode-copilot for commands return join(cwd, '.github', 'prompts', `${name}.prompt.md`) + case 'cursor': + return join(cwd, '.cursor', 'commands', `${name}.md`) + case 'windsurf': + return join(cwd, '.windsurf', 'workflows', `${name}.md`) + case 'continue-dev': + return join(cwd, '.continue', 'prompts', `${name}.md`) default: throw new Error(`Unknown environment for command: ${envId}`) } @@ -108,6 +138,8 @@ function resolveSkillPath(name, envId, cwd) { return join(cwd, '.opencode', 'skills', `${name}.md`) case 'copilot-cli': return join(homedir(), '.copilot', 'skills', `${name}.md`) + case 'cursor': + return join(cwd, '.cursor', 'skills', `${name}.md`) default: throw new Error(`Environment "${envId}" does not support skill entries`) } @@ -129,11 +161,48 @@ function resolveAgentPath(name, envId, cwd) { return join(cwd, '.opencode', 'agents', `${name}.md`) case 'copilot-cli': return join(homedir(), '.copilot', 'agents', `${name}.md`) + case 'continue-dev': + return join(cwd, '.continue', 'agents', `${name}.md`) + case 'amazon-q': + return join(homedir(), '.aws', 'amazonq', 'cli-agents', `${name}.json`) default: throw new Error(`Environment "${envId}" does not support agent entries`) } } +/** + * @param {string} name + * @param {EnvironmentId} envId + * @param {string} cwd + * @returns {string} + */ +function resolveRulePath(name, envId, cwd) { + switch (envId) { + case 'vscode-copilot': + return join(cwd, '.github', 'instructions', `${name}.md`) + case 'claude-code': + return join(cwd, '.claude', 'rules', `${name}.md`) + case 'opencode': + return join(cwd, 'AGENTS.md') + case 'gemini-cli': + return join(cwd, 'GEMINI.md') + case 'copilot-cli': + return join(cwd, '.github', 'copilot-instructions.md') + case 'cursor': + return join(cwd, '.cursor', 'rules', `${name}.mdc`) + case 'windsurf': + return join(cwd, '.windsurf', 'rules', `${name}.md`) + case 'continue-dev': + return join(cwd, '.continue', 'rules', `${name}.md`) + case 'zed': + return join(cwd, '.rules') + case 'amazon-q': + return join(cwd, '.amazonq', 'rules', `${name}.md`) + default: + throw new Error(`Environment "${envId}" does not support rule entries`) + } +} + // ────────────────────────────────────────────────────────────────────────────── // TOML rendering // ────────────────────────────────────────────────────────────────────────────── @@ -158,6 +227,23 @@ ${safeContent} ` } +/** + * Render a Cursor rule as MDC (Markdown with YAML frontmatter). + * @param {string} name - Rule name (used as identifier) + * @param {string} description - Short description + * @param {string} content - Rule content + * @returns {string} + */ +function renderCursorMDC(name, description, content) { + return `--- +description: ${description || name} +globs: +alwaysApply: false +--- +${content} +` +} + // ────────────────────────────────────────────────────────────────────────────── // JSON helpers // ────────────────────────────────────────────────────────────────────────────── @@ -191,6 +277,32 @@ async function writeJson(filePath, data) { await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8') } +/** + * Read a YAML file from disk. Returns an empty object when the file is missing. + * Throws if the file exists but cannot be parsed. + * @param {string} filePath + * @returns {Promise>} + */ +async function readYamlOrEmpty(filePath) { + if (!existsSync(filePath)) return {} + const raw = await readFile(filePath, 'utf8') + return /** @type {Record} */ (yaml.load(raw) ?? {}) +} + +/** + * Write a value to disk as YAML, creating parent directories as needed. + * @param {string} filePath + * @param {unknown} data + * @returns {Promise} + */ +async function writeYaml(filePath, data) { + const dir = dirname(filePath) + if (!existsSync(dir)) { + await mkdir(dir, {recursive: true}) + } + await writeFile(filePath, yaml.dump(data, {lineWidth: -1}), 'utf8') +} + // ────────────────────────────────────────────────────────────────────────────── // Build MCP server object from entry params // ────────────────────────────────────────────────────────────────────────────── @@ -241,7 +353,7 @@ export async function deployMCPEntry(entry, envId, cwd) { if (!target) return const filePath = target.resolvePath(cwd) - const json = await readJsonOrEmpty(filePath) + const json = target.isYaml ? await readYamlOrEmpty(filePath) : await readJsonOrEmpty(filePath) if (!json[target.mcpKey] || typeof json[target.mcpKey] !== 'object') { json[target.mcpKey] = {} @@ -251,7 +363,11 @@ export async function deployMCPEntry(entry, envId, cwd) { const mcpKey = /** @type {any} */ (json[target.mcpKey]) mcpKey[entry.name] = buildMCPServerObject(/** @type {import('../types.js').MCPParams} */ (entry.params)) - await writeJson(filePath, json) + if (target.isYaml) { + await writeYaml(filePath, json) + } else { + await writeJson(filePath, json) + } } /** @@ -272,13 +388,17 @@ export async function undeployMCPEntry(entryName, envId, cwd) { const filePath = target.resolvePath(cwd) if (!existsSync(filePath)) return - const json = await readJsonOrEmpty(filePath) + const json = target.isYaml ? await readYamlOrEmpty(filePath) : await readJsonOrEmpty(filePath) if (json[target.mcpKey] && typeof json[target.mcpKey] === 'object') { delete (/** @type {any} */ (json[target.mcpKey])[entryName]) } - await writeJson(filePath, json) + if (target.isYaml) { + await writeYaml(filePath, json) + } else { + await writeJson(filePath, json) + } } // ────────────────────────────────────────────────────────────────────────────── @@ -317,6 +437,14 @@ export async function deployFileEntry(entry, envId, cwd) { const params = /** @type {any} */ (entry.params) + // Cursor rules use MDC format (Markdown with YAML frontmatter) + if (envId === 'cursor' && entry.type === 'rule') { + const description = params.description ?? '' + const content = params.content ?? '' + await writeFile(filePath, renderCursorMDC(entry.name, description, content), 'utf8') + return + } + // Gemini CLI commands use TOML format if (envId === 'gemini-cli' && entry.type === 'command') { const description = params.description ?? '' diff --git a/src/services/ai-env-scanner.js b/src/services/ai-env-scanner.js index 518283c..280a455 100644 --- a/src/services/ai-env-scanner.js +++ b/src/services/ai-env-scanner.js @@ -3,11 +3,12 @@ * Detects AI coding environments by scanning well-known project and global config paths. */ -import {existsSync, readFileSync} from 'node:fs' +import {existsSync, readFileSync, readdirSync} from 'node:fs' import {resolve, join} from 'node:path' import {homedir} from 'node:os' +import yaml from 'js-yaml' -/** @import { CategoryType, EnvironmentId, PathStatus, CategoryCounts, DetectedEnvironment, CategoryEntry } from '../types.js' */ +/** @import { CategoryType, EnvironmentId, PathStatus, CategoryCounts, DetectedEnvironment, CategoryEntry, NativeEntry, DriftInfo } from '../types.js' */ /** * @typedef {Object} PathSpec @@ -41,7 +42,7 @@ export const ENVIRONMENTS = Object.freeze([ {path: '.github/skills/', isJson: false}, ], globalPaths: [], - supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']), }, { id: /** @type {EnvironmentId} */ ('claude-code'), @@ -54,8 +55,12 @@ export const ENVIRONMENTS = Object.freeze([ {path: '.claude/agents/', isJson: false}, {path: '.claude/rules/', isJson: false}, ], - globalPaths: [], - supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + globalPaths: [ + {path: '~/.claude.json', isJson: true}, + {path: '~/.claude/commands/', isJson: false}, + {path: '~/.claude/agents/', isJson: false}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']), }, { id: /** @type {EnvironmentId} */ ('opencode'), @@ -66,6 +71,7 @@ export const ENVIRONMENTS = Object.freeze([ {path: '.opencode/skills/', isJson: false}, {path: '.opencode/agents/', isJson: false}, {path: 'opencode.json', isJson: true}, + {path: 'opencode.toml', isJson: false}, ], globalPaths: [ {path: '~/.config/opencode/opencode.json', isJson: true}, @@ -73,7 +79,7 @@ export const ENVIRONMENTS = Object.freeze([ {path: '~/.config/opencode/agents/', isJson: false}, {path: '~/.config/opencode/skills/', isJson: false}, ], - supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']), }, { id: /** @type {EnvironmentId} */ ('gemini-cli'), @@ -82,13 +88,19 @@ export const ENVIRONMENTS = Object.freeze([ globalPaths: [ {path: '~/.gemini/settings.json', isJson: true}, {path: '~/.gemini/commands/', isJson: false}, + {path: '~/.gemini/GEMINI.md', isJson: false}, ], - supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command']), + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule']), }, { id: /** @type {EnvironmentId} */ ('copilot-cli'), name: 'GitHub Copilot CLI', - projectPaths: [], + projectPaths: [ + {path: '.github/copilot-instructions.md', isJson: false}, + {path: '.github/prompts/', isJson: false}, + {path: '.github/agents/', isJson: false}, + {path: '.github/skills/', isJson: false}, + ], globalPaths: [ {path: '~/.copilot/config.json', isJson: true}, {path: '~/.copilot/mcp-config.json', isJson: true}, @@ -96,10 +108,100 @@ export const ENVIRONMENTS = Object.freeze([ {path: '~/.copilot/skills/', isJson: false}, {path: '~/.copilot/copilot-instructions.md', isJson: false}, ], - supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill', 'agent']), + }, + { + id: /** @type {EnvironmentId} */ ('cursor'), + name: 'Cursor', + projectPaths: [ + {path: '.cursor/mcp.json', isJson: true}, + {path: '.cursor/commands/', isJson: false}, + {path: '.cursor/rules/', isJson: false}, + {path: '.cursor/skills/', isJson: false}, + ], + globalPaths: [ + {path: '~/.cursor/mcp.json', isJson: true}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'skill']), + }, + { + id: /** @type {EnvironmentId} */ ('windsurf'), + name: 'Windsurf', + projectPaths: [ + {path: '.windsurf/workflows/', isJson: false}, + {path: '.windsurf/rules/', isJson: false}, + ], + globalPaths: [ + {path: '~/.codeium/windsurf/mcp_config.json', isJson: true}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule']), + }, + { + id: /** @type {EnvironmentId} */ ('continue-dev'), + name: 'Continue.dev', + projectPaths: [ + {path: '.continue/config.yaml', isJson: false}, + {path: '.continue/prompts/', isJson: false}, + {path: '.continue/rules/', isJson: false}, + {path: '.continue/agents/', isJson: false}, + ], + globalPaths: [ + {path: '~/.continue/config.yaml', isJson: false}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'rule', 'agent']), + }, + { + id: /** @type {EnvironmentId} */ ('zed'), + name: 'Zed', + projectPaths: [ + {path: '.rules', isJson: false}, + ], + globalPaths: [ + {path: '~/.config/zed/settings.json', isJson: true}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'rule']), + }, + { + id: /** @type {EnvironmentId} */ ('amazon-q'), + name: 'Amazon Q Developer', + projectPaths: [ + {path: '.amazonq/mcp.json', isJson: true}, + {path: '.amazonq/rules/', isJson: false}, + {path: '.amazonq/cli-agents/', isJson: false}, + ], + globalPaths: [ + {path: '~/.aws/amazonq/mcp.json', isJson: true}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'rule', 'agent']), }, ]) +/** @type {Record} */ +export const COMPATIBILITY = { + 'vscode-copilot': ['mcp', 'command', 'rule', 'skill', 'agent'], + 'claude-code': ['mcp', 'command', 'rule', 'skill', 'agent'], + 'opencode': ['mcp', 'command', 'rule', 'skill', 'agent'], + 'gemini-cli': ['mcp', 'command', 'rule'], + 'copilot-cli': ['mcp', 'command', 'rule', 'skill', 'agent'], + 'cursor': ['mcp', 'command', 'rule', 'skill'], + 'windsurf': ['mcp', 'command', 'rule'], + 'continue-dev': ['mcp', 'command', 'rule', 'agent'], + 'zed': ['mcp', 'rule'], + 'amazon-q': ['mcp', 'rule', 'agent'], +} + +/** + * Groups of environments that share the same config file for a given path. + * Used for auto-grouping in the form's environment multi-select. + * @type {Record} + */ +export const SHARED_PATHS = { + '.mcp.json': ['claude-code', 'copilot-cli'], + '.github/prompts/': ['vscode-copilot', 'copilot-cli'], + '.github/copilot-instructions.md': ['vscode-copilot', 'copilot-cli'], + '.github/agents/': ['vscode-copilot', 'copilot-cli'], +} + /** * Resolve a path spec into an absolute path. * Project paths are resolved relative to `cwd`; global paths have their `~/` prefix @@ -201,7 +303,10 @@ export function scanEnvironments(cwd = process.cwd()) { globalPaths: globalStatuses, unreadable, supportedCategories: env.supportedCategories, - counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + counts: {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}, + nativeCounts: {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}, + nativeEntries: [], + driftedEntries: [], scope: computeScope(projectStatuses, globalStatuses), }) } @@ -230,7 +335,7 @@ export function getCompatibleEnvironments(type, detectedEnvs) { */ export function computeCategoryCounts(envId, entries) { /** @type {CategoryCounts} */ - const counts = {mcp: 0, command: 0, skill: 0, agent: 0} + const counts = {mcp: 0, command: 0, rule: 0, skill: 0, agent: 0} for (const entry of entries) { if (entry.active && entry.environments.includes(envId)) { @@ -240,3 +345,577 @@ export function computeCategoryCounts(envId, entries) { return counts } + +// ────────────────────────────────────────────────────────────────────────────── +// Native entry parsing (T006) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Build a Set key for matching managed entries by name+type. + * @param {string} name + * @param {CategoryType} type + * @returns {string} + */ +function managedKey(name, type) { + return `${type}:${name}` +} + +/** + * Parse MCP entries from a JSON config file. + * @param {string} filePath - Absolute path to the JSON file + * @param {string} mcpKey - The key in the JSON that holds the MCP map (e.g. 'mcpServers', 'servers', 'context_servers') + * @param {EnvironmentId} envId + * @param {'project'|'global'} level + * @param {Set} managedSet - Set of 'type:name' keys to exclude + * @returns {NativeEntry[]} + */ +function parseMCPsFromJson(filePath, mcpKey, envId, level, managedSet) { + if (!existsSync(filePath)) return [] + try { + const raw = readFileSync(filePath, 'utf8') + const json = JSON.parse(raw) + const section = json[mcpKey] + 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) + /** @type {NativeEntry} */ + const entry = { + name, + type: 'mcp', + environmentId: envId, + level, + sourcePath: filePath, + params: { + transport: s.type ?? 'stdio', + ...(s.command !== undefined ? {command: s.command} : {}), + ...(s.args !== undefined ? {args: s.args} : {}), + ...(s.env !== undefined ? {env: s.env} : {}), + ...(s.url !== undefined ? {url: s.url} : {}), + }, + } + entries.push(entry) + } + return entries + } catch { + return [] + } +} + +/** + * Parse MCP entries from a YAML config file (Continue.dev format). + * @param {string} filePath + * @param {EnvironmentId} envId + * @param {'project'|'global'} level + * @param {Set} managedSet + * @returns {NativeEntry[]} + */ +function parseMCPsFromYaml(filePath, envId, level, managedSet) { + if (!existsSync(filePath)) return [] + try { + const raw = readFileSync(filePath, 'utf8') + const parsed = /** @type {any} */ (yaml.load(raw) ?? {}) + const section = parsed.mcpServers + if (!Array.isArray(section) && (typeof section !== 'object' || section === null)) return [] + + /** @type {NativeEntry[]} */ + const entries = [] + const servers = Array.isArray(section) ? section : Object.entries(section).map(([k, v]) => ({name: k, .../** @type {any} */ (v)})) + for (const server of servers) { + const name = server.name ?? server.id ?? String(server) + if (!name || managedSet.has(managedKey(name, 'mcp'))) continue + /** @type {NativeEntry} */ + const entry = { + name, + type: 'mcp', + environmentId: envId, + level, + sourcePath: filePath, + params: { + transport: server.transport ?? 'stdio', + ...(server.command !== undefined ? {command: server.command} : {}), + ...(server.args !== undefined ? {args: server.args} : {}), + ...(server.env !== undefined ? {env: server.env} : {}), + ...(server.url !== undefined ? {url: server.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. + * @param {string} dirPath - Absolute path to the directory + * @param {EnvironmentId} envId + * @param {CategoryType} type + * @param {'project'|'global'} level + * @param {Set} managedSet + * @param {RegExp} [filePattern] - Only include files matching this pattern (default: all files) + * @returns {NativeEntry[]} + */ +function parseEntriesFromDir(dirPath, envId, type, level, managedSet, filePattern = /.*/) { + if (!existsSync(dirPath)) return [] + try { + const files = readdirSync(dirPath, {withFileTypes: true}) + /** @type {NativeEntry[]} */ + const entries = [] + for (const dirent of files) { + if (!dirent.isFile()) continue + if (!filePattern.test(dirent.name)) continue + // Strip extension to get the entry name + const name = dirent.name.replace(/\.[^.]+$/, '') + if (!name || managedSet.has(managedKey(name, type))) continue + entries.push({ + name, + type, + environmentId: envId, + level, + sourcePath: join(dirPath, dirent.name), + params: {}, + }) + } + return entries + } catch { + return [] + } +} + +/** + * Parse a single-file entry (e.g. CLAUDE.md, GEMINI.md, .rules). + * @param {string} filePath - Absolute path to the file + * @param {string} name - Entry name to use + * @param {EnvironmentId} envId + * @param {CategoryType} type + * @param {'project'|'global'} level + * @param {Set} managedSet + * @returns {NativeEntry[]} + */ +function parseSingleFileEntry(filePath, name, envId, type, level, managedSet) { + if (!existsSync(filePath)) return [] + if (managedSet.has(managedKey(name, type))) return [] + return [{name, type, environmentId: envId, level, sourcePath: filePath, params: {}}] +} + +/** + * Parse all native entries for a single detected environment. + * Returns items that exist in the environment's config files but are NOT managed by dvmi. + * Managed entries are matched by name+type and excluded. + * + * @param {EnvironmentDef} envDef - The environment definition (from ENVIRONMENTS) + * @param {string} cwd - Project working directory + * @param {CategoryEntry[]} managedEntries - All entries from the AI config store + * @returns {NativeEntry[]} + */ +export function parseNativeEntries(envDef, cwd, managedEntries) { + const home = homedir() + + // Build a Set of 'type:name' strings for managed entries targeting this environment + const managedSet = new Set( + managedEntries + .filter((e) => e.environments.includes(envDef.id)) + .map((e) => managedKey(e.name, e.type)), + ) + + /** @type {NativeEntry[]} */ + const result = [] + + const id = envDef.id + + // ── MCPs ── + switch (id) { + case 'vscode-copilot': + result.push(...parseMCPsFromJson(join(cwd, '.vscode', 'mcp.json'), 'servers', id, 'project', managedSet)) + break + case 'claude-code': + result.push(...parseMCPsFromJson(join(cwd, '.mcp.json'), 'mcpServers', id, 'project', managedSet)) + result.push(...parseMCPsFromJson(join(home, '.claude.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)) + break + case 'gemini-cli': + result.push(...parseMCPsFromJson(join(home, '.gemini', 'settings.json'), 'mcpServers', id, 'global', managedSet)) + break + case 'copilot-cli': + result.push(...parseMCPsFromJson(join(home, '.copilot', 'mcp-config.json'), 'mcpServers', id, 'global', managedSet)) + break + case 'cursor': + result.push(...parseMCPsFromJson(join(cwd, '.cursor', 'mcp.json'), 'mcpServers', id, 'project', managedSet)) + result.push(...parseMCPsFromJson(join(home, '.cursor', 'mcp.json'), 'mcpServers', id, 'global', managedSet)) + break + case 'windsurf': + result.push(...parseMCPsFromJson(join(home, '.codeium', 'windsurf', 'mcp_config.json'), 'mcpServers', id, 'global', managedSet)) + break + case 'continue-dev': + result.push(...parseMCPsFromYaml(join(cwd, '.continue', 'config.yaml'), id, 'project', managedSet)) + result.push(...parseMCPsFromYaml(join(home, '.continue', 'config.yaml'), id, 'global', managedSet)) + break + case 'zed': + result.push(...parseMCPsFromJson(join(home, '.config', 'zed', 'settings.json'), 'context_servers', id, 'global', managedSet)) + break + case 'amazon-q': + result.push(...parseMCPsFromJson(join(cwd, '.amazonq', 'mcp.json'), 'mcpServers', id, 'project', managedSet)) + result.push(...parseMCPsFromJson(join(home, '.aws', 'amazonq', 'mcp.json'), 'mcpServers', id, 'global', managedSet)) + break + default: + break + } + + // ── Commands ── + if (envDef.supportedCategories.includes('command')) { + switch (id) { + case 'vscode-copilot': + case 'copilot-cli': + result.push(...parseEntriesFromDir(join(cwd, '.github', 'prompts'), id, 'command', 'project', managedSet, /\.prompt\.md$/)) + break + case 'claude-code': + result.push(...parseEntriesFromDir(join(cwd, '.claude', 'commands'), id, 'command', 'project', managedSet, /\.md$/)) + break + case 'opencode': + result.push(...parseEntriesFromDir(join(cwd, '.opencode', 'commands'), id, 'command', 'project', managedSet, /\.md$/)) + break + case 'gemini-cli': + result.push(...parseEntriesFromDir(join(home, '.gemini', 'commands'), id, 'command', 'global', managedSet, /\.toml$/)) + break + case 'cursor': + result.push(...parseEntriesFromDir(join(cwd, '.cursor', 'commands'), id, 'command', 'project', managedSet, /\.md$/)) + break + case 'windsurf': + result.push(...parseEntriesFromDir(join(cwd, '.windsurf', 'workflows'), id, 'command', 'project', managedSet, /\.md$/)) + break + case 'continue-dev': + result.push(...parseEntriesFromDir(join(cwd, '.continue', 'prompts'), id, 'command', 'project', managedSet, /\.md$/)) + break + default: + break + } + } + + // ── Rules ── + if (envDef.supportedCategories.includes('rule')) { + switch (id) { + case 'vscode-copilot': + result.push(...parseSingleFileEntry(join(cwd, '.github', 'copilot-instructions.md'), 'copilot-instructions', id, 'rule', 'project', managedSet)) + result.push(...parseEntriesFromDir(join(cwd, '.github', 'instructions'), id, 'rule', 'project', managedSet, /\.md$/)) + break + case 'claude-code': + result.push(...parseSingleFileEntry(join(cwd, 'CLAUDE.md'), 'CLAUDE', id, 'rule', 'project', managedSet)) + result.push(...parseEntriesFromDir(join(cwd, '.claude', 'rules'), id, 'rule', 'project', managedSet, /\.md$/)) + break + case 'opencode': + result.push(...parseSingleFileEntry(join(cwd, 'AGENTS.md'), 'AGENTS', id, 'rule', 'project', managedSet)) + break + case 'gemini-cli': + result.push(...parseSingleFileEntry(join(cwd, 'GEMINI.md'), 'GEMINI', id, 'rule', 'project', managedSet)) + result.push(...parseSingleFileEntry(join(home, '.gemini', 'GEMINI.md'), 'GEMINI', id, 'rule', 'global', managedSet)) + break + case 'copilot-cli': + result.push(...parseSingleFileEntry(join(cwd, '.github', 'copilot-instructions.md'), 'copilot-instructions', id, 'rule', 'project', managedSet)) + break + case 'cursor': + result.push(...parseEntriesFromDir(join(cwd, '.cursor', 'rules'), id, 'rule', 'project', managedSet, /\.mdc$/)) + break + case 'windsurf': + result.push(...parseEntriesFromDir(join(cwd, '.windsurf', 'rules'), id, 'rule', 'project', managedSet, /\.md$/)) + break + case 'continue-dev': + result.push(...parseEntriesFromDir(join(cwd, '.continue', 'rules'), id, 'rule', 'project', managedSet, /\.md$/)) + break + case 'zed': + result.push(...parseSingleFileEntry(join(cwd, '.rules'), '.rules', id, 'rule', 'project', managedSet)) + break + case 'amazon-q': + result.push(...parseEntriesFromDir(join(cwd, '.amazonq', 'rules'), id, 'rule', 'project', managedSet, /\.md$/)) + break + default: + break + } + } + + // ── Skills ── + if (envDef.supportedCategories.includes('skill')) { + switch (id) { + case 'vscode-copilot': { + // Skills are directories: .github/skills//SKILL.md + const skillsDir = join(cwd, '.github', 'skills') + if (existsSync(skillsDir)) { + try { + for (const dirent of readdirSync(skillsDir, {withFileTypes: true})) { + if (!dirent.isDirectory()) continue + const name = dirent.name + if (managedSet.has(managedKey(name, 'skill'))) continue + result.push({name, type: 'skill', environmentId: id, level: 'project', sourcePath: join(skillsDir, name, 'SKILL.md'), params: {}}) + } + } catch { /* ignore */ } + } + break + } + case 'claude-code': + result.push(...parseEntriesFromDir(join(cwd, '.claude', 'skills'), id, 'skill', 'project', managedSet, /\.md$/)) + break + case 'opencode': + result.push(...parseEntriesFromDir(join(cwd, '.opencode', 'skills'), id, 'skill', 'project', managedSet, /\.md$/)) + break + case 'copilot-cli': + result.push(...parseEntriesFromDir(join(home, '.copilot', 'skills'), id, 'skill', 'global', managedSet, /\.md$/)) + break + case 'cursor': + result.push(...parseEntriesFromDir(join(cwd, '.cursor', 'skills'), id, 'skill', 'project', managedSet, /\.md$/)) + break + default: + break + } + } + + // ── Agents ── + if (envDef.supportedCategories.includes('agent')) { + switch (id) { + case 'vscode-copilot': + result.push(...parseEntriesFromDir(join(cwd, '.github', 'agents'), id, 'agent', 'project', managedSet, /\.agent\.md$/)) + break + case 'claude-code': + result.push(...parseEntriesFromDir(join(cwd, '.claude', 'agents'), id, 'agent', 'project', managedSet, /\.md$/)) + break + case 'opencode': + result.push(...parseEntriesFromDir(join(cwd, '.opencode', 'agents'), id, 'agent', 'project', managedSet, /\.md$/)) + break + case 'copilot-cli': + result.push(...parseEntriesFromDir(join(home, '.copilot', 'agents'), id, 'agent', 'global', managedSet, /\.md$/)) + break + case 'continue-dev': + result.push(...parseEntriesFromDir(join(cwd, '.continue', 'agents'), id, 'agent', 'project', managedSet, /\.md$/)) + break + case 'amazon-q': + result.push(...parseEntriesFromDir(join(home, '.aws', 'amazonq', 'cli-agents'), id, 'agent', 'global', managedSet, /\.json$/)) + break + default: + break + } + } + + return result +} + +// ────────────────────────────────────────────────────────────────────────────── +// Drift detection (T007) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Read the current value of an MCP entry from an environment's config file. + * Returns null if the entry is not found or the file does not exist. + * @param {string} entryName + * @param {EnvironmentId} envId + * @param {string} cwd + * @returns {object|null} + */ +function readDeployedMCPEntry(entryName, envId, cwd) { + const home = homedir() + + /** @type {string|null} */ + let filePath = null + /** @type {string} */ + let mcpKey = 'mcpServers' + let isYaml = false + + 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 '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 'windsurf': filePath = join(home, '.codeium', 'windsurf', 'mcp_config.json'); break + case 'continue-dev': filePath = 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 + default: return null + } + + if (!filePath || !existsSync(filePath)) return null + + try { + const raw = readFileSync(filePath, 'utf8') + if (isYaml) { + const parsed = /** @type {any} */ (yaml.load(raw) ?? {}) + const section = parsed[mcpKey] + if (!section) return null + if (Array.isArray(section)) { + const found = section.find((s) => /** @type {any} */ (s).name === entryName) + return found ?? null + } + return section[entryName] ?? null + } + const json = JSON.parse(raw) + return json[mcpKey]?.[entryName] ?? null + } catch { + return null + } +} + +/** + * Read the current content of a file-based entry from an environment's config. + * Returns null if the file does not exist. + * @param {string} entryName + * @param {import('../types.js').CategoryType} type + * @param {EnvironmentId} envId + * @param {string} cwd + * @returns {string|null} + */ +function readDeployedFileEntry(entryName, type, envId, cwd) { + const home = homedir() + + /** @type {string|null} */ + let filePath = null + + if (type === 'command') { + switch (envId) { + case 'vscode-copilot': case 'copilot-cli': filePath = join(cwd, '.github', 'prompts', `${entryName}.prompt.md`); break + case 'claude-code': filePath = join(cwd, '.claude', 'commands', `${entryName}.md`); break + case 'opencode': filePath = join(cwd, '.opencode', 'commands', `${entryName}.md`); break + case 'gemini-cli': filePath = join(home, '.gemini', 'commands', `${entryName}.toml`); break + case 'cursor': filePath = join(cwd, '.cursor', 'commands', `${entryName}.md`); break + case 'windsurf': filePath = join(cwd, '.windsurf', 'workflows', `${entryName}.md`); break + case 'continue-dev': filePath = join(cwd, '.continue', 'prompts', `${entryName}.md`); break + default: return null + } + } else if (type === 'rule') { + switch (envId) { + case 'vscode-copilot': filePath = join(cwd, '.github', 'instructions', `${entryName}.md`); break + case 'claude-code': filePath = join(cwd, '.claude', 'rules', `${entryName}.md`); break + case 'cursor': filePath = join(cwd, '.cursor', 'rules', `${entryName}.mdc`); break + case 'windsurf': filePath = join(cwd, '.windsurf', 'rules', `${entryName}.md`); break + case 'continue-dev': filePath = join(cwd, '.continue', 'rules', `${entryName}.md`); break + case 'amazon-q': filePath = join(cwd, '.amazonq', 'rules', `${entryName}.md`); break + default: return null + } + } else if (type === 'skill') { + switch (envId) { + case 'vscode-copilot': filePath = join(cwd, '.github', 'skills', entryName, 'SKILL.md'); break + case 'claude-code': filePath = join(cwd, '.claude', 'skills', `${entryName}.md`); break + case 'opencode': filePath = join(cwd, '.opencode', 'skills', `${entryName}.md`); break + case 'copilot-cli': filePath = join(home, '.copilot', 'skills', `${entryName}.md`); break + case 'cursor': filePath = join(cwd, '.cursor', 'skills', `${entryName}.md`); break + default: return null + } + } else if (type === 'agent') { + switch (envId) { + case 'vscode-copilot': filePath = join(cwd, '.github', 'agents', `${entryName}.agent.md`); break + case 'claude-code': filePath = join(cwd, '.claude', 'agents', `${entryName}.md`); break + case 'opencode': filePath = join(cwd, '.opencode', 'agents', `${entryName}.md`); break + case 'copilot-cli': filePath = join(home, '.copilot', 'agents', `${entryName}.md`); break + case 'continue-dev': filePath = join(cwd, '.continue', 'agents', `${entryName}.md`); break + case 'amazon-q': filePath = join(home, '.aws', 'amazonq', 'cli-agents', `${entryName}.json`); break + default: return null + } + } + + if (!filePath || !existsSync(filePath)) return null + + try { + return readFileSync(filePath, 'utf8') + } catch { + return null + } +} + +/** + * Detect drift between managed entry expected state and actual file content. + * For each active managed entry deployed to a detected environment, compares + * the stored params against what is actually in the file. + * + * @param {DetectedEnvironment[]} detectedEnvs + * @param {CategoryEntry[]} managedEntries + * @param {string} [cwd] + * @returns {DriftInfo[]} + */ +export function detectDrift(detectedEnvs, managedEntries, cwd = process.cwd()) { + const detectedIds = new Set(detectedEnvs.map((e) => e.id)) + /** @type {DriftInfo[]} */ + const drifted = [] + + for (const entry of managedEntries) { + if (!entry.active) continue + + for (const envId of entry.environments) { + if (!detectedIds.has(envId)) continue + + const params = /** @type {any} */ (entry.params) + + if (entry.type === 'mcp') { + const actual = readDeployedMCPEntry(entry.name, envId, cwd) + if (actual === null) continue // not deployed yet — not drift + + // Build expected server object + 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} : {}), + } + + if (JSON.stringify(expected) !== JSON.stringify(actual)) { + drifted.push({entryId: entry.id, environmentId: envId, expected, actual}) + } + } else { + const actual = readDeployedFileEntry(entry.name, entry.type, envId, cwd) + if (actual === null) continue + + const expectedContent = params.content ?? params.instructions ?? '' + if (expectedContent !== actual) { + drifted.push({entryId: entry.id, environmentId: envId, expected: {content: expectedContent}, actual: {content: actual}}) + } + } + } + } + + return drifted +} + +// ────────────────────────────────────────────────────────────────────────────── +// Shared config path grouping (T008) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Return groups of environment IDs that share the same config file for a given + * category type. Each group is an array of EnvironmentIds. Groups with only one + * environment are excluded. + * + * Used in the form's environment multi-select to auto-select related environments. + * + * @param {CategoryType} categoryType + * @returns {EnvironmentId[][]} + */ +export function getSharedPathGroups(categoryType) { + if (categoryType === 'mcp') { + return [ + ['claude-code', 'copilot-cli'], // share .mcp.json + ] + } + if (categoryType === 'command') { + return [ + ['vscode-copilot', 'copilot-cli'], // share .github/prompts/ + ] + } + if (categoryType === 'rule') { + return [ + ['vscode-copilot', 'copilot-cli'], // share .github/copilot-instructions.md + ] + } + if (categoryType === 'agent') { + return [ + ['vscode-copilot', 'copilot-cli'], // share .github/agents/ + ] + } + // skills, unknown types: no shared paths + return [] +} diff --git a/src/types.js b/src/types.js index 011cfeb..731af53 100644 --- a/src/types.js +++ b/src/types.js @@ -337,11 +337,11 @@ // ────────────────────────────────────────────────────────────────────────────── /** - * @typedef {'mcp'|'command'|'skill'|'agent'} CategoryType + * @typedef {'mcp'|'command'|'rule'|'skill'|'agent'} CategoryType */ /** - * @typedef {'vscode-copilot'|'claude-code'|'opencode'|'gemini-cli'|'copilot-cli'} EnvironmentId + * @typedef {'vscode-copilot'|'claude-code'|'opencode'|'gemini-cli'|'copilot-cli'|'cursor'|'windsurf'|'continue-dev'|'zed'|'amazon-q'} EnvironmentId */ /** @@ -359,6 +359,12 @@ * @property {string} [description] - Short description of the command */ +/** + * @typedef {Object} RuleParams + * @property {string} content - Rules/instructions content (multi-line Markdown) + * @property {string} [description] - Short description of the rule + */ + /** * @typedef {Object} SkillParams * @property {string} content - Skill definition content (multi-line) @@ -378,7 +384,7 @@ * @property {CategoryType} type - Category type * @property {boolean} active - true = deployed to environments, false = removed but kept in store * @property {EnvironmentId[]} environments - Target environments for deployment - * @property {MCPParams|CommandParams|SkillParams|AgentParams} params - Type-specific parameters + * @property {MCPParams|CommandParams|RuleParams|SkillParams|AgentParams} params - Type-specific parameters * @property {string} createdAt - ISO 8601 timestamp * @property {string} updatedAt - ISO 8601 timestamp */ @@ -400,10 +406,33 @@ * @typedef {Object} CategoryCounts * @property {number} mcp * @property {number} command + * @property {number} rule * @property {number} skill * @property {number} agent */ +/** + * @typedef {Object} NativeEntry + * Runtime only — not persisted. Represents an item found in an environment's config + * file that is NOT managed by dvmi. + * @property {string} name - Entry name (extracted from config key or filename) + * @property {CategoryType} type - Category type + * @property {EnvironmentId} environmentId - Source environment + * @property {'project'|'global'} level - Whether from project-level or global-level config + * @property {string} sourcePath - Absolute path to the source config file + * @property {object} params - Normalized parameters (same structure as managed entry params) + */ + +/** + * @typedef {Object} DriftInfo + * Runtime only — not persisted. Describes a managed entry whose deployed state + * diverges from dvmi's stored expected state. + * @property {string} entryId - ID of the managed CategoryEntry that drifted + * @property {EnvironmentId} environmentId - Environment where drift was detected + * @property {object} expected - What dvmi expects (from store) + * @property {object} actual - What was found in the file + */ + /** * @typedef {Object} DetectedEnvironment * @property {EnvironmentId} id - Environment identifier @@ -414,6 +443,9 @@ * @property {string[]} unreadable - Paths that exist but failed to parse * @property {CategoryType[]} supportedCategories - Category types this environment supports * @property {CategoryCounts} counts - Per-category item counts from dvmi-managed entries + * @property {CategoryCounts} nativeCounts - Per-category native item counts (items in config files) + * @property {NativeEntry[]} nativeEntries - All native entries found for this environment + * @property {DriftInfo[]} driftedEntries - Managed entries that have drifted from expected state * @property {'project'|'global'|'both'} scope - Where detection occurred */ diff --git a/src/utils/tui/form.js b/src/utils/tui/form.js index 93b7013..f0c0206 100644 --- a/src/utils/tui/form.js +++ b/src/utils/tui/form.js @@ -950,6 +950,70 @@ export function getSkillFormFields(entry = null, compatibleEnvs = []) { ] } +/** + * Return form fields for creating or editing an Agent entry. + * + * Fields: name (text), environments (multiselect), description (text, optional), instructions (editor). + * + * @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 + * @returns {Field[]} + */ +/** + * Return form fields for creating or editing a Rule entry. + * + * Fields: name (text), environments (multiselect), description (text, optional), content (editor). + * + * @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 + * @returns {Field[]} + */ +export function getRuleFormFields(entry = null, compatibleEnvs = []) { + /** @type {import('../../types.js').RuleParams|null} */ + const p = entry ? /** @type {import('../../types.js').RuleParams} */ (entry.params) : null + const contentStr = p?.content ?? '' + const contentLines = contentStr.length > 0 ? contentStr.split('\n') : [''] + + return [ + /** @type {TextField} */ ({ + type: 'text', + label: 'Name', + key: 'name', + value: entry ? entry.name : '', + cursor: entry ? entry.name.length : 0, + required: true, + placeholder: 'my-rule', + }), + /** @type {MultiSelectField} */ ({ + type: 'multiselect', + label: 'Environments', + key: 'environments', + options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})), + selected: new Set(entry ? entry.environments : []), + focusedOptionIndex: 0, + required: true, + }), + /** @type {TextField} */ ({ + type: 'text', + label: 'Description', + key: 'description', + value: p?.description ?? '', + cursor: (p?.description ?? '').length, + required: false, + placeholder: 'Optional description', + }), + /** @type {MiniEditorField} */ ({ + type: 'editor', + label: 'Content', + key: 'content', + lines: contentLines, + cursorLine: 0, + cursorCol: 0, + required: true, + }), + ] +} + /** * Return form fields for creating or editing an Agent entry. * diff --git a/src/utils/tui/tab-tui.js b/src/utils/tui/tab-tui.js index e57b98d..5489463 100644 --- a/src/utils/tui/tab-tui.js +++ b/src/utils/tui/tab-tui.js @@ -12,6 +12,7 @@ import { handleFormKeypress, getMCPFormFields, getCommandFormFields, + getRuleFormFields, getSkillFormFields, getAgentFormFields, } from './form.js' @@ -76,12 +77,16 @@ let _keypressListener = null /** * @typedef {Object} CatTabState - * @property {import('../../types.js').CategoryEntry[]} entries - All category entries - * @property {number} selectedIndex - Highlighted row - * @property {'list'|'form'|'confirm-delete'} mode - Current sub-mode + * @property {import('../../types.js').CategoryEntry[]} entries - Managed entries for this category type + * @property {import('../../types.js').NativeEntry[]} nativeEntries - Native (unmanaged) entries for this category type + * @property {number} selectedIndex - Highlighted row in the active section + * @property {'native'|'managed'} section - Which section is focused + * @property {'list'|'form'|'confirm-delete'|'drift'} mode - Current sub-mode * @property {import('./form.js').FormState|null} formState - Active form state (null when mode is 'list') * @property {string|null} confirmDeleteId - Entry id pending deletion confirmation * @property {string} chezmoidTip - Footer tip (empty if chezmoi configured) + * @property {string|null} revealedEntryId - Entry id whose env vars are currently revealed + * @property {import('../../types.js').DriftInfo[]} driftInfos - All drift infos for this category */ // ────────────────────────────────────────────────────────────────────────────── @@ -233,27 +238,84 @@ export function handleEnvironmentsKeypress(state, key) { } // ────────────────────────────────────────────────────────────────────────────── -// Categories tab content builder (T036) — defined here for single-module TUI +// Categories tab content builder — dual Native/Managed sections // ────────────────────────────────────────────────────────────────────────────── /** - * Build the content lines for the Categories tab. + * Build the content lines for a category tab with Native and Managed sections. + * @param {CatTabState} tabState + * @param {number} viewportHeight + * @param {import('../../formatters/ai-config.js').formatCategoriesTable} formatManaged + * @param {import('../../formatters/ai-config.js').formatNativeEntriesTable} formatNative + * @param {number} termCols + * @returns {string[]} + */ +export function buildCategoriesTab(tabState, viewportHeight, formatManaged, formatNative, termCols = 120) { + const {entries, nativeEntries = [], selectedIndex, section = 'managed', mode} = tabState + const confirmDeleteName = tabState._confirmDeleteName ?? null + const driftedIds = new Set((tabState.driftInfos ?? []).map((d) => d.entryId)) + + const lines = [] + + // ── Native section ── + if (nativeEntries.length > 0) { + lines.push(chalk.bold.cyan(' ── Native (read-only) ──────────────────────────────')) + + const nativeLines = formatNative(nativeEntries, termCols) + const HEADER = 2 + for (let i = 0; i < nativeLines.length; i++) { + const dataIndex = i - HEADER + if (section === 'native' && dataIndex >= 0 && dataIndex === selectedIndex) { + lines.push(`${ANSI_INVERSE_ON}${nativeLines[i]}${ANSI_INVERSE_OFF}`) + } else { + lines.push(chalk.dim(nativeLines[i])) + } + } + lines.push('') + } + + // ── Managed section ── + lines.push(chalk.bold.white(' ── Managed ────────────────────────────────────────')) + + if (entries.length === 0) { + lines.push(chalk.dim(' No managed entries yet.')) + lines.push(chalk.dim(' Press ' + chalk.bold('n') + ' to create your first entry.')) + } else { + // Annotate entries with drift flag for formatter + const annotated = entries.map((e) => ({...e, drifted: driftedIds.has(e.id)})) + const tableLines = formatManaged(annotated, termCols) + const HEADER_LINES = 2 + for (let i = 0; i < tableLines.length; i++) { + const dataIndex = i - HEADER_LINES + if (section === 'managed' && dataIndex >= 0 && dataIndex === selectedIndex) { + lines.push(`${ANSI_INVERSE_ON}${tableLines[i]}${ANSI_INVERSE_OFF}`) + } else { + lines.push(tableLines[i]) + } + } + } + + // Confirmation prompt + if (mode === 'confirm-delete' && confirmDeleteName) { + lines.push('') + lines.push(chalk.red(` Delete "${confirmDeleteName}"? This cannot be undone. `) + chalk.bold('[y/N]')) + } + + return lines.slice(0, viewportHeight) +} + +/** + * Build content lines for the legacy single-section categories tab. + * Kept for backward compatibility in tests. * @param {import('../../types.js').CategoryEntry[]} entries * @param {number} selectedIndex * @param {number} viewportHeight * @param {import('../../formatters/ai-config.js').formatCategoriesTable} formatFn * @param {number} termCols - * @param {string|null} [confirmDeleteName] - Name of entry pending delete confirmation + * @param {string|null} [confirmDeleteName] * @returns {string[]} */ -export function buildCategoriesTab( - entries, - selectedIndex, - viewportHeight, - formatFn, - termCols = 120, - confirmDeleteName = null, -) { +export function buildCategoriesTabLegacy(entries, selectedIndex, viewportHeight, formatFn, termCols = 120, confirmDeleteName = null) { if (entries.length === 0) { const lines = [ '', @@ -277,7 +339,6 @@ export function buildCategoriesTab( } } - // Confirmation prompt overlay if (confirmDeleteName !== null) { resultLines.push('') resultLines.push(chalk.red(` Delete "${confirmDeleteName}"? This cannot be undone. `) + chalk.bold('[y/N]')) @@ -286,6 +347,61 @@ export function buildCategoriesTab( return resultLines.slice(0, viewportHeight) } +// ────────────────────────────────────────────────────────────────────────────── +// Drift resolution screen +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Build the drift resolution screen for a drifted managed entry. + * @param {CatTabState} tabState + * @param {number} viewportHeight + * @param {number} termCols + * @returns {string[]} + */ +export function buildDriftScreen(tabState, viewportHeight, termCols) { + const entryId = tabState._driftEntryId + const drift = (tabState.driftInfos ?? []).find((d) => d.entryId === entryId) + const entry = (tabState.entries ?? []).find((e) => e.id === entryId) + + if (!drift || !entry) { + return [chalk.dim(' No drift info available.')] + } + + const lines = [] + lines.push(chalk.bold.yellow(` ⚠ Drift detected: ${entry.name}`)) + lines.push(chalk.dim('─'.repeat(Math.min(termCols, 70)))) + lines.push('') + lines.push(chalk.bold(' Expected (managed):')) + const expected = JSON.stringify(drift.expected, null, 2) + for (const l of expected.split('\n').slice(0, 8)) { + lines.push(chalk.green(` ${l}`)) + } + lines.push('') + lines.push(chalk.bold(' Actual (on disk):')) + const actual = JSON.stringify(drift.actual, null, 2) + for (const l of actual.split('\n').slice(0, 8)) { + lines.push(chalk.red(` ${l}`)) + } + lines.push('') + lines.push(chalk.dim(' Press r to re-deploy (overwrite file) a to accept changes (update store) Esc to go back')) + + return lines.slice(0, viewportHeight) +} + +/** + * Minimal fallback formatter for native entries (no chalk dependency at module load). + * Used only when formatNative is not provided to startTabTUI. + * @param {import('../../types.js').NativeEntry[]} entries + * @returns {string[]} + */ +function formatNativeEntriesTableFallback(entries) { + const lines = [' Name Environment Level Config', ' ' + '─'.repeat(60)] + for (const e of entries) { + lines.push(` ${e.name.padEnd(25)} ${e.environmentId.padEnd(13)} ${e.level.padEnd(8)} ${e.sourcePath}`) + } + return lines +} + // ────────────────────────────────────────────────────────────────────────────── // Categories tab keypress reducer (T037) // ────────────────────────────────────────────────────────────────────────────── @@ -297,41 +413,63 @@ export function buildCategoriesTab( * @returns {CatTabState | { exit: true }} */ export function handleCategoriesKeypress(state, key) { - const {selectedIndex, entries, mode, confirmDeleteId} = state - const maxIndex = Math.max(0, entries.length - 1) + const {selectedIndex, entries, nativeEntries = [], section = 'managed', mode} = state + const activeList = section === 'native' ? nativeEntries : entries + const maxIndex = Math.max(0, activeList.length - 1) // Confirm-delete mode if (mode === 'confirm-delete') { if (key.name === 'y') { - return { - ...state, - mode: 'list', - confirmDeleteId: key.name === 'y' ? confirmDeleteId : null, - _deleteConfirmed: true, - } + return {...state, mode: 'list', _deleteConfirmed: true} } // Any other key cancels return {...state, mode: 'list', confirmDeleteId: null} } - // List mode + // Drift resolution mode + if (mode === 'drift') { + if (key.name === 'escape') return {...state, mode: 'list'} + if (key.name === 'r') return {...state, mode: 'list', _redeploy: state._driftEntryId, _driftEntryId: null} + if (key.name === 'a') return {...state, mode: 'list', _acceptDrift: state._driftEntryId, _driftEntryId: null} + return state + } + + // Navigation — clears env var reveal on any movement if (key.name === 'up' || key.name === 'k') { - return {...state, selectedIndex: Math.max(0, selectedIndex - 1)} + return {...state, selectedIndex: Math.max(0, selectedIndex - 1), revealedEntryId: null} } if (key.name === 'down' || key.name === 'j') { - return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1)} + return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1), revealedEntryId: null} } if (key.name === 'pageup') { - return {...state, selectedIndex: Math.max(0, selectedIndex - 10)} + return {...state, selectedIndex: Math.max(0, selectedIndex - 10), revealedEntryId: null} } if (key.name === 'pagedown') { - return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10)} + return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10), revealedEntryId: null} + } + + // Native section actions + if (section === 'native') { + if (key.name === 'i' && nativeEntries.length > 0) { + const nativeEntry = nativeEntries[selectedIndex] + if (nativeEntry) return {...state, _importNative: nativeEntry} + } + return state } + + // Managed section actions if (key.name === 'n') { return {...state, mode: 'form', _action: 'create'} } if (key.name === 'return' && entries.length > 0) { - return {...state, mode: 'form', _action: 'edit', _editId: entries[selectedIndex]?.id} + const entry = entries[selectedIndex] + if (entry) { + const driftedIds = new Set((state.driftInfos ?? []).map((d) => d.entryId)) + if (driftedIds.has(entry.id)) { + return {...state, mode: 'drift', _driftEntryId: entry.id} + } + return {...state, mode: 'form', _action: 'edit', _editId: entry.id} + } } if (key.name === 'd' && entries.length > 0) { return {...state, _toggleId: entries[selectedIndex]?.id} @@ -342,6 +480,13 @@ export function handleCategoriesKeypress(state, key) { return {...state, mode: 'confirm-delete', confirmDeleteId: entry.id, _confirmDeleteName: entry.name} } } + // r — reveal/hide env vars for the selected MCP entry + if (key.name === 'r' && entries.length > 0) { + const entry = entries[selectedIndex] + if (entry?.type === 'mcp') { + return {...state, revealedEntryId: state.revealedEntryId === entry.id ? null : entry.id} + } + } return state } @@ -411,6 +556,7 @@ export function cleanupTerminal() { * @property {(action: object) => Promise} onAction - Callback for CRUD actions from category tabs * @property {import('../../formatters/ai-config.js').formatEnvironmentsTable} formatEnvs - Environments table formatter * @property {import('../../formatters/ai-config.js').formatCategoriesTable} formatCats - Categories table formatter + * @property {import('../../formatters/ai-config.js').formatNativeEntriesTable} [formatNative] - Native entries table formatter * @property {(() => Promise) | undefined} [refreshEntries] - Reload entries from store after mutations */ @@ -423,7 +569,7 @@ export function cleanupTerminal() { * @returns {Promise} */ export async function startTabTUI(opts) { - const {envs, onAction, formatEnvs, formatCats} = opts + const {envs, onAction, formatEnvs, formatCats, formatNative} = opts const {entries: initialEntries, chezmoiEnabled} = opts _cleanupCalled = false @@ -443,11 +589,12 @@ export async function startTabTUI(opts) { {label: 'Environments', key: 'environments'}, {label: 'MCPs', key: 'mcp'}, {label: 'Commands', key: 'command'}, + {label: 'Rules', key: 'rule'}, {label: 'Skills', key: 'skill'}, {label: 'Agents', key: 'agent'}, ] - const CATEGORY_TYPES = ['mcp', 'command', 'skill', 'agent'] + const CATEGORY_TYPES = ['mcp', 'command', 'rule', 'skill', 'agent'] const chezmoidTip = chezmoiEnabled ? '' : 'Tip: Run `dvmi dotfiles setup` to enable automatic backup of your AI configs' /** @type {TabTUIState} */ @@ -466,27 +613,53 @@ export async function startTabTUI(opts) { /** @type {import('../../types.js').CategoryEntry[]} */ let allEntries = [...initialEntries] + /** Aggregate all drift infos from detected envs (flat list). */ + const allDriftInfos = envs.flatMap((e) => e.driftedEntries ?? []) + + /** + * @param {string} type + * @returns {import('../../types.js').NativeEntry[]} + */ + function getNativesByType(type) { + return envs.flatMap((e) => (e.nativeEntries ?? []).filter((ne) => ne.type === type)) + } + + /** + * @param {string} type + * @returns {import('../../types.js').DriftInfo[]} + */ + function getDriftsByType(type) { + const ids = new Set(allEntries.filter((e) => e.type === type).map((e) => e.id)) + return allDriftInfos.filter((d) => ids.has(d.entryId)) + } + /** @type {Record} */ let catTabStates = Object.fromEntries( CATEGORY_TYPES.map((type) => [ type, /** @type {CatTabState} */ ({ entries: allEntries.filter((e) => e.type === type), + nativeEntries: getNativesByType(type), selectedIndex: 0, + section: 'managed', mode: 'list', formState: null, confirmDeleteId: null, chezmoidTip, + revealedEntryId: null, + driftInfos: getDriftsByType(type), }), ]), ) - /** Push filtered entries into each tab state — call after allEntries changes. */ + /** Push filtered entries and drift infos into each tab state — call after allEntries changes. */ function syncTabEntries() { for (const type of CATEGORY_TYPES) { + const entriesForType = allEntries.filter((e) => e.type === type) + const driftsForType = getDriftsByType(type) catTabStates = { ...catTabStates, - [type]: {...catTabStates[type], entries: allEntries.filter((e) => e.type === type)}, + [type]: {...catTabStates[type], entries: entriesForType, driftInfos: driftsForType}, } } } @@ -525,20 +698,15 @@ export async function startTabTUI(opts) { if (tabState.mode === 'form' && tabState.formState) { contentLines = buildFormScreen(tabState.formState, contentViewportHeight, termCols) hintStr = chalk.dim(' Tab next field Shift+Tab prev Ctrl+S save Esc cancel') + } else if (tabState.mode === 'drift') { + contentLines = buildDriftScreen(tabState, contentViewportHeight, termCols) + hintStr = chalk.dim(' r re-deploy a accept changes Esc back') } else { - const confirmName = - tabState.mode === 'confirm-delete' && tabState._confirmDeleteName - ? /** @type {string} */ (tabState._confirmDeleteName) - : null - contentLines = buildCategoriesTab( - tabState.entries, - tabState.selectedIndex, - contentViewportHeight, - formatCats, - termCols, - confirmName, - ) - hintStr = chalk.dim(' ↑↓ navigate n new Enter edit d toggle Del delete Tab switch q exit') + const nativeFmt = formatNative ?? formatNativeEntriesTableFallback + contentLines = buildCategoriesTab(tabState, contentViewportHeight, formatCats, nativeFmt, termCols) + const sectionHint = tabState.nativeEntries.length > 0 ? ' Tab switch section' : ' Tab switch tabs' + 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`) } } @@ -592,10 +760,25 @@ export async function startTabTUI(opts) { return } - // Tab switching — only when not in form mode (Tab navigates form fields when a form is open) + // 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' 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') { + catTabStates = { + ...catTabStates, + [activeTabKey]: { + ...catState, + section: catState.section === 'managed' ? 'native' : 'managed', + selectedIndex: 0, + revealedEntryId: null, + }, + } + render() + return + } tuiState = { ...tuiState, activeTabIndex: (tuiState.activeTabIndex + 1) % tabs.length, @@ -714,6 +897,72 @@ export async function startTabTUI(opts) { return } + // T017: Import native entry into managed sync + if (result._importNative) { + const nativeEntry = result._importNative + catTabStates = {...catTabStates, [tabKey]: {...result, _importNative: null}} + render() + try { + await onAction({type: 'import-native', nativeEntry}) + if (opts.refreshEntries) { + allEntries = await opts.refreshEntries() + syncTabEntries() + // Update native entries: remove imported entry from native list + catTabStates = { + ...catTabStates, + [tabKey]: { + ...catTabStates[tabKey], + nativeEntries: catTabStates[tabKey].nativeEntries.filter( + (ne) => !(ne.name === nativeEntry.name && ne.environmentId === nativeEntry.environmentId), + ), + section: 'managed', + selectedIndex: 0, + }, + } + render() + } + } catch { + /* ignore */ + } + return + } + + // T018: Re-deploy after drift resolution + if (result._redeploy) { + const idToRedeploy = result._redeploy + catTabStates = {...catTabStates, [tabKey]: {...result, _redeploy: null}} + render() + try { + await onAction({type: 'redeploy', id: idToRedeploy}) + if (opts.refreshEntries) { + allEntries = await opts.refreshEntries() + syncTabEntries() + render() + } + } catch { + /* ignore */ + } + return + } + + // T018: Accept drift (update store from file) + if (result._acceptDrift) { + const idToAccept = result._acceptDrift + catTabStates = {...catTabStates, [tabKey]: {...result, _acceptDrift: null}} + render() + try { + await onAction({type: 'accept-drift', id: idToAccept}) + if (opts.refreshEntries) { + allEntries = await opts.refreshEntries() + syncTabEntries() + render() + } + } catch { + /* ignore */ + } + return + } + if (result._action === 'create') { const compatibleEnvs = envs.filter((e) => e.supportedCategories.includes(tabKey)) const fields = @@ -721,9 +970,11 @@ export async function startTabTUI(opts) { ? getMCPFormFields(null, compatibleEnvs) : tabKey === 'command' ? getCommandFormFields(null, compatibleEnvs) - : tabKey === 'skill' - ? getSkillFormFields(null, compatibleEnvs) - : getAgentFormFields(null, compatibleEnvs) + : tabKey === 'rule' + ? getRuleFormFields(null, compatibleEnvs) + : tabKey === 'skill' + ? getSkillFormFields(null, compatibleEnvs) + : getAgentFormFields(null, compatibleEnvs) const tabLabel = tabKey === 'mcp' ? 'MCP' : tabKey.charAt(0).toUpperCase() + tabKey.slice(1) catTabStates = { ...catTabStates, @@ -754,9 +1005,11 @@ export async function startTabTUI(opts) { ? getMCPFormFields(entry, compatibleEnvs) : entry.type === 'command' ? getCommandFormFields(entry, compatibleEnvs) - : entry.type === 'skill' - ? getSkillFormFields(entry, compatibleEnvs) - : getAgentFormFields(entry, compatibleEnvs) + : entry.type === 'rule' + ? getRuleFormFields(entry, compatibleEnvs) + : entry.type === 'skill' + ? getSkillFormFields(entry, compatibleEnvs) + : getAgentFormFields(entry, compatibleEnvs) catTabStates = { ...catTabStates, [tabKey]: { diff --git a/tests/integration/sync-config-ai.test.js b/tests/integration/sync-config-ai.test.js index c5fd3d1..570d494 100644 --- a/tests/integration/sync-config-ai.test.js +++ b/tests/integration/sync-config-ai.test.js @@ -9,17 +9,36 @@ describe('dvmi sync-config-ai', () => { expect(stdout.toLowerCase()).toMatch(/ai|config|sync|environment/) }) - // T047: --json exits 0 and outputs valid JSON with environments and categories - it('--json exits 0 and outputs valid JSON with environments and categories keys', async () => { + // T023: --json exits 0 and outputs complete structured JSON per cli-schema.md + it('--json exits 0 and outputs valid JSON with environments, categories (5 types), and nativeEntries', async () => { const result = await runCliJson(['sync-config-ai']) + // Top-level keys expect(result).toHaveProperty('environments') expect(result).toHaveProperty('categories') + expect(result).toHaveProperty('nativeEntries') + // Environments is an array expect(Array.isArray(result.environments)).toBe(true) + // Categories has all 5 types (including rule) expect(result.categories).toHaveProperty('mcp') expect(result.categories).toHaveProperty('command') + expect(result.categories).toHaveProperty('rule') expect(result.categories).toHaveProperty('skill') expect(result.categories).toHaveProperty('agent') - expect(Array.isArray(result.categories.mcp)).toBe(true) - expect(Array.isArray(result.categories.command)).toBe(true) + for (const type of ['mcp', 'command', 'rule', 'skill', 'agent']) { + expect(Array.isArray(result.categories[type])).toBe(true) + // Each managed entry has a drifted boolean + for (const entry of result.categories[type]) { + expect(typeof entry.drifted).toBe('boolean') + } + } + // nativeEntries has all 5 types + expect(result.nativeEntries).toHaveProperty('mcp') + expect(result.nativeEntries).toHaveProperty('command') + expect(result.nativeEntries).toHaveProperty('rule') + expect(result.nativeEntries).toHaveProperty('skill') + expect(result.nativeEntries).toHaveProperty('agent') + for (const type of ['mcp', 'command', 'rule', 'skill', 'agent']) { + expect(Array.isArray(result.nativeEntries[type])).toBe(true) + } }) }) diff --git a/tests/services/ai-config-sync.test.js b/tests/services/ai-config-sync.test.js index 6e28e9c..93edae1 100644 --- a/tests/services/ai-config-sync.test.js +++ b/tests/services/ai-config-sync.test.js @@ -1,9 +1,15 @@ /** - * Service-level integration test: full AI config sync flow. + * Service-level integration test: full AI config sync flow (T024). * - * Creates a real temp directory, seeds fixture files to make a claude-code - * environment detectable, then exercises the full create → deploy → deactivate - * → undeploy → activate → redeploy lifecycle using the real store and deployer. + * Covers: + * 1. Full scan → detect → deploy flow with a seeded .mcp.json + * 2. Import native: parseNativeEntries returns correct type/name/environmentId + * 3. Modify and deploy: addEntry + deployEntry updates the file on disk + * 4. Drift detection: manual file edit triggers detectDrift + * 5. Re-deploy: deployEntry restores the expected state after drift + * + * Also retains the original create → deploy → deactivate → undeploy → + * activate → redeploy lifecycle tests from the initial integration suite. */ import {describe, it, expect, beforeEach, afterEach} from 'vitest' @@ -13,7 +19,7 @@ import {readFile, mkdir, writeFile, rm} from 'node:fs/promises' import {existsSync} from 'node:fs' import {randomUUID} from 'node:crypto' -import {scanEnvironments} from '../../src/services/ai-env-scanner.js' +import {scanEnvironments, parseNativeEntries, detectDrift, ENVIRONMENTS} from '../../src/services/ai-env-scanner.js' import { loadAIConfig, addEntry, @@ -37,10 +43,335 @@ async function readJson(filePath) { } // ────────────────────────────────────────────────────────────────────────────── -// Test suite +// T024-1: Full scan → detect → deploy with seeded .mcp.json +// ────────────────────────────────────────────────────────────────────────────── + +describe('T024-1: scan → detect → deploy flow with .mcp.json', () => { + let tmpDir + let configPath + let originalEnv + + beforeEach(async () => { + tmpDir = makeTmpDir() + configPath = join(tmpDir, 'ai-config.json') + await mkdir(tmpDir, {recursive: true}) + + // Seed a .mcp.json in claude-code format so the environment is detectable + await writeFile( + join(tmpDir, '.mcp.json'), + JSON.stringify({mcpServers: {}}), + 'utf8', + ) + + originalEnv = process.env.DVMI_AI_CONFIG_PATH + process.env.DVMI_AI_CONFIG_PATH = configPath + }) + + afterEach(async () => { + process.env.DVMI_AI_CONFIG_PATH = originalEnv + await rm(tmpDir, {recursive: true, force: true}) + }) + + it('detects claude-code when .mcp.json is present', () => { + const envs = scanEnvironments(tmpDir) + const claudeEnv = envs.find((e) => e.id === 'claude-code') + expect(claudeEnv).toBeDefined() + expect(claudeEnv.detected).toBe(true) + }) + + it('scan → detect → deploy writes new server into existing .mcp.json', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + const claudeEnv = detectedEnvs.find((e) => e.id === 'claude-code') + expect(claudeEnv).toBeDefined() + + const entry = await addEntry({ + name: 'scan-deploy-server', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: ['index.js']}, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + const mcpJson = join(tmpDir, '.mcp.json') + expect(existsSync(mcpJson)).toBe(true) + + const parsed = await readJson(mcpJson) + expect(parsed.mcpServers?.['scan-deploy-server']).toBeDefined() + expect(parsed.mcpServers['scan-deploy-server'].command).toBe('node') + expect(parsed.mcpServers['scan-deploy-server'].args).toEqual(['index.js']) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// T024-2: Import native entries +// ────────────────────────────────────────────────────────────────────────────── + +describe('T024-2: parseNativeEntries — import native MCP entries', () => { + let tmpDir + + beforeEach(async () => { + tmpDir = makeTmpDir() + await mkdir(tmpDir, {recursive: true}) + }) + + afterEach(async () => { + await rm(tmpDir, {recursive: true, force: true}) + }) + + it('returns native MCP entries from .mcp.json that are not managed', async () => { + // Seed a .mcp.json with two unmanaged servers + await writeFile( + join(tmpDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'native-server-a': {command: 'npx', args: ['-y', 'pkg-a']}, + 'native-server-b': {command: 'node', args: ['server.js']}, + }, + }), + 'utf8', + ) + + const claudeCodeDef = ENVIRONMENTS.find((e) => e.id === 'claude-code') + expect(claudeCodeDef).toBeDefined() + + // No managed entries — all should be returned as native + const natives = parseNativeEntries(claudeCodeDef, tmpDir, []) + + const mcpNatives = natives.filter((n) => n.type === 'mcp') + expect(mcpNatives.length).toBeGreaterThanOrEqual(2) + + const serverA = mcpNatives.find((n) => n.name === 'native-server-a') + expect(serverA).toBeDefined() + expect(serverA.type).toBe('mcp') + expect(serverA.environmentId).toBe('claude-code') + + const serverB = mcpNatives.find((n) => n.name === 'native-server-b') + expect(serverB).toBeDefined() + expect(serverB.type).toBe('mcp') + expect(serverB.environmentId).toBe('claude-code') + }) + + it('excludes managed entries from native results', async () => { + await writeFile( + join(tmpDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'managed-server': {command: 'node', args: ['managed.js']}, + 'unmanaged-server': {command: 'node', args: ['free.js']}, + }, + }), + 'utf8', + ) + + const claudeCodeDef = ENVIRONMENTS.find((e) => e.id === 'claude-code') + + // Simulate one managed entry + const managedEntries = [ + { + id: randomUUID(), + name: 'managed-server', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: ['managed.js']}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + + const natives = parseNativeEntries(claudeCodeDef, tmpDir, managedEntries) + const mcpNatives = natives.filter((n) => n.type === 'mcp') + + expect(mcpNatives.find((n) => n.name === 'managed-server')).toBeUndefined() + expect(mcpNatives.find((n) => n.name === 'unmanaged-server')).toBeDefined() + }) + + it('returns native rule entry from CLAUDE.md', async () => { + await writeFile(join(tmpDir, 'CLAUDE.md'), '# Project rules\n', 'utf8') + + const claudeCodeDef = ENVIRONMENTS.find((e) => e.id === 'claude-code') + const natives = parseNativeEntries(claudeCodeDef, tmpDir, []) + + const ruleEntry = natives.find((n) => n.name === 'CLAUDE' && n.type === 'rule') + expect(ruleEntry).toBeDefined() + expect(ruleEntry.environmentId).toBe('claude-code') + expect(ruleEntry.level).toBe('project') + }) + + it('returns native command entries from .claude/commands/', async () => { + const commandsDir = join(tmpDir, '.claude', 'commands') + await mkdir(commandsDir, {recursive: true}) + await writeFile(join(commandsDir, 'my-cmd.md'), '# My command\n', 'utf8') + await writeFile(join(commandsDir, 'another-cmd.md'), '# Another\n', 'utf8') + + const claudeCodeDef = ENVIRONMENTS.find((e) => e.id === 'claude-code') + const natives = parseNativeEntries(claudeCodeDef, tmpDir, []) + + const commandNatives = natives.filter((n) => n.type === 'command') + expect(commandNatives.length).toBeGreaterThanOrEqual(2) + + const myCmd = commandNatives.find((n) => n.name === 'my-cmd') + expect(myCmd).toBeDefined() + expect(myCmd.environmentId).toBe('claude-code') + expect(myCmd.type).toBe('command') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// T024-3 / T024-4 / T024-5: Modify → deploy → drift detect → re-deploy +// ────────────────────────────────────────────────────────────────────────────── + +describe('T024-3-5: modify → deploy → drift → re-deploy', () => { + let tmpDir + let configPath + let originalEnv + + beforeEach(async () => { + tmpDir = makeTmpDir() + configPath = join(tmpDir, 'ai-config.json') + await mkdir(tmpDir, {recursive: true}) + + // Seed CLAUDE.md so claude-code is detected + await writeFile(join(tmpDir, 'CLAUDE.md'), '# Test project\n', 'utf8') + + originalEnv = process.env.DVMI_AI_CONFIG_PATH + process.env.DVMI_AI_CONFIG_PATH = configPath + }) + + afterEach(async () => { + process.env.DVMI_AI_CONFIG_PATH = originalEnv + await rm(tmpDir, {recursive: true, force: true}) + }) + + it('T024-3: addEntry → deployEntry writes the entry to .mcp.json', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'modify-deploy-server', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', args: ['-y', 'test-pkg'], env: {MY_VAR: 'hello'}}, + }) + + expect(entry.name).toBe('modify-deploy-server') + expect(entry.active).toBe(true) + + await deployEntry(entry, detectedEnvs, tmpDir) + + const mcpJson = join(tmpDir, '.mcp.json') + expect(existsSync(mcpJson)).toBe(true) + + const parsed = await readJson(mcpJson) + const server = parsed.mcpServers?.['modify-deploy-server'] + expect(server).toBeDefined() + expect(server.command).toBe('npx') + expect(server.args).toEqual(['-y', 'test-pkg']) + expect(server.env).toEqual({MY_VAR: 'hello'}) + }) + + it('T024-4: drift is detected after manually modifying the deployed file', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'drifting-server', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: ['original.js']}, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + // Manually tamper with the deployed file to introduce drift + const mcpJsonPath = join(tmpDir, '.mcp.json') + const deployed = await readJson(mcpJsonPath) + deployed.mcpServers['drifting-server'].command = 'deno' + deployed.mcpServers['drifting-server'].args = ['tampered.ts'] + await writeFile(mcpJsonPath, JSON.stringify(deployed, null, 2), 'utf8') + + // Reload store to get the managed entries + const store = await loadAIConfig(configPath) + + const drifts = detectDrift(detectedEnvs, store.entries, tmpDir) + + const drift = drifts.find((d) => d.entryId === entry.id) + expect(drift).toBeDefined() + expect(drift.environmentId).toBe('claude-code') + expect(drift.expected.command).toBe('node') + expect(drift.actual.command).toBe('deno') + }) + + it('T024-5: re-deploying after drift restores the expected state', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'redeploy-after-drift', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'python', args: ['-m', 'srv']}, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + // Introduce drift + const mcpJsonPath = join(tmpDir, '.mcp.json') + const deployed = await readJson(mcpJsonPath) + deployed.mcpServers['redeploy-after-drift'].command = 'ruby' + await writeFile(mcpJsonPath, JSON.stringify(deployed, null, 2), 'utf8') + + // Confirm drift + const store = await loadAIConfig(configPath) + const driftsBefore = detectDrift(detectedEnvs, store.entries, tmpDir) + expect(driftsBefore.find((d) => d.entryId === entry.id)).toBeDefined() + + // Re-deploy to fix drift + await deployEntry(entry, detectedEnvs, tmpDir) + + // Verify the file now matches expected state + const restored = await readJson(mcpJsonPath) + const server = restored.mcpServers?.['redeploy-after-drift'] + expect(server).toBeDefined() + expect(server.command).toBe('python') + expect(server.args).toEqual(['-m', 'srv']) + + // Drift should be gone + const driftsAfter = detectDrift(detectedEnvs, store.entries, tmpDir) + expect(driftsAfter.find((d) => d.entryId === entry.id)).toBeUndefined() + }) + + it('drift detection for file-based entries (command) works correctly', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'drift-cmd', + type: 'command', + environments: ['claude-code'], + params: {description: 'A driftable command', content: 'Original content.'}, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + const cmdFile = join(tmpDir, '.claude', 'commands', 'drift-cmd.md') + expect(existsSync(cmdFile)).toBe(true) + + // Introduce drift by changing the file content + await writeFile(cmdFile, 'Tampered content.', 'utf8') + + const store = await loadAIConfig(configPath) + const drifts = detectDrift(detectedEnvs, store.entries, tmpDir) + + const drift = drifts.find((d) => d.entryId === entry.id) + expect(drift).toBeDefined() + expect(drift.expected.content).toBe('Original content.') + expect(drift.actual.content).toBe('Tampered content.') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Original lifecycle tests (retained from initial integration suite) // ────────────────────────────────────────────────────────────────────────────── -describe('AI config sync — full flow', () => { +describe('AI config sync — full lifecycle', () => { let tmpDir let configPath let originalEnv diff --git a/tests/snapshots/__snapshots__/sync-config-ai.test.js.snap b/tests/snapshots/__snapshots__/sync-config-ai.test.js.snap index cb42b1c..3f7234f 100644 --- a/tests/snapshots/__snapshots__/sync-config-ai.test.js.snap +++ b/tests/snapshots/__snapshots__/sync-config-ai.test.js.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`dvmi sync-config-ai snapshots > --help output matches snapshot 1`] = ` +exports[`dvmi sync-config-ai snapshots > --help exits 0 and contains key sections 1`] = ` "Manage AI coding tool configurations across environments via TUI USAGE diff --git a/tests/snapshots/sync-config-ai.test.js b/tests/snapshots/sync-config-ai.test.js index 29ef362..1c71346 100644 --- a/tests/snapshots/sync-config-ai.test.js +++ b/tests/snapshots/sync-config-ai.test.js @@ -2,9 +2,19 @@ import {describe, it, expect} from 'vitest' import {runCli} from '../integration/helpers.js' describe('dvmi sync-config-ai snapshots', () => { - it('--help output matches snapshot', async () => { + it('--help exits 0 and contains key sections', async () => { const {stdout, exitCode} = await runCli(['sync-config-ai', '--help']) expect(exitCode).toBe(0) + + // Description section + expect(stdout.toLowerCase()).toMatch(/sync|ai|config/) + + // Usage section + expect(stdout.toLowerCase()).toMatch(/usage/) + + // Examples section + expect(stdout.toLowerCase()).toMatch(/example/) + expect(stdout).toMatchSnapshot() }) }) diff --git a/tests/unit/services/ai-config-store.test.js b/tests/unit/services/ai-config-store.test.js index bccbca3..7e0bcd4 100644 --- a/tests/unit/services/ai-config-store.test.js +++ b/tests/unit/services/ai-config-store.test.js @@ -63,7 +63,7 @@ afterEach(async () => { describe('loadAIConfig', () => { it('returns defaults when file does not exist', async () => { const store = await loadAIConfig(tmpPath) - expect(store).toEqual({version: 1, entries: []}) + expect(store).toEqual({version: 2, entries: []}) }) it('returns parsed content from an existing valid file', async () => { @@ -87,7 +87,7 @@ describe('loadAIConfig', () => { await writeFile(tmpPath, JSON.stringify(data), 'utf8') const store = await loadAIConfig(tmpPath) - expect(store.version).toBe(1) + expect(store.version).toBe(2) expect(store.entries).toHaveLength(1) expect(store.entries[0].name).toBe('existing-mcp') }) @@ -152,6 +152,63 @@ describe('addEntry', () => { }) }) +// ────────────────────────────────────────────────────────────────────────────── +// rule type CRUD +// ────────────────────────────────────────────────────────────────────────────── + +describe('rule type CRUD', () => { + it('creates a rule entry with type: rule and correct params', async () => { + const ruleData = { + name: 'test-rule', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('rule'), + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {content: 'do stuff', description: 'test rule'}, + } + + const entry = await addEntry(ruleData, tmpPath) + + expect(entry.type).toBe('rule') + expect(entry.name).toBe('test-rule') + expect(entry.params).toMatchObject({content: 'do stuff', description: 'test rule'}) + expect(entry.active).toBe(true) + expect(entry.id).toBeTruthy() + }) + + it('updates a rule entry', async () => { + const ruleData = { + name: 'rule-to-update', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('rule'), + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {content: 'original content'}, + } + + const created = await addEntry(ruleData, tmpPath) + await new Promise((r) => setTimeout(r, 5)) + + const updated = await updateEntry(created.id, {params: {content: 'updated content'}}, tmpPath) + + expect(updated.id).toBe(created.id) + expect(updated.type).toBe('rule') + expect(updated.params).toMatchObject({content: 'updated content'}) + expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan(new Date(created.updatedAt).getTime()) + }) + + it('deletes a rule entry', async () => { + const ruleData = { + name: 'rule-to-delete', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('rule'), + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {content: 'delete me'}, + } + + const created = await addEntry(ruleData, tmpPath) + await deleteEntry(created.id, tmpPath) + + const store = await loadAIConfig(tmpPath) + expect(store.entries.find((e) => e.id === created.id)).toBeUndefined() + }) +}) + // ────────────────────────────────────────────────────────────────────────────── // updateEntry // ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/unit/services/ai-env-deployer.test.js b/tests/unit/services/ai-env-deployer.test.js index 5d2c90a..61917ef 100644 --- a/tests/unit/services/ai-env-deployer.test.js +++ b/tests/unit/services/ai-env-deployer.test.js @@ -1,9 +1,10 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest' import {join} from 'node:path' -import {tmpdir} from 'node:os' +import {tmpdir, homedir} from 'node:os' import {readFile, mkdir, writeFile, rm} from 'node:fs/promises' import {existsSync} from 'node:fs' import {randomUUID} from 'node:crypto' +import yaml from 'js-yaml' import { deployMCPEntry, @@ -75,6 +76,63 @@ function makeCommandEntry(overrides = {}) { } } +/** + * Build a minimal CategoryEntry for skill type. + * @param {Partial} [overrides] + * @returns {import('../../../src/types.js').CategoryEntry} + */ +function makeSkillEntry(overrides = {}) { + return { + id: randomUUID(), + name: 'my-skill', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('skill'), + active: true, + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {content: '# My Skill\nSkill content here.', description: 'A test skill'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +/** + * Build a minimal CategoryEntry for agent type. + * @param {Partial} [overrides] + * @returns {import('../../../src/types.js').CategoryEntry} + */ +function makeAgentEntry(overrides = {}) { + return { + id: randomUUID(), + name: 'my-agent', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('agent'), + active: true, + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {instructions: 'Agent instructions here.', description: 'A test agent'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +/** + * Build a minimal CategoryEntry for rule type. + * @param {Partial} [overrides] + * @returns {import('../../../src/types.js').CategoryEntry} + */ +function makeRuleEntry(overrides = {}) { + return { + id: randomUUID(), + name: 'my-rule', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('rule'), + active: true, + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {content: 'Rule content here.', description: 'A test rule'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + /** * Build a minimal DetectedEnvironment stub. * @param {import('../../../src/types.js').EnvironmentId} id @@ -95,6 +153,30 @@ function makeDetected(id, unreadable = []) { } } +/** + * Save, run fn, then restore a file at the given path. + * Useful for tests that write to real homedir paths. + * @param {string} filePath + * @param {() => Promise} fn + * @returns {Promise} + */ +async function withRestoredFile(filePath, fn) { + const hadExistingFile = existsSync(filePath) + let originalContent = null + if (hadExistingFile) { + originalContent = await readFile(filePath, 'utf8') + } + try { + await fn() + } finally { + if (hadExistingFile && originalContent !== null) { + await writeFile(filePath, originalContent, 'utf8') + } else if (existsSync(filePath)) { + await rm(filePath, {force: true}) + } + } +} + // ────────────────────────────────────────────────────────────────────────────── // Test lifecycle // ────────────────────────────────────────────────────────────────────────────── @@ -184,19 +266,9 @@ describe('deployMCPEntry', () => { }) it('handles gemini-cli: writes to ~/.gemini/settings.json with "mcpServers" key', async () => { - // We cannot write to real homedir in tests; we verify the path structure by - // pre-creating the directory under a unique path then checking the written file - const {homedir} = await import('node:os') const geminiSettingsPath = join(homedir(), '.gemini', 'settings.json') - // Read current state (may not exist) so we can restore it - const hadExistingFile = existsSync(geminiSettingsPath) - let originalContent = null - if (hadExistingFile) { - originalContent = await readFile(geminiSettingsPath, 'utf8') - } - - try { + await withRestoredFile(geminiSettingsPath, async () => { const entry = makeMCPEntry({name: 'gemini-mcp', environments: ['gemini-cli']}) await deployMCPEntry(entry, 'gemini-cli', cwd) @@ -204,27 +276,13 @@ describe('deployMCPEntry', () => { const json = await readJson(geminiSettingsPath) expect(json).toHaveProperty('mcpServers') expect(json.mcpServers).toHaveProperty('gemini-mcp') - } finally { - // Restore previous state - if (hadExistingFile && originalContent !== null) { - await writeFile(geminiSettingsPath, originalContent, 'utf8') - } else if (existsSync(geminiSettingsPath)) { - await rm(geminiSettingsPath, {force: true}) - } - } + }) }) it('handles copilot-cli: writes to ~/.copilot/mcp-config.json with "mcpServers" key', async () => { - const {homedir} = await import('node:os') const copilotMcpPath = join(homedir(), '.copilot', 'mcp-config.json') - const hadExistingFile = existsSync(copilotMcpPath) - let originalContent = null - if (hadExistingFile) { - originalContent = await readFile(copilotMcpPath, 'utf8') - } - - try { + await withRestoredFile(copilotMcpPath, async () => { const entry = makeMCPEntry({name: 'copilot-mcp', environments: ['copilot-cli']}) await deployMCPEntry(entry, 'copilot-cli', cwd) @@ -232,13 +290,100 @@ describe('deployMCPEntry', () => { const json = await readJson(copilotMcpPath) expect(json).toHaveProperty('mcpServers') expect(json.mcpServers).toHaveProperty('copilot-mcp') - } finally { - if (hadExistingFile && originalContent !== null) { - await writeFile(copilotMcpPath, originalContent, 'utf8') - } else if (existsSync(copilotMcpPath)) { - await rm(copilotMcpPath, {force: true}) - } - } + }) + }) + + it('handles cursor: writes to .cursor/mcp.json with "mcpServers" key', async () => { + const entry = makeMCPEntry({name: 'cursor-mcp', environments: ['cursor']}) + + await deployMCPEntry(entry, 'cursor', cwd) + + const filePath = join(cwd, '.cursor', 'mcp.json') + expect(existsSync(filePath)).toBe(true) + + const json = await readJson(filePath) + expect(json).toHaveProperty('mcpServers') + expect(json.mcpServers).toHaveProperty('cursor-mcp') + expect(json.mcpServers['cursor-mcp']).toMatchObject({command: 'npx'}) + }) + + it('handles windsurf: writes to ~/.codeium/windsurf/mcp_config.json with "mcpServers" key', async () => { + const windsurfMcpPath = join(homedir(), '.codeium', 'windsurf', 'mcp_config.json') + + await withRestoredFile(windsurfMcpPath, async () => { + const entry = makeMCPEntry({name: 'windsurf-mcp', environments: ['windsurf']}) + await deployMCPEntry(entry, 'windsurf', cwd) + + expect(existsSync(windsurfMcpPath)).toBe(true) + const json = await readJson(windsurfMcpPath) + expect(json).toHaveProperty('mcpServers') + expect(json.mcpServers).toHaveProperty('windsurf-mcp') + }) + }) + + it('handles continue-dev: writes YAML to ~/.continue/config.yaml with "mcpServers" key', async () => { + const continuePath = join(homedir(), '.continue', 'config.yaml') + + await withRestoredFile(continuePath, async () => { + const entry = makeMCPEntry({name: 'continue-mcp', environments: ['continue-dev']}) + await deployMCPEntry(entry, 'continue-dev', cwd) + + expect(existsSync(continuePath)).toBe(true) + const raw = await readFile(continuePath, 'utf8') + // Must be YAML, not JSON + expect(raw).not.toMatch(/^\s*\{/) + const parsed = /** @type {any} */ (yaml.load(raw)) + expect(parsed).toHaveProperty('mcpServers') + expect(parsed.mcpServers).toHaveProperty('continue-mcp') + expect(parsed.mcpServers['continue-mcp']).toMatchObject({command: 'npx'}) + }) + }) + + it('handles continue-dev: merges into existing YAML without clobbering other entries', async () => { + const continuePath = join(homedir(), '.continue', 'config.yaml') + + await withRestoredFile(continuePath, async () => { + // Pre-populate with an existing entry + const existing = {mcpServers: {'existing-server': {command: 'node', args: []}}} + await mkdir(join(homedir(), '.continue'), {recursive: true}) + await writeFile(continuePath, yaml.dump(existing), 'utf8') + + const entry = makeMCPEntry({name: 'new-continue-mcp', environments: ['continue-dev']}) + await deployMCPEntry(entry, 'continue-dev', cwd) + + const raw = await readFile(continuePath, 'utf8') + const parsed = /** @type {any} */ (yaml.load(raw)) + expect(parsed.mcpServers).toHaveProperty('existing-server') + expect(parsed.mcpServers).toHaveProperty('new-continue-mcp') + }) + }) + + it('handles zed: writes to ~/.config/zed/settings.json with "context_servers" key', async () => { + const zedSettingsPath = join(homedir(), '.config', 'zed', 'settings.json') + + await withRestoredFile(zedSettingsPath, async () => { + const entry = makeMCPEntry({name: 'zed-mcp', environments: ['zed']}) + await deployMCPEntry(entry, 'zed', cwd) + + expect(existsSync(zedSettingsPath)).toBe(true) + const json = await readJson(zedSettingsPath) + expect(json).toHaveProperty('context_servers') + expect(json).not.toHaveProperty('mcpServers') + expect(/** @type {any} */ (json.context_servers)).toHaveProperty('zed-mcp') + }) + }) + + it('handles amazon-q: writes to .amazonq/mcp.json with "mcpServers" key', async () => { + const entry = makeMCPEntry({name: 'amazonq-mcp', environments: ['amazon-q']}) + + await deployMCPEntry(entry, 'amazon-q', cwd) + + const filePath = join(cwd, '.amazonq', 'mcp.json') + expect(existsSync(filePath)).toBe(true) + + const json = await readJson(filePath) + expect(json).toHaveProperty('mcpServers') + expect(json.mcpServers).toHaveProperty('amazonq-mcp') }) it('is a no-op when entry type is not mcp', async () => { @@ -297,6 +442,65 @@ describe('undeployMCPEntry', () => { await expect(undeployMCPEntry('nonexistent', 'claude-code', cwd)).resolves.toBeUndefined() expect(existsSync(join(cwd, '.mcp.json'))).toBe(false) }) + + it('removes an entry from cursor .cursor/mcp.json while preserving others', async () => { + const filePath = join(cwd, '.cursor', 'mcp.json') + await mkdir(join(cwd, '.cursor'), {recursive: true}) + const initial = { + mcpServers: { + 'server-keep': {command: 'node', args: []}, + 'server-remove': {command: 'npx', args: []}, + }, + } + await writeFile(filePath, JSON.stringify(initial), 'utf8') + + await undeployMCPEntry('server-remove', 'cursor', cwd) + + const json = await readJson(filePath) + expect(json.mcpServers).not.toHaveProperty('server-remove') + expect(json.mcpServers).toHaveProperty('server-keep') + }) + + it('removes an entry from continue-dev YAML while preserving others', async () => { + const continuePath = join(homedir(), '.continue', 'config.yaml') + + await withRestoredFile(continuePath, async () => { + const initial = { + mcpServers: { + 'server-keep': {command: 'node', args: []}, + 'server-remove': {command: 'npx', args: []}, + }, + } + await mkdir(join(homedir(), '.continue'), {recursive: true}) + await writeFile(continuePath, yaml.dump(initial), 'utf8') + + await undeployMCPEntry('server-remove', 'continue-dev', cwd) + + const raw = await readFile(continuePath, 'utf8') + const parsed = /** @type {any} */ (yaml.load(raw)) + expect(parsed.mcpServers).not.toHaveProperty('server-remove') + expect(parsed.mcpServers).toHaveProperty('server-keep') + // Should still be valid YAML, not JSON + expect(raw).not.toMatch(/^\s*\{/) + }) + }) + + it('removes an entry from amazon-q .amazonq/mcp.json', async () => { + const filePath = join(cwd, '.amazonq', 'mcp.json') + await mkdir(join(cwd, '.amazonq'), {recursive: true}) + const initial = { + mcpServers: { + 'aq-server': {command: 'node', args: []}, + }, + } + await writeFile(filePath, JSON.stringify(initial), 'utf8') + + await undeployMCPEntry('aq-server', 'amazon-q', cwd) + + const json = await readJson(filePath) + expect(json.mcpServers).not.toHaveProperty('aq-server') + expect(json).toHaveProperty('mcpServers') + }) }) // ────────────────────────────────────────────────────────────────────────────── @@ -326,32 +530,18 @@ describe('deployFileEntry', () => { params: {content: 'Summarise the current file.', description: 'Summarise'}, }) - // Use a real temp dir for the gemini path; we capture the expected path and - // clean it up afterwards. - const {homedir} = await import('node:os') const tomlPath = join(homedir(), '.gemini', 'commands', 'summarise.toml') - const hadExistingFile = existsSync(tomlPath) - let originalContent = null - if (hadExistingFile) { - originalContent = await readFile(tomlPath, 'utf8') - } - - try { + await withRestoredFile(tomlPath, async () => { await deployFileEntry(entry, 'gemini-cli', cwd) expect(existsSync(tomlPath)).toBe(true) const raw = await readFile(tomlPath, 'utf8') expect(raw).toContain('description = "Summarise"') expect(raw).toContain('[prompt]') + expect(raw).toContain('text = """') expect(raw).toContain('Summarise the current file.') - } finally { - if (hadExistingFile && originalContent !== null) { - await writeFile(tomlPath, originalContent, 'utf8') - } else if (existsSync(tomlPath)) { - await rm(tomlPath, {force: true}) - } - } + }) }) it('creates nested directory structure {name}/SKILL.md for vscode-copilot skills', async () => { @@ -422,6 +612,125 @@ describe('deployFileEntry', () => { expect(content).toBe('Review code for quality and security.') }) + it('creates an .mdc file with YAML frontmatter for a cursor rule', async () => { + const entry = makeRuleEntry({ + name: 'no-console', + environments: ['cursor'], + params: {content: 'Never use console.log in production code.', description: 'No console logs'}, + }) + + await deployFileEntry(entry, 'cursor', cwd) + + const filePath = join(cwd, '.cursor', 'rules', 'no-console.mdc') + expect(existsSync(filePath)).toBe(true) + + const raw = await readFile(filePath, 'utf8') + // Must have YAML frontmatter + expect(raw).toMatch(/^---\n/) + expect(raw).toContain('description: No console logs') + expect(raw).toContain('globs:') + expect(raw).toContain('alwaysApply: false') + expect(raw).toContain('---') + // Must contain the rule content after the frontmatter + expect(raw).toContain('Never use console.log in production code.') + }) + + it('creates a .md file at .claude/rules/.md for a claude-code rule', async () => { + const entry = makeRuleEntry({ + name: 'style-guide', + environments: ['claude-code'], + params: {content: 'Follow the project style guide.', description: 'Style guide'}, + }) + + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'rules', 'style-guide.md') + expect(existsSync(filePath)).toBe(true) + + const content = await readFile(filePath, 'utf8') + expect(content).toBe('Follow the project style guide.') + }) + + it('creates a .md file at .continue/rules/.md for a continue-dev rule', async () => { + const entry = makeRuleEntry({ + name: 'best-practices', + environments: ['continue-dev'], + params: {content: 'Always write tests.', description: 'Best practices'}, + }) + + await deployFileEntry(entry, 'continue-dev', cwd) + + const filePath = join(cwd, '.continue', 'rules', 'best-practices.md') + expect(existsSync(filePath)).toBe(true) + + const content = await readFile(filePath, 'utf8') + expect(content).toBe('Always write tests.') + }) + + it('creates a .md file for a claude-code skill', async () => { + const entry = makeSkillEntry({ + name: 'refactor-skill', + environments: ['claude-code'], + params: {content: '# Refactor Skill\nRefactor code.', description: 'Refactor'}, + }) + + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'skills', 'refactor-skill.md') + expect(existsSync(filePath)).toBe(true) + + const content = await readFile(filePath, 'utf8') + expect(content).toBe('# Refactor Skill\nRefactor code.') + }) + + it('creates a .md file for a cursor skill', async () => { + const entry = makeSkillEntry({ + name: 'debug-skill', + environments: ['cursor'], + params: {content: 'Debugging skill content.', description: 'Debug'}, + }) + + await deployFileEntry(entry, 'cursor', cwd) + + const filePath = join(cwd, '.cursor', 'skills', 'debug-skill.md') + expect(existsSync(filePath)).toBe(true) + + const content = await readFile(filePath, 'utf8') + expect(content).toBe('Debugging skill content.') + }) + + it('creates a .md file for an opencode agent', async () => { + const entry = makeAgentEntry({ + name: 'test-agent', + environments: ['opencode'], + params: {content: 'OpenCode agent instructions.', description: 'Test agent'}, + }) + + await deployFileEntry(entry, 'opencode', cwd) + + const filePath = join(cwd, '.opencode', 'agents', 'test-agent.md') + expect(existsSync(filePath)).toBe(true) + + const content = await readFile(filePath, 'utf8') + expect(content).toBe('OpenCode agent instructions.') + }) + + it('creates parent directories as needed when they do not exist', async () => { + const entry = makeCommandEntry({ + name: 'deep-cmd', + environments: ['claude-code'], + params: {content: 'Deep command content.'}, + }) + + // The .claude/commands directory does not exist yet + expect(existsSync(join(cwd, '.claude'))).toBe(false) + + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'commands', 'deep-cmd.md') + expect(existsSync(filePath)).toBe(true) + }) + it('is a no-op when entry is null', async () => { await expect(deployFileEntry(null, 'claude-code', cwd)).resolves.toBeUndefined() }) @@ -454,6 +763,71 @@ describe('undeployFileEntry', () => { it('is a no-op when the file does not exist', async () => { await expect(undeployFileEntry('nonexistent', 'command', 'claude-code', cwd)).resolves.toBeUndefined() }) + + it('removes a deployed skill file for claude-code', async () => { + const entry = makeSkillEntry({name: 'skill-to-remove', environments: ['claude-code']}) + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'skills', 'skill-to-remove.md') + expect(existsSync(filePath)).toBe(true) + + await undeployFileEntry('skill-to-remove', 'skill', 'claude-code', cwd) + + expect(existsSync(filePath)).toBe(false) + }) + + it('removes a deployed agent file for opencode', async () => { + const entry = makeAgentEntry({ + name: 'agent-to-remove', + environments: ['opencode'], + params: {content: 'Agent content.'}, + }) + await deployFileEntry(entry, 'opencode', cwd) + + const filePath = join(cwd, '.opencode', 'agents', 'agent-to-remove.md') + expect(existsSync(filePath)).toBe(true) + + await undeployFileEntry('agent-to-remove', 'agent', 'opencode', cwd) + + expect(existsSync(filePath)).toBe(false) + }) + + it('removes a deployed cursor .mdc rule file', async () => { + const entry = makeRuleEntry({ + name: 'rule-to-remove', + environments: ['cursor'], + params: {content: 'Rule content.', description: 'Test'}, + }) + await deployFileEntry(entry, 'cursor', cwd) + + const filePath = join(cwd, '.cursor', 'rules', 'rule-to-remove.mdc') + expect(existsSync(filePath)).toBe(true) + + await undeployFileEntry('rule-to-remove', 'rule', 'cursor', cwd) + + expect(existsSync(filePath)).toBe(false) + }) + + it('removes a deployed claude-code rule file', async () => { + const entry = makeRuleEntry({ + name: 'rule-claude', + environments: ['claude-code'], + params: {content: 'Claude rule.', description: 'Test'}, + }) + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'rules', 'rule-claude.md') + expect(existsSync(filePath)).toBe(true) + + await undeployFileEntry('rule-claude', 'rule', 'claude-code', cwd) + + expect(existsSync(filePath)).toBe(false) + }) + + it('is a no-op when type is mcp', async () => { + await expect(undeployFileEntry('some-mcp', 'mcp', 'claude-code', cwd)).resolves.toBeUndefined() + expect(existsSync(join(cwd, '.mcp.json'))).toBe(false) + }) }) // ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/unit/services/ai-env-scanner.test.js b/tests/unit/services/ai-env-scanner.test.js index 24b4a20..001ae41 100644 --- a/tests/unit/services/ai-env-scanner.test.js +++ b/tests/unit/services/ai-env-scanner.test.js @@ -2,16 +2,20 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' vi.mock('node:fs') -import {existsSync, readFileSync} from 'node:fs' +import {existsSync, readFileSync, readdirSync} from 'node:fs' import { scanEnvironments, getCompatibleEnvironments, computeCategoryCounts, + parseNativeEntries, + getSharedPathGroups, + ENVIRONMENTS, } from '../../../src/services/ai-env-scanner.js' const CWD = '/fake/project' beforeEach(() => { + vi.mocked(readdirSync).mockReturnValue([]) // Default: nothing exists vi.mocked(existsSync).mockReturnValue(false) vi.mocked(readFileSync).mockReturnValue('{}') @@ -126,11 +130,11 @@ describe('scanEnvironments', () => { const byId = Object.fromEntries(result.map((e) => [e.id, e])) - expect(byId['vscode-copilot']?.supportedCategories).toEqual(['mcp', 'command', 'skill', 'agent']) - expect(byId['claude-code']?.supportedCategories).toEqual(['mcp', 'command', 'skill', 'agent']) - expect(byId['opencode']?.supportedCategories).toEqual(['mcp', 'command', 'skill', 'agent']) - expect(byId['gemini-cli']?.supportedCategories).toEqual(['mcp', 'command']) - expect(byId['copilot-cli']?.supportedCategories).toEqual(['mcp', 'command', 'skill', 'agent']) + expect(byId['vscode-copilot']?.supportedCategories).toEqual(['mcp', 'command', 'rule', 'skill', 'agent']) + expect(byId['claude-code']?.supportedCategories).toEqual(['mcp', 'command', 'rule', 'skill', 'agent']) + expect(byId['opencode']?.supportedCategories).toEqual(['mcp', 'command', 'rule', 'skill', 'agent']) + expect(byId['gemini-cli']?.supportedCategories).toEqual(['mcp', 'command', 'rule']) + expect(byId['copilot-cli']?.supportedCategories).toEqual(['mcp', 'command', 'rule', 'skill', 'agent']) }) it('non-JSON paths are always readable when they exist', () => { @@ -149,7 +153,7 @@ describe('scanEnvironments', () => { const result = scanEnvironments(CWD) - expect(result[0].counts).toEqual({mcp: 0, command: 0, skill: 0, agent: 0}) + expect(result[0].counts).toEqual({mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}) }) }) @@ -294,7 +298,7 @@ describe('computeCategoryCounts', () => { const counts = computeCategoryCounts('claude-code', entries) - expect(counts).toEqual({mcp: 1, command: 1, skill: 0, agent: 1}) + expect(counts).toEqual({mcp: 1, command: 1, rule: 0, skill: 0, agent: 1}) }) it('excludes inactive entries', () => { @@ -345,13 +349,13 @@ describe('computeCategoryCounts', () => { const counts = computeCategoryCounts('claude-code', entries) - expect(counts).toEqual({mcp: 0, command: 0, skill: 0, agent: 0}) + expect(counts).toEqual({mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}) }) it('returns all zeros when entries array is empty', () => { const counts = computeCategoryCounts('claude-code', []) - expect(counts).toEqual({mcp: 0, command: 0, skill: 0, agent: 0}) + expect(counts).toEqual({mcp: 0, command: 0, rule: 0, skill: 0, agent: 0}) }) it('counts entries correctly when env appears in a multi-env list', () => { @@ -385,3 +389,194 @@ describe('computeCategoryCounts', () => { expect(counts.mcp).toBe(0) }) }) + +// ────────────────────────────────────────────────────────────────────────────── +// 10 environments detection (T011) +// ────────────────────────────────────────────────────────────────────────────── + +describe('scanEnvironments — 10 environments', () => { + it('detects cursor via .cursor/mcp.json', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.cursor/mcp.json')) + vi.mocked(readFileSync).mockReturnValue('{"mcpServers":{}}') + + const result = scanEnvironments(CWD) + + expect(result.some((e) => e.id === 'cursor')).toBe(true) + }) + + it('detects windsurf via .windsurf/rules directory', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.windsurf/rules')) + + const result = scanEnvironments(CWD) + + expect(result.some((e) => e.id === 'windsurf')).toBe(true) + }) + + it('detects continue-dev via .continue/config.yaml', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.continue/config.yaml')) + + const result = scanEnvironments(CWD) + + expect(result.some((e) => e.id === 'continue-dev')).toBe(true) + }) + + it('detects zed via .rules', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.rules')) + + const result = scanEnvironments(CWD) + + expect(result.some((e) => e.id === 'zed')).toBe(true) + }) + + it('detects amazon-q via .amazonq/mcp.json', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.amazonq/mcp.json')) + vi.mocked(readFileSync).mockReturnValue('{"mcpServers":{}}') + + const result = scanEnvironments(CWD) + + expect(result.some((e) => e.id === 'amazon-q')).toBe(true) + }) + + it('ENVIRONMENTS array contains all 10 environments', () => { + const ids = ENVIRONMENTS.map((e) => e.id) + expect(ids).toContain('vscode-copilot') + expect(ids).toContain('claude-code') + expect(ids).toContain('opencode') + expect(ids).toContain('gemini-cli') + expect(ids).toContain('copilot-cli') + expect(ids).toContain('cursor') + expect(ids).toContain('windsurf') + expect(ids).toContain('continue-dev') + expect(ids).toContain('zed') + expect(ids).toContain('amazon-q') + expect(ids).toHaveLength(10) + }) + + it('cursor supportedCategories includes rule but not agent', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.cursor/mcp.json')) + vi.mocked(readFileSync).mockReturnValue('{"mcpServers":{}}') + + const result = scanEnvironments(CWD) + const cursor = result.find((e) => e.id === 'cursor') + + expect(cursor?.supportedCategories).toContain('rule') + expect(cursor?.supportedCategories).not.toContain('agent') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// parseNativeEntries (T011) +// ────────────────────────────────────────────────────────────────────────────── + +describe('parseNativeEntries', () => { + const claudeCodeDef = ENVIRONMENTS.find((e) => e.id === 'claude-code') + + it('returns MCP entries from .mcp.json', () => { + const mcpJson = JSON.stringify({mcpServers: {'my-server': {command: 'npx', args: ['-y', 'server'], type: 'stdio'}}}) + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.mcp.json')) + vi.mocked(readFileSync).mockReturnValue(mcpJson) + + const result = parseNativeEntries(claudeCodeDef, CWD, []) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('my-server') + expect(result[0].type).toBe('mcp') + expect(result[0].environmentId).toBe('claude-code') + expect(result[0].level).toBe('project') + expect(result[0].params.command).toBe('npx') + }) + + it('excludes managed entries matched by name+type', () => { + const mcpJson = JSON.stringify({mcpServers: {'managed-server': {command: 'node'}, 'native-server': {command: 'npx'}}}) + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.mcp.json')) + vi.mocked(readFileSync).mockReturnValue(mcpJson) + + const managedEntries = [ + { + id: '1', + name: 'managed-server', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ] + + const result = parseNativeEntries(claudeCodeDef, CWD, managedEntries) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('native-server') + }) + + it('returns file-based rule entries from .claude/rules/ directory', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).includes('.claude/rules')) + vi.mocked(readdirSync).mockImplementation((p) => { + if (String(p).endsWith('.claude/rules')) { + return /** @type {any} */ ([{name: 'my-rule.md', isFile: () => true, isDirectory: () => false}]) + } + return [] + }) + + const result = parseNativeEntries(claudeCodeDef, CWD, []) + const rules = result.filter((e) => e.type === 'rule') + + expect(rules.length).toBeGreaterThanOrEqual(1) + expect(rules.some((r) => r.name === 'my-rule')).toBe(true) + }) + + it('returns empty array when no config files exist', () => { + vi.mocked(existsSync).mockReturnValue(false) + + const result = parseNativeEntries(claudeCodeDef, CWD, []) + + expect(result).toHaveLength(0) + }) + + it('returns entries from command directory', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).includes('.claude/commands')) + vi.mocked(readdirSync).mockImplementation((p) => { + if (String(p).endsWith('.claude/commands')) { + return /** @type {any} */ ([ + {name: 'refactor.md', isFile: () => true, isDirectory: () => false}, + {name: 'review.md', isFile: () => true, isDirectory: () => false}, + ]) + } + return [] + }) + + const result = parseNativeEntries(claudeCodeDef, CWD, []) + const cmds = result.filter((e) => e.type === 'command') + + expect(cmds).toHaveLength(2) + expect(cmds.map((c) => c.name)).toContain('refactor') + expect(cmds.map((c) => c.name)).toContain('review') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getSharedPathGroups (T011) +// ────────────────────────────────────────────────────────────────────────────── + +describe('getSharedPathGroups', () => { + it('returns mcp group for claude-code and copilot-cli', () => { + const groups = getSharedPathGroups('mcp') + expect(groups.some((g) => g.includes('claude-code') && g.includes('copilot-cli'))).toBe(true) + }) + + it('returns command group for vscode-copilot and copilot-cli', () => { + const groups = getSharedPathGroups('command') + expect(groups.some((g) => g.includes('vscode-copilot') && g.includes('copilot-cli'))).toBe(true) + }) + + it('returns empty array for skill', () => { + const groups = getSharedPathGroups('skill') + expect(groups).toHaveLength(0) + }) + + it('returns group for agent (vscode-copilot and copilot-cli share .github/agents/)', () => { + const groups = getSharedPathGroups('agent') + expect(groups.some((g) => g.includes('vscode-copilot') && g.includes('copilot-cli'))).toBe(true) + }) +})