From 6324b1c2dcd4ab19df3f83aede6de96d37bb14f1 Mon Sep 17 00:00:00 2001 From: savez Date: Wed, 1 Apr 2026 10:57:03 +0200 Subject: [PATCH 01/10] feat(sync-config-ai): add AI config sync TUI command (spec 007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `dvmi sync-config-ai` — an interactive full-screen TUI for managing AI coding tool configurations (MCP servers, commands, skills, agents) across VS Code Copilot, Claude Code, OpenCode, Gemini CLI, and GitHub Copilot CLI from a single place. Key capabilities: - Filesystem scan on every launch detects installed AI tools (project + global paths, including ~/.config/opencode/ for globally installed OpenCode) - 5-tab TUI: Environments (read-only) + dedicated tab per category type (MCPs | Commands | Skills | Agents) — each tab shows only its entries - Inline forms with type-specific fields and a mini text editor for multi-line content; Environments multi-select filtered to compatible tools per compatibility matrix - Full CRUD: create, edit, deactivate/activate, delete with confirmation - Entries persist in ~/.config/dvmi/ai-config.json and are deployed to target environment config files on save - Chezmoi integration: auto-syncs AI config after mutations if configured; shows setup tip in footer otherwise - --json flag for non-interactive/CI use Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 29 + package.json | 1 + src/commands/sync-config-ai/index.js | 143 +++ src/formatters/ai-config.js | 127 +++ src/help.js | 326 +++--- src/services/ai-config-store.js | 318 ++++++ src/services/ai-env-deployer.js | 444 ++++++++ src/services/ai-env-scanner.js | 242 ++++ src/types.js | 85 ++ src/utils/tui/form.js | 1006 +++++++++++++++++ src/utils/tui/tab-tui.js | 800 +++++++++++++ tests/integration/helpers.js | 33 +- tests/integration/pr-review.test.js | 18 +- tests/integration/sync-config-ai.test.js | 25 + tests/services/ai-config-sync.test.js | 253 +++++ .../__snapshots__/sync-config-ai.test.js.snap | 23 + tests/snapshots/sync-config-ai.test.js | 10 + tests/unit/services/ai-config-store.test.js | 335 ++++++ tests/unit/services/ai-env-deployer.test.js | 615 ++++++++++ tests/unit/services/ai-env-scanner.test.js | 387 +++++++ tests/unit/utils/tui/form.test.js | 872 ++++++++++++++ 21 files changed, 5909 insertions(+), 183 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/commands/sync-config-ai/index.js create mode 100644 src/formatters/ai-config.js create mode 100644 src/services/ai-config-store.js create mode 100644 src/services/ai-env-deployer.js create mode 100644 src/services/ai-env-scanner.js create mode 100644 src/utils/tui/form.js create mode 100644 src/utils/tui/tab-tui.js create mode 100644 tests/integration/sync-config-ai.test.js create mode 100644 tests/services/ai-config-sync.test.js create mode 100644 tests/snapshots/__snapshots__/sync-config-ai.test.js.snap create mode 100644 tests/snapshots/sync-config-ai.test.js create mode 100644 tests/unit/services/ai-config-store.test.js create mode 100644 tests/unit/services/ai-env-deployer.test.js create mode 100644 tests/unit/services/ai-env-scanner.test.js create mode 100644 tests/unit/utils/tui/form.test.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..518c44e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# devvami Development Guidelines + +Auto-generated from all feature plans. Last updated: 2026-04-01 + +## Active Technologies + +- 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) + +## Project Structure + +```text +src/ +tests/ +``` + +## Commands + +npm test && npm run lint + +## Code Style + +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 — zero new TUI dependencies + + + diff --git a/package.json b/package.json index 84cdda4..5c3c806 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "test:integration": "vitest run --project integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "pretest": "oclif manifest", "prepack": "oclif manifest", "postpack": "shx rm -f oclif.manifest.json", "prepare": "lefthook install" diff --git a/src/commands/sync-config-ai/index.js b/src/commands/sync-config-ai/index.js new file mode 100644 index 0000000..e69a05d --- /dev/null +++ b/src/commands/sync-config-ai/index.js @@ -0,0 +1,143 @@ +import {Command, Flags} from '@oclif/core' +import ora from 'ora' + +import {scanEnvironments, computeCategoryCounts} from '../../services/ai-env-scanner.js' +import { + loadAIConfig, + addEntry, + updateEntry, + deactivateEntry, + activateEntry, + deleteEntry, +} 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 {startTabTUI} from '../../utils/tui/tab-tui.js' +import {DvmiError} from '../../utils/errors.js' + +/** @import { DetectedEnvironment, CategoryEntry } from '../../types.js' */ + +export default class SyncConfigAi extends Command { + static description = 'Manage AI coding tool configurations across environments via TUI' + + static examples = ['<%= config.bin %> sync-config-ai', '<%= config.bin %> sync-config-ai --json'] + + static enableJsonFlag = true + + static flags = { + help: Flags.help({char: 'h'}), + } + + async run() { + const {flags} = await this.parse(SyncConfigAi) + const isJson = flags.json + + // ── Scan environments ──────────────────────────────────────────────────── + const spinner = isJson ? null : ora('Scanning AI coding environments…').start() + let detectedEnvs + + try { + detectedEnvs = scanEnvironments(process.cwd()) + } catch (err) { + spinner?.fail('Scan failed') + throw new DvmiError( + 'Failed to scan AI coding environments', + err instanceof Error ? err.message : 'Check filesystem permissions', + ) + } + + // ── Load AI config store ───────────────────────────────────────────────── + let store + try { + store = await loadAIConfig() + } catch { + spinner?.fail('Failed to load AI config') + throw new DvmiError( + 'AI config file is corrupted', + 'Delete `~/.config/dvmi/ai-config.json` to reset, or fix the JSON manually', + ) + } + + // ── Reconcile: re-deploy/undeploy based on current environment detection ─ + if (detectedEnvs.length > 0 && store.entries.length > 0) { + try { + await reconcileOnScan(store.entries, detectedEnvs, process.cwd()) + // Reload store after reconciliation in case it mutated entries + store = await loadAIConfig() + } catch { + // Reconciliation errors are non-fatal — continue with current state + } + } + + // ── Compute per-environment category counts ────────────────────────────── + for (const env of detectedEnvs) { + env.counts = computeCategoryCounts(env.id, store.entries) + } + + spinner?.stop() + + // ── JSON mode ──────────────────────────────────────────────────────────── + if (isJson) { + 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'), + } + return {environments: detectedEnvs, categories} + } + + // ── Check chezmoi config ───────────────────────────────────────────────── + let chezmoiEnabled = false + try { + const cliConfig = await loadConfig() + chezmoiEnabled = cliConfig.dotfiles?.enabled === true + } catch { + // Non-fatal — chezmoi tip will show + } + + // ── Launch TUI ─────────────────────────────────────────────────────────── + await startTabTUI({ + envs: detectedEnvs, + entries: store.entries, + chezmoiEnabled, + formatEnvs: formatEnvironmentsTable, + formatCats: formatCategoriesTable, + refreshEntries: async () => { + const s = await loadAIConfig() + return s.entries + }, + onAction: async (action) => { + // Reload current entries for each action to avoid stale data + const currentStore = await loadAIConfig() + + if (action.type === 'create') { + const created = await addEntry({ + name: action.values.name, + type: action.tabKey || 'mcp', + environments: action.values.environments || [], + params: action.values, + }) + await deployEntry(created, detectedEnvs, process.cwd()) + } else if (action.type === 'edit') { + const updated = await updateEntry(action.id, {params: action.values}) + await deployEntry(updated, detectedEnvs, process.cwd()) + } else if (action.type === 'delete') { + await deleteEntry(action.id) + await undeployEntry( + currentStore.entries.find((e) => e.id === action.id), + detectedEnvs, + process.cwd(), + ) + } else if (action.type === 'deactivate') { + const entry = await deactivateEntry(action.id) + await undeployEntry(entry, detectedEnvs, process.cwd()) + } else if (action.type === 'activate') { + const entry = await activateEntry(action.id) + await deployEntry(entry, detectedEnvs, process.cwd()) + } + }, + }) + } +} diff --git a/src/formatters/ai-config.js b/src/formatters/ai-config.js new file mode 100644 index 0000000..de68414 --- /dev/null +++ b/src/formatters/ai-config.js @@ -0,0 +1,127 @@ +import chalk from 'chalk' + +/** @import { DetectedEnvironment, CategoryEntry } from '../types.js' */ + +// ────────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Pad a string to a fixed width, truncating with '…' if needed. + * @param {string} str + * @param {number} width + * @returns {string} + */ +function padCell(str, width) { + if (!str) str = '' + if (str.length > width) return str.slice(0, width - 1) + '…' + return str.padEnd(width) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Environments table formatter +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Format a list of detected environments as a table string for display in the TUI. + * Columns: Environment (name), Status, Scope, MCPs, Commands, Skills, Agents + * @param {DetectedEnvironment[]} detectedEnvs + * @param {number} [termCols] + * @returns {string[]} Array of formatted lines (no ANSI clear/home) + */ +export function formatEnvironmentsTable(detectedEnvs, termCols = 120) { + const COL_ENV = 22 + const COL_STATUS = 24 + const COL_SCOPE = 8 + const COL_COUNT = 9 + + const headerParts = [ + chalk.bold.white(padCell('Environment', COL_ENV)), + chalk.bold.white(padCell('Status', COL_STATUS)), + 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('Skills', COL_COUNT)), + chalk.bold.white(padCell('Agents', COL_COUNT)), + ] + + const dividerWidth = COL_ENV + COL_STATUS + COL_SCOPE + COL_COUNT * 4 + 6 * 2 + const lines = [] + lines.push(headerParts.join(' ')) + lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth)))) + + for (const env of detectedEnvs) { + const hasUnreadable = env.unreadable.length > 0 + const statusText = hasUnreadable ? 'Detected (unreadable)' : 'Detected' + const statusStr = hasUnreadable + ? chalk.yellow(padCell(statusText, COL_STATUS)) + : 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 skillStr = env.supportedCategories.includes('skill') + ? padCell(String(env.counts.skill), COL_COUNT) + : padCell('—', COL_COUNT) + const agentStr = env.supportedCategories.includes('agent') + ? padCell(String(env.counts.agent), COL_COUNT) + : padCell('—', COL_COUNT) + + lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, skillStr, agentStr].join(' ')) + } + + return lines +} + +// ────────────────────────────────────────────────────────────────────────────── +// Categories table formatter +// ────────────────────────────────────────────────────────────────────────────── + +/** @type {Record} */ +const ENV_SHORT_NAMES = { + 'vscode-copilot': 'VSCode', + 'claude-code': 'Claude', + opencode: 'OpenCode', + 'gemini-cli': 'Gemini', + 'copilot-cli': 'Copilot', +} + +/** + * Format a list of category entries as a table string for display in the TUI. + * Columns: Name, Type, Status, Environments + * @param {CategoryEntry[]} entries + * @param {number} [termCols] + * @returns {string[]} Array of formatted lines (no ANSI clear/home) + */ +export function formatCategoriesTable(entries, termCols = 120) { + const COL_NAME = 24 + const COL_TYPE = 9 + const COL_STATUS = 10 + const COL_ENVS = 36 + + const headerParts = [ + chalk.bold.white(padCell('Name', COL_NAME)), + chalk.bold.white(padCell('Type', COL_TYPE)), + chalk.bold.white(padCell('Status', COL_STATUS)), + chalk.bold.white(padCell('Environments', COL_ENVS)), + ] + + const dividerWidth = COL_NAME + COL_TYPE + COL_STATUS + COL_ENVS + 3 * 2 + const lines = [] + lines.push(headerParts.join(' ')) + lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth)))) + + for (const entry of entries) { + const statusStr = entry.active + ? chalk.green(padCell('Active', COL_STATUS)) + : chalk.dim(padCell('Inactive', COL_STATUS)) + + const envNames = entry.environments.map((id) => ENV_SHORT_NAMES[id] ?? id).join(', ') + + lines.push( + [padCell(entry.name, COL_NAME), padCell(entry.type, COL_TYPE), statusStr, padCell(envNames, COL_ENVS)].join(' '), + ) + } + + return lines +} diff --git a/src/help.js b/src/help.js index 1bf99b5..29ed34e 100644 --- a/src/help.js +++ b/src/help.js @@ -1,13 +1,13 @@ -import { Help } from '@oclif/core' +import {Help} from '@oclif/core' import chalk from 'chalk' -import { isColorEnabled } from './utils/gradient.js' -import { printBanner } from './utils/banner.js' +import {isColorEnabled} from './utils/gradient.js' +import {printBanner} from './utils/banner.js' // ─── Brand palette (flat — no gradient on help rows) ──────────────────────── -const ORANGE = '#FF6B2B' +const ORANGE = '#FF6B2B' const LIGHT_ORANGE = '#FF9A5C' -const DIM_BLUE = '#4A9EFF' -const DIM_GRAY = '#888888' +const DIM_BLUE = '#4A9EFF' +const DIM_GRAY = '#888888' // Strip ANSI escape codes const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g @@ -27,92 +27,91 @@ const CATEGORIES = [ { title: 'GitHub & Documentazione', cmds: [ - { id: 'repo:list', hint: '[--language] [--search]' }, - { id: 'docs:read', hint: '[FILE] [--repo] [--raw] [--render]' }, - { id: 'docs:list', hint: '[--repo] [--search]' }, - { id: 'docs:search', hint: ' [--repo]' }, - { id: 'docs:projects', hint: '[--search]' }, - { id: 'create:repo', hint: '[TEMPLATE] [--list] [--name]' }, - { id: 'search', hint: '' }, - { id: 'open', hint: '' }, + {id: 'repo:list', hint: '[--language] [--search]'}, + {id: 'docs:read', hint: '[FILE] [--repo] [--raw] [--render]'}, + {id: 'docs:list', hint: '[--repo] [--search]'}, + {id: 'docs:search', hint: ' [--repo]'}, + {id: 'docs:projects', hint: '[--search]'}, + {id: 'create:repo', hint: '[TEMPLATE] [--list] [--name]'}, + {id: 'search', hint: ''}, + {id: 'open', hint: ''}, ], }, { title: 'Pull Request', cmds: [ - { id: 'pr:create', hint: '' }, - { id: 'pr:status', hint: '' }, - { id: 'pr:detail', hint: ' --repo ' }, - { id: 'pr:review', hint: '' }, + {id: 'pr:create', hint: ''}, + {id: 'pr:status', hint: ''}, + {id: 'pr:detail', hint: ' --repo '}, + {id: 'pr:review', hint: ''}, ], }, { title: 'Pipeline & DevOps', cmds: [ - { id: 'pipeline:status', hint: '[--repo] [--branch]' }, - { id: 'pipeline:rerun', hint: ' --repo ' }, - { id: 'pipeline:logs', hint: ' --repo ' }, - { id: 'changelog', hint: '' }, + {id: 'pipeline:status', hint: '[--repo] [--branch]'}, + {id: 'pipeline:rerun', hint: ' --repo '}, + {id: 'pipeline:logs', hint: ' --repo '}, + {id: 'changelog', hint: ''}, ], }, { title: 'Tasks (ClickUp)', cmds: [ - { id: 'tasks:list', hint: '[--status] [--search]' }, - { id: 'tasks:today', hint: '' }, - { id: 'tasks:assigned', hint: '[--status] [--search]' }, + {id: 'tasks:list', hint: '[--status] [--search]'}, + {id: 'tasks:today', hint: ''}, + {id: 'tasks:assigned', hint: '[--status] [--search]'}, ], }, { title: 'Cloud & Costi', cmds: [ - { id: 'costs:get', hint: '[SERVICE] [--period] [--group-by] [--tag-key]' }, - { id: 'costs:trend', hint: '[--group-by] [--tag-key] [--line]' }, - { id: 'logs', hint: '[--group] [--filter] [--since] [--limit] [--region]' }, + {id: 'costs:get', hint: '[SERVICE] [--period] [--group-by] [--tag-key]'}, + {id: 'costs:trend', hint: '[--group-by] [--tag-key] [--line]'}, + {id: 'logs', hint: '[--group] [--filter] [--since] [--limit] [--region]'}, ], }, { title: 'AI Prompts', cmds: [ - { id: 'prompts:list', hint: '[--filter]' }, - { id: 'prompts:download', hint: ' [--overwrite]' }, - { id: 'prompts:browse', hint: '[--source] [--query] [--category]' }, - { id: 'prompts:install-speckit', hint: '[--force]' }, - { id: 'prompts:run', hint: '[PATH] [--tool]' }, + {id: 'prompts:list', hint: '[--filter]'}, + {id: 'prompts:download', hint: ' [--overwrite]'}, + {id: 'prompts:browse', hint: '[--source] [--query] [--category]'}, + {id: 'prompts:install-speckit', hint: '[--force]'}, + {id: 'prompts:run', hint: '[PATH] [--tool]'}, + {id: 'sync-config-ai', hint: '[--json]'}, ], }, { title: 'Sicurezza & Credenziali', - cmds: [ - { id: 'security:setup', hint: '[--json]' }, - ], + cmds: [{id: 'security:setup', hint: '[--json]'}], }, { title: 'CVE & Vulnerabilità', cmds: [ - { id: 'vuln:search', hint: '[KEYWORD] [--days] [--severity] [--limit]' }, - { id: 'vuln:detail', hint: ' [--open]' }, - { id: 'vuln:scan', hint: '[--severity] [--no-fail] [--report]' }, + {id: 'vuln:search', hint: '[KEYWORD] [--days] [--severity] [--limit]'}, + {id: 'vuln:detail', hint: ' [--open]'}, + {id: 'vuln:scan', hint: '[--severity] [--no-fail] [--report]'}, ], }, { title: 'Dotfiles & Cifratura', cmds: [ - { id: 'dotfiles:setup', hint: '[--json]' }, - { id: 'dotfiles:add', hint: '[FILES...] [--encrypt]' }, - { id: 'dotfiles:status', hint: '[--json]' }, - { id: 'dotfiles:sync', hint: '[--push] [--pull] [--dry-run]' }, + {id: 'dotfiles:setup', hint: '[--json]'}, + {id: 'dotfiles:add', hint: '[FILES...] [--encrypt]'}, + {id: 'dotfiles:status', hint: '[--json]'}, + {id: 'dotfiles:sync', hint: '[--push] [--pull] [--dry-run]'}, ], }, { title: 'Setup & Ambiente', cmds: [ - { id: 'init', hint: '[--dry-run]' }, - { id: 'doctor', hint: '' }, - { id: 'auth:login', hint: '' }, - { id: 'whoami', hint: '' }, - { id: 'welcome', hint: '' }, - { id: 'upgrade', hint: '' }, + {id: 'init', hint: '[--dry-run]'}, + {id: 'doctor', hint: ''}, + {id: 'auth:login', hint: ''}, + {id: 'whoami', hint: ''}, + {id: 'welcome', hint: ''}, + {id: 'upgrade', hint: ''}, ], }, ] @@ -126,31 +125,30 @@ const CATEGORIES = [ * - Gradient solo sul logo; tutto il resto usa colori flat chalk */ export default class CustomHelp extends Help { + /** + * Root help override: banner animato → layout categorizzato. + * Override di showRootHelp() (async) per evitare che formatRoot() (sync) + * debba attendere la Promise del banner. + * @returns {Promise} + */ + async showRootHelp() { + // Animated logo — identical to `dvmi init` (no-ops in CI/non-TTY) + await printBanner() + + // Version check: uses cached result (populated by init hook) — 800 ms timeout + let versionInfo = null + try { + const {checkForUpdate} = await import('./services/version-check.js') + versionInfo = await Promise.race([ + checkForUpdate(), + new Promise((resolve) => setTimeout(() => resolve(null), 800)), + ]) + } catch { + // never block help output + } - /** - * Root help override: banner animato → layout categorizzato. - * Override di showRootHelp() (async) per evitare che formatRoot() (sync) - * debba attendere la Promise del banner. - * @returns {Promise} - */ - async showRootHelp() { - // Animated logo — identical to `dvmi init` (no-ops in CI/non-TTY) - await printBanner() - - // Version check: uses cached result (populated by init hook) — 800 ms timeout - let versionInfo = null - try { - const { checkForUpdate } = await import('./services/version-check.js') - versionInfo = await Promise.race([ - checkForUpdate(), - new Promise((resolve) => setTimeout(() => resolve(null), 800)), - ]) - } catch { - // never block help output - } - - this.log(this.#buildRootLayout(versionInfo)) - } + this.log(this.#buildRootLayout(versionInfo)) + } /** * @param {import('@oclif/core').Interfaces.Topic[]} topics @@ -179,69 +177,64 @@ export default class CustomHelp extends Help { // ─── Private helpers ────────────────────────────────────────────────────── - /** - * Build the full categorized root help layout. - * @param {{ hasUpdate: boolean, current: string, latest: string|null }|null} [versionInfo] - * @returns {string} - */ + /** + * Build the full categorized root help layout. + * @param {{ hasUpdate: boolean, current: string, latest: string|null }|null} [versionInfo] + * @returns {string} + */ #buildRootLayout(versionInfo = null) { /** @type {Map} */ const cmdMap = new Map(this.config.commands.map((c) => [c.id, c])) /** @type {Array<{cmd: string, note: string}>} */ const EXAMPLES = [ - { cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' }, - { cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' }, - { cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' }, - { cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' }, - { cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' }, - { cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' }, - { cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' }, - { cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' }, - { cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' }, - { cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' }, - { cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' }, - { cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' }, - { cmd: 'dvmi tasks today', note: 'Task in lavorazione oggi' }, - { cmd: 'dvmi costs get --period mtd', note: 'Costi AWS mese corrente per servizio' }, - { cmd: 'dvmi costs get --group-by tag --tag-key env', note: 'Costi raggruppati per tag env' }, - { cmd: 'dvmi costs trend --line', note: 'Trend costi 2 mesi (grafico lineare)' }, - { cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' }, - { cmd: 'dvmi logs', note: 'Sfoglia log CloudWatch in modo interattivo' }, - { cmd: 'dvmi logs --group /aws/lambda/my-fn --since 24h', note: 'Log Lambda ultimi 24h' }, - { cmd: 'dvmi logs --group /aws/lambda/my-fn --filter "ERROR"', note: 'Filtra eventi ERROR su un log group' }, - { cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' }, - { cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' }, - { cmd: 'dvmi dotfiles setup', note: 'Configura chezmoi con cifratura age' }, - { cmd: 'dvmi dotfiles add ~/.zshrc ~/.gitconfig', note: 'Aggiungi dotfile a chezmoi' }, - { cmd: 'dvmi dotfiles status --json', note: 'Stato dotfile gestiti (JSON)' }, - { cmd: 'dvmi dotfiles sync --push', note: 'Push dotfile al repository remoto' }, - { cmd: 'dvmi welcome', note: 'Dashboard missione dvmi con intro animata' }, - { cmd: 'dvmi vuln search openssl', note: 'Cerca CVE recenti per keyword' }, - { cmd: 'dvmi vuln search log4j --days 30 --severity critical', note: 'CVE critiche Log4j negli ultimi 30 giorni' }, - { cmd: 'dvmi vuln detail CVE-2021-44228', note: 'Dettaglio completo di una CVE' }, - { cmd: 'dvmi vuln detail CVE-2021-44228 --open', note: 'Apri la prima referenza nel browser' }, - { cmd: 'dvmi vuln scan', note: 'Scansiona dipendenze del progetto corrente' }, - { cmd: 'dvmi vuln scan --severity high --no-fail', note: 'Scansione senza bloccare CI (solo high+)' }, - { cmd: 'dvmi vuln scan --report ./vuln-report.md', note: 'Esporta report Markdown delle vulnerabilità' }, + {cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository'}, + {cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave'}, + {cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente'}, + {cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh'}, + {cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents'}, + {cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode'}, + {cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente'}, + {cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente'}, + {cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome'}, + {cmd: 'dvmi pr status', note: 'PR aperte e review in attesa'}, + {cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD'}, + {cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp'}, + {cmd: 'dvmi tasks today', note: 'Task in lavorazione oggi'}, + {cmd: 'dvmi costs get --period mtd', note: 'Costi AWS mese corrente per servizio'}, + {cmd: 'dvmi costs get --group-by tag --tag-key env', note: 'Costi raggruppati per tag env'}, + {cmd: 'dvmi costs trend --line', note: 'Trend costi 2 mesi (grafico lineare)'}, + {cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON'}, + {cmd: 'dvmi logs', note: 'Sfoglia log CloudWatch in modo interattivo'}, + {cmd: 'dvmi logs --group /aws/lambda/my-fn --since 24h', note: 'Log Lambda ultimi 24h'}, + {cmd: 'dvmi logs --group /aws/lambda/my-fn --filter "ERROR"', note: 'Filtra eventi ERROR su un log group'}, + {cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza'}, + {cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM'}, + {cmd: 'dvmi dotfiles setup', note: 'Configura chezmoi con cifratura age'}, + {cmd: 'dvmi dotfiles add ~/.zshrc ~/.gitconfig', note: 'Aggiungi dotfile a chezmoi'}, + {cmd: 'dvmi dotfiles status --json', note: 'Stato dotfile gestiti (JSON)'}, + {cmd: 'dvmi dotfiles sync --push', note: 'Push dotfile al repository remoto'}, + {cmd: 'dvmi welcome', note: 'Dashboard missione dvmi con intro animata'}, + {cmd: 'dvmi vuln search openssl', note: 'Cerca CVE recenti per keyword'}, + {cmd: 'dvmi vuln search log4j --days 30 --severity critical', note: 'CVE critiche Log4j negli ultimi 30 giorni'}, + {cmd: 'dvmi vuln detail CVE-2021-44228', note: 'Dettaglio completo di una CVE'}, + {cmd: 'dvmi vuln detail CVE-2021-44228 --open', note: 'Apri la prima referenza nel browser'}, + {cmd: 'dvmi vuln scan', note: 'Scansiona dipendenze del progetto corrente'}, + {cmd: 'dvmi vuln scan --severity high --no-fail', note: 'Scansione senza bloccare CI (solo high+)'}, + {cmd: 'dvmi vuln scan --report ./vuln-report.md', note: 'Esporta report Markdown delle vulnerabilità'}, ] const lines = [] - // ── Usage ────────────────────────────────────────────────────────────── - lines.push(this.#sectionHeader('USAGE')) - lines.push( - ' ' + (isColorEnabled ? chalk.hex(ORANGE).bold('dvmi') : 'dvmi') + - chalk.dim(' [FLAGS]\n'), - ) + // ── Usage ────────────────────────────────────────────────────────────── + lines.push(this.#sectionHeader('USAGE')) + lines.push(' ' + (isColorEnabled ? chalk.hex(ORANGE).bold('dvmi') : 'dvmi') + chalk.dim(' [FLAGS]\n')) // ── Comandi per categoria ────────────────────────────────────────────── lines.push(this.#sectionHeader('COMMANDS')) for (const cat of CATEGORIES) { - lines.push( - ' ' + (isColorEnabled ? chalk.hex(ORANGE).bold(cat.title) : cat.title), - ) + lines.push(' ' + (isColorEnabled ? chalk.hex(ORANGE).bold(cat.title) : cat.title)) for (const entry of cat.cmds) { const cmd = cmdMap.get(entry.id) @@ -249,17 +242,14 @@ export default class CustomHelp extends Help { const displayId = entry.id.replaceAll(':', ' ') const hint = entry.hint || '' - const desc = cmd.summary ?? (typeof cmd.description === 'string' - ? cmd.description.split('\n')[0] - : '') + const desc = cmd.summary ?? (typeof cmd.description === 'string' ? cmd.description.split('\n')[0] : '') // Left column (name + flags hint), right-padded to align descriptions const rawLeft = ' ' + displayId + (hint ? ' ' + hint : '') const pad = ' '.repeat(Math.max(2, 50 - rawLeft.length)) const leftPart = isColorEnabled - ? ' ' + chalk.hex(LIGHT_ORANGE).bold(displayId) + - (hint ? ' ' + chalk.dim(hint) : '') + ? ' ' + chalk.hex(LIGHT_ORANGE).bold(displayId) + (hint ? ' ' + chalk.dim(hint) : '') : rawLeft lines.push(leftPart + pad + chalk.dim(desc)) @@ -270,8 +260,8 @@ export default class CustomHelp extends Help { // ── Flag globali ─────────────────────────────────────────────────────── lines.push(this.#sectionHeader('GLOBAL FLAGS')) - lines.push(this.#flagLine('-h, --help', 'Mostra aiuto per un comando')) - lines.push(this.#flagLine(' --json', 'Output in formato JSON strutturato')) + lines.push(this.#flagLine('-h, --help', 'Mostra aiuto per un comando')) + lines.push(this.#flagLine(' --json', 'Output in formato JSON strutturato')) lines.push(this.#flagLine('-v, --version', 'Versione installata')) lines.push('') @@ -281,39 +271,49 @@ export default class CustomHelp extends Help { const maxCmdLen = Math.max(...EXAMPLES.map((e) => e.cmd.length)) for (const ex of EXAMPLES) { const pad = ' '.repeat(maxCmdLen - ex.cmd.length + 4) - const sub = ex.cmd.replace(/^dvmi /, '') - const formatted = isColorEnabled - ? chalk.dim('$') + ' ' + chalk.hex(ORANGE).bold('dvmi') + ' ' + - chalk.white(sub) + pad + chalk.hex(DIM_GRAY)(ex.note) - : '$ ' + ex.cmd + pad + ex.note + const sub = ex.cmd.replace(/^dvmi /, '') + const formatted = isColorEnabled + ? chalk.dim('$') + + ' ' + + chalk.hex(ORANGE).bold('dvmi') + + ' ' + + chalk.white(sub) + + pad + + chalk.hex(DIM_GRAY)(ex.note) + : '$ ' + ex.cmd + pad + ex.note lines.push(' ' + formatted) } - lines.push('') - - // ── Versione + update notice ─────────────────────────────────────────── - const current = versionInfo?.current ?? this.config.version - const versionStr = isColorEnabled - ? chalk.dim('version ') + chalk.hex(DIM_BLUE)(current) - : `version ${current}` - - if (versionInfo?.hasUpdate && versionInfo.latest) { - const updateStr = isColorEnabled - ? chalk.yellow('update disponibile: ') + - chalk.dim(current) + chalk.yellow(' → ') + chalk.green(versionInfo.latest) + - chalk.dim(' (esegui ') + chalk.hex(LIGHT_ORANGE)('dvmi upgrade') + chalk.dim(')') - : `update disponibile: ${current} → ${versionInfo.latest} (esegui dvmi upgrade)` - lines.push(' ' + versionStr + chalk.dim(' · ') + updateStr) - } else { - lines.push(' ' + versionStr) - } - - lines.push( - ' ' + chalk.dim('Approfondisci:') + ' ' + - chalk.hex(DIM_BLUE)('dvmi --help') + - chalk.dim(' · ') + - chalk.hex(DIM_BLUE)('dvmi --help') + '\n', - ) + lines.push('') + + // ── Versione + update notice ─────────────────────────────────────────── + const current = versionInfo?.current ?? this.config.version + const versionStr = isColorEnabled ? chalk.dim('version ') + chalk.hex(DIM_BLUE)(current) : `version ${current}` + + if (versionInfo?.hasUpdate && versionInfo.latest) { + const updateStr = isColorEnabled + ? chalk.yellow('update disponibile: ') + + chalk.dim(current) + + chalk.yellow(' → ') + + chalk.green(versionInfo.latest) + + chalk.dim(' (esegui ') + + chalk.hex(LIGHT_ORANGE)('dvmi upgrade') + + chalk.dim(')') + : `update disponibile: ${current} → ${versionInfo.latest} (esegui dvmi upgrade)` + lines.push(' ' + versionStr + chalk.dim(' · ') + updateStr) + } else { + lines.push(' ' + versionStr) + } + + lines.push( + ' ' + + chalk.dim('Approfondisci:') + + ' ' + + chalk.hex(DIM_BLUE)('dvmi --help') + + chalk.dim(' · ') + + chalk.hex(DIM_BLUE)('dvmi --help') + + '\n', + ) return lines.join('\n') } @@ -379,12 +379,10 @@ export default class CustomHelp extends Help { const plain = strip(line) if (!plain.trim()) return line - // Example lines: "$ dvmi …" - if (plain.includes('$ dvmi') || plain.trim().startsWith('$ dvmi')) { - return plain.replace(/\$ (dvmi\S*)/g, (_, cmd) => - '$ ' + chalk.hex(ORANGE).bold(cmd), - ) - } + // Example lines: "$ dvmi …" + if (plain.includes('$ dvmi') || plain.trim().startsWith('$ dvmi')) { + return plain.replace(/\$ (dvmi\S*)/g, (_, cmd) => '$ ' + chalk.hex(ORANGE).bold(cmd)) + } // Flag rows: "--flag desc" or "-f, --flag desc" const flagMatch = plain.match(/^(\s{2,})((?:-\w,\s*)?--[\w-]+)(\s+)(.*)$/) diff --git a/src/services/ai-config-store.js b/src/services/ai-config-store.js new file mode 100644 index 0000000..1875146 --- /dev/null +++ b/src/services/ai-config-store.js @@ -0,0 +1,318 @@ +import {readFile, writeFile, mkdir, chmod} from 'node:fs/promises' +import {existsSync} from 'node:fs' +import {join, dirname} from 'node:path' +import {homedir} from 'node:os' +import {randomUUID} from 'node:crypto' + +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' */ + +// ────────────────────────────────────────────────────────────────────────────── +// Path resolution +// ────────────────────────────────────────────────────────────────────────────── + +const CONFIG_DIR = process.env.XDG_CONFIG_HOME + ? join(process.env.XDG_CONFIG_HOME, 'dvmi') + : join(homedir(), '.config', 'dvmi') + +export const AI_CONFIG_PATH = join(CONFIG_DIR, 'ai-config.json') + +// ────────────────────────────────────────────────────────────────────────────── +// Compatibility matrix +// ────────────────────────────────────────────────────────────────────────────── + +/** @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'], +} + +/** All known environment IDs. */ +const KNOWN_ENVIRONMENTS = /** @type {EnvironmentId[]} */ (Object.keys(COMPATIBILITY)) + +/** Regex for filename-unsafe characters. */ +const UNSAFE_CHARS = /[/\\:*?"<>|]/ + +// ────────────────────────────────────────────────────────────────────────────── +// Default store +// ────────────────────────────────────────────────────────────────────────────── + +/** @returns {AIConfigStore} */ +function defaultStore() { + return {version: 1, entries: []} +} + +// ────────────────────────────────────────────────────────────────────────────── +// Validation helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Assert that a name is non-empty and contains no filename-unsafe characters. + * @param {string} name + * @returns {void} + */ +function validateName(name) { + if (!name || typeof name !== 'string' || name.trim() === '') { + throw new DvmiError( + 'Entry name must be a non-empty string', + 'Provide a valid name for the entry, e.g. "my-mcp-server"', + ) + } + if (UNSAFE_CHARS.test(name)) { + throw new DvmiError( + `Entry name "${name}" contains invalid characters`, + 'Remove characters like / \\ : * ? " < > | from the name', + ) + } +} + +/** + * Assert that all environment IDs are compatible with the given entry type. + * @param {EnvironmentId[]} environments + * @param {CategoryType} type + * @returns {void} + */ +function validateEnvironments(environments, type) { + for (const envId of environments) { + const supported = COMPATIBILITY[envId] + if (!supported) { + throw new DvmiError(`Unknown environment "${envId}"`, `Valid environments are: ${KNOWN_ENVIRONMENTS.join(', ')}`) + } + if (!supported.includes(type)) { + throw new DvmiError( + `Environment "${envId}" does not support type "${type}"`, + `"${envId}" supports: ${supported.join(', ')}`, + ) + } + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Core I/O +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Load the AI config store from disk. + * Returns `{ version: 1, entries: [] }` if the file is missing or unparseable. + * @param {string} [configPath] - Override config path (used in tests; falls back to DVMI_AI_CONFIG_PATH or AI_CONFIG_PATH) + * @returns {Promise} + */ +export async function loadAIConfig(configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + if (!existsSync(configPath)) return defaultStore() + try { + const raw = await readFile(configPath, 'utf8') + const parsed = JSON.parse(raw) + return { + version: parsed.version ?? 1, + entries: Array.isArray(parsed.entries) ? parsed.entries : [], + } + } catch { + return defaultStore() + } +} + +/** + * Persist the AI config store to disk. + * Creates the parent directory if it does not exist and sets file permissions to 0o600. + * @param {AIConfigStore} store + * @param {string} [configPath] - Override config path (used in tests) + * @returns {Promise} + */ +export async function saveAIConfig(store, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + const dir = dirname(configPath) + if (!existsSync(dir)) { + await mkdir(dir, {recursive: true}) + } + await writeFile(configPath, JSON.stringify(store, null, 2), 'utf8') + await chmod(configPath, 0o600) +} + +// ────────────────────────────────────────────────────────────────────────────── +// CRUD operations +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Add a new entry to the AI config store. + * @param {{ name: string, type: CategoryType, environments: EnvironmentId[], params: MCPParams|CommandParams|SkillParams|AgentParams }} entryData + * @param {string} [configPath] + * @returns {Promise} + */ +export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + const {name, type, environments, params} = entryData + + validateName(name) + validateEnvironments(environments, type) + + const store = await loadAIConfig(configPath) + + const duplicate = store.entries.find((e) => e.name === name && e.type === type) + if (duplicate) { + throw new DvmiError( + `An entry named "${name}" of type "${type}" already exists`, + 'Use a unique name or update the existing entry with `dvmi sync-config-ai update`', + ) + } + + const now = new Date().toISOString() + /** @type {CategoryEntry} */ + const entry = { + id: randomUUID(), + name, + type, + active: true, + environments, + params, + createdAt: now, + updatedAt: now, + } + + store.entries.push(entry) + await saveAIConfig(store, configPath) + await syncAIConfigToChezmoi() + return entry +} + +/** + * Update an existing entry by id. + * @param {string} id - UUID of the entry to update + * @param {{ name?: string, environments?: EnvironmentId[], params?: MCPParams|CommandParams|SkillParams|AgentParams, active?: boolean }} changes + * @param {string} [configPath] + * @returns {Promise} + */ +export async function updateEntry(id, changes, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + const store = await loadAIConfig(configPath) + + const index = store.entries.findIndex((e) => e.id === id) + if (index === -1) { + throw new DvmiError( + `Entry with id "${id}" not found`, + 'Run `dvmi sync-config-ai list` to see available entries and their IDs', + ) + } + + const existing = store.entries[index] + + if (changes.name !== undefined) { + validateName(changes.name) + if (changes.name !== existing.name) { + const duplicate = store.entries.find((e) => e.id !== id && e.name === changes.name && e.type === existing.type) + if (duplicate) { + throw new DvmiError( + `An entry named "${changes.name}" of type "${existing.type}" already exists`, + 'Choose a different name or update the conflicting entry', + ) + } + } + } + + const newEnvironments = changes.environments ?? existing.environments + const newType = existing.type + if (changes.environments !== undefined) { + validateEnvironments(newEnvironments, newType) + } + + /** @type {CategoryEntry} */ + const updated = { + ...existing, + ...(changes.name !== undefined ? {name: changes.name} : {}), + ...(changes.environments !== undefined ? {environments: changes.environments} : {}), + ...(changes.params !== undefined ? {params: changes.params} : {}), + ...(changes.active !== undefined ? {active: changes.active} : {}), + updatedAt: new Date().toISOString(), + } + + store.entries[index] = updated + await saveAIConfig(store, configPath) + await syncAIConfigToChezmoi() + return updated +} + +/** + * Set an entry's `active` flag to `false`. + * @param {string} id - UUID of the entry to deactivate + * @param {string} [configPath] + * @returns {Promise} + */ +export async function deactivateEntry(id, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + return updateEntry(id, {active: false}, configPath) +} + +/** + * Set an entry's `active` flag to `true`. + * @param {string} id - UUID of the entry to activate + * @param {string} [configPath] + * @returns {Promise} + */ +export async function activateEntry(id, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + return updateEntry(id, {active: true}, configPath) +} + +/** + * Permanently remove an entry from the store. + * @param {string} id - UUID of the entry to delete + * @param {string} [configPath] + * @returns {Promise} + */ +export async function deleteEntry(id, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + const store = await loadAIConfig(configPath) + + const index = store.entries.findIndex((e) => e.id === id) + if (index === -1) { + throw new DvmiError( + `Entry with id "${id}" not found`, + 'Run `dvmi sync-config-ai list` to see available entries and their IDs', + ) + } + + store.entries.splice(index, 1) + await saveAIConfig(store, configPath) + await syncAIConfigToChezmoi() +} + +/** + * Return all active entries that target a given environment. + * @param {EnvironmentId} envId + * @param {string} [configPath] + * @returns {Promise} + */ +export async function getEntriesByEnvironment(envId, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + const store = await loadAIConfig(configPath) + return store.entries.filter((e) => e.active && e.environments.includes(envId)) +} + +/** + * Return all entries (active and inactive) of a given type. + * @param {CategoryType} type + * @param {string} [configPath] + * @returns {Promise} + */ +export async function getEntriesByType(type, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { + const store = await loadAIConfig(configPath) + return store.entries.filter((e) => e.type === type) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Chezmoi sync +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Sync the AI config file to chezmoi if dotfiles management is enabled. + * Non-blocking — silently ignores errors. + * @returns {Promise} + */ +export async function syncAIConfigToChezmoi() { + try { + const cliConfig = await loadConfig() + if (!cliConfig.dotfiles?.enabled) return + const configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH + await exec('chezmoi', ['add', configPath]) + } catch { + // Non-blocking — chezmoi sync failures should not disrupt the user's workflow + } +} diff --git a/src/services/ai-env-deployer.js b/src/services/ai-env-deployer.js new file mode 100644 index 0000000..d01b9f2 --- /dev/null +++ b/src/services/ai-env-deployer.js @@ -0,0 +1,444 @@ +/** + * @module ai-env-deployer + * Translates dvmi's abstract CategoryEntry objects into actual filesystem writes + * (JSON mutations for MCP servers, markdown/TOML files for commands, skills, and agents) + * for each supported AI coding environment. + */ + +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 { CategoryEntry, CategoryType, EnvironmentId, DetectedEnvironment } from '../types.js' */ + +// ────────────────────────────────────────────────────────────────────────────── +// Path & key resolution tables +// ────────────────────────────────────────────────────────────────────────────── + +/** + * 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 }>} + */ +const MCP_TARGETS = { + 'vscode-copilot': { + resolvePath: (cwd) => join(cwd, '.vscode', 'mcp.json'), + mcpKey: 'servers', + }, + 'claude-code': { + resolvePath: (cwd) => join(cwd, '.mcp.json'), + mcpKey: 'mcpServers', + }, + opencode: { + resolvePath: (cwd) => join(cwd, 'opencode.json'), + mcpKey: 'mcpServers', + }, + 'gemini-cli': { + resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'), + mcpKey: 'mcpServers', + }, + 'copilot-cli': { + resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'), + mcpKey: 'mcpServers', + }, +} + +/** + * Resolve the target file path for a file-based entry (command, skill, agent). + * + * @param {string} name - Entry name (used as filename base) + * @param {CategoryType} type - Category type + * @param {EnvironmentId} envId - Target environment + * @param {string} cwd - Project working directory + * @returns {string} Absolute path to write + */ +function resolveFilePath(name, type, envId, cwd) { + switch (type) { + case 'command': + return resolveCommandPath(name, envId, cwd) + case 'skill': + return resolveSkillPath(name, envId, cwd) + case 'agent': + return resolveAgentPath(name, envId, cwd) + default: + throw new Error(`Unsupported file entry type: ${type}`) + } +} + +/** + * @param {string} name + * @param {EnvironmentId} envId + * @param {string} cwd + * @returns {string} + */ +function resolveCommandPath(name, envId, cwd) { + switch (envId) { + case 'vscode-copilot': + return join(cwd, '.github', 'prompts', `${name}.prompt.md`) + case 'claude-code': + return join(cwd, '.claude', 'commands', `${name}.md`) + case 'opencode': + return join(cwd, '.opencode', 'commands', `${name}.md`) + case 'gemini-cli': + return join(homedir(), '.gemini', 'commands', `${name}.toml`) + case 'copilot-cli': + // shared path with vscode-copilot for commands + return join(cwd, '.github', 'prompts', `${name}.prompt.md`) + default: + throw new Error(`Unknown environment for command: ${envId}`) + } +} + +/** + * @param {string} name + * @param {EnvironmentId} envId + * @param {string} cwd + * @returns {string} + */ +function resolveSkillPath(name, envId, cwd) { + switch (envId) { + case 'vscode-copilot': + // vscode uses a nested directory with SKILL.md inside + return join(cwd, '.github', 'skills', name, 'SKILL.md') + case 'claude-code': + return join(cwd, '.claude', 'skills', `${name}.md`) + case 'opencode': + return join(cwd, '.opencode', 'skills', `${name}.md`) + case 'copilot-cli': + return join(homedir(), '.copilot', 'skills', `${name}.md`) + default: + throw new Error(`Environment "${envId}" does not support skill entries`) + } +} + +/** + * @param {string} name + * @param {EnvironmentId} envId + * @param {string} cwd + * @returns {string} + */ +function resolveAgentPath(name, envId, cwd) { + switch (envId) { + case 'vscode-copilot': + return join(cwd, '.github', 'agents', `${name}.agent.md`) + case 'claude-code': + return join(cwd, '.claude', 'agents', `${name}.md`) + case 'opencode': + return join(cwd, '.opencode', 'agents', `${name}.md`) + case 'copilot-cli': + return join(homedir(), '.copilot', 'agents', `${name}.md`) + default: + throw new Error(`Environment "${envId}" does not support agent entries`) + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// TOML rendering +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Render a Gemini CLI command entry as a TOML string. + * No external TOML library is used — we generate the string directly. + * + * @param {string} description - Short description of the command + * @param {string} content - Prompt text content + * @returns {string} TOML-formatted string + */ +function renderGeminiToml(description, content) { + // Escape triple-quotes inside the content to prevent TOML parse errors + const safeContent = content.replace(/"""/g, '\\"\\"\\"') + return `description = ${JSON.stringify(description)} + +[prompt] +text = """ +${safeContent} +""" +` +} + +// ────────────────────────────────────────────────────────────────────────────── +// JSON helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Read a JSON file from disk. Returns an empty object when the file is missing. + * Throws if the file exists but cannot be parsed. + * + * @param {string} filePath - Absolute path to the JSON file + * @returns {Promise>} + */ +async function readJsonOrEmpty(filePath) { + if (!existsSync(filePath)) return {} + const raw = await readFile(filePath, 'utf8') + return JSON.parse(raw) +} + +/** + * Write a value to disk as pretty-printed JSON, creating parent directories + * as needed. + * + * @param {string} filePath - Absolute path + * @param {unknown} data - Serialisable value + * @returns {Promise} + */ +async function writeJson(filePath, data) { + const dir = dirname(filePath) + if (!existsSync(dir)) { + await mkdir(dir, {recursive: true}) + } + await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8') +} + +// ────────────────────────────────────────────────────────────────────────────── +// Build MCP server object from entry params +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Convert an MCP entry's params into the server descriptor object written into + * the target JSON file. + * + * @param {import('../types.js').MCPParams} params + * @returns {Record} + */ +function buildMCPServerObject(params) { + /** @type {Record} */ + const server = {} + + if (params.command !== undefined) server.command = params.command + if (params.args !== undefined) server.args = params.args + if (params.env !== undefined) server.env = params.env + if (params.url !== undefined) server.url = params.url + if (params.transport !== undefined) server.type = params.transport + + return server +} + +// ────────────────────────────────────────────────────────────────────────────── +// Public API — MCP +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Deploy an MCP entry to a specific AI environment by merging it into the + * appropriate JSON config file. Creates the file (and parent directories) if + * it does not yet exist. Existing entries under other names are preserved. + * + * Skips silently when: + * - `entry` is falsy + * - `entry.type` is not `'mcp'` + * - `entry.params` is absent + * + * @param {CategoryEntry} entry - The MCP entry to deploy + * @param {EnvironmentId} envId - Target environment identifier + * @param {string} cwd - Project working directory (used for project-relative paths) + * @returns {Promise} + */ +export async function deployMCPEntry(entry, envId, cwd) { + if (!entry || entry.type !== 'mcp' || !entry.params) return + + const target = MCP_TARGETS[envId] + if (!target) return + + const filePath = target.resolvePath(cwd) + const json = await readJsonOrEmpty(filePath) + + if (!json[target.mcpKey] || typeof json[target.mcpKey] !== 'object') { + json[target.mcpKey] = {} + } + + /** @type {Record} */ + const mcpKey = /** @type {any} */ (json[target.mcpKey]) + mcpKey[entry.name] = buildMCPServerObject(/** @type {import('../types.js').MCPParams} */ (entry.params)) + + await writeJson(filePath, json) +} + +/** + * Remove an MCP entry by name from a specific AI environment's JSON config file. + * If the file does not exist the function is a no-op. + * If the MCP key becomes empty after removal, it is kept as an empty object + * (the structure is preserved). + * + * @param {string} entryName - Name of the MCP server to remove + * @param {EnvironmentId} envId - Target environment identifier + * @param {string} cwd - Project working directory + * @returns {Promise} + */ +export async function undeployMCPEntry(entryName, envId, cwd) { + const target = MCP_TARGETS[envId] + if (!target) return + + const filePath = target.resolvePath(cwd) + if (!existsSync(filePath)) return + + const json = await readJsonOrEmpty(filePath) + + if (json[target.mcpKey] && typeof json[target.mcpKey] === 'object') { + delete (/** @type {any} */ (json[target.mcpKey])[entryName]) + } + + await writeJson(filePath, json) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Public API — File-based entries +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Deploy a file-based entry (command, skill, or agent) to a specific AI + * environment. Creates parent directories as needed. + * + * For Gemini CLI commands the output is TOML; for everything else it is the raw + * markdown content stored in `entry.params.content` or `entry.params.instructions`. + * + * For VS Code Copilot skills the directory structure `{name}/SKILL.md` is + * created automatically. + * + * Skips silently when: + * - `entry` is falsy + * - `entry.type` is `'mcp'` (wrong function) + * - `entry.params` is absent + * + * @param {CategoryEntry} entry - The entry to deploy + * @param {EnvironmentId} envId - Target environment identifier + * @param {string} cwd - Project working directory + * @returns {Promise} + */ +export async function deployFileEntry(entry, envId, cwd) { + if (!entry || entry.type === 'mcp' || !entry.params) return + + const filePath = resolveFilePath(entry.name, entry.type, envId, cwd) + const dir = dirname(filePath) + + if (!existsSync(dir)) { + await mkdir(dir, {recursive: true}) + } + + const params = /** @type {any} */ (entry.params) + + // Gemini CLI commands use TOML format + if (envId === 'gemini-cli' && entry.type === 'command') { + const description = params.description ?? '' + const content = params.content ?? '' + await writeFile(filePath, renderGeminiToml(description, content), 'utf8') + return + } + + // All other file entries use markdown + const content = params.content ?? params.instructions ?? '' + await writeFile(filePath, content, 'utf8') +} + +/** + * Remove a deployed file-based entry from disk. This is a no-op if the file + * does not exist. + * + * @param {string} entryName - Name of the entry (used to derive the file path) + * @param {CategoryType} type - Category type of the entry + * @param {EnvironmentId} envId - Target environment identifier + * @param {string} cwd - Project working directory + * @returns {Promise} + */ +export async function undeployFileEntry(entryName, type, envId, cwd) { + if (type === 'mcp') return + + const filePath = resolveFilePath(entryName, type, envId, cwd) + if (!existsSync(filePath)) return + + await rm(filePath, {force: true}) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Public API — Composite helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Deploy an entry to all of its target environments that are currently detected + * and readable. + * + * - Environments listed in `entry.environments` but absent from `detectedEnvs` + * are silently skipped. + * - Environments that are detected but have unreadable JSON config files are + * also skipped (to avoid clobbering corrupt files). + * + * @param {CategoryEntry} entry - The entry to deploy + * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine + * @param {string} cwd - Project working directory + * @returns {Promise} + */ +export async function deployEntry(entry, detectedEnvs, cwd) { + if (!entry) return + + const detectedIds = new Set(detectedEnvs.map((e) => e.id)) + + for (const envId of entry.environments) { + if (!detectedIds.has(envId)) continue + + const detectedEnv = detectedEnvs.find((e) => e.id === envId) + // Skip if the environment has unreadable JSON config files that correspond + // to the MCP target path (we don't want to overwrite corrupt files) + if (detectedEnv && entry.type === 'mcp') { + const target = MCP_TARGETS[envId] + if (target) { + const targetPath = target.resolvePath(cwd) + if (detectedEnv.unreadable.includes(targetPath)) continue + } + } + + if (entry.type === 'mcp') { + await deployMCPEntry(entry, envId, cwd) + } else { + await deployFileEntry(entry, envId, cwd) + } + } +} + +/** + * Undeploy an entry from all of its target environments that are currently + * detected. This is safe to call even when `entry` is `null` or `undefined` + * (it becomes a no-op). + * + * @param {CategoryEntry | null | undefined} entry - The entry to undeploy + * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine + * @param {string} cwd - Project working directory + * @returns {Promise} + */ +export async function undeployEntry(entry, detectedEnvs, cwd) { + if (!entry) return + + const detectedIds = new Set(detectedEnvs.map((e) => e.id)) + + for (const envId of entry.environments) { + if (!detectedIds.has(envId)) continue + + if (entry.type === 'mcp') { + await undeployMCPEntry(entry.name, envId, cwd) + } else { + await undeployFileEntry(entry.name, entry.type, envId, cwd) + } + } +} + +/** + * Reconcile all active entries against the currently detected environments. + * + * For each active entry, every detected environment listed in + * `entry.environments` is deployed (idempotent write). Environments that are + * listed but not currently detected are left untouched — we never undeploy on + * scan because the files may have been managed by the user directly + * (FR-004d: re-activation on re-detection). + * + * Inactive entries are not touched. + * + * @param {CategoryEntry[]} entries - All managed entries from the AI config store + * @param {DetectedEnvironment[]} detectedEnvs - Environments found on the current machine + * @param {string} cwd - Project working directory + * @returns {Promise} + */ +export async function reconcileOnScan(entries, detectedEnvs, cwd) { + for (const entry of entries) { + if (!entry.active) continue + await deployEntry(entry, detectedEnvs, cwd) + } +} diff --git a/src/services/ai-env-scanner.js b/src/services/ai-env-scanner.js new file mode 100644 index 0000000..518283c --- /dev/null +++ b/src/services/ai-env-scanner.js @@ -0,0 +1,242 @@ +/** + * @module ai-env-scanner + * Detects AI coding environments by scanning well-known project and global config paths. + */ + +import {existsSync, readFileSync} from 'node:fs' +import {resolve, join} from 'node:path' +import {homedir} from 'node:os' + +/** @import { CategoryType, EnvironmentId, PathStatus, CategoryCounts, DetectedEnvironment, CategoryEntry } from '../types.js' */ + +/** + * @typedef {Object} PathSpec + * @property {string} path - Relative (project) or absolute (global) path string + * @property {boolean} isJson - Whether to attempt JSON.parse after reading + */ + +/** + * @typedef {Object} EnvironmentDef + * @property {EnvironmentId} id + * @property {string} name - Display name + * @property {PathSpec[]} projectPaths - Paths relative to cwd + * @property {PathSpec[]} globalPaths - Absolute paths (resolved from homedir) + * @property {CategoryType[]} supportedCategories + */ + +/** + * All recognised AI coding environments with their detection paths and capabilities. + * @type {Readonly} + */ +export const ENVIRONMENTS = Object.freeze([ + { + id: /** @type {EnvironmentId} */ ('vscode-copilot'), + name: 'VS Code Copilot', + projectPaths: [ + {path: '.github/copilot-instructions.md', isJson: false}, + {path: '.vscode/mcp.json', isJson: true}, + {path: '.github/instructions/', isJson: false}, + {path: '.github/prompts/', isJson: false}, + {path: '.github/agents/', isJson: false}, + {path: '.github/skills/', isJson: false}, + ], + globalPaths: [], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + }, + { + id: /** @type {EnvironmentId} */ ('claude-code'), + name: 'Claude Code', + projectPaths: [ + {path: 'CLAUDE.md', isJson: false}, + {path: '.mcp.json', isJson: true}, + {path: '.claude/commands/', isJson: false}, + {path: '.claude/skills/', isJson: false}, + {path: '.claude/agents/', isJson: false}, + {path: '.claude/rules/', isJson: false}, + ], + globalPaths: [], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + }, + { + id: /** @type {EnvironmentId} */ ('opencode'), + name: 'OpenCode', + projectPaths: [ + {path: 'AGENTS.md', isJson: false}, + {path: '.opencode/commands/', isJson: false}, + {path: '.opencode/skills/', isJson: false}, + {path: '.opencode/agents/', isJson: false}, + {path: 'opencode.json', isJson: true}, + ], + globalPaths: [ + {path: '~/.config/opencode/opencode.json', isJson: true}, + {path: '~/.config/opencode/commands/', isJson: false}, + {path: '~/.config/opencode/agents/', isJson: false}, + {path: '~/.config/opencode/skills/', isJson: false}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + }, + { + id: /** @type {EnvironmentId} */ ('gemini-cli'), + name: 'Gemini CLI', + projectPaths: [{path: 'GEMINI.md', isJson: false}], + globalPaths: [ + {path: '~/.gemini/settings.json', isJson: true}, + {path: '~/.gemini/commands/', isJson: false}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command']), + }, + { + id: /** @type {EnvironmentId} */ ('copilot-cli'), + name: 'GitHub Copilot CLI', + projectPaths: [], + globalPaths: [ + {path: '~/.copilot/config.json', isJson: true}, + {path: '~/.copilot/mcp-config.json', isJson: true}, + {path: '~/.copilot/agents/', isJson: false}, + {path: '~/.copilot/skills/', isJson: false}, + {path: '~/.copilot/copilot-instructions.md', isJson: false}, + ], + supportedCategories: /** @type {CategoryType[]} */ (['mcp', 'command', 'skill', 'agent']), + }, +]) + +/** + * Resolve a path spec into an absolute path. + * Project paths are resolved relative to `cwd`; global paths have their `~/` prefix + * replaced with the actual home directory. + * + * @param {PathSpec} spec + * @param {string} cwd + * @param {boolean} isGlobal + * @returns {string} + */ +function resolvePathSpec(spec, cwd, isGlobal) { + if (isGlobal) { + // Global paths are stored with a leading `~/` + return resolve(join(homedir(), spec.path.replace(/^~\//, ''))) + } + return resolve(join(cwd, spec.path)) +} + +/** + * Build a PathStatus for one path spec. + * For JSON files that exist, attempt to parse them; failure marks the path as unreadable. + * + * @param {PathSpec} spec + * @param {string} absolutePath + * @param {string[]} unreadable - Mutable array; unreadable paths are pushed here + * @returns {PathStatus} + */ +function evaluatePathSpec(spec, absolutePath, unreadable) { + const exists = existsSync(absolutePath) + + if (!exists) { + return {path: absolutePath, exists: false, readable: false} + } + + if (!spec.isJson) { + return {path: absolutePath, exists: true, readable: true} + } + + // JSON file — try to parse + try { + JSON.parse(readFileSync(absolutePath, 'utf8')) + return {path: absolutePath, exists: true, readable: true} + } catch { + unreadable.push(absolutePath) + return {path: absolutePath, exists: true, readable: false} + } +} + +/** + * Compute the detection scope based on which path groups produced hits. + * + * @param {PathStatus[]} projectStatuses + * @param {PathStatus[]} globalStatuses + * @returns {'project'|'global'|'both'} + */ +function computeScope(projectStatuses, globalStatuses) { + const hasProject = projectStatuses.some((s) => s.exists) + const hasGlobal = globalStatuses.some((s) => s.exists) + + if (hasProject && hasGlobal) return 'both' + if (hasGlobal) return 'global' + return 'project' +} + +/** + * Scan the filesystem for each known AI coding environment and return only those + * that were detected (i.e. at least one config path exists on disk). + * + * @param {string} [cwd] - Working directory for project-relative path resolution (defaults to process.cwd()) + * @returns {DetectedEnvironment[]} Detected environments only + */ +export function scanEnvironments(cwd = process.cwd()) { + /** @type {DetectedEnvironment[]} */ + const detected = [] + + for (const env of ENVIRONMENTS) { + /** @type {string[]} */ + const unreadable = [] + + const projectStatuses = env.projectPaths.map((spec) => { + const absPath = resolvePathSpec(spec, cwd, false) + return evaluatePathSpec(spec, absPath, unreadable) + }) + + const globalStatuses = env.globalPaths.map((spec) => { + const absPath = resolvePathSpec(spec, cwd, true) + return evaluatePathSpec(spec, absPath, unreadable) + }) + + const isDetected = [...projectStatuses, ...globalStatuses].some((s) => s.exists) + + if (!isDetected) continue + + detected.push({ + id: env.id, + name: env.name, + detected: true, + projectPaths: projectStatuses, + globalPaths: globalStatuses, + unreadable, + supportedCategories: env.supportedCategories, + counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + scope: computeScope(projectStatuses, globalStatuses), + }) + } + + return detected +} + +/** + * Filter detected environments to those that support a given category type. + * + * @param {CategoryType} type - Category type to filter by + * @param {DetectedEnvironment[]} detectedEnvs - Array of detected environments from {@link scanEnvironments} + * @returns {EnvironmentId[]} IDs of environments that support the given type + */ +export function getCompatibleEnvironments(type, detectedEnvs) { + return detectedEnvs.filter((env) => env.supportedCategories.includes(type)).map((env) => env.id) +} + +/** + * Count active entries from the AI config store that target a given environment, + * grouped by category type. + * + * @param {EnvironmentId} envId - Environment to count entries for + * @param {CategoryEntry[]} entries - All entries from the AI config store + * @returns {CategoryCounts} Per-category active entry counts + */ +export function computeCategoryCounts(envId, entries) { + /** @type {CategoryCounts} */ + const counts = {mcp: 0, command: 0, skill: 0, agent: 0} + + for (const entry of entries) { + if (entry.active && entry.environments.includes(envId)) { + counts[entry.type] = (counts[entry.type] ?? 0) + 1 + } + } + + return counts +} diff --git a/src/types.js b/src/types.js index e9819f3..011cfeb 100644 --- a/src/types.js +++ b/src/types.js @@ -332,6 +332,91 @@ * @typedef {'macos'|'wsl2'|'linux'} Platform */ +// ────────────────────────────────────────────────────────────────────────────── +// AI Config Sync TUI types +// ────────────────────────────────────────────────────────────────────────────── + +/** + * @typedef {'mcp'|'command'|'skill'|'agent'} CategoryType + */ + +/** + * @typedef {'vscode-copilot'|'claude-code'|'opencode'|'gemini-cli'|'copilot-cli'} EnvironmentId + */ + +/** + * @typedef {Object} MCPParams + * @property {'stdio'|'sse'|'streamable-http'} transport - MCP transport type + * @property {string} [command] - Command to execute (required for stdio transport) + * @property {string[]} [args] - Command arguments + * @property {Record} [env] - Environment variables + * @property {string} [url] - Server URL (required for sse/streamable-http transport) + */ + +/** + * @typedef {Object} CommandParams + * @property {string} content - Prompt/command text content (multi-line) + * @property {string} [description] - Short description of the command + */ + +/** + * @typedef {Object} SkillParams + * @property {string} content - Skill definition content (multi-line) + * @property {string} [description] - Short description of the skill + */ + +/** + * @typedef {Object} AgentParams + * @property {string} instructions - Agent instructions (multi-line) + * @property {string} [description] - Short description of the agent + */ + +/** + * @typedef {Object} CategoryEntry + * @property {string} id - UUID v4, auto-generated + * @property {string} name - Unique within its type; used as filename/key when deploying + * @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 {string} createdAt - ISO 8601 timestamp + * @property {string} updatedAt - ISO 8601 timestamp + */ + +/** + * @typedef {Object} AIConfigStore + * @property {number} version - Schema version + * @property {CategoryEntry[]} entries - All managed configuration entries + */ + +/** + * @typedef {Object} PathStatus + * @property {string} path - Absolute path + * @property {boolean} exists - Whether the path exists on disk + * @property {boolean} readable - Whether the file could be parsed (for JSON/TOML files) + */ + +/** + * @typedef {Object} CategoryCounts + * @property {number} mcp + * @property {number} command + * @property {number} skill + * @property {number} agent + */ + +/** + * @typedef {Object} DetectedEnvironment + * @property {EnvironmentId} id - Environment identifier + * @property {string} name - Display name (e.g. "Claude Code") + * @property {boolean} detected - Whether any config files were found + * @property {PathStatus[]} projectPaths - Project-level paths and their existence status + * @property {PathStatus[]} globalPaths - Global-level paths and their existence status + * @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 {'project'|'global'|'both'} scope - Where detection occurred + */ + /** * @typedef {Object} PlatformInfo * @property {Platform} platform diff --git a/src/utils/tui/form.js b/src/utils/tui/form.js new file mode 100644 index 0000000..93b7013 --- /dev/null +++ b/src/utils/tui/form.js @@ -0,0 +1,1006 @@ +/** + * @module form + * Inline form component for the dvmi sync-config-ai TUI. + * All rendering functions are pure (no terminal side effects). + * The parent tab-tui.js is responsible for writing rendered lines to the screen. + */ + +import chalk from 'chalk' + +// ────────────────────────────────────────────────────────────────────────────── +// Typedefs +// ────────────────────────────────────────────────────────────────────────────── + +/** + * @typedef {Object} TextField + * @property {'text'} type + * @property {string} label + * @property {string} value + * @property {number} cursor - Cursor position (0 = before first char) + * @property {boolean} required + * @property {string} placeholder + * @property {string} [key] - Optional override key for extractValues output + */ + +/** + * @typedef {Object} SelectorField + * @property {'selector'} type + * @property {string} label + * @property {string[]} options + * @property {number} selectedIndex + * @property {boolean} required + * @property {string} [key] + */ + +/** + * @typedef {{ id: string, label: string }} MultiSelectOption + */ + +/** + * @typedef {Object} MultiSelectField + * @property {'multiselect'} type + * @property {string} label + * @property {MultiSelectOption[]} options + * @property {Set} selected + * @property {number} focusedOptionIndex + * @property {boolean} required + * @property {string} [key] + */ + +/** + * @typedef {Object} MiniEditorField + * @property {'editor'} type + * @property {string} label + * @property {string[]} lines + * @property {number} cursorLine + * @property {number} cursorCol + * @property {boolean} required + * @property {string} [key] + */ + +/** + * @typedef {TextField|SelectorField|MultiSelectField|MiniEditorField} Field + */ + +/** + * @typedef {Object} FormState + * @property {Field[]} fields + * @property {number} focusedFieldIndex + * @property {string} title + * @property {'editing'|'submitted'|'cancelled'} status + * @property {string|null} errorMessage + */ + +/** + * @typedef {Object} SubmitResult + * @property {true} submitted + * @property {object} values + */ + +/** + * @typedef {Object} CancelResult + * @property {true} cancelled + */ + +// ────────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Convert a field label to a plain object key (lowercase, spaces → underscores). + * If the field has a `key` property, use that instead. + * @param {Field} field + * @returns {string} + */ +function fieldKey(field) { + if (field.key) return field.key + return field.label.toLowerCase().replace(/\s+/g, '_') +} + +/** + * Render the text cursor inside a string value at the given position. + * Inserts a `|` character at the cursor index. + * @param {string} value + * @param {number} cursor + * @returns {string} + */ +function renderCursor(value, cursor) { + return value.slice(0, cursor) + chalk.inverse('|') + value.slice(cursor) +} + +// ────────────────────────────────────────────────────────────────────────────── +// buildFieldLine +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Render a single form field as a terminal line. + * + * - TextField: ` [label]: [value with cursor shown as |]` + * - SelectorField: ` [label]: < option >` + * - MultiSelectField: ` [label]: [N/total checked]` + * - MiniEditorField: ` [label]: [N lines]` + * + * When focused, the line is prefixed with a bold `> ` indicator instead of ` `. + * + * @param {Field} field + * @param {boolean} focused + * @returns {string} + */ +export function buildFieldLine(field, focused) { + const prefix = focused ? chalk.bold('> ') : ' ' + + if (field.type === 'text') { + const display = focused + ? renderCursor(field.value, field.cursor) + : field.value || chalk.dim(field.placeholder || '') + return `${prefix}${chalk.bold(field.label)}: ${display}` + } + + if (field.type === 'selector') { + const option = field.options[field.selectedIndex] ?? '' + const arrows = focused ? `${chalk.bold('< ')}${chalk.cyan(option)}${chalk.bold(' >')}` : `< ${option} >` + return `${prefix}${chalk.bold(field.label)}: ${arrows}` + } + + if (field.type === 'multiselect') { + const count = field.selected.size + const total = field.options.length + const summary = focused ? chalk.cyan(`${count}/${total} selected`) : `${count}/${total} selected` + return `${prefix}${chalk.bold(field.label)}: ${summary}` + } + + if (field.type === 'editor') { + const lineCount = field.lines.length + const summary = focused + ? chalk.cyan(`${lineCount} line${lineCount === 1 ? '' : 's'}`) + : `${lineCount} line${lineCount === 1 ? '' : 's'}` + return `${prefix}${chalk.bold(field.label)}: ${summary}` + } + + return `${prefix}${chalk.bold(/** @type {any} */ (field).label)}: —` +} + +// ────────────────────────────────────────────────────────────────────────────── +// buildMultiSelectLines +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Render expanded MultiSelectField options as multiple lines (shown when focused). + * Each option shows `[x]` when selected and `[ ]` when not. + * The option under the cursor is highlighted with chalk.bold. + * + * @param {MultiSelectField} field + * @param {boolean} focused + * @param {number} maxLines - Maximum number of lines to return + * @returns {string[]} + */ +export function buildMultiSelectLines(field, focused, maxLines) { + const lines = [] + for (let i = 0; i < field.options.length; i++) { + const opt = field.options[i] + const checked = field.selected.has(opt.id) ? chalk.green('[x]') : '[ ]' + const label = opt.label + const isCursor = focused && i === field.focusedOptionIndex + const line = isCursor ? chalk.bold(` ${checked} ${label}`) : ` ${checked} ${label}` + lines.push(line) + if (lines.length >= maxLines) break + } + return lines +} + +// ────────────────────────────────────────────────────────────────────────────── +// buildMiniEditorLines +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Render MiniEditorField content with line numbers. + * When focused, inserts `|` at the cursor column on the active line. + * Returns up to `maxLines` lines. + * + * @param {MiniEditorField} field + * @param {boolean} focused + * @param {number} maxLines - Maximum number of lines to return + * @returns {string[]} + */ +export function buildMiniEditorLines(field, focused, maxLines) { + const lines = [] + const numWidth = String(field.lines.length).length + + for (let i = 0; i < field.lines.length; i++) { + const lineNum = String(i + 1).padStart(numWidth) + const rawLine = field.lines[i] + let content + if (focused && i === field.cursorLine) { + content = renderCursor(rawLine, field.cursorCol) + } else { + content = rawLine + } + lines.push(` ${chalk.dim(lineNum + ' │')} ${content}`) + if (lines.length >= maxLines) break + } + return lines +} + +// ────────────────────────────────────────────────────────────────────────────── +// buildFormScreen +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Render all form fields into an array of terminal lines. + * + * For the currently focused field: + * - MultiSelectField: renders expanded options below the field header line + * - MiniEditorField: renders editor content lines below the field header line + * - Other types: renders just the single header line + * + * Returns an array of lines (no ANSI clear/home — the parent handles that). + * Includes the form title at the top, an error message if set, all fields, + * and a footer hint line at the bottom. + * + * @param {FormState} formState + * @param {number} viewportHeight - Available content lines + * @param {number} termCols - Terminal width + * @returns {string[]} + */ +export function buildFormScreen(formState, viewportHeight, termCols) { + const lines = [] + + // ── Title ────────────────────────────────────────────────────────────────── + lines.push('') + lines.push(` ${chalk.bold.cyan(formState.title)}`) + lines.push(` ${chalk.dim('─'.repeat(Math.min(termCols - 4, 60)))}`) + + // ── Error message ───────────────────────────────────────────────────────── + if (formState.errorMessage) { + lines.push(` ${chalk.red('✖ ' + formState.errorMessage)}`) + } + + lines.push('') + + // ── Fields ──────────────────────────────────────────────────────────────── + const FOOTER_RESERVE = 2 + const availableForFields = viewportHeight - lines.length - FOOTER_RESERVE + + for (let i = 0; i < formState.fields.length; i++) { + const field = formState.fields[i] + const isFocused = i === formState.focusedFieldIndex + + // Header line + lines.push(buildFieldLine(field, isFocused)) + + // Expanded inline content for focused multiselect / editor + if (isFocused) { + const remaining = availableForFields - lines.length + if (field.type === 'multiselect' && remaining > 0) { + const expanded = buildMultiSelectLines(field, true, remaining) + lines.push(...expanded) + } else if (field.type === 'editor' && remaining > 0) { + const expanded = buildMiniEditorLines(field, true, remaining) + lines.push(...expanded) + } + } + } + + // ── Footer hint ─────────────────────────────────────────────────────────── + lines.push('') + lines.push(chalk.dim(' Tab next field Shift+Tab prev Ctrl+S save Esc cancel')) + + return lines +} + +// ────────────────────────────────────────────────────────────────────────────── +// extractValues +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Extract form field values into a plain object. + * + * - TextField → string value + * - SelectorField → selected option string + * - MultiSelectField → array of selected ids + * - MiniEditorField → lines joined with `\n` + * + * The key for each field is `field.key` if set, otherwise the label lowercased + * with spaces replaced by underscores. + * + * @param {FormState} formState + * @returns {object} + */ +export function extractValues(formState) { + /** @type {Record} */ + const result = {} + + for (const field of formState.fields) { + const key = fieldKey(field) + + if (field.type === 'text') { + result[key] = field.value + } else if (field.type === 'selector') { + result[key] = field.options[field.selectedIndex] ?? '' + } else if (field.type === 'multiselect') { + result[key] = Array.from(field.selected) + } else if (field.type === 'editor') { + result[key] = field.lines.join('\n') + } + } + + return result +} + +// ────────────────────────────────────────────────────────────────────────────── +// Validation helper +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Check that all required fields have a non-empty value. + * Returns the label of the first invalid field, or null if all are valid. + * @param {FormState} formState + * @returns {string|null} + */ +function validateForm(formState) { + for (const field of formState.fields) { + if (!field.required) continue + + if (field.type === 'text' && field.value.trim() === '') { + return field.label + } + if (field.type === 'selector' && field.options.length === 0) { + return field.label + } + if (field.type === 'multiselect' && field.selected.size === 0) { + return field.label + } + if (field.type === 'editor') { + const content = field.lines.join('\n').trim() + if (content === '') return field.label + } + } + return null +} + +// ────────────────────────────────────────────────────────────────────────────── +// Field-specific keypress handlers (pure) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Handle a keypress on a focused TextField. Returns updated field. + * @param {TextField} field + * @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key + * @returns {TextField} + */ +function handleTextFieldKey(field, key) { + const {value, cursor} = field + + if (key.name === 'backspace') { + if (cursor === 0) return field + return { + ...field, + value: value.slice(0, cursor - 1) + value.slice(cursor), + cursor: cursor - 1, + } + } + + if (key.name === 'delete') { + if (cursor >= value.length) return field + return { + ...field, + value: value.slice(0, cursor) + value.slice(cursor + 1), + } + } + + if (key.name === 'left') { + return {...field, cursor: Math.max(0, cursor - 1)} + } + if (key.name === 'right') { + return {...field, cursor: Math.min(value.length, cursor + 1)} + } + if (key.name === 'home') { + return {...field, cursor: 0} + } + if (key.name === 'end') { + return {...field, cursor: value.length} + } + + // Printable character + if (key.sequence && key.sequence.length === 1 && !key.ctrl) { + const ch = key.sequence + if (ch >= ' ') { + return { + ...field, + value: value.slice(0, cursor) + ch + value.slice(cursor), + cursor: cursor + 1, + } + } + } + + return field +} + +/** + * Handle a keypress on a focused SelectorField. Returns updated field. + * @param {SelectorField} field + * @param {{ name: string }} key + * @returns {SelectorField} + */ +function handleSelectorFieldKey(field, key) { + const len = field.options.length + if (len === 0) return field + + if (key.name === 'left') { + return {...field, selectedIndex: (field.selectedIndex - 1 + len) % len} + } + if (key.name === 'right') { + return {...field, selectedIndex: (field.selectedIndex + 1) % len} + } + + return field +} + +/** + * Handle a keypress on a focused MultiSelectField. + * Returns updated field or { advanceField: true } signal object. + * @param {MultiSelectField} field + * @param {{ name: string }} key + * @returns {MultiSelectField | { advanceField: true }} + */ +function handleMultiSelectFieldKey(field, key) { + const len = field.options.length + + if (key.name === 'up') { + return {...field, focusedOptionIndex: Math.max(0, field.focusedOptionIndex - 1)} + } + if (key.name === 'down') { + return {...field, focusedOptionIndex: Math.min(len - 1, field.focusedOptionIndex + 1)} + } + + if (key.name === 'space') { + const opt = field.options[field.focusedOptionIndex] + if (!opt) return field + const newSelected = new Set(field.selected) + if (newSelected.has(opt.id)) { + newSelected.delete(opt.id) + } else { + newSelected.add(opt.id) + } + return {...field, selected: newSelected} + } + + if (key.name === 'return') { + return {advanceField: /** @type {true} */ (true)} + } + + return field +} + +/** + * Handle a keypress on a focused MiniEditorField. + * Returns updated field or { advanceField: true } signal object. + * @param {MiniEditorField} field + * @param {{ name: string, sequence?: string, ctrl?: boolean }} key + * @returns {MiniEditorField | { advanceField: true }} + */ +function handleEditorFieldKey(field, key) { + const {lines, cursorLine, cursorCol} = field + + // Esc exits the editor — move to next field + if (key.name === 'escape') { + return {advanceField: /** @type {true} */ (true)} + } + + if (key.name === 'left') { + if (cursorCol > 0) { + return {...field, cursorCol: cursorCol - 1} + } + if (cursorLine > 0) { + const prevLine = lines[cursorLine - 1] + return {...field, cursorLine: cursorLine - 1, cursorCol: prevLine.length} + } + return field + } + + if (key.name === 'right') { + const line = lines[cursorLine] + if (cursorCol < line.length) { + return {...field, cursorCol: cursorCol + 1} + } + if (cursorLine < lines.length - 1) { + return {...field, cursorLine: cursorLine + 1, cursorCol: 0} + } + return field + } + + if (key.name === 'up') { + if (cursorLine === 0) return field + const newLine = cursorLine - 1 + const newCol = Math.min(cursorCol, lines[newLine].length) + return {...field, cursorLine: newLine, cursorCol: newCol} + } + + if (key.name === 'down') { + if (cursorLine >= lines.length - 1) return field + const newLine = cursorLine + 1 + const newCol = Math.min(cursorCol, lines[newLine].length) + return {...field, cursorLine: newLine, cursorCol: newCol} + } + + if (key.name === 'home') { + return {...field, cursorCol: 0} + } + + if (key.name === 'end') { + return {...field, cursorCol: lines[cursorLine].length} + } + + if (key.name === 'backspace') { + if (cursorCol > 0) { + const newLines = [...lines] + const ln = newLines[cursorLine] + newLines[cursorLine] = ln.slice(0, cursorCol - 1) + ln.slice(cursorCol) + return {...field, lines: newLines, cursorCol: cursorCol - 1} + } + if (cursorLine > 0) { + // Merge current line into previous + const newLines = [...lines] + const prevLine = newLines[cursorLine - 1] + const currLine = newLines[cursorLine] + const mergedCol = prevLine.length + newLines.splice(cursorLine, 1) + newLines[cursorLine - 1] = prevLine + currLine + return {...field, lines: newLines, cursorLine: cursorLine - 1, cursorCol: mergedCol} + } + return field + } + + if (key.name === 'delete') { + const line = lines[cursorLine] + if (cursorCol < line.length) { + const newLines = [...lines] + newLines[cursorLine] = line.slice(0, cursorCol) + line.slice(cursorCol + 1) + return {...field, lines: newLines} + } + if (cursorLine < lines.length - 1) { + // Merge next line + const newLines = [...lines] + newLines[cursorLine] = newLines[cursorLine] + newLines[cursorLine + 1] + newLines.splice(cursorLine + 1, 1) + return {...field, lines: newLines} + } + return field + } + + // Enter inserts a new line after the cursor position + if (key.name === 'return') { + const line = lines[cursorLine] + const before = line.slice(0, cursorCol) + const after = line.slice(cursorCol) + const newLines = [...lines] + newLines.splice(cursorLine, 1, before, after) + return {...field, lines: newLines, cursorLine: cursorLine + 1, cursorCol: 0} + } + + // Printable character + if (key.sequence && key.sequence.length === 1 && !key.ctrl) { + const ch = key.sequence + if (ch >= ' ') { + const newLines = [...lines] + const ln = newLines[cursorLine] + newLines[cursorLine] = ln.slice(0, cursorCol) + ch + ln.slice(cursorCol) + return {...field, lines: newLines, cursorCol: cursorCol + 1} + } + } + + return field +} + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Pure reducer for form keypresses. + * + * Global keys handled regardless of focused field: + * - Tab: move to next field + * - Shift+Tab: move to previous field (wraps) + * - Ctrl+S: validate and submit + * - Esc: cancel → return `{ cancelled: true }` + * - Enter on last field: validate and submit + * + * Field-specific handling when field is focused: + * - TextField: printable chars append to value, Backspace deletes, ← → move cursor, Home/End jump + * - SelectorField: ← → cycle options + * - MultiSelectField: ↑ ↓ navigate, Space toggle, Enter advances to next field + * - MiniEditorField: printable chars insert, Enter inserts new line, Esc exits to next field + * + * On submit: validates required fields. If invalid, sets `errorMessage` and returns + * the state. If valid, returns `{ submitted: true, values: extractValues(formState) }`. + * + * @param {FormState} formState + * @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key + * @returns {FormState | SubmitResult | CancelResult} + */ +export function handleFormKeypress(formState, key) { + const {fields, focusedFieldIndex} = formState + const lastFieldIndex = fields.length - 1 + + // ── Esc: cancel (unless inside a MiniEditorField) ───────────────────────── + // For editor fields, Esc is handled inside the field handler to advance focus, + // not cancel the form. Only cancel when a non-editor field is focused. + const focusedField = fields[focusedFieldIndex] + if (key.name === 'escape' && focusedField?.type !== 'editor') { + return {cancelled: /** @type {true} */ (true)} + } + + // ── Ctrl+S: submit ──────────────────────────────────────────────────────── + if (key.ctrl && key.name === 's') { + return attemptSubmit(formState) + } + + // ── Tab: next field ─────────────────────────────────────────────────────── + if (key.name === 'tab' && !key.shift) { + return { + ...formState, + focusedFieldIndex: (focusedFieldIndex + 1) % fields.length, + errorMessage: null, + } + } + + // ── Shift+Tab: previous field ───────────────────────────────────────────── + if (key.name === 'tab' && key.shift) { + return { + ...formState, + focusedFieldIndex: (focusedFieldIndex - 1 + fields.length) % fields.length, + errorMessage: null, + } + } + + // ── Enter on last non-editor field: submit ───────────────────────────────── + if ( + key.name === 'return' && + focusedFieldIndex === lastFieldIndex && + focusedField?.type !== 'editor' && + focusedField?.type !== 'multiselect' + ) { + return attemptSubmit(formState) + } + + // ── Delegate to focused field ───────────────────────────────────────────── + if (!focusedField) return formState + + if (focusedField.type === 'text') { + const updated = handleTextFieldKey(focusedField, key) + if (updated === focusedField) return formState + return { + ...formState, + errorMessage: null, + fields: replaceAt(fields, focusedFieldIndex, updated), + } + } + + if (focusedField.type === 'selector') { + const updated = handleSelectorFieldKey(focusedField, key) + if (updated === focusedField) return formState + return { + ...formState, + fields: replaceAt(fields, focusedFieldIndex, updated), + } + } + + if (focusedField.type === 'multiselect') { + const result = handleMultiSelectFieldKey(focusedField, key) + if ('advanceField' in result) { + return { + ...formState, + focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex), + } + } + if (result === focusedField) return formState + return { + ...formState, + fields: replaceAt(fields, focusedFieldIndex, result), + } + } + + if (focusedField.type === 'editor') { + const result = handleEditorFieldKey(focusedField, key) + if ('advanceField' in result) { + // Esc in editor cancels the form only if we treat it as a field-level escape. + // Per spec, Esc in editor moves to next field. + return { + ...formState, + focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex), + } + } + if (result === focusedField) return formState + return { + ...formState, + errorMessage: null, + fields: replaceAt(fields, focusedFieldIndex, result), + } + } + + return formState +} + +/** + * Attempt to submit the form: validate, then return SubmitResult or FormState with error. + * @param {FormState} formState + * @returns {FormState | SubmitResult} + */ +function attemptSubmit(formState) { + const invalidLabel = validateForm(formState) + if (invalidLabel !== null) { + return { + ...formState, + errorMessage: `"${invalidLabel}" is required.`, + } + } + return { + submitted: /** @type {true} */ (true), + values: extractValues(formState), + } +} + +/** + * Return a new array with element at `index` replaced by `value`. + * @template T + * @param {T[]} arr + * @param {number} index + * @param {T} value + * @returns {T[]} + */ +function replaceAt(arr, index, value) { + return arr.map((item, i) => (i === index ? value : item)) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Form field definitions +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Return form fields for creating or editing an MCP entry. + * + * Fields: name (text), environments (multiselect), transport (selector), command (text), + * args (text), url (text), description (text, optional). + * + * @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 getMCPFormFields(entry = null, compatibleEnvs = []) { + /** @type {import('../../types.js').MCPParams|null} */ + const p = entry ? /** @type {import('../../types.js').MCPParams} */ (entry.params) : null + + const transportOptions = ['stdio', 'sse', 'streamable-http'] + const transportIndex = p ? Math.max(0, transportOptions.indexOf(p.transport)) : 0 + + return [ + /** @type {TextField} */ ({ + type: 'text', + label: 'Name', + key: 'name', + value: entry ? entry.name : '', + cursor: entry ? entry.name.length : 0, + required: true, + placeholder: 'my-mcp-server', + }), + /** @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 {SelectorField} */ ({ + type: 'selector', + label: 'Transport', + key: 'transport', + options: transportOptions, + selectedIndex: transportIndex, + required: true, + }), + /** @type {TextField} */ ({ + type: 'text', + label: 'Command', + key: 'command', + value: p?.command ?? '', + cursor: (p?.command ?? '').length, + required: false, + placeholder: 'npx my-mcp-server', + }), + /** @type {TextField} */ ({ + type: 'text', + label: 'Args', + key: 'args', + value: p?.args ? p.args.join(' ') : '', + cursor: p?.args ? p.args.join(' ').length : 0, + required: false, + placeholder: '--port 3000 --verbose', + }), + /** @type {TextField} */ ({ + type: 'text', + label: 'URL', + key: 'url', + value: p?.url ?? '', + cursor: (p?.url ?? '').length, + required: false, + placeholder: 'https://mcp.example.com', + }), + /** @type {TextField} */ ({ + type: 'text', + label: 'Description', + key: 'description', + value: p?.description ?? (entry?.params ? /** @type {any} */ ((entry.params).description ?? '') : ''), + cursor: 0, + required: false, + placeholder: 'Optional description', + }), + ] +} + +/** + * Return form fields for creating or editing a Command 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 getCommandFormFields(entry = null, compatibleEnvs = []) { + /** @type {import('../../types.js').CommandParams|null} */ + const p = entry ? /** @type {import('../../types.js').CommandParams} */ (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-command', + }), + /** @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 a Skill 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 getSkillFormFields(entry = null, compatibleEnvs = []) { + /** @type {import('../../types.js').SkillParams|null} */ + const p = entry ? /** @type {import('../../types.js').SkillParams} */ (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-skill', + }), + /** @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. + * + * 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[]} + */ +export function getAgentFormFields(entry = null, compatibleEnvs = []) { + /** @type {import('../../types.js').AgentParams|null} */ + const p = entry ? /** @type {import('../../types.js').AgentParams} */ (entry.params) : null + const instructionsStr = p?.instructions ?? '' + const instructionLines = instructionsStr.length > 0 ? instructionsStr.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-agent', + }), + /** @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: 'Instructions', + key: 'instructions', + lines: instructionLines, + cursorLine: 0, + cursorCol: 0, + required: true, + }), + ] +} diff --git a/src/utils/tui/tab-tui.js b/src/utils/tui/tab-tui.js new file mode 100644 index 0000000..e57b98d --- /dev/null +++ b/src/utils/tui/tab-tui.js @@ -0,0 +1,800 @@ +/** + * @module tab-tui + * Tab-based full-screen TUI framework for dvmi sync-config-ai. + * Follows the same ANSI + readline + chalk pattern as navigable-table.js. + * Zero new dependencies — uses only Node.js built-ins + chalk. + */ + +import readline from 'node:readline' +import chalk from 'chalk' +import { + buildFormScreen, + handleFormKeypress, + getMCPFormFields, + getCommandFormFields, + getSkillFormFields, + getAgentFormFields, +} from './form.js' + +// ────────────────────────────────────────────────────────────────────────────── +// ANSI escape sequences +// ────────────────────────────────────────────────────────────────────────────── + +const ANSI_CLEAR = '\x1b[2J' +const ANSI_HOME = '\x1b[H' +const ANSI_ALT_SCREEN_ON = '\x1b[?1049h' +const ANSI_ALT_SCREEN_OFF = '\x1b[?1049l' +const ANSI_CURSOR_HIDE = '\x1b[?25l' +const ANSI_CURSOR_SHOW = '\x1b[?25h' +const ANSI_INVERSE_ON = '\x1b[7m' +const ANSI_INVERSE_OFF = '\x1b[27m' + +// ────────────────────────────────────────────────────────────────────────────── +// Layout constants +// ────────────────────────────────────────────────────────────────────────────── + +const MIN_COLS = 80 +const MIN_ROWS = 24 +const TAB_BAR_LINES = 2 // tab bar line + divider +const FOOTER_LINES = 2 // empty line + keyboard hints + +// ────────────────────────────────────────────────────────────────────────────── +// Module-level terminal session state +// ────────────────────────────────────────────────────────────────────────────── + +let _cleanupCalled = false +let _altScreenActive = false +let _rawModeActive = false +/** @type {((...args: unknown[]) => void) | null} */ +let _keypressListener = null + +// ────────────────────────────────────────────────────────────────────────────── +// Typedefs +// ────────────────────────────────────────────────────────────────────────────── + +/** + * @typedef {Object} TabDef + * @property {string} label - Display label shown in the tab bar + * @property {string} key - Unique identifier for this tab + */ + +/** + * @typedef {Object} TabTUIState + * @property {TabDef[]} tabs - All tabs + * @property {number} activeTabIndex - Index of the currently active tab + * @property {number} termRows - Current terminal height + * @property {number} termCols - Current terminal width + * @property {number} contentViewportHeight - Usable content lines (termRows - TAB_BAR_LINES - FOOTER_LINES) + * @property {boolean} tooSmall - Whether the terminal is below minimum size + */ + +/** + * @typedef {Object} EnvTabState + * @property {import('../../types.js').DetectedEnvironment[]} envs - Detected environments + * @property {number} selectedIndex - Highlighted row + */ + +/** + * @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('./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) + */ + +// ────────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ────────────────────────────────────────────────────────────────────────────── + +// ────────────────────────────────────────────────────────────────────────────── +// T017: buildTabBar — renders horizontal tab bar +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Build the tab bar string (one line of tab labels + a divider line). + * Active tab is highlighted with inverse video. + * @param {TabDef[]} tabs + * @param {number} activeIndex + * @returns {string[]} Two lines: [tabBarLine, divider] + */ +export function buildTabBar(tabs, activeIndex) { + const parts = tabs.map((tab, i) => { + const label = ` ${tab.label} ` + if (i === activeIndex) { + return `${ANSI_INVERSE_ON}${label}${ANSI_INVERSE_OFF}` + } + return chalk.dim(label) + }) + const tabBarLine = parts.join(chalk.dim('│')) + const divider = chalk.dim('─'.repeat(60)) + return [tabBarLine, divider] +} + +// ────────────────────────────────────────────────────────────────────────────── +// T017: buildTabScreen — full screen composition +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Compose the full terminal screen from tab bar, content lines, and footer. + * Prepends ANSI clear + home to replace the previous frame. + * @param {string[]} tabBarLines - Output of buildTabBar + * @param {string[]} contentLines - Tab-specific content lines + * @param {string[]} footerLines - Footer hint lines + * @param {number} termRows - Terminal height + * @returns {string} + */ +export function buildTabScreen(tabBarLines, contentLines, footerLines, termRows) { + const lines = [...tabBarLines, ...contentLines] + + // Pad to fill terminal height minus footer + const targetContentLines = termRows - tabBarLines.length - footerLines.length + while (lines.length < targetContentLines) { + lines.push('') + } + + lines.push(...footerLines) + return ANSI_CLEAR + ANSI_HOME + lines.join('\n') +} + +// ────────────────────────────────────────────────────────────────────────────── +// T018: terminal size check +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Build a "terminal too small" warning screen. + * @param {number} termRows + * @param {number} termCols + * @returns {string} + */ +export function buildTooSmallScreen(termRows, termCols) { + const lines = [] + const midRow = Math.floor(termRows / 2) + + for (let i = 0; i < midRow - 1; i++) lines.push('') + + lines.push(chalk.red.bold(` Terminal too small (${termCols}×${termRows}, minimum: ${MIN_COLS}×${MIN_ROWS})`)) + lines.push(chalk.dim(' Resize your terminal window and try again.')) + + return ANSI_CLEAR + ANSI_HOME + lines.join('\n') +} + +// ────────────────────────────────────────────────────────────────────────────── +// T020: buildEnvironmentsTab — content builder +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Build the content lines for the Environments tab. + * @param {import('../../types.js').DetectedEnvironment[]} envs - Detected environments + * @param {number} selectedIndex - Currently highlighted row + * @param {number} viewportHeight - Available content lines + * @param {import('../../formatters/ai-config.js').formatEnvironmentsTable} formatFn - Formatter function + * @param {number} termCols - Terminal width for formatter + * @returns {string[]} + */ +export function buildEnvironmentsTab(envs, selectedIndex, viewportHeight, formatFn, termCols = 120) { + if (envs.length === 0) { + return [ + '', + chalk.dim(' No AI coding environments detected.'), + chalk.dim(' Ensure at least one AI tool is configured in the current project or globally.'), + ] + } + + const tableLines = formatFn(envs, termCols) + + // Add row highlighting to data rows (skip header lines — first 2 lines are header + divider) + const HEADER_LINES = 2 + const resultLines = [] + + for (let i = 0; i < tableLines.length; i++) { + const line = tableLines[i] + const dataIndex = i - HEADER_LINES + if (dataIndex >= 0 && dataIndex === selectedIndex) { + resultLines.push(`${ANSI_INVERSE_ON}${line}${ANSI_INVERSE_OFF}`) + } else { + resultLines.push(line) + } + } + + // Viewport: only show lines that fit + return resultLines.slice(0, viewportHeight) +} + +// ────────────────────────────────────────────────────────────────────────────── +// T021: handleEnvironmentsKeypress — pure reducer +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Pure state reducer for keypresses in the Environments tab. + * @param {EnvTabState} state + * @param {{ name: string, ctrl?: boolean }} key + * @returns {EnvTabState | { exit: true } | { switchTab: number }} + */ +export function handleEnvironmentsKeypress(state, key) { + const {selectedIndex, envs} = state + const maxIndex = Math.max(0, envs.length - 1) + + if (key.name === 'up' || key.name === 'k') { + return {...state, selectedIndex: Math.max(0, selectedIndex - 1)} + } + if (key.name === 'down' || key.name === 'j') { + return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1)} + } + if (key.name === 'pageup') { + return {...state, selectedIndex: Math.max(0, selectedIndex - 10)} + } + if (key.name === 'pagedown') { + return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10)} + } + + return state +} + +// ────────────────────────────────────────────────────────────────────────────── +// Categories tab content builder (T036) — defined here for single-module TUI +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Build the content lines for the Categories tab. + * @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 + * @returns {string[]} + */ +export function buildCategoriesTab( + entries, + selectedIndex, + viewportHeight, + formatFn, + termCols = 120, + confirmDeleteName = null, +) { + if (entries.length === 0) { + const lines = [ + '', + chalk.dim(' No configuration entries yet.'), + chalk.dim(' Press ' + chalk.bold('n') + ' to create your first entry.'), + ] + if (confirmDeleteName === null) return lines + } + + const tableLines = formatFn(entries, termCols) + const HEADER_LINES = 2 + const resultLines = [] + + for (let i = 0; i < tableLines.length; i++) { + const line = tableLines[i] + const dataIndex = i - HEADER_LINES + if (dataIndex >= 0 && dataIndex === selectedIndex) { + resultLines.push(`${ANSI_INVERSE_ON}${line}${ANSI_INVERSE_OFF}`) + } else { + resultLines.push(line) + } + } + + // Confirmation prompt overlay + if (confirmDeleteName !== null) { + resultLines.push('') + resultLines.push(chalk.red(` Delete "${confirmDeleteName}"? This cannot be undone. `) + chalk.bold('[y/N]')) + } + + return resultLines.slice(0, viewportHeight) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Categories tab keypress reducer (T037) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Pure state reducer for keypresses in the Categories tab list mode. + * @param {CatTabState} state + * @param {{ name: string, ctrl?: boolean, sequence?: string }} key + * @returns {CatTabState | { exit: true }} + */ +export function handleCategoriesKeypress(state, key) { + const {selectedIndex, entries, mode, confirmDeleteId} = state + const maxIndex = Math.max(0, entries.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, + } + } + // Any other key cancels + return {...state, mode: 'list', confirmDeleteId: null} + } + + // List mode + if (key.name === 'up' || key.name === 'k') { + return {...state, selectedIndex: Math.max(0, selectedIndex - 1)} + } + if (key.name === 'down' || key.name === 'j') { + return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1)} + } + if (key.name === 'pageup') { + return {...state, selectedIndex: Math.max(0, selectedIndex - 10)} + } + if (key.name === 'pagedown') { + return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10)} + } + 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} + } + if (key.name === 'd' && entries.length > 0) { + return {...state, _toggleId: entries[selectedIndex]?.id} + } + if ((key.name === 'delete' || key.name === 'backspace') && entries.length > 0) { + const entry = entries[selectedIndex] + if (entry) { + return {...state, mode: 'confirm-delete', confirmDeleteId: entry.id, _confirmDeleteName: entry.name} + } + } + + return state +} + +// ────────────────────────────────────────────────────────────────────────────── +// Terminal lifecycle management +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Enter the alternate screen buffer, hide the cursor, and enable raw stdin keypresses. + * @returns {void} + */ +export function setupTerminal() { + _cleanupCalled = false + _altScreenActive = true + _rawModeActive = true + process.stdout.write(ANSI_ALT_SCREEN_ON) + process.stdout.write(ANSI_CURSOR_HIDE) + readline.emitKeypressEvents(process.stdin) + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } +} + +/** + * Restore the terminal to its original state. + * Idempotent — safe to call multiple times. + * @returns {void} + */ +export function cleanupTerminal() { + if (_cleanupCalled) return + _cleanupCalled = true + + if (_keypressListener) { + process.stdin.removeListener('keypress', _keypressListener) + _keypressListener = null + } + if (_rawModeActive && process.stdin.isTTY) { + try { + process.stdin.setRawMode(false) + } catch { + /* ignore */ + } + _rawModeActive = false + } + if (_altScreenActive) { + process.stdout.write(ANSI_CURSOR_SHOW) + process.stdout.write(ANSI_ALT_SCREEN_OFF) + _altScreenActive = false + } + try { + process.stdin.pause() + } catch { + /* ignore */ + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// T016: startTabTUI — main orchestrator +// ────────────────────────────────────────────────────────────────────────────── + +/** + * @typedef {Object} TabTUIOptions + * @property {import('../../types.js').DetectedEnvironment[]} envs - Detected environments (from scanner) + * @property {import('../../types.js').CategoryEntry[]} entries - All category entries (from store) + * @property {boolean} chezmoiEnabled - Whether chezmoi is configured + * @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 {(() => Promise) | undefined} [refreshEntries] - Reload entries from store after mutations + */ + +/** + * Start the interactive tab TUI session. + * Blocks until the user exits (Esc / q / Ctrl+C). + * Manages the full TUI lifecycle: terminal setup, keypress loop, tab switching, cleanup. + * + * @param {TabTUIOptions} opts + * @returns {Promise} + */ +export async function startTabTUI(opts) { + const {envs, onAction, formatEnvs, formatCats} = opts + const {entries: initialEntries, chezmoiEnabled} = opts + + _cleanupCalled = false + + const sigHandler = () => { + cleanupTerminal() + process.exit(0) + } + const exitHandler = () => { + if (!_cleanupCalled) cleanupTerminal() + } + process.once('SIGINT', sigHandler) + process.once('SIGTERM', sigHandler) + process.once('exit', exitHandler) + + const tabs = [ + {label: 'Environments', key: 'environments'}, + {label: 'MCPs', key: 'mcp'}, + {label: 'Commands', key: 'command'}, + {label: 'Skills', key: 'skill'}, + {label: 'Agents', key: 'agent'}, + ] + + const CATEGORY_TYPES = ['mcp', 'command', 'skill', 'agent'] + const chezmoidTip = chezmoiEnabled ? '' : 'Tip: Run `dvmi dotfiles setup` to enable automatic backup of your AI configs' + + /** @type {TabTUIState} */ + let tuiState = { + tabs, + activeTabIndex: 0, + termRows: process.stdout.rows || 24, + termCols: process.stdout.columns || 80, + contentViewportHeight: Math.max(1, (process.stdout.rows || 24) - TAB_BAR_LINES - FOOTER_LINES), + tooSmall: (process.stdout.columns || 80) < MIN_COLS || (process.stdout.rows || 24) < MIN_ROWS, + } + + /** @type {EnvTabState} */ + let envState = {envs, selectedIndex: 0} + + /** @type {import('../../types.js').CategoryEntry[]} */ + let allEntries = [...initialEntries] + + /** @type {Record} */ + let catTabStates = Object.fromEntries( + CATEGORY_TYPES.map((type) => [ + type, + /** @type {CatTabState} */ ({ + entries: allEntries.filter((e) => e.type === type), + selectedIndex: 0, + mode: 'list', + formState: null, + confirmDeleteId: null, + chezmoidTip, + }), + ]), + ) + + /** Push filtered entries into each tab state — call after allEntries changes. */ + function syncTabEntries() { + for (const type of CATEGORY_TYPES) { + catTabStates = { + ...catTabStates, + [type]: {...catTabStates[type], entries: allEntries.filter((e) => e.type === type)}, + } + } + } + + setupTerminal() + + /** + * Build and render the current frame. + * @returns {void} + */ + function render() { + const {termRows, termCols, activeTabIndex, tooSmall, contentViewportHeight} = tuiState + + if (tooSmall) { + process.stdout.write(buildTooSmallScreen(termRows, termCols)) + return + } + + const tabBarLines = buildTabBar(tabs, activeTabIndex) + let contentLines + let hintStr + + if (activeTabIndex === 0) { + contentLines = buildEnvironmentsTab( + envState.envs, + envState.selectedIndex, + contentViewportHeight, + formatEnvs, + termCols, + ) + hintStr = chalk.dim(' ↑↓ navigate Tab switch tabs q exit') + } else { + const tabKey = tabs[activeTabIndex].key + const tabState = catTabStates[tabKey] + + 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 { + 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 footerTip = chezmoidTip ? [chalk.dim(chezmoidTip)] : [] + const footerLines = ['', hintStr, ...footerTip] + process.stdout.write(buildTabScreen(tabBarLines, contentLines, footerLines, termRows)) + } + + // Resize handler + function onResize() { + const newRows = process.stdout.rows || 24 + const newCols = process.stdout.columns || 80 + tuiState = { + ...tuiState, + termRows: newRows, + termCols: newCols, + contentViewportHeight: Math.max(1, newRows - TAB_BAR_LINES - FOOTER_LINES), + tooSmall: newCols < MIN_COLS || newRows < MIN_ROWS, + } + render() + } + process.stdout.on('resize', onResize) + + render() + + return new Promise((resolve) => { + /** + * @param {string} _str + * @param {{ name: string, ctrl?: boolean, shift?: boolean, sequence?: string }} key + */ + const listener = async (_str, key) => { + if (!key) return + + // Global keys + if (key.name === 'escape' || key.name === 'q') { + process.stdout.removeListener('resize', onResize) + process.removeListener('SIGINT', sigHandler) + process.removeListener('SIGTERM', sigHandler) + process.removeListener('exit', exitHandler) + cleanupTerminal() + resolve() + return + } + if (key.ctrl && key.name === 'c') { + process.stdout.removeListener('resize', onResize) + process.removeListener('SIGINT', sigHandler) + process.removeListener('SIGTERM', sigHandler) + process.removeListener('exit', exitHandler) + cleanupTerminal() + resolve() + return + } + + // Tab switching — only when not in form mode (Tab navigates form fields when a form is open) + 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) { + tuiState = { + ...tuiState, + activeTabIndex: (tuiState.activeTabIndex + 1) % tabs.length, + } + render() + return + } + + // Delegate to active tab + if (tuiState.activeTabIndex === 0) { + // Environments tab — read-only + const result = handleEnvironmentsKeypress(envState, key) + envState = /** @type {EnvTabState} */ (result) + render() + } else { + // Category tab (MCPs | Commands | Skills | Agents) + const tabKey = tabs[tuiState.activeTabIndex].key + const tabState = catTabStates[tabKey] + + // Form mode: delegate to form keypress handler + if (tabState.mode === 'form' && tabState.formState) { + const formResult = handleFormKeypress(tabState.formState, key) + + if ('cancelled' in formResult && formResult.cancelled) { + catTabStates = { + ...catTabStates, + [tabKey]: {...tabState, mode: 'list', formState: null, _formAction: null, _editId: null}, + } + render() + return + } + + if ('submitted' in formResult && formResult.submitted) { + const formAction = tabState._formAction + const editId = tabState._editId + const savedFormState = tabState.formState + catTabStates = { + ...catTabStates, + [tabKey]: {...tabState, mode: 'list', formState: null, _formAction: null, _editId: null}, + } + render() + try { + await onAction({type: formAction, tabKey, values: formResult.values, id: editId}) + if (opts.refreshEntries) { + allEntries = await opts.refreshEntries() + syncTabEntries() + render() + } + } catch (err) { + // Restore form with error message so the user sees what went wrong + const msg = err instanceof Error ? err.message : String(err) + catTabStates = { + ...catTabStates, + [tabKey]: { + ...catTabStates[tabKey], + mode: 'form', + formState: {...savedFormState, errorMessage: msg}, + _formAction: formAction, + _editId: editId, + }, + } + render() + } + return + } + + // Still editing — update form state + catTabStates = { + ...catTabStates, + [tabKey]: {...tabState, formState: /** @type {import('./form.js').FormState} */ (formResult)}, + } + render() + return + } + + // List / confirm-delete mode + const result = handleCategoriesKeypress(tabState, key) + + if (result._deleteConfirmed && result.confirmDeleteId) { + const idToDelete = result.confirmDeleteId + catTabStates = { + ...catTabStates, + [tabKey]: {...result, confirmDeleteId: null, _deleteConfirmed: false}, + } + render() + try { + await onAction({type: 'delete', id: idToDelete}) + if (opts.refreshEntries) { + allEntries = await opts.refreshEntries() + syncTabEntries() + render() + } + } catch { + /* ignore */ + } + return + } + + if (result._toggleId) { + const idToToggle = result._toggleId + const entry = tabState.entries.find((e) => e.id === idToToggle) + catTabStates = {...catTabStates, [tabKey]: {...result, _toggleId: null}} + render() + if (entry) { + try { + await onAction({type: entry.active ? 'deactivate' : 'activate', id: idToToggle}) + 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 = + tabKey === 'mcp' + ? getMCPFormFields(null, compatibleEnvs) + : tabKey === 'command' + ? getCommandFormFields(null, compatibleEnvs) + : tabKey === 'skill' + ? getSkillFormFields(null, compatibleEnvs) + : getAgentFormFields(null, compatibleEnvs) + const tabLabel = tabKey === 'mcp' ? 'MCP' : tabKey.charAt(0).toUpperCase() + tabKey.slice(1) + catTabStates = { + ...catTabStates, + [tabKey]: { + ...result, + _action: null, + mode: 'form', + _formAction: 'create', + formState: { + fields, + focusedFieldIndex: 0, + title: `Create ${tabLabel}`, + status: 'editing', + errorMessage: null, + }, + }, + } + render() + return + } + + if (result._action === 'edit' && result._editId) { + const entry = tabState.entries.find((e) => e.id === result._editId) + if (entry) { + const compatibleEnvs = envs.filter((e) => e.supportedCategories.includes(entry.type)) + const fields = + entry.type === 'mcp' + ? getMCPFormFields(entry, compatibleEnvs) + : entry.type === 'command' + ? getCommandFormFields(entry, compatibleEnvs) + : entry.type === 'skill' + ? getSkillFormFields(entry, compatibleEnvs) + : getAgentFormFields(entry, compatibleEnvs) + catTabStates = { + ...catTabStates, + [tabKey]: { + ...result, + _action: null, + mode: 'form', + _formAction: 'edit', + formState: { + fields, + focusedFieldIndex: 0, + title: `Edit ${entry.name}`, + status: 'editing', + errorMessage: null, + }, + }, + } + render() + return + } + } + + catTabStates = {...catTabStates, [tabKey]: /** @type {CatTabState} */ (result)} + render() + } + } + + _keypressListener = listener + process.stdin.on('keypress', listener) + process.stdin.resume() + }) +} + +/** + * Update the entries displayed in the Categories tab (called after store mutations). + * @param {import('../../types.js').CategoryEntry[]} _newEntries + * @returns {void} + */ +export function updateTUIEntries(_newEntries) { + // This is a lightweight state update — the TUI re-renders on next keypress. + // Callers should call render() manually after this if needed. +} diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index 67943ec..a0568ff 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -1,7 +1,7 @@ -import { execaNode } from 'execa' -import { createServer } from 'node:http' -import { resolve, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' +import {execaNode} from 'execa' +import {createServer} from 'node:http' +import {resolve, dirname} from 'node:path' +import {fileURLToPath} from 'node:url' import stripAnsi from 'strip-ansi' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -60,14 +60,11 @@ export async function runCliJson(args) { export async function createMockServer(handler) { const server = createServer(handler) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const { port } = /** @type {import('node:net').AddressInfo} */ (server.address()) + const {port} = /** @type {import('node:net').AddressInfo} */ (server.address()) return { port, url: `http://127.0.0.1:${port}`, - stop: () => - new Promise((resolve, reject) => - server.close((err) => (err ? reject(err) : resolve())), - ), + stop: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), } } @@ -96,5 +93,21 @@ export function jsonResponse(res, data, status = 200) { * @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>} */ export function runCliWithMockGitHub(args, port, extraEnv = {}) { - return runCli(args, { GITHUB_API_URL: `http://127.0.0.1:${port}`, ...extraEnv }) + return runCli(args, {GITHUB_API_URL: `http://127.0.0.1:${port}`, ...extraEnv}) +} + +/** + * Run `dvmi sync-config-ai --json` and return the parsed result. + * Uses a temporary AI config path so tests are isolated from the real store. + * + * @param {string} [aiConfigPath] - Override AI config path (defaults to DVMI_AI_CONFIG_PATH env or a temp path) + * @returns {Promise<{ environments: unknown[], categories: { mcp: unknown[], command: unknown[], skill: unknown[], agent: unknown[] } }>} + */ +export async function runSyncConfigAi(aiConfigPath) { + const env = aiConfigPath ? {DVMI_AI_CONFIG_PATH: aiConfigPath} : {} + const result = await runCli(['sync-config-ai', '--json'], env) + if (result.exitCode !== 0) { + throw new Error(`sync-config-ai exited with ${result.exitCode}: ${result.stderr}`) + } + return JSON.parse(result.stdout) } diff --git a/tests/integration/pr-review.test.js b/tests/integration/pr-review.test.js index 29c51a3..26eff7b 100644 --- a/tests/integration/pr-review.test.js +++ b/tests/integration/pr-review.test.js @@ -1,23 +1,23 @@ -import { describe, it, expect } from 'vitest' -import { runCli } from './helpers.js' +import {describe, it, expect} from 'vitest' +import {runCli} from './helpers.js' describe('pr review', () => { - it('mostra errore se org non configurata', async () => { - // DVMI_CONFIG_PATH punta a file inesistente → config vuota → errore non-zero - const { exitCode } = await runCli(['pr', 'review']) + it('exits non-zero when org is not configured', async () => { + // DVMI_CONFIG_PATH points to a non-existent file → empty config → non-zero exit + const {exitCode} = await runCli(['pr', 'review']) expect(exitCode).not.toBe(0) }) }) describe('pr detail', () => { - it('errore se --repo non è nel formato owner/repo', async () => { - const { exitCode, stderr } = await runCli(['pr', 'detail', '42', '--repo', 'repo-senza-owner']) + it('exits non-zero when --repo is not in owner/repo format', async () => { + const {exitCode, stderr} = await runCli(['pr', 'detail', '42', '--repo', 'repo-without-owner']) expect(exitCode).not.toBe(0) expect(stderr).toContain('owner/repo') }) - it('errore se manca il numero PR', async () => { - const { exitCode } = await runCli(['pr', 'detail']) + it('exits non-zero when PR number is missing', async () => { + const {exitCode} = await runCli(['pr', 'detail']) expect(exitCode).not.toBe(0) }) }) diff --git a/tests/integration/sync-config-ai.test.js b/tests/integration/sync-config-ai.test.js new file mode 100644 index 0000000..c5fd3d1 --- /dev/null +++ b/tests/integration/sync-config-ai.test.js @@ -0,0 +1,25 @@ +import {describe, it, expect} from 'vitest' +import {runCli, runCliJson} from './helpers.js' + +describe('dvmi sync-config-ai', () => { + // T023: --help exits 0 and mentions AI/config/sync + it('--help exits 0 and mentions AI environments or config', async () => { + const {stdout, exitCode} = await runCli(['sync-config-ai', '--help']) + expect(exitCode).toBe(0) + 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 () => { + const result = await runCliJson(['sync-config-ai']) + expect(result).toHaveProperty('environments') + expect(result).toHaveProperty('categories') + expect(Array.isArray(result.environments)).toBe(true) + expect(result.categories).toHaveProperty('mcp') + expect(result.categories).toHaveProperty('command') + 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) + }) +}) diff --git a/tests/services/ai-config-sync.test.js b/tests/services/ai-config-sync.test.js new file mode 100644 index 0000000..6e28e9c --- /dev/null +++ b/tests/services/ai-config-sync.test.js @@ -0,0 +1,253 @@ +/** + * Service-level integration test: full AI config sync flow. + * + * 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. + */ + +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {join} from 'node:path' +import {tmpdir} from 'node:os' +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 { + loadAIConfig, + addEntry, + deactivateEntry, + activateEntry, + deleteEntry, +} from '../../src/services/ai-config-store.js' +import {deployEntry, undeployEntry} from '../../src/services/ai-env-deployer.js' + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +function makeTmpDir() { + return join(tmpdir(), `dvmi-sync-test-${Date.now()}-${randomUUID().slice(0, 8)}`) +} + +async function readJson(filePath) { + const raw = await readFile(filePath, 'utf8') + return JSON.parse(raw) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Test suite +// ────────────────────────────────────────────────────────────────────────────── + +describe('AI config sync — full flow', () => { + 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 environment is detected + await writeFile(join(tmpDir, 'CLAUDE.md'), '# Test project\n', 'utf8') + + // Override the store path via env var + 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 environment after seeding CLAUDE.md', () => { + const envs = scanEnvironments(tmpDir) + const claudeEnv = envs.find((e) => e.id === 'claude-code') + expect(claudeEnv).toBeDefined() + expect(claudeEnv.detected).toBe(true) + }) + + it('create → deploy: writes MCP entry to .mcp.json', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'my-test-server', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', args: ['-y', 'my-test-pkg'], env: {}}, + }) + + expect(entry.id).toBeTruthy() + expect(entry.active).toBe(true) + expect(entry.name).toBe('my-test-server') + + await deployEntry(entry, detectedEnvs, tmpDir) + + const mcpJson = join(tmpDir, '.mcp.json') + expect(existsSync(mcpJson)).toBe(true) + + const parsed = await readJson(mcpJson) + expect(parsed.mcpServers?.['my-test-server']).toBeDefined() + expect(parsed.mcpServers['my-test-server'].command).toBe('npx') + expect(parsed.mcpServers['my-test-server'].args).toEqual(['-y', 'my-test-pkg']) + }) + + it('deactivate → undeploy: removes entry from .mcp.json', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'removable-server', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: ['server.js'], env: {}}, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + // Verify deployed + const mcpJson = join(tmpDir, '.mcp.json') + const before = await readJson(mcpJson) + expect(before.mcpServers?.['removable-server']).toBeDefined() + + // Deactivate + const deactivated = await deactivateEntry(entry.id) + expect(deactivated.active).toBe(false) + + // Undeploy + await undeployEntry(deactivated, detectedEnvs, tmpDir) + + // Verify removed + const after = await readJson(mcpJson) + expect(after.mcpServers?.['removable-server']).toBeUndefined() + }) + + it('activate → redeploy: restores entry in .mcp.json', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'restorable-server', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'python', args: ['-m', 'srv'], env: {}}, + }) + + // Deploy → undeploy → redeploy + await deployEntry(entry, detectedEnvs, tmpDir) + + const deactivated = await deactivateEntry(entry.id) + await undeployEntry(deactivated, detectedEnvs, tmpDir) + + const mcpJson = join(tmpDir, '.mcp.json') + const afterUndeploy = await readJson(mcpJson) + expect(afterUndeploy.mcpServers?.['restorable-server']).toBeUndefined() + + // Re-activate + const reactivated = await activateEntry(entry.id) + expect(reactivated.active).toBe(true) + + // Redeploy + await deployEntry(reactivated, detectedEnvs, tmpDir) + + const afterRedeploy = await readJson(mcpJson) + expect(afterRedeploy.mcpServers?.['restorable-server']).toBeDefined() + expect(afterRedeploy.mcpServers['restorable-server'].command).toBe('python') + }) + + it('delete → undeploy: permanently removes entry from store and filesystem', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'deletable-server', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'deno', args: ['run', 'server.ts'], env: {}}, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + const mcpJson = join(tmpDir, '.mcp.json') + const before = await readJson(mcpJson) + expect(before.mcpServers?.['deletable-server']).toBeDefined() + + // Undeploy first (simulating delete flow in the command) + await undeployEntry(entry, detectedEnvs, tmpDir) + await deleteEntry(entry.id) + + // Entry removed from .mcp.json + const after = await readJson(mcpJson) + expect(after.mcpServers?.['deletable-server']).toBeUndefined() + + // Entry removed from store + const store = await loadAIConfig() + const found = store.entries.find((e) => e.id === entry.id) + expect(found).toBeUndefined() + }) + + it('deploy command entry: writes markdown file to .claude/commands/', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'my-command', + type: 'command', + environments: ['claude-code'], + params: { + description: 'A test command', + content: 'Do something useful.', + }, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + const cmdFile = join(tmpDir, '.claude', 'commands', 'my-command.md') + expect(existsSync(cmdFile)).toBe(true) + + const content = await readFile(cmdFile, 'utf8') + // deployer writes params.content directly (not the description) + expect(content).toContain('Do something useful.') + }) + + it('undeploy command entry: removes the markdown file', async () => { + const detectedEnvs = scanEnvironments(tmpDir) + + const entry = await addEntry({ + name: 'removable-command', + type: 'command', + environments: ['claude-code'], + params: {description: 'Temp command', content: 'Content here.'}, + }) + + await deployEntry(entry, detectedEnvs, tmpDir) + + const cmdFile = join(tmpDir, '.claude', 'commands', 'removable-command.md') + expect(existsSync(cmdFile)).toBe(true) + + const deactivated = await deactivateEntry(entry.id) + await undeployEntry(deactivated, detectedEnvs, tmpDir) + + expect(existsSync(cmdFile)).toBe(false) + }) + + it('store persists multiple entries across reloads', async () => { + await addEntry({ + name: 'server-a', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: ['a.js'], env: {}}, + }) + + await addEntry({ + name: 'server-b', + type: 'mcp', + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: ['b.js'], env: {}}, + }) + + const store = await loadAIConfig() + expect(store.entries).toHaveLength(2) + expect(store.entries.map((e) => e.name)).toContain('server-a') + expect(store.entries.map((e) => e.name)).toContain('server-b') + }) +}) diff --git a/tests/snapshots/__snapshots__/sync-config-ai.test.js.snap b/tests/snapshots/__snapshots__/sync-config-ai.test.js.snap new file mode 100644 index 0000000..cb42b1c --- /dev/null +++ b/tests/snapshots/__snapshots__/sync-config-ai.test.js.snap @@ -0,0 +1,23 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`dvmi sync-config-ai snapshots > --help output matches snapshot 1`] = ` +"Manage AI coding tool configurations across environments via TUI + +USAGE + $ dvmi sync-config-ai [--json] [-h] + +FLAGS + -h, --help Show CLI help. + +GLOBAL FLAGS + --json Format output as json. + +DESCRIPTION + Manage AI coding tool configurations across environments via TUI + +EXAMPLES + $ dvmi sync-config-ai + + $ dvmi sync-config-ai --json +" +`; diff --git a/tests/snapshots/sync-config-ai.test.js b/tests/snapshots/sync-config-ai.test.js new file mode 100644 index 0000000..29ef362 --- /dev/null +++ b/tests/snapshots/sync-config-ai.test.js @@ -0,0 +1,10 @@ +import {describe, it, expect} from 'vitest' +import {runCli} from '../integration/helpers.js' + +describe('dvmi sync-config-ai snapshots', () => { + it('--help output matches snapshot', async () => { + const {stdout, exitCode} = await runCli(['sync-config-ai', '--help']) + expect(exitCode).toBe(0) + expect(stdout).toMatchSnapshot() + }) +}) diff --git a/tests/unit/services/ai-config-store.test.js b/tests/unit/services/ai-config-store.test.js new file mode 100644 index 0000000..bccbca3 --- /dev/null +++ b/tests/unit/services/ai-config-store.test.js @@ -0,0 +1,335 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest' +import {join} from 'node:path' +import {tmpdir} from 'node:os' +import {writeFile, mkdir, rm} from 'node:fs/promises' +import {existsSync} from 'node:fs' + +import { + loadAIConfig, + addEntry, + updateEntry, + deactivateEntry, + activateEntry, + deleteEntry, + getEntriesByEnvironment, + getEntriesByType, +} from '../../../src/services/ai-config-store.js' +import {DvmiError} from '../../../src/utils/errors.js' + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Build a unique temp file path per test run. + * @returns {string} + */ +function makeTmpPath() { + const dir = join(tmpdir(), `dvmi-ai-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + return join(dir, 'ai-config.json') +} + +/** Minimal valid entry data for an MCP entry targeting compatible environments. */ +const MCP_ENTRY = { + name: 'test-mcp', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('mcp'), + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {transport: 'stdio', command: 'npx', args: [], env: {}}, +} + +// ────────────────────────────────────────────────────────────────────────────── +// Test setup / teardown +// ────────────────────────────────────────────────────────────────────────────── + +let tmpPath + +beforeEach(() => { + tmpPath = makeTmpPath() + process.env.DVMI_AI_CONFIG_PATH = tmpPath +}) + +afterEach(async () => { + delete process.env.DVMI_AI_CONFIG_PATH + const dir = join(tmpPath, '..') + if (existsSync(dir)) { + await rm(dir, {recursive: true, force: true}) + } +}) + +// ────────────────────────────────────────────────────────────────────────────── +// loadAIConfig +// ────────────────────────────────────────────────────────────────────────────── + +describe('loadAIConfig', () => { + it('returns defaults when file does not exist', async () => { + const store = await loadAIConfig(tmpPath) + expect(store).toEqual({version: 1, entries: []}) + }) + + it('returns parsed content from an existing valid file', async () => { + const dir = join(tmpPath, '..') + await mkdir(dir, {recursive: true}) + const data = { + version: 1, + entries: [ + { + id: '00000000-0000-0000-0000-000000000001', + name: 'existing-mcp', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', args: [], env: {}}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + } + await writeFile(tmpPath, JSON.stringify(data), 'utf8') + + const store = await loadAIConfig(tmpPath) + expect(store.version).toBe(1) + expect(store.entries).toHaveLength(1) + expect(store.entries[0].name).toBe('existing-mcp') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// addEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('addEntry', () => { + it('creates an entry with UUID, active: true, and timestamps', async () => { + const before = new Date() + const entry = await addEntry(MCP_ENTRY, tmpPath) + const after = new Date() + + expect(entry.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(entry.active).toBe(true) + expect(entry.name).toBe(MCP_ENTRY.name) + expect(entry.type).toBe(MCP_ENTRY.type) + expect(entry.environments).toEqual(MCP_ENTRY.environments) + + const createdAt = new Date(entry.createdAt) + const updatedAt = new Date(entry.updatedAt) + expect(createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime()) + expect(createdAt.getTime()).toBeLessThanOrEqual(after.getTime()) + expect(updatedAt.getTime()).toEqual(createdAt.getTime()) + }) + + it('throws DvmiError for a duplicate name within the same type', async () => { + await addEntry(MCP_ENTRY, tmpPath) + await expect(addEntry(MCP_ENTRY, tmpPath)).rejects.toThrow(DvmiError) + await expect(addEntry(MCP_ENTRY, tmpPath)).rejects.toThrow(/already exists/) + }) + + it('throws DvmiError when the name contains invalid filename characters', async () => { + const bad = {...MCP_ENTRY, name: 'bad/name'} + await expect(addEntry(bad, tmpPath)).rejects.toThrow(DvmiError) + await expect(addEntry(bad, tmpPath)).rejects.toThrow(/invalid characters/) + }) + + it('throws DvmiError when an environment is incompatible with the entry type', async () => { + const incompatible = { + name: 'agent-for-gemini', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('agent'), + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['gemini-cli']), + params: {instructions: 'do stuff'}, + } + await expect(addEntry(incompatible, tmpPath)).rejects.toThrow(DvmiError) + await expect(addEntry(incompatible, tmpPath)).rejects.toThrow(/does not support type/) + }) + + it('succeeds for compatible environment and type combinations', async () => { + const compatible = { + name: 'mcp-for-gemini', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('mcp'), + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['gemini-cli']), + params: {transport: 'stdio', command: 'npx', args: [], env: {}}, + } + const entry = await addEntry(compatible, tmpPath) + expect(entry.id).toBeTruthy() + expect(entry.environments).toContain('gemini-cli') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// updateEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('updateEntry', () => { + it('merges changes and updates updatedAt', async () => { + const original = await addEntry(MCP_ENTRY, tmpPath) + + // Small delay to ensure updatedAt differs from createdAt + await new Promise((r) => setTimeout(r, 5)) + + const updated = await updateEntry(original.id, {name: 'renamed-mcp', environments: ['opencode']}, tmpPath) + + expect(updated.id).toBe(original.id) + expect(updated.name).toBe('renamed-mcp') + expect(updated.environments).toEqual(['opencode']) + expect(updated.type).toBe(original.type) + expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan(new Date(original.updatedAt).getTime()) + }) + + it('throws DvmiError when the entry id is not found', async () => { + await expect(updateEntry('non-existent-id', {name: 'x'}, tmpPath)).rejects.toThrow(DvmiError) + await expect(updateEntry('non-existent-id', {name: 'x'}, tmpPath)).rejects.toThrow(/not found/) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// deactivateEntry / activateEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('deactivateEntry', () => { + it('sets active to false', async () => { + const entry = await addEntry(MCP_ENTRY, tmpPath) + const deactivated = await deactivateEntry(entry.id, tmpPath) + expect(deactivated.active).toBe(false) + + const store = await loadAIConfig(tmpPath) + expect(store.entries[0].active).toBe(false) + }) +}) + +describe('activateEntry', () => { + it('sets active to true after deactivation', async () => { + const entry = await addEntry(MCP_ENTRY, tmpPath) + await deactivateEntry(entry.id, tmpPath) + const activated = await activateEntry(entry.id, tmpPath) + expect(activated.active).toBe(true) + + const store = await loadAIConfig(tmpPath) + expect(store.entries[0].active).toBe(true) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// deleteEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('deleteEntry', () => { + it('removes the entry from the store', async () => { + const entry = await addEntry(MCP_ENTRY, tmpPath) + await deleteEntry(entry.id, tmpPath) + + const store = await loadAIConfig(tmpPath) + expect(store.entries).toHaveLength(0) + }) + + it('throws DvmiError when the entry id is not found', async () => { + await expect(deleteEntry('non-existent-id', tmpPath)).rejects.toThrow(DvmiError) + await expect(deleteEntry('non-existent-id', tmpPath)).rejects.toThrow(/not found/) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getEntriesByEnvironment +// ────────────────────────────────────────────────────────────────────────────── + +describe('getEntriesByEnvironment', () => { + it('returns only active entries that include the given environment', async () => { + const active = await addEntry({...MCP_ENTRY, name: 'active-mcp', environments: ['claude-code']}, tmpPath) + const alsoActive = await addEntry( + { + name: 'active-opencode', + type: 'mcp', + environments: ['opencode'], + params: {transport: 'stdio', command: 'npx', args: [], env: {}}, + }, + tmpPath, + ) + await deactivateEntry(active.id, tmpPath) + + const results = await getEntriesByEnvironment('claude-code', tmpPath) + expect(results.every((e) => e.active)).toBe(true) + expect(results.every((e) => e.environments.includes('claude-code'))).toBe(true) + expect(results.find((e) => e.id === active.id)).toBeUndefined() + expect(results.find((e) => e.id === alsoActive.id)).toBeUndefined() + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getEntriesByType +// ────────────────────────────────────────────────────────────────────────────── + +describe('getEntriesByType', () => { + it('returns all entries of the given type regardless of active flag', async () => { + const mcp1 = await addEntry({...MCP_ENTRY, name: 'mcp-one'}, tmpPath) + const mcp2 = await addEntry({...MCP_ENTRY, name: 'mcp-two'}, tmpPath) + await addEntry( + { + name: 'a-command', + type: 'command', + environments: ['claude-code'], + params: {content: 'do something'}, + }, + tmpPath, + ) + await deactivateEntry(mcp2.id, tmpPath) + + const results = await getEntriesByType('mcp', tmpPath) + expect(results).toHaveLength(2) + expect(results.map((e) => e.id).sort()).toEqual([mcp1.id, mcp2.id].sort()) + // Both active and inactive are returned + expect(results.find((e) => e.id === mcp2.id)?.active).toBe(false) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// syncAIConfigToChezmoi +// ────────────────────────────────────────────────────────────────────────────── + +vi.mock('../../../src/services/shell.js', () => ({exec: vi.fn()})) +vi.mock('../../../src/services/config.js', () => ({loadConfig: vi.fn()})) + +describe('syncAIConfigToChezmoi', () => { + let execMock + let loadConfigMock + + beforeEach(async () => { + const shellModule = await import('../../../src/services/shell.js') + const configModule = await import('../../../src/services/config.js') + execMock = shellModule.exec + loadConfigMock = configModule.loadConfig + vi.clearAllMocks() + }) + + it('calls chezmoi add when dotfiles.enabled is true', async () => { + loadConfigMock.mockResolvedValue({dotfiles: {enabled: true}}) + execMock.mockResolvedValue({stdout: '', stderr: '', exitCode: 0}) + + const {syncAIConfigToChezmoi} = await import('../../../src/services/ai-config-store.js') + await syncAIConfigToChezmoi() + + expect(execMock).toHaveBeenCalledOnce() + expect(execMock).toHaveBeenCalledWith('chezmoi', ['add', expect.any(String)]) + }) + + it('skips when dotfiles.enabled is false', async () => { + loadConfigMock.mockResolvedValue({dotfiles: {enabled: false}}) + + const {syncAIConfigToChezmoi} = await import('../../../src/services/ai-config-store.js') + await syncAIConfigToChezmoi() + + expect(execMock).not.toHaveBeenCalled() + }) + + it('skips when dotfiles is not configured', async () => { + loadConfigMock.mockResolvedValue({}) + + const {syncAIConfigToChezmoi} = await import('../../../src/services/ai-config-store.js') + await syncAIConfigToChezmoi() + + expect(execMock).not.toHaveBeenCalled() + }) + + it('does not throw when chezmoi fails', async () => { + loadConfigMock.mockResolvedValue({dotfiles: {enabled: true}}) + execMock.mockRejectedValue(new Error('chezmoi not found')) + + const {syncAIConfigToChezmoi} = await import('../../../src/services/ai-config-store.js') + await expect(syncAIConfigToChezmoi()).resolves.toBeUndefined() + }) +}) diff --git a/tests/unit/services/ai-env-deployer.test.js b/tests/unit/services/ai-env-deployer.test.js new file mode 100644 index 0000000..5d2c90a --- /dev/null +++ b/tests/unit/services/ai-env-deployer.test.js @@ -0,0 +1,615 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {join} from 'node:path' +import {tmpdir} from 'node:os' +import {readFile, mkdir, writeFile, rm} from 'node:fs/promises' +import {existsSync} from 'node:fs' +import {randomUUID} from 'node:crypto' + +import { + deployMCPEntry, + undeployMCPEntry, + deployFileEntry, + undeployFileEntry, + deployEntry, + undeployEntry, + reconcileOnScan, +} from '../../../src/services/ai-env-deployer.js' + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Create a unique temporary directory per test. + * @returns {string} + */ +function makeTmpDir() { + return join(tmpdir(), `dvmi-deployer-test-${Date.now()}-${randomUUID().slice(0, 8)}`) +} + +/** + * Read and parse a JSON file from disk. + * @param {string} filePath + * @returns {Promise>} + */ +async function readJson(filePath) { + const raw = await readFile(filePath, 'utf8') + return JSON.parse(raw) +} + +/** + * Build a minimal CategoryEntry for MCP type. + * @param {Partial} [overrides] + * @returns {import('../../../src/types.js').CategoryEntry} + */ +function makeMCPEntry(overrides = {}) { + return { + id: randomUUID(), + name: 'test-server', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx', args: ['-y', 'test-pkg'], env: {}}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +/** + * Build a minimal CategoryEntry for command type. + * @param {Partial} [overrides] + * @returns {import('../../../src/types.js').CategoryEntry} + */ +function makeCommandEntry(overrides = {}) { + return { + id: randomUUID(), + name: 'my-command', + type: 'command', + active: true, + environments: ['claude-code'], + params: {content: '# My Command\nDo something useful.', description: 'A test command'}, + 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 + * @param {string[]} [unreadable] + * @returns {import('../../../src/types.js').DetectedEnvironment} + */ +function makeDetected(id, unreadable = []) { + return { + id, + name: id, + detected: true, + projectPaths: [], + globalPaths: [], + unreadable, + supportedCategories: ['mcp', 'command', 'skill', 'agent'], + counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + scope: 'project', + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Test lifecycle +// ────────────────────────────────────────────────────────────────────────────── + +let cwd + +beforeEach(async () => { + cwd = makeTmpDir() + await mkdir(cwd, {recursive: true}) +}) + +afterEach(async () => { + if (existsSync(cwd)) { + await rm(cwd, {recursive: true, force: true}) + } +}) + +// ────────────────────────────────────────────────────────────────────────────── +// deployMCPEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('deployMCPEntry', () => { + it('creates a new JSON file with the mcpServers entry when file does not exist', async () => { + const entry = makeMCPEntry({name: 'my-mcp', environments: ['claude-code']}) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.mcp.json') + expect(existsSync(filePath)).toBe(true) + + const json = await readJson(filePath) + expect(json).toHaveProperty('mcpServers') + expect(json.mcpServers).toHaveProperty('my-mcp') + expect(json.mcpServers['my-mcp']).toMatchObject({command: 'npx'}) + }) + + it('merges into an existing JSON file, preserving other entries', async () => { + const filePath = join(cwd, '.mcp.json') + const existing = { + mcpServers: { + 'existing-server': {command: 'node', args: ['server.js'], env: {}}, + }, + } + await mkdir(join(cwd), {recursive: true}) + await writeFile(filePath, JSON.stringify(existing), 'utf8') + + const entry = makeMCPEntry({name: 'new-server', environments: ['claude-code']}) + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(filePath) + expect(json.mcpServers).toHaveProperty('existing-server') + expect(json.mcpServers).toHaveProperty('new-server') + }) + + it('handles vscode-copilot: writes to .vscode/mcp.json with "servers" key', async () => { + const entry = makeMCPEntry({name: 'vscode-mcp', environments: ['vscode-copilot']}) + + await deployMCPEntry(entry, 'vscode-copilot', cwd) + + const filePath = join(cwd, '.vscode', 'mcp.json') + expect(existsSync(filePath)).toBe(true) + + const json = await readJson(filePath) + expect(json).toHaveProperty('servers') + expect(json).not.toHaveProperty('mcpServers') + expect(json.servers).toHaveProperty('vscode-mcp') + }) + + it('handles claude-code: writes to .mcp.json with "mcpServers" key', async () => { + const entry = makeMCPEntry({name: 'claude-mcp', environments: ['claude-code']}) + + await deployMCPEntry(entry, 'claude-code', cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + expect(json).toHaveProperty('mcpServers') + expect(json.mcpServers).toHaveProperty('claude-mcp') + }) + + it('handles opencode: writes to opencode.json with "mcpServers" key', async () => { + const entry = makeMCPEntry({name: 'oc-mcp', environments: ['opencode']}) + + await deployMCPEntry(entry, 'opencode', cwd) + + const json = await readJson(join(cwd, 'opencode.json')) + expect(json).toHaveProperty('mcpServers') + expect(json.mcpServers).toHaveProperty('oc-mcp') + }) + + 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 { + const entry = makeMCPEntry({name: 'gemini-mcp', environments: ['gemini-cli']}) + await deployMCPEntry(entry, 'gemini-cli', cwd) + + expect(existsSync(geminiSettingsPath)).toBe(true) + 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 { + const entry = makeMCPEntry({name: 'copilot-mcp', environments: ['copilot-cli']}) + await deployMCPEntry(entry, 'copilot-cli', cwd) + + expect(existsSync(copilotMcpPath)).toBe(true) + 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('is a no-op when entry type is not mcp', async () => { + const entry = makeCommandEntry() + + // Should not throw and should not create any file + await expect(deployMCPEntry(entry, 'claude-code', cwd)).resolves.toBeUndefined() + expect(existsSync(join(cwd, '.mcp.json'))).toBe(false) + }) + + it('is a no-op when entry is null', async () => { + await expect(deployMCPEntry(null, 'claude-code', cwd)).resolves.toBeUndefined() + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// undeployMCPEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('undeployMCPEntry', () => { + it('removes an entry by name while preserving other entries', async () => { + const filePath = join(cwd, '.mcp.json') + const initial = { + mcpServers: { + 'server-a': {command: 'node', args: [], env: {}}, + 'server-b': {command: 'npx', args: ['-y', 'pkg'], env: {}}, + }, + } + await writeFile(filePath, JSON.stringify(initial), 'utf8') + + await undeployMCPEntry('server-a', 'claude-code', cwd) + + const json = await readJson(filePath) + expect(json.mcpServers).not.toHaveProperty('server-a') + expect(json.mcpServers).toHaveProperty('server-b') + }) + + it('leaves an empty mcpServers object when the last entry is removed', async () => { + const filePath = join(cwd, '.mcp.json') + const initial = { + mcpServers: { + 'only-server': {command: 'node', args: [], env: {}}, + }, + } + await writeFile(filePath, JSON.stringify(initial), 'utf8') + + await undeployMCPEntry('only-server', 'claude-code', cwd) + + const json = await readJson(filePath) + expect(json).toHaveProperty('mcpServers') + expect(Object.keys(json.mcpServers)).toHaveLength(0) + }) + + it('is a no-op when the target file does not exist', async () => { + // Should not throw + await expect(undeployMCPEntry('nonexistent', 'claude-code', cwd)).resolves.toBeUndefined() + expect(existsSync(join(cwd, '.mcp.json'))).toBe(false) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// deployFileEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('deployFileEntry', () => { + it('creates a markdown file at the correct path for a claude-code command', async () => { + const entry = makeCommandEntry({ + name: 'refactor', + environments: ['claude-code'], + params: {content: '# Refactor\nRefactor the selected code.', description: 'Refactor'}, + }) + + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'commands', 'refactor.md') + expect(existsSync(filePath)).toBe(true) + const content = await readFile(filePath, 'utf8') + expect(content).toBe('# Refactor\nRefactor the selected code.') + }) + + it('creates a TOML file for a gemini-cli command', async () => { + const entry = makeCommandEntry({ + name: 'summarise', + environments: ['gemini-cli'], + 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 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('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 () => { + const entry = { + id: randomUUID(), + name: 'my-skill', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('skill'), + active: true, + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['vscode-copilot']), + params: {content: '# My Skill\nThis is a skill definition.'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + + await deployFileEntry(entry, 'vscode-copilot', cwd) + + const skillDir = join(cwd, '.github', 'skills', 'my-skill') + const skillFile = join(skillDir, 'SKILL.md') + + expect(existsSync(skillDir)).toBe(true) + expect(existsSync(skillFile)).toBe(true) + + const content = await readFile(skillFile, 'utf8') + expect(content).toBe('# My Skill\nThis is a skill definition.') + }) + + it('creates a markdown file for an opencode command', async () => { + const entry = makeCommandEntry({ + name: 'generate-tests', + environments: ['opencode'], + }) + + await deployFileEntry(entry, 'opencode', cwd) + + const filePath = join(cwd, '.opencode', 'commands', 'generate-tests.md') + expect(existsSync(filePath)).toBe(true) + }) + + it('creates a markdown file for a vscode-copilot command (prompt.md)', async () => { + const entry = makeCommandEntry({ + name: 'fix-types', + environments: ['vscode-copilot'], + }) + + await deployFileEntry(entry, 'vscode-copilot', cwd) + + const filePath = join(cwd, '.github', 'prompts', 'fix-types.prompt.md') + expect(existsSync(filePath)).toBe(true) + }) + + it('creates a markdown file for a claude-code agent using instructions field', async () => { + const entry = { + id: randomUUID(), + name: 'code-reviewer', + type: /** @type {import('../../../src/types.js').CategoryType} */ ('agent'), + active: true, + environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), + params: {instructions: 'Review code for quality and security.'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'agents', 'code-reviewer.md') + expect(existsSync(filePath)).toBe(true) + const content = await readFile(filePath, 'utf8') + expect(content).toBe('Review code for quality and security.') + }) + + it('is a no-op when entry is null', async () => { + await expect(deployFileEntry(null, 'claude-code', cwd)).resolves.toBeUndefined() + }) + + it('is a no-op when entry type is mcp', async () => { + const entry = makeMCPEntry() + await expect(deployFileEntry(entry, 'claude-code', cwd)).resolves.toBeUndefined() + expect(existsSync(join(cwd, '.claude', 'commands'))).toBe(false) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// undeployFileEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('undeployFileEntry', () => { + it('removes the file at the target path', async () => { + // First deploy so the file exists + const entry = makeCommandEntry({name: 'to-remove', environments: ['claude-code']}) + await deployFileEntry(entry, 'claude-code', cwd) + + const filePath = join(cwd, '.claude', 'commands', 'to-remove.md') + expect(existsSync(filePath)).toBe(true) + + await undeployFileEntry('to-remove', 'command', 'claude-code', cwd) + + expect(existsSync(filePath)).toBe(false) + }) + + it('is a no-op when the file does not exist', async () => { + await expect(undeployFileEntry('nonexistent', 'command', 'claude-code', cwd)).resolves.toBeUndefined() + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// deployEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('deployEntry', () => { + it('only deploys to environments that are in detectedEnvs', async () => { + const entry = makeMCPEntry({ + name: 'multi-env-mcp', + environments: ['claude-code', 'vscode-copilot'], + }) + + // Only claude-code is detected + const detectedEnvs = [makeDetected('claude-code')] + + await deployEntry(entry, detectedEnvs, cwd) + + // claude-code file should exist + expect(existsSync(join(cwd, '.mcp.json'))).toBe(true) + // vscode-copilot file should NOT exist (not detected) + expect(existsSync(join(cwd, '.vscode', 'mcp.json'))).toBe(false) + }) + + it('deploys to all detected environments listed in entry.environments', async () => { + const entry = makeMCPEntry({ + name: 'both-env-mcp', + environments: ['claude-code', 'vscode-copilot'], + }) + + const detectedEnvs = [makeDetected('claude-code'), makeDetected('vscode-copilot')] + + await deployEntry(entry, detectedEnvs, cwd) + + expect(existsSync(join(cwd, '.mcp.json'))).toBe(true) + expect(existsSync(join(cwd, '.vscode', 'mcp.json'))).toBe(true) + }) + + it('skips environments whose target MCP JSON file is marked as unreadable', async () => { + const mcpPath = join(cwd, '.mcp.json') + // Write a corrupt JSON file so it is "unreadable" + await writeFile(mcpPath, 'NOT VALID JSON }{', 'utf8') + + const entry = makeMCPEntry({name: 'skip-unreadable', environments: ['claude-code']}) + + // The detected env has the target file in its unreadable list + const detectedEnvs = [makeDetected('claude-code', [mcpPath])] + + const originalStat = await readFile(mcpPath, 'utf8') + await deployEntry(entry, detectedEnvs, cwd) + const afterStat = await readFile(mcpPath, 'utf8') + + // The corrupt file must NOT have been overwritten + expect(afterStat).toBe(originalStat) + }) + + it('is a no-op for an empty detectedEnvs array', async () => { + const entry = makeMCPEntry({environments: ['claude-code']}) + + await deployEntry(entry, [], cwd) + + expect(existsSync(join(cwd, '.mcp.json'))).toBe(false) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// undeployEntry +// ────────────────────────────────────────────────────────────────────────────── + +describe('undeployEntry', () => { + it('removes deployed files for all detected environments', async () => { + const entry = makeCommandEntry({ + name: 'cleanup-cmd', + environments: ['claude-code', 'vscode-copilot'], + }) + + const detectedEnvs = [makeDetected('claude-code'), makeDetected('vscode-copilot')] + + // Deploy first + await deployEntry(entry, detectedEnvs, cwd) + expect(existsSync(join(cwd, '.claude', 'commands', 'cleanup-cmd.md'))).toBe(true) + expect(existsSync(join(cwd, '.github', 'prompts', 'cleanup-cmd.prompt.md'))).toBe(true) + + await undeployEntry(entry, detectedEnvs, cwd) + + expect(existsSync(join(cwd, '.claude', 'commands', 'cleanup-cmd.md'))).toBe(false) + expect(existsSync(join(cwd, '.github', 'prompts', 'cleanup-cmd.prompt.md'))).toBe(false) + }) + + it('is a no-op when entry is null', async () => { + await expect(undeployEntry(null, [makeDetected('claude-code')], cwd)).resolves.toBeUndefined() + }) + + it('is a no-op when entry is undefined', async () => { + await expect(undeployEntry(undefined, [makeDetected('claude-code')], cwd)).resolves.toBeUndefined() + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// reconcileOnScan +// ────────────────────────────────────────────────────────────────────────────── + +describe('reconcileOnScan', () => { + it('deploys active entries to detected environments', async () => { + const entries = [ + makeMCPEntry({name: 'active-mcp', environments: ['claude-code'], active: true}), + makeCommandEntry({name: 'active-cmd', environments: ['claude-code'], active: true}), + ] + + const detectedEnvs = [makeDetected('claude-code')] + + await reconcileOnScan(entries, detectedEnvs, cwd) + + expect(existsSync(join(cwd, '.mcp.json'))).toBe(true) + const json = await readJson(join(cwd, '.mcp.json')) + expect(json.mcpServers).toHaveProperty('active-mcp') + + expect(existsSync(join(cwd, '.claude', 'commands', 'active-cmd.md'))).toBe(true) + }) + + it('does not deploy inactive entries', async () => { + const entries = [makeMCPEntry({name: 'inactive-mcp', environments: ['claude-code'], active: false})] + + const detectedEnvs = [makeDetected('claude-code')] + + await reconcileOnScan(entries, detectedEnvs, cwd) + + expect(existsSync(join(cwd, '.mcp.json'))).toBe(false) + }) + + it('does not deploy to environments that are not detected', async () => { + const entries = [makeMCPEntry({name: 'no-env-mcp', environments: ['vscode-copilot'], active: true})] + + // Only claude-code is detected, not vscode-copilot + const detectedEnvs = [makeDetected('claude-code')] + + await reconcileOnScan(entries, detectedEnvs, cwd) + + expect(existsSync(join(cwd, '.vscode', 'mcp.json'))).toBe(false) + }) + + it('is idempotent — calling twice produces the same result', async () => { + const entries = [makeMCPEntry({name: 'idempotent-mcp', environments: ['claude-code'], active: true})] + + const detectedEnvs = [makeDetected('claude-code')] + + await reconcileOnScan(entries, detectedEnvs, cwd) + await reconcileOnScan(entries, detectedEnvs, cwd) + + const json = await readJson(join(cwd, '.mcp.json')) + // Entry should appear exactly once (not duplicated) + const keys = Object.keys(json.mcpServers) + expect(keys.filter((k) => k === 'idempotent-mcp')).toHaveLength(1) + }) + + it('is a no-op when entries array is empty', async () => { + await expect(reconcileOnScan([], [makeDetected('claude-code')], cwd)).resolves.toBeUndefined() + }) +}) diff --git a/tests/unit/services/ai-env-scanner.test.js b/tests/unit/services/ai-env-scanner.test.js new file mode 100644 index 0000000..24b4a20 --- /dev/null +++ b/tests/unit/services/ai-env-scanner.test.js @@ -0,0 +1,387 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' + +vi.mock('node:fs') + +import {existsSync, readFileSync} from 'node:fs' +import { + scanEnvironments, + getCompatibleEnvironments, + computeCategoryCounts, +} from '../../../src/services/ai-env-scanner.js' + +const CWD = '/fake/project' + +beforeEach(() => { + // Default: nothing exists + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(readFileSync).mockReturnValue('{}') +}) + +afterEach(() => { + vi.resetAllMocks() +}) + +// ────────────────────────────────────────────────────────────────────────────── +// scanEnvironments +// ────────────────────────────────────────────────────────────────────────────── + +describe('scanEnvironments', () => { + it('returns only detected environments when some paths exist', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('CLAUDE.md')) + + const result = scanEnvironments(CWD) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('claude-code') + expect(result[0].detected).toBe(true) + }) + + it('returns empty array when no paths exist', () => { + vi.mocked(existsSync).mockReturnValue(false) + + const result = scanEnvironments(CWD) + + expect(result).toHaveLength(0) + }) + + it('marks JSON file as unreadable when it exists but cannot be parsed', () => { + // .mcp.json exists but contains invalid JSON + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.mcp.json')) + vi.mocked(readFileSync).mockReturnValue('not valid json {{{}') + + const result = scanEnvironments(CWD) + + expect(result).toHaveLength(1) + const env = result[0] + expect(env.id).toBe('claude-code') + + const mcpJsonStatus = env.projectPaths.find((s) => s.path.endsWith('.mcp.json')) + expect(mcpJsonStatus).toBeDefined() + expect(mcpJsonStatus.exists).toBe(true) + expect(mcpJsonStatus.readable).toBe(false) + expect(env.unreadable).toHaveLength(1) + expect(env.unreadable[0]).toMatch(/.mcp.json$/) + }) + + it('marks JSON file as readable when it exists and parses successfully', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.mcp.json')) + vi.mocked(readFileSync).mockReturnValue('{"mcpServers":{}}') + + const result = scanEnvironments(CWD) + + expect(result).toHaveLength(1) + const mcpJsonStatus = result[0].projectPaths.find((s) => s.path.endsWith('.mcp.json')) + expect(mcpJsonStatus.exists).toBe(true) + expect(mcpJsonStatus.readable).toBe(true) + expect(result[0].unreadable).toHaveLength(0) + }) + + it('computes scope as "project" when only project paths exist', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('CLAUDE.md')) + + const result = scanEnvironments(CWD) + + expect(result[0].scope).toBe('project') + }) + + it('computes scope as "global" when only global paths exist', () => { + // gemini-cli has both project (GEMINI.md) and global paths; trigger only global + vi.mocked(existsSync).mockImplementation((p) => String(p).includes('.gemini/settings.json')) + + const result = scanEnvironments(CWD) + + const gemini = result.find((e) => e.id === 'gemini-cli') + expect(gemini).toBeDefined() + expect(gemini.scope).toBe('global') + }) + + it('computes scope as "both" when project and global paths both exist', () => { + // GEMINI.md (project) + ~/.gemini/settings.json (global) + vi.mocked(existsSync).mockImplementation( + (p) => String(p).endsWith('GEMINI.md') || String(p).includes('.gemini/settings.json'), + ) + + const result = scanEnvironments(CWD) + + const gemini = result.find((e) => e.id === 'gemini-cli') + expect(gemini).toBeDefined() + expect(gemini.scope).toBe('both') + }) + + it('each detected environment has the correct supportedCategories', () => { + // Make every first project/global path of every env exist + const firstProjectPaths = [ + 'copilot-instructions.md', // vscode-copilot: .github/copilot-instructions.md + 'CLAUDE.md', // claude-code + 'AGENTS.md', // opencode + 'GEMINI.md', // gemini-cli + ] + + vi.mocked(existsSync).mockImplementation((p) => { + const str = String(p) + return firstProjectPaths.some((fp) => str.endsWith(fp)) || str.includes('.copilot/config.json') + }) + + const result = scanEnvironments(CWD) + + 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']) + }) + + it('non-JSON paths are always readable when they exist', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('CLAUDE.md')) + + const result = scanEnvironments(CWD) + + const claudeMdStatus = result[0].projectPaths.find((s) => s.path.endsWith('CLAUDE.md')) + expect(claudeMdStatus.exists).toBe(true) + expect(claudeMdStatus.readable).toBe(true) + expect(result[0].unreadable).toHaveLength(0) + }) + + it('initialises counts to all zeros', () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('CLAUDE.md')) + + const result = scanEnvironments(CWD) + + expect(result[0].counts).toEqual({mcp: 0, command: 0, skill: 0, agent: 0}) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getCompatibleEnvironments +// ────────────────────────────────────────────────────────────────────────────── + +describe('getCompatibleEnvironments', () => { + /** @type {import('../../../src/services/ai-env-scanner.js').DetectedEnvironment[]} */ + const allDetected = [ + { + id: 'vscode-copilot', + name: 'VS Code Copilot', + detected: true, + projectPaths: [], + globalPaths: [], + unreadable: [], + supportedCategories: ['mcp', 'command', 'skill', 'agent'], + counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + scope: 'project', + }, + { + id: 'claude-code', + name: 'Claude Code', + detected: true, + projectPaths: [], + globalPaths: [], + unreadable: [], + supportedCategories: ['mcp', 'command', 'skill', 'agent'], + counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + scope: 'project', + }, + { + id: 'opencode', + name: 'OpenCode', + detected: true, + projectPaths: [], + globalPaths: [], + unreadable: [], + supportedCategories: ['mcp', 'command', 'skill', 'agent'], + counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + scope: 'project', + }, + { + id: 'gemini-cli', + name: 'Gemini CLI', + detected: true, + projectPaths: [], + globalPaths: [], + unreadable: [], + supportedCategories: ['mcp', 'command'], + counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + scope: 'global', + }, + { + id: 'copilot-cli', + name: 'GitHub Copilot CLI', + detected: true, + projectPaths: [], + globalPaths: [], + unreadable: [], + supportedCategories: ['mcp', 'command', 'skill', 'agent'], + counts: {mcp: 0, command: 0, skill: 0, agent: 0}, + scope: 'global', + }, + ] + + it('filters by type "agent" — excludes gemini-cli', () => { + const result = getCompatibleEnvironments('agent', allDetected) + + expect(result).not.toContain('gemini-cli') + expect(result).toContain('vscode-copilot') + expect(result).toContain('claude-code') + expect(result).toContain('opencode') + expect(result).toContain('copilot-cli') + }) + + it('filters by type "skill" — excludes gemini-cli', () => { + const result = getCompatibleEnvironments('skill', allDetected) + + expect(result).not.toContain('gemini-cli') + expect(result).toHaveLength(4) + }) + + it('returns all env ids when type is "mcp" (every env supports mcp)', () => { + const result = getCompatibleEnvironments('mcp', allDetected) + + expect(result).toHaveLength(5) + expect(result).toContain('gemini-cli') + }) + + it('returns all env ids when type is "command" (every env supports command)', () => { + const result = getCompatibleEnvironments('command', allDetected) + + expect(result).toHaveLength(5) + }) + + it('returns empty array when detectedEnvs is empty', () => { + expect(getCompatibleEnvironments('mcp', [])).toHaveLength(0) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// computeCategoryCounts +// ────────────────────────────────────────────────────────────────────────────── + +describe('computeCategoryCounts', () => { + it('counts active entries for the given environment', () => { + /** @type {import('../../../src/types.js').CategoryEntry[]} */ + const entries = [ + { + id: '1', + name: 'my-mcp', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: ['server.js']}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: '2', + name: 'my-command', + type: 'command', + active: true, + environments: ['claude-code', 'vscode-copilot'], + params: {content: 'do something'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: '3', + name: 'my-agent', + type: 'agent', + active: true, + environments: ['claude-code'], + params: {instructions: 'be helpful'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ] + + const counts = computeCategoryCounts('claude-code', entries) + + expect(counts).toEqual({mcp: 1, command: 1, skill: 0, agent: 1}) + }) + + it('excludes inactive entries', () => { + /** @type {import('../../../src/types.js').CategoryEntry[]} */ + const entries = [ + { + id: '1', + name: 'disabled-mcp', + type: 'mcp', + active: false, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'node', args: []}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: '2', + name: 'active-command', + type: 'command', + active: true, + environments: ['claude-code'], + params: {content: 'do something'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ] + + const counts = computeCategoryCounts('claude-code', entries) + + expect(counts.mcp).toBe(0) + expect(counts.command).toBe(1) + }) + + it('returns all zeros when no entries match the environment', () => { + /** @type {import('../../../src/types.js').CategoryEntry[]} */ + const entries = [ + { + id: '1', + name: 'vscode-mcp', + type: 'mcp', + active: true, + environments: ['vscode-copilot'], + params: {transport: 'stdio', command: 'node', args: []}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ] + + const counts = computeCategoryCounts('claude-code', entries) + + expect(counts).toEqual({mcp: 0, command: 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}) + }) + + it('counts entries correctly when env appears in a multi-env list', () => { + /** @type {import('../../../src/types.js').CategoryEntry[]} */ + const entries = [ + { + id: '1', + name: 'shared-skill', + type: 'skill', + active: true, + environments: ['claude-code', 'opencode', 'vscode-copilot'], + params: {content: 'skill content'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: '2', + name: 'claude-only-skill', + type: 'skill', + active: true, + environments: ['claude-code'], + params: {content: 'another skill'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ] + + const counts = computeCategoryCounts('claude-code', entries) + + expect(counts.skill).toBe(2) + expect(counts.mcp).toBe(0) + }) +}) diff --git a/tests/unit/utils/tui/form.test.js b/tests/unit/utils/tui/form.test.js new file mode 100644 index 0000000..77e1558 --- /dev/null +++ b/tests/unit/utils/tui/form.test.js @@ -0,0 +1,872 @@ +import {describe, it, expect} from 'vitest' +import { + buildFieldLine, + buildMultiSelectLines, + buildMiniEditorLines, + buildFormScreen, + handleFormKeypress, + extractValues, + getMCPFormFields, + getCommandFormFields, + getSkillFormFields, + getAgentFormFields, +} from '../../../../src/utils/tui/form.js' + +// ────────────────────────────────────────────────────────────────────────────── +// Test helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Strip ANSI escape codes from a string. + * @param {string} str + * @returns {string} + */ +function stripAnsi(str) { + return str.replace(/\x1b\[[0-9;]*[mGKHJ]/g, '') +} + +/** + * Build a minimal FormState for tests. + * @param {object} [overrides] + * @returns {import('../../../../src/utils/tui/form.js').FormState} + */ +function makeFormState(overrides = {}) { + return { + title: 'Test Form', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + { + type: 'text', + label: 'Name', + key: 'name', + value: 'hello', + cursor: 5, + required: true, + placeholder: '', + }, + { + type: 'selector', + label: 'Transport', + key: 'transport', + options: ['stdio', 'sse', 'streamable-http'], + selectedIndex: 0, + required: true, + }, + ], + ...overrides, + } +} + +/** + * Build a minimal FormState with all required fields filled in. + * @param {object} [overrides] + * @returns {import('../../../../src/utils/tui/form.js').FormState} + */ +function makeValidFormState(overrides = {}) { + return makeFormState({ + fields: [ + { + type: 'text', + label: 'Name', + key: 'name', + value: 'my-entry', + cursor: 8, + required: true, + placeholder: '', + }, + { + type: 'selector', + label: 'Transport', + key: 'transport', + options: ['stdio', 'sse'], + selectedIndex: 0, + required: true, + }, + ], + ...overrides, + }) +} + +/** + * Simulate a printable key event. + * @param {string} ch - Single character to type + * @returns {{ name: string, sequence: string, ctrl: boolean }} + */ +function charKey(ch) { + return {name: ch, sequence: ch, ctrl: false} +} + +/** + * Simulate a named key event (e.g. tab, backspace, return). + * @param {string} name + * @param {object} [extra] + * @returns {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} + */ +function namedKey(name, extra = {}) { + return {name, ...extra} +} + +// ────────────────────────────────────────────────────────────────────────────── +// buildFieldLine +// ────────────────────────────────────────────────────────────────────────────── + +describe('buildFieldLine', () => { + it('renders a TextField with cursor indicator when focused', () => { + /** @type {import('../../../../src/utils/tui/form.js').TextField} */ + const field = { + type: 'text', + label: 'Name', + value: 'hello', + cursor: 5, + required: true, + placeholder: '', + } + const line = buildFieldLine(field, true) + expect(stripAnsi(line)).toContain('Name') + expect(stripAnsi(line)).toContain('hello') + expect(stripAnsi(line)).toContain('|') + expect(line.startsWith('\x1b') || line.includes('> ')).toBe(true) + }) + + it('renders a TextField without cursor when not focused', () => { + /** @type {import('../../../../src/utils/tui/form.js').TextField} */ + const field = { + type: 'text', + label: 'Name', + value: 'hello', + cursor: 5, + required: true, + placeholder: '', + } + const line = buildFieldLine(field, false) + expect(stripAnsi(line)).toContain('hello') + expect(stripAnsi(line)).not.toContain('|') + }) + + it('renders a SelectorField with arrows', () => { + /** @type {import('../../../../src/utils/tui/form.js').SelectorField} */ + const field = { + type: 'selector', + label: 'Transport', + options: ['stdio', 'sse'], + selectedIndex: 0, + required: true, + } + const line = stripAnsi(buildFieldLine(field, false)) + expect(line).toContain('Transport') + expect(line).toContain('stdio') + expect(line).toContain('<') + expect(line).toContain('>') + }) + + it('renders a MultiSelectField with count summary', () => { + /** @type {import('../../../../src/utils/tui/form.js').MultiSelectField} */ + const field = { + type: 'multiselect', + label: 'Environments', + options: [ + {id: 'claude-code', label: 'Claude Code'}, + {id: 'opencode', label: 'OpenCode'}, + ], + selected: new Set(['claude-code']), + focusedOptionIndex: 0, + required: true, + } + const line = stripAnsi(buildFieldLine(field, false)) + expect(line).toContain('Environments') + expect(line).toContain('1/2') + }) + + it('renders a MiniEditorField with line count', () => { + /** @type {import('../../../../src/utils/tui/form.js').MiniEditorField} */ + const field = { + type: 'editor', + label: 'Content', + lines: ['line one', 'line two'], + cursorLine: 0, + cursorCol: 0, + required: true, + } + const line = stripAnsi(buildFieldLine(field, false)) + expect(line).toContain('Content') + expect(line).toContain('2 lines') + }) + + it('prefixes focused field with ">"', () => { + /** @type {import('../../../../src/utils/tui/form.js').TextField} */ + const field = { + type: 'text', + label: 'Name', + value: '', + cursor: 0, + required: true, + placeholder: '', + } + const focused = stripAnsi(buildFieldLine(field, true)) + const unfocused = stripAnsi(buildFieldLine(field, false)) + expect(focused).toContain('>') + expect(unfocused).not.toContain('>') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// buildMultiSelectLines +// ────────────────────────────────────────────────────────────────────────────── + +describe('buildMultiSelectLines', () => { + /** @type {import('../../../../src/utils/tui/form.js').MultiSelectField} */ + const field = { + type: 'multiselect', + label: 'Envs', + options: [ + {id: 'claude-code', label: 'Claude Code'}, + {id: 'opencode', label: 'OpenCode'}, + ], + selected: new Set(['claude-code']), + focusedOptionIndex: 0, + required: true, + } + + it('renders one line per option', () => { + const lines = buildMultiSelectLines(field, true, 10) + expect(lines).toHaveLength(2) + }) + + it('marks selected option with [x]', () => { + const lines = buildMultiSelectLines(field, true, 10).map(stripAnsi) + expect(lines[0]).toContain('[x]') + expect(lines[0]).toContain('Claude Code') + }) + + it('marks unselected option with [ ]', () => { + const lines = buildMultiSelectLines(field, true, 10).map(stripAnsi) + expect(lines[1]).toContain('[ ]') + expect(lines[1]).toContain('OpenCode') + }) + + it('respects maxLines limit', () => { + const lines = buildMultiSelectLines(field, true, 1) + expect(lines).toHaveLength(1) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// buildMiniEditorLines +// ────────────────────────────────────────────────────────────────────────────── + +describe('buildMiniEditorLines', () => { + /** @type {import('../../../../src/utils/tui/form.js').MiniEditorField} */ + const field = { + type: 'editor', + label: 'Content', + lines: ['hello world', 'second line'], + cursorLine: 0, + cursorCol: 5, + required: true, + } + + it('renders one line per content line', () => { + const lines = buildMiniEditorLines(field, true, 20) + expect(lines).toHaveLength(2) + }) + + it('inserts cursor on the active line when focused', () => { + const lines = buildMiniEditorLines(field, true, 20).map(stripAnsi) + expect(lines[0]).toContain('|') + }) + + it('does not insert cursor when not focused', () => { + const lines = buildMiniEditorLines(field, false, 20).map(stripAnsi) + expect(lines[0]).not.toContain('|') + }) + + it('includes line numbers', () => { + const lines = buildMiniEditorLines(field, false, 20).map(stripAnsi) + expect(lines[0]).toContain('1') + expect(lines[1]).toContain('2') + }) + + it('respects maxLines limit', () => { + const lines = buildMiniEditorLines(field, true, 1) + expect(lines).toHaveLength(1) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// buildFormScreen +// ────────────────────────────────────────────────────────────────────────────── + +describe('buildFormScreen', () => { + it('renders without throwing', () => { + const state = makeFormState() + expect(() => buildFormScreen(state, 24, 80)).not.toThrow() + }) + + it('includes the form title', () => { + const state = makeFormState({title: 'My Fancy Form'}) + const lines = buildFormScreen(state, 24, 80).map(stripAnsi).join('\n') + expect(lines).toContain('My Fancy Form') + }) + + it('includes field labels', () => { + const state = makeFormState() + const lines = buildFormScreen(state, 24, 80).map(stripAnsi).join('\n') + expect(lines).toContain('Name') + expect(lines).toContain('Transport') + }) + + it('renders the error message when set', () => { + const state = makeFormState({errorMessage: 'Something went wrong'}) + const lines = buildFormScreen(state, 24, 80).map(stripAnsi).join('\n') + expect(lines).toContain('Something went wrong') + }) + + it('includes footer hint', () => { + const state = makeFormState() + const lines = buildFormScreen(state, 24, 80).map(stripAnsi).join('\n') + expect(lines).toContain('Tab') + expect(lines).toContain('Esc') + }) + + it('returns an array of strings', () => { + const state = makeFormState() + const lines = buildFormScreen(state, 24, 80) + expect(Array.isArray(lines)).toBe(true) + for (const line of lines) { + expect(typeof line).toBe('string') + } + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress — navigation +// ────────────────────────────────────────────────────────────────────────────── + +describe('handleFormKeypress — Tab moves to next field', () => { + it('Tab advances focusedFieldIndex', () => { + const state = makeFormState({focusedFieldIndex: 0}) + const result = handleFormKeypress(state, namedKey('tab')) + expect(result).not.toHaveProperty('cancelled') + expect(result).not.toHaveProperty('submitted') + expect(/** @type {any} */ (result).focusedFieldIndex).toBe(1) + }) + + it('Tab wraps from last field back to first', () => { + const state = makeFormState({focusedFieldIndex: 1}) + const result = handleFormKeypress(state, namedKey('tab')) + expect(/** @type {any} */ (result).focusedFieldIndex).toBe(0) + }) +}) + +describe('handleFormKeypress — Shift+Tab moves to previous field', () => { + it('Shift+Tab decrements focusedFieldIndex', () => { + const state = makeFormState({focusedFieldIndex: 1}) + const result = handleFormKeypress(state, namedKey('tab', {shift: true})) + expect(/** @type {any} */ (result).focusedFieldIndex).toBe(0) + }) + + it('Shift+Tab wraps from first field to last', () => { + const state = makeFormState({focusedFieldIndex: 0}) + const result = handleFormKeypress(state, namedKey('tab', {shift: true})) + expect(/** @type {any} */ (result).focusedFieldIndex).toBe(state.fields.length - 1) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress — cancel +// ────────────────────────────────────────────────────────────────────────────── + +describe('handleFormKeypress — Esc cancels', () => { + it('returns { cancelled: true } when Esc is pressed on a text field', () => { + const state = makeFormState({focusedFieldIndex: 0}) + const result = handleFormKeypress(state, namedKey('escape')) + expect(result).toEqual({cancelled: true}) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress — submit +// ────────────────────────────────────────────────────────────────────────────── + +describe('handleFormKeypress — Ctrl+S submits when valid', () => { + it('returns { submitted: true, values } when all required fields are filled', () => { + const state = makeValidFormState() + const result = handleFormKeypress(state, namedKey('s', {ctrl: true})) + expect(result).toHaveProperty('submitted', true) + expect(result).toHaveProperty('values') + expect(/** @type {any} */ (result).values.name).toBe('my-entry') + }) +}) + +describe('handleFormKeypress — Ctrl+S returns errorMessage when required field empty', () => { + it('sets errorMessage and returns FormState when required text field is empty', () => { + const state = makeFormState({ + fields: [ + { + type: 'text', + label: 'Name', + key: 'name', + value: '', + cursor: 0, + required: true, + placeholder: '', + }, + ], + }) + const result = handleFormKeypress(state, namedKey('s', {ctrl: true})) + expect(result).not.toHaveProperty('submitted') + expect(/** @type {any} */ (result).errorMessage).toBeTruthy() + expect(/** @type {any} */ (result).errorMessage).toContain('Name') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress — TextField +// ────────────────────────────────────────────────────────────────────────────── + +describe('handleFormKeypress — printable char appended to TextField', () => { + it('appends character at cursor position', () => { + const state = makeFormState({ + focusedFieldIndex: 0, + fields: [ + { + type: 'text', + label: 'Name', + key: 'name', + value: 'helo', + cursor: 3, + required: true, + placeholder: '', + }, + ], + }) + const result = handleFormKeypress(state, charKey('l')) + const field = /** @type {any} */ (result).fields[0] + expect(field.value).toBe('hello') + expect(field.cursor).toBe(4) + }) +}) + +describe('handleFormKeypress — Backspace removes char before cursor', () => { + it('deletes the character immediately before the cursor', () => { + const state = makeFormState({ + focusedFieldIndex: 0, + fields: [ + { + type: 'text', + label: 'Name', + key: 'name', + value: 'hello', + cursor: 5, + required: true, + placeholder: '', + }, + ], + }) + const result = handleFormKeypress(state, namedKey('backspace')) + const field = /** @type {any} */ (result).fields[0] + expect(field.value).toBe('hell') + expect(field.cursor).toBe(4) + }) + + it('does nothing when cursor is at position 0', () => { + const state = makeFormState({ + focusedFieldIndex: 0, + fields: [ + { + type: 'text', + label: 'Name', + key: 'name', + value: 'hello', + cursor: 0, + required: true, + placeholder: '', + }, + ], + }) + const result = handleFormKeypress(state, namedKey('backspace')) + const field = /** @type {any} */ (result).fields[0] + expect(field.value).toBe('hello') + expect(field.cursor).toBe(0) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress — SelectorField +// ────────────────────────────────────────────────────────────────────────────── + +describe('handleFormKeypress — ← → cycles SelectorField options', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const selectorState = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + { + type: 'selector', + label: 'Transport', + key: 'transport', + options: ['stdio', 'sse', 'streamable-http'], + selectedIndex: 0, + required: true, + }, + ], + } + + it('Right arrow moves to next option', () => { + const result = handleFormKeypress(selectorState, namedKey('right')) + expect(/** @type {any} */ (result).fields[0].selectedIndex).toBe(1) + }) + + it('Left arrow on first option wraps to last', () => { + const result = handleFormKeypress(selectorState, namedKey('left')) + expect(/** @type {any} */ (result).fields[0].selectedIndex).toBe(2) + }) + + it('Right arrow on last option wraps to first', () => { + const state = {...selectorState, fields: [{...selectorState.fields[0], selectedIndex: 2}]} + const result = handleFormKeypress(state, namedKey('right')) + expect(/** @type {any} */ (result).fields[0].selectedIndex).toBe(0) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress — MultiSelectField +// ────────────────────────────────────────────────────────────────────────────── + +describe('handleFormKeypress — Space toggles MultiSelectField option', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const msState = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + { + type: 'multiselect', + label: 'Environments', + key: 'environments', + options: [ + {id: 'claude-code', label: 'Claude Code'}, + {id: 'opencode', label: 'OpenCode'}, + ], + selected: new Set(['claude-code']), + focusedOptionIndex: 0, + required: true, + }, + ], + } + + it('Space deselects an already-selected option', () => { + const result = handleFormKeypress(msState, namedKey('space')) + const field = /** @type {any} */ (result).fields[0] + expect(field.selected.has('claude-code')).toBe(false) + }) + + it('Space selects an unselected option', () => { + const state = { + ...msState, + fields: [{...msState.fields[0], focusedOptionIndex: 1}], + } + const result = handleFormKeypress(state, namedKey('space')) + const field = /** @type {any} */ (result).fields[0] + expect(field.selected.has('opencode')).toBe(true) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// handleFormKeypress — MiniEditorField +// ────────────────────────────────────────────────────────────────────────────── + +describe('handleFormKeypress — Enter in MiniEditorField inserts new line', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const editorState = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + { + type: 'editor', + label: 'Content', + key: 'content', + lines: ['hello world'], + cursorLine: 0, + cursorCol: 5, + required: true, + }, + ], + } + + it('splits line at cursor on Enter', () => { + const result = handleFormKeypress(editorState, namedKey('return')) + const field = /** @type {any} */ (result).fields[0] + expect(field.lines).toHaveLength(2) + expect(field.lines[0]).toBe('hello') + expect(field.lines[1]).toBe(' world') + expect(field.cursorLine).toBe(1) + expect(field.cursorCol).toBe(0) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// extractValues +// ────────────────────────────────────────────────────────────────────────────── + +describe('extractValues', () => { + it('returns correct object from mixed form state', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const state = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + { + type: 'text', + label: 'Name', + key: 'name', + value: 'my-server', + cursor: 9, + required: true, + placeholder: '', + }, + { + type: 'selector', + label: 'Transport', + key: 'transport', + options: ['stdio', 'sse', 'streamable-http'], + selectedIndex: 1, + required: true, + }, + { + type: 'multiselect', + label: 'Environments', + key: 'environments', + options: [ + {id: 'claude-code', label: 'Claude Code'}, + {id: 'opencode', label: 'OpenCode'}, + ], + selected: new Set(['claude-code', 'opencode']), + focusedOptionIndex: 0, + required: true, + }, + { + type: 'editor', + label: 'Content', + key: 'content', + lines: ['line one', 'line two'], + cursorLine: 0, + cursorCol: 0, + required: true, + }, + ], + } + + const values = extractValues(state) + expect(values.name).toBe('my-server') + expect(values.transport).toBe('sse') + expect(values.environments).toEqual(expect.arrayContaining(['claude-code', 'opencode'])) + expect(values.content).toBe('line one\nline two') + }) + + it('uses label as key when field.key is not set', () => { + /** @type {import('../../../../src/utils/tui/form.js').FormState} */ + const state = { + title: 'Test', + focusedFieldIndex: 0, + status: 'editing', + errorMessage: null, + fields: [ + { + type: 'text', + label: 'My Field', + value: 'val', + cursor: 3, + required: true, + placeholder: '', + }, + ], + } + const values = extractValues(state) + expect(values.my_field).toBe('val') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getMCPFormFields +// ────────────────────────────────────────────────────────────────────────────── + +describe('getMCPFormFields', () => { + it('returns fields with correct labels', () => { + const fields = getMCPFormFields() + const labels = fields.map((f) => f.label) + expect(labels).toContain('Name') + expect(labels).toContain('Transport') + expect(labels).toContain('Command') + expect(labels).toContain('Args') + expect(labels).toContain('URL') + expect(labels).toContain('Description') + }) + + it('Name field is required', () => { + const fields = getMCPFormFields() + const nameField = fields.find((f) => f.label === 'Name') + expect(nameField?.required).toBe(true) + }) + + it('Transport field is a selector with stdio/sse/streamable-http', () => { + const fields = getMCPFormFields() + const transport = fields.find((f) => f.label === 'Transport') + expect(transport?.type).toBe('selector') + expect(/** @type {any} */ (transport).options).toEqual(['stdio', 'sse', 'streamable-http']) + }) + + it('returns correct number of fields', () => { + const fields = getMCPFormFields() + expect(fields.length).toBe(7) // name, environments, transport, command, args, url, description + }) +}) + +describe('getMCPFormFields with existing entry', () => { + it('pre-fills values from entry', () => { + /** @type {import('../../../../src/types.js').CategoryEntry} */ + const entry = { + id: 'abc-123', + name: 'my-mcp', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: { + transport: 'sse', + url: 'https://mcp.example.com', + command: 'npx run', + args: ['--port', '3000'], + }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + + const fields = getMCPFormFields(entry) + + const nameField = fields.find((f) => f.label === 'Name') + expect(/** @type {any} */ (nameField).value).toBe('my-mcp') + + const transportField = fields.find((f) => f.label === 'Transport') + expect(/** @type {any} */ (transportField).selectedIndex).toBe(1) // sse + + const urlField = fields.find((f) => f.label === 'URL') + expect(/** @type {any} */ (urlField).value).toBe('https://mcp.example.com') + + const argsField = fields.find((f) => f.label === 'Args') + expect(/** @type {any} */ (argsField).value).toBe('--port 3000') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getCommandFormFields +// ────────────────────────────────────────────────────────────────────────────── + +describe('getCommandFormFields', () => { + it('returns fields with Name, Description, Content labels', () => { + const fields = getCommandFormFields() + const labels = fields.map((f) => f.label) + expect(labels).toContain('Name') + expect(labels).toContain('Description') + expect(labels).toContain('Content') + }) + + it('Content field is an editor', () => { + const fields = getCommandFormFields() + const content = fields.find((f) => f.label === 'Content') + expect(content?.type).toBe('editor') + }) + + it('pre-fills values when entry is provided', () => { + /** @type {import('../../../../src/types.js').CategoryEntry} */ + const entry = { + id: 'xyz', + name: 'refactor', + type: 'command', + active: true, + environments: ['claude-code'], + params: {content: 'line one\nline two', description: 'My command'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + const fields = getCommandFormFields(entry) + const contentField = fields.find((f) => f.label === 'Content') + expect(/** @type {any} */ (contentField).lines).toEqual(['line one', 'line two']) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getSkillFormFields +// ────────────────────────────────────────────────────────────────────────────── + +describe('getSkillFormFields', () => { + it('returns fields with Name, Description, Content labels', () => { + const fields = getSkillFormFields() + const labels = fields.map((f) => f.label) + expect(labels).toContain('Name') + expect(labels).toContain('Description') + expect(labels).toContain('Content') + }) + + it('pre-fills name from entry', () => { + /** @type {import('../../../../src/types.js').CategoryEntry} */ + const entry = { + id: 'skill-1', + name: 'my-skill', + type: 'skill', + active: true, + environments: [], + params: {content: 'skill content'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + const fields = getSkillFormFields(entry) + const nameField = fields.find((f) => f.label === 'Name') + expect(/** @type {any} */ (nameField).value).toBe('my-skill') + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getAgentFormFields +// ────────────────────────────────────────────────────────────────────────────── + +describe('getAgentFormFields', () => { + it('returns fields with Name, Description, Instructions labels', () => { + const fields = getAgentFormFields() + const labels = fields.map((f) => f.label) + expect(labels).toContain('Name') + expect(labels).toContain('Description') + expect(labels).toContain('Instructions') + }) + + it('Instructions field is an editor', () => { + const fields = getAgentFormFields() + const instructions = fields.find((f) => f.label === 'Instructions') + expect(instructions?.type).toBe('editor') + }) + + it('pre-fills instructions from entry', () => { + /** @type {import('../../../../src/types.js').CategoryEntry} */ + const entry = { + id: 'agent-1', + name: 'my-agent', + type: 'agent', + active: true, + environments: [], + params: {instructions: 'do this\ndo that'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } + const fields = getAgentFormFields(entry) + const instructionsField = fields.find((f) => f.label === 'Instructions') + expect(/** @type {any} */ (instructionsField).lines).toEqual(['do this', 'do that']) + }) +}) From d7e811405d2f4dfa9ef5c20312c8ed2ce30b13be Mon Sep 17 00:00:00 2001 From: savez Date: Wed, 1 Apr 2026 11:08:03 +0200 Subject: [PATCH 02/10] chore(release): sync version to 1.5.0 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5c3c806..8f2d1a6 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.4.2", + "version": "1.5.0", "author": "", "type": "module", "bin": { From 20352bfff782ad7e349d943c22eaf43acc1f20c2 Mon Sep 17 00:00:00 2001 From: savez Date: Wed, 1 Apr 2026 11:22:21 +0200 Subject: [PATCH 03/10] refactor: apply consistent code style across codebase Format all source and test files with Prettier (space-free destructuring, consistent import spacing). Add project-level Claude Code slash commands under .claude/commands/ for speckit workflow integration. Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/speckit.analyze.md | 184 +++++++++++ .claude/commands/speckit.checklist.md | 295 +++++++++++++++++ .claude/commands/speckit.clarify.md | 181 +++++++++++ .claude/commands/speckit.constitution.md | 84 +++++ .claude/commands/speckit.implement.md | 198 ++++++++++++ .claude/commands/speckit.plan.md | 153 +++++++++ .claude/commands/speckit.specify.md | 306 ++++++++++++++++++ .claude/commands/speckit.tasks.md | 200 ++++++++++++ .claude/commands/speckit.taskstoissues.md | 30 ++ src/commands/auth/login.js | 36 ++- src/commands/changelog.js | 24 +- src/commands/costs/get.js | 38 +-- src/commands/costs/trend.js | 37 +-- src/commands/create/repo.js | 126 ++++---- src/commands/docs/list.js | 54 ++-- src/commands/docs/projects.js | 82 +++-- src/commands/docs/read.js | 95 +++--- src/commands/docs/search.js | 62 ++-- src/commands/doctor.js | 72 +++-- src/commands/dotfiles/add.js | 90 +++--- src/commands/dotfiles/setup.js | 95 ++++-- src/commands/dotfiles/status.js | 36 +-- src/commands/dotfiles/sync.js | 108 ++++--- src/commands/init.js | 275 ++++++++-------- src/commands/logs/index.js | 26 +- src/commands/open.js | 24 +- src/commands/pipeline/logs.js | 19 +- src/commands/pipeline/rerun.js | 37 ++- src/commands/pipeline/status.js | 52 +-- src/commands/pr/create.js | 67 ++-- src/commands/pr/detail.js | 16 +- src/commands/pr/review.js | 37 ++- src/commands/pr/status.js | 48 +-- src/commands/prompts/browse.js | 30 +- src/commands/prompts/download.js | 31 +- src/commands/prompts/install-speckit.js | 23 +- src/commands/prompts/list.js | 24 +- src/commands/prompts/run.js | 35 +- src/commands/repo/list.js | 98 +++--- src/commands/search.js | 38 +-- src/commands/security/setup.js | 72 +++-- src/commands/tasks/assigned.js | 76 +++-- src/commands/tasks/list.js | 76 +++-- src/commands/tasks/today.js | 62 ++-- src/commands/upgrade.js | 35 +- src/commands/vuln/detail.js | 16 +- src/commands/vuln/scan.js | 59 ++-- src/commands/vuln/search.js | 41 +-- src/commands/welcome.js | 4 +- src/commands/whoami.js | 42 ++- src/formatters/charts.js | 29 +- src/formatters/cost.js | 8 +- src/formatters/dotfiles.js | 67 ++-- src/formatters/markdown.js | 17 +- src/formatters/openapi.js | 16 +- src/formatters/prompts.js | 147 ++++----- src/formatters/security.js | 4 +- src/formatters/status.js | 2 +- src/formatters/table.js | 4 +- src/formatters/vuln.js | 53 +-- src/hooks/init.js | 4 +- src/hooks/postrun.js | 12 +- src/index.js | 2 +- src/services/audit-detector.js | 4 +- src/services/audit-runner.js | 71 ++-- src/services/auth.js | 18 +- src/services/awesome-copilot.js | 11 +- src/services/aws-costs.js | 44 +-- src/services/clickup.js | 52 +-- src/services/cloudwatch-logs.js | 14 +- src/services/config.js | 26 +- src/services/docs.js | 39 ++- src/services/dotfiles.js | 200 +++++++++--- src/services/github.js | 46 ++- src/services/nvd.js | 52 ++- src/services/platform.js | 4 +- src/services/prompts.js | 58 ++-- src/services/security.js | 196 +++++++---- src/services/shell.js | 8 +- src/services/skills-sh.js | 12 +- src/services/speckit.js | 11 +- src/services/version-check.js | 20 +- src/utils/aws-vault.js | 59 ++-- src/utils/banner.js | 12 +- src/utils/errors.js | 88 +++-- src/utils/frontmatter.js | 8 +- src/utils/gradient.js | 34 +- src/utils/open-browser.js | 6 +- src/utils/tui/modal.js | 29 +- src/utils/tui/navigable-table.js | 32 +- src/utils/typewriter.js | 6 +- src/utils/welcome.js | 39 ++- src/validators/repo-name.js | 4 +- .../audit-outputs/composer-audit.json | 4 +- tests/fixtures/audit-outputs/npm-audit.json | 8 +- tests/fixtures/audit-outputs/pnpm-audit.json | 8 +- tests/fixtures/config/valid.json | 2 +- tests/fixtures/msw-handlers.js | 296 ++++++++++++----- tests/fixtures/nvd-responses/cve-detail.json | 8 +- .../nvd-responses/search-results.json | 20 +- tests/integration/changelog.test.js | 8 +- tests/integration/costs-get.test.js | 25 +- tests/integration/costs-trend.test.js | 23 +- tests/integration/doctor.test.js | 18 +- tests/integration/dotfiles/add.test.js | 19 +- tests/integration/dotfiles/setup.test.js | 22 +- tests/integration/dotfiles/status.test.js | 20 +- tests/integration/dotfiles/sync.test.js | 16 +- tests/integration/help.test.js | 8 +- tests/integration/init.test.js | 16 +- tests/integration/json-output.test.js | 22 +- tests/integration/logs.test.js | 41 +-- tests/integration/prompts/browse.test.js | 26 +- tests/integration/prompts/download.test.js | 48 ++- .../prompts/install-speckit.test.js | 20 +- tests/integration/prompts/list.test.js | 42 +-- tests/integration/prompts/run.test.js | 57 ++-- tests/integration/security/setup.test.js | 22 +- tests/integration/setup.js | 48 +-- tests/integration/tasks-assigned.test.js | 8 +- tests/integration/vuln-detail.test.js | 30 +- tests/integration/vuln-scan.test.js | 38 +-- tests/integration/vuln-search.test.js | 33 +- tests/services/audit-runner.test.js | 138 ++++++-- tests/services/auth.test.js | 28 +- tests/services/aws-costs.test.js | 123 +++---- tests/services/changelog.test.js | 13 +- tests/services/clickup.test.js | 144 +++++---- tests/services/cloudwatch-logs.test.js | 48 ++- tests/services/config.test.js | 18 +- tests/services/costs.test.js | 40 +-- tests/services/docs.test.js | 54 ++-- tests/services/doctor.test.js | 18 +- tests/services/dotfiles/dotfiles.test.js | 58 ++-- tests/services/github-pr.test.js | 20 +- tests/services/nvd.test.js | 104 +++--- tests/services/pr-qa.test.js | 24 +- tests/services/pr.test.js | 6 +- .../services/prompts/awesome-copilot.test.js | 45 ++- tests/services/prompts/prompts.test.js | 152 ++++----- tests/services/prompts/skills-sh.test.js | 40 ++- tests/services/prompts/speckit.test.js | 77 +++-- tests/services/security/security.test.js | 72 ++--- tests/services/version-check.test.js | 42 +-- tests/setup.js | 8 +- tests/unit/formatters/charts.test.js | 14 +- tests/unit/formatters/cost.test.js | 10 +- tests/unit/formatters/dotfiles.test.js | 48 +-- tests/unit/formatters/markdown.test.js | 4 +- tests/unit/formatters/openapi.test.js | 41 ++- tests/unit/formatters/vuln.test.js | 47 ++- tests/unit/gradient.test.js | 51 ++- tests/unit/platform.test.js | 6 +- tests/unit/prompts/frontmatter.test.js | 24 +- tests/unit/security/steps.test.js | 12 +- tests/unit/services/audit-detector.test.js | 34 +- tests/unit/typewriter.test.js | 27 +- tests/unit/utils/modal.test.js | 90 +++--- tests/unit/utils/navigable-table.test.js | 96 +++--- tests/unit/validators/repo-name.test.js | 8 +- 160 files changed, 5243 insertions(+), 3014 deletions(-) create mode 100644 .claude/commands/speckit.analyze.md create mode 100644 .claude/commands/speckit.checklist.md create mode 100644 .claude/commands/speckit.clarify.md create mode 100644 .claude/commands/speckit.constitution.md create mode 100644 .claude/commands/speckit.implement.md create mode 100644 .claude/commands/speckit.plan.md create mode 100644 .claude/commands/speckit.specify.md create mode 100644 .claude/commands/speckit.tasks.md create mode 100644 .claude/commands/speckit.taskstoissues.md diff --git a/.claude/commands/speckit.analyze.md b/.claude/commands/speckit.analyze.md new file mode 100644 index 0000000..0c71cf3 --- /dev/null +++ b/.claude/commands/speckit.analyze.md @@ -0,0 +1,184 @@ +--- +description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Goal + +Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`. + +## Operating Constraints + +**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually). + +**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`. + +## Execution Steps + +### 1. Initialize Analysis Context + +Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths: + +- SPEC = FEATURE_DIR/spec.md +- PLAN = FEATURE_DIR/plan.md +- TASKS = FEATURE_DIR/tasks.md + +Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command). +For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). + +### 2. Load Artifacts (Progressive Disclosure) + +Load only the minimal necessary context from each artifact: + +**From spec.md:** + +- Overview/Context +- Functional Requirements +- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact) +- User Stories +- Edge Cases (if present) + +**From plan.md:** + +- Architecture/stack choices +- Data Model references +- Phases +- Technical constraints + +**From tasks.md:** + +- Task IDs +- Descriptions +- Phase grouping +- Parallel markers [P] +- Referenced file paths + +**From constitution:** + +- Load `.specify/memory/constitution.md` for principle validation + +### 3. Build Semantic Models + +Create internal representations (do not include raw artifacts in output): + +- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%"). +- **User story/action inventory**: Discrete user actions with acceptance criteria +- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases) +- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements + +### 4. Detection Passes (Token-Efficient Analysis) + +Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary. + +#### A. Duplication Detection + +- Identify near-duplicate requirements +- Mark lower-quality phrasing for consolidation + +#### B. Ambiguity Detection + +- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria +- Flag unresolved placeholders (TODO, TKTK, ???, ``, etc.) + +#### C. Underspecification + +- Requirements with verbs but missing object or measurable outcome +- User stories missing acceptance criteria alignment +- Tasks referencing files or components not defined in spec/plan + +#### D. Constitution Alignment + +- Any requirement or plan element conflicting with a MUST principle +- Missing mandated sections or quality gates from constitution + +#### E. Coverage Gaps + +- Requirements with zero associated tasks +- Tasks with no mapped requirement/story +- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks + +#### F. Inconsistency + +- Terminology drift (same concept named differently across files) +- Data entities referenced in plan but absent in spec (or vice versa) +- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note) +- Conflicting requirements (e.g., one requires Next.js while other specifies Vue) + +### 5. Severity Assignment + +Use this heuristic to prioritize findings: + +- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality +- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion +- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case +- **LOW**: Style/wording improvements, minor redundancy not affecting execution order + +### 6. Produce Compact Analysis Report + +Output a Markdown report (no file writes) with the following structure: + +## Specification Analysis Report + +| ID | Category | Severity | Location(s) | Summary | Recommendation | +|----|----------|----------|-------------|---------|----------------| +| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version | + +(Add one row per finding; generate stable IDs prefixed by category initial.) + +**Coverage Summary Table:** + +| Requirement Key | Has Task? | Task IDs | Notes | +|-----------------|-----------|----------|-------| + +**Constitution Alignment Issues:** (if any) + +**Unmapped Tasks:** (if any) + +**Metrics:** + +- Total Requirements +- Total Tasks +- Coverage % (requirements with >=1 task) +- Ambiguity Count +- Duplication Count +- Critical Issues Count + +### 7. Provide Next Actions + +At end of report, output a concise Next Actions block: + +- If CRITICAL issues exist: Recommend resolving before `/speckit.implement` +- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions +- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'" + +### 8. Offer Remediation + +Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.) + +## Operating Principles + +### Context Efficiency + +- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation +- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis +- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow +- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts + +### Analysis Guidelines + +- **NEVER modify files** (this is read-only analysis) +- **NEVER hallucinate missing sections** (if absent, report them accurately) +- **Prioritize constitution violations** (these are always CRITICAL) +- **Use examples over exhaustive rules** (cite specific instances, not generic patterns) +- **Report zero issues gracefully** (emit success report with coverage statistics) + +## Context + +$ARGUMENTS diff --git a/.claude/commands/speckit.checklist.md b/.claude/commands/speckit.checklist.md new file mode 100644 index 0000000..b7624e2 --- /dev/null +++ b/.claude/commands/speckit.checklist.md @@ -0,0 +1,295 @@ +--- +description: Generate a custom checklist for the current feature based on user requirements. +--- + +## Checklist Purpose: "Unit Tests for English" + +**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain. + +**NOT for verification/testing**: + +- ❌ NOT "Verify the button clicks correctly" +- ❌ NOT "Test error handling works" +- ❌ NOT "Confirm the API returns 200" +- ❌ NOT checking if code/implementation matches the spec + +**FOR requirements quality validation**: + +- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness) +- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity) +- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency) +- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage) +- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases) + +**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Execution Steps + +1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list. + - All file paths must be absolute. + - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). + +2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST: + - Be generated from the user's phrasing + extracted signals from spec/plan/tasks + - Only ask about information that materially changes checklist content + - Be skipped individually if already unambiguous in `$ARGUMENTS` + - Prefer precision over breadth + + Generation algorithm: + 1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts"). + 2. Cluster signals into candidate focus areas (max 4) ranked by relevance. + 3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit. + 4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria. + 5. Formulate questions chosen from these archetypes: + - Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?") + - Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?") + - Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?") + - Audience framing (e.g., "Will this be used by the author only or peers during PR review?") + - Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?") + - Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?") + + Question formatting rules: + - If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters + - Limit to A–E options maximum; omit table if a free-form answer is clearer + - Never ask the user to restate what they already said + - Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope." + + Defaults when interaction impossible: + - Depth: Standard + - Audience: Reviewer (PR) if code-related; Author otherwise + - Focus: Top 2 relevance clusters + + Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more. + +3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers: + - Derive checklist theme (e.g., security, review, deploy, ux) + - Consolidate explicit must-have items mentioned by user + - Map focus selections to category scaffolding + - Infer any missing context from spec/plan/tasks (do NOT hallucinate) + +4. **Load feature context**: Read from FEATURE_DIR: + - spec.md: Feature requirements and scope + - plan.md (if exists): Technical details, dependencies + - tasks.md (if exists): Implementation tasks + + **Context Loading Strategy**: + - Load only necessary portions relevant to active focus areas (avoid full-file dumping) + - Prefer summarizing long sections into concise scenario/requirement bullets + - Use progressive disclosure: add follow-on retrieval only if gaps detected + - If source docs are large, generate interim summary items instead of embedding raw text + +5. **Generate checklist** - Create "Unit Tests for Requirements": + - Create `FEATURE_DIR/checklists/` directory if it doesn't exist + - Generate unique checklist filename: + - Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`) + - Format: `[domain].md` + - File handling behavior: + - If file does NOT exist: Create new file and number items starting from CHK001 + - If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016) + - Never delete or replace existing checklist content - always preserve and append + + **CORE PRINCIPLE - Test the Requirements, Not the Implementation**: + Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for: + - **Completeness**: Are all necessary requirements present? + - **Clarity**: Are requirements unambiguous and specific? + - **Consistency**: Do requirements align with each other? + - **Measurability**: Can requirements be objectively verified? + - **Coverage**: Are all scenarios/edge cases addressed? + + **Category Structure** - Group items by requirement quality dimensions: + - **Requirement Completeness** (Are all necessary requirements documented?) + - **Requirement Clarity** (Are requirements specific and unambiguous?) + - **Requirement Consistency** (Do requirements align without conflicts?) + - **Acceptance Criteria Quality** (Are success criteria measurable?) + - **Scenario Coverage** (Are all flows/cases addressed?) + - **Edge Case Coverage** (Are boundary conditions defined?) + - **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?) + - **Dependencies & Assumptions** (Are they documented and validated?) + - **Ambiguities & Conflicts** (What needs clarification?) + + **HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**: + + ❌ **WRONG** (Testing implementation): + - "Verify landing page displays 3 episode cards" + - "Test hover states work on desktop" + - "Confirm logo click navigates home" + + ✅ **CORRECT** (Testing requirements quality): + - "Are the exact number and layout of featured episodes specified?" [Completeness] + - "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity] + - "Are hover state requirements consistent across all interactive elements?" [Consistency] + - "Are keyboard navigation requirements defined for all interactive UI?" [Coverage] + - "Is the fallback behavior specified when logo image fails to load?" [Edge Cases] + - "Are loading states defined for asynchronous episode data?" [Completeness] + - "Does the spec define visual hierarchy for competing UI elements?" [Clarity] + + **ITEM STRUCTURE**: + Each item should follow this pattern: + - Question format asking about requirement quality + - Focus on what's WRITTEN (or not written) in the spec/plan + - Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.] + - Reference spec section `[Spec §X.Y]` when checking existing requirements + - Use `[Gap]` marker when checking for missing requirements + + **EXAMPLES BY QUALITY DIMENSION**: + + Completeness: + - "Are error handling requirements defined for all API failure modes? [Gap]" + - "Are accessibility requirements specified for all interactive elements? [Completeness]" + - "Are mobile breakpoint requirements defined for responsive layouts? [Gap]" + + Clarity: + - "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]" + - "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]" + - "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]" + + Consistency: + - "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]" + - "Are card component requirements consistent between landing and detail pages? [Consistency]" + + Coverage: + - "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]" + - "Are concurrent user interaction scenarios addressed? [Coverage, Gap]" + - "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]" + + Measurability: + - "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]" + - "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]" + + **Scenario Classification & Coverage** (Requirements Quality Focus): + - Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios + - For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?" + - If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]" + - Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]" + + **Traceability Requirements**: + - MINIMUM: ≥80% of items MUST include at least one traceability reference + - Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]` + - If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]" + + **Surface & Resolve Issues** (Requirements Quality Problems): + Ask questions about the requirements themselves: + - Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]" + - Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]" + - Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]" + - Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]" + - Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]" + + **Content Consolidation**: + - Soft cap: If raw candidate items > 40, prioritize by risk/impact + - Merge near-duplicates checking the same requirement aspect + - If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]" + + **🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test: + - ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior + - ❌ References to code execution, user actions, system behavior + - ❌ "Displays correctly", "works properly", "functions as expected" + - ❌ "Click", "navigate", "render", "load", "execute" + - ❌ Test cases, test plans, QA procedures + - ❌ Implementation details (frameworks, APIs, algorithms) + + **✅ REQUIRED PATTERNS** - These test requirements quality: + - ✅ "Are [requirement type] defined/specified/documented for [scenario]?" + - ✅ "Is [vague term] quantified/clarified with specific criteria?" + - ✅ "Are requirements consistent between [section A] and [section B]?" + - ✅ "Can [requirement] be objectively measured/verified?" + - ✅ "Are [edge cases/scenarios] addressed in requirements?" + - ✅ "Does the spec define [missing aspect]?" + +6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### ` lines with globally incrementing IDs starting at CHK001. + +7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize: + - Focus areas selected + - Depth level + - Actor/timing + - Any explicit user-specified must-have items incorporated + +**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows: + +- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`) +- Simple, memorable filenames that indicate checklist purpose +- Easy identification and navigation in the `checklists/` folder + +To avoid clutter, use descriptive types and clean up obsolete checklists when done. + +## Example Checklist Types & Sample Items + +**UX Requirements Quality:** `ux.md` + +Sample items (testing the requirements, NOT the implementation): + +- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]" +- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]" +- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]" +- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]" +- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]" +- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]" + +**API Requirements Quality:** `api.md` + +Sample items: + +- "Are error response formats specified for all failure scenarios? [Completeness]" +- "Are rate limiting requirements quantified with specific thresholds? [Clarity]" +- "Are authentication requirements consistent across all endpoints? [Consistency]" +- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]" +- "Is versioning strategy documented in requirements? [Gap]" + +**Performance Requirements Quality:** `performance.md` + +Sample items: + +- "Are performance requirements quantified with specific metrics? [Clarity]" +- "Are performance targets defined for all critical user journeys? [Coverage]" +- "Are performance requirements under different load conditions specified? [Completeness]" +- "Can performance requirements be objectively measured? [Measurability]" +- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]" + +**Security Requirements Quality:** `security.md` + +Sample items: + +- "Are authentication requirements specified for all protected resources? [Coverage]" +- "Are data protection requirements defined for sensitive information? [Completeness]" +- "Is the threat model documented and requirements aligned to it? [Traceability]" +- "Are security requirements consistent with compliance obligations? [Consistency]" +- "Are security failure/breach response requirements defined? [Gap, Exception Flow]" + +## Anti-Examples: What NOT To Do + +**❌ WRONG - These test implementation, not requirements:** + +```markdown +- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001] +- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003] +- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010] +- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005] +``` + +**✅ CORRECT - These test requirements quality:** + +```markdown +- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001] +- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003] +- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010] +- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005] +- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap] +- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001] +``` + +**Key Differences:** + +- Wrong: Tests if the system works correctly +- Correct: Tests if the requirements are written correctly +- Wrong: Verification of behavior +- Correct: Validation of requirement quality +- Wrong: "Does it do X?" +- Correct: "Is X clearly specified?" diff --git a/.claude/commands/speckit.clarify.md b/.claude/commands/speckit.clarify.md new file mode 100644 index 0000000..3f4376a --- /dev/null +++ b/.claude/commands/speckit.clarify.md @@ -0,0 +1,181 @@ +--- +description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. +handoffs: + - label: Build Technical Plan + agent: speckit.plan + prompt: Create a plan for the spec. I am building with... +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Outline + +Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file. + +Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases. + +Execution steps: + +1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields: + - `FEATURE_DIR` + - `FEATURE_SPEC` + - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.) + - If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment. + - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). + +2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). + + Functional Scope & Behavior: + - Core user goals & success criteria + - Explicit out-of-scope declarations + - User roles / personas differentiation + + Domain & Data Model: + - Entities, attributes, relationships + - Identity & uniqueness rules + - Lifecycle/state transitions + - Data volume / scale assumptions + + Interaction & UX Flow: + - Critical user journeys / sequences + - Error/empty/loading states + - Accessibility or localization notes + + Non-Functional Quality Attributes: + - Performance (latency, throughput targets) + - Scalability (horizontal/vertical, limits) + - Reliability & availability (uptime, recovery expectations) + - Observability (logging, metrics, tracing signals) + - Security & privacy (authN/Z, data protection, threat assumptions) + - Compliance / regulatory constraints (if any) + + Integration & External Dependencies: + - External services/APIs and failure modes + - Data import/export formats + - Protocol/versioning assumptions + + Edge Cases & Failure Handling: + - Negative scenarios + - Rate limiting / throttling + - Conflict resolution (e.g., concurrent edits) + + Constraints & Tradeoffs: + - Technical constraints (language, storage, hosting) + - Explicit tradeoffs or rejected alternatives + + Terminology & Consistency: + - Canonical glossary terms + - Avoided synonyms / deprecated terms + + Completion Signals: + - Acceptance criteria testability + - Measurable Definition of Done style indicators + + Misc / Placeholders: + - TODO markers / unresolved decisions + - Ambiguous adjectives ("robust", "intuitive") lacking quantification + + For each category with Partial or Missing status, add a candidate question opportunity unless: + - Clarification would not materially change implementation or validation strategy + - Information is better deferred to planning phase (note internally) + +3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: + - Maximum of 5 total questions across the whole session. + - Each question must be answerable with EITHER: + - A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR + - A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words"). + - Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation. + - Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved. + - Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness). + - Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests. + - If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic. + +4. Sequential questioning loop (interactive): + - Present EXACTLY ONE question at a time. + - For multiple‑choice questions: + - **Analyze all options** and determine the **most suitable option** based on: + - Best practices for the project type + - Common patterns in similar implementations + - Risk reduction (security, performance, maintainability) + - Alignment with any explicit project goals or constraints visible in the spec + - Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice). + - Format as: `**Recommended:** Option [X] - ` + - Then render all options as a Markdown table: + + | Option | Description | + |--------|-------------| + | A |