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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "devvami",
"description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
"version": "1.5.0",
"version": "1.5.1",
"author": "",
"type": "module",
"bin": {
Expand Down
83 changes: 76 additions & 7 deletions src/commands/sync-config-ai/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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,
updateEntry,
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'

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────
Expand All @@ -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
Expand All @@ -120,22 +162,49 @@ 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(
currentStore.entries.find((e) => e.id === action.id),
detectedEnvs,
process.cwd(),
)
await syncAIConfigToChezmoi()
} else if (action.type === 'deactivate') {
const entry = await deactivateEntry(action.id)
await undeployEntry(entry, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'activate') {
const entry = await activateEntry(action.id)
await deployEntry(entry, detectedEnvs, process.cwd())
await syncAIConfigToChezmoi()
} else if (action.type === 'import-native') {
// T017: Import native entry into dvmi-managed sync
const ne = action.nativeEntry
const created = await addEntry({
name: ne.name,
type: ne.type,
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()
}
}
},
})
Expand Down
110 changes: 98 additions & 12 deletions src/formatters/ai-config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import chalk from 'chalk'

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

// ──────────────────────────────────────────────────────────────────────────────
// Internal helpers
Expand Down Expand Up @@ -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))))
Expand All @@ -58,34 +59,117 @@ 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<string, string>} */
const ENV_SHORT_NAMES = {
'vscode-copilot': 'VSCode',
'claude-code': 'Claude',
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
Expand Down Expand Up @@ -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(', ')
Expand Down
Loading
Loading