UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

256 lines 13.6 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; import { Box, Text, useFocus, useInput } from 'ink'; import SelectInput from 'ink-select-input'; import Spinner from 'ink-spinner'; import { useEffect, useState } from 'react'; import { TitledBoxWithPreferences } from '../components/ui/titled-box.js'; import { colors } from '../config/index.js'; import { getConfigPath } from '../config/paths.js'; import { useResponsiveTerminal } from '../hooks/useTerminalWidth.js'; import { logError, logInfo } from '../utils/message-queue.js'; import { LocationStep } from './steps/location-step.js'; import { McpStep } from './steps/mcp-step.js'; import { buildMcpConfigObject } from './validation.js'; export function McpWizard({ projectDir, onComplete, onCancel }) { const [step, setStep] = useState('location'); const [mcpConfigPath, setMcpConfigPath] = useState(''); const [mcpServers, setMcpServers] = useState({}); const [error, setError] = useState(null); const { boxWidth, isNarrow } = useResponsiveTerminal(); // Capture focus to ensure keyboard handling works properly useFocus({ autoFocus: true, id: 'mcp-wizard' }); // Load existing config if editing useEffect(() => { if (!mcpConfigPath) { return; } // Use a microtask to defer state updates void Promise.resolve().then(() => { try { let loadedMcpServers = {}; // Try to load MCP servers from .mcp.json if (existsSync(mcpConfigPath)) { try { const mcpContent = readFileSync(mcpConfigPath, 'utf-8'); const mcpConfig = JSON.parse(mcpContent); if (mcpConfig.mcpServers) { loadedMcpServers = mcpConfig.mcpServers; } } catch (err) { logError('Failed to load MCP configuration', true, { context: { mcpConfigPath }, error: err instanceof Error ? err.message : String(err), }); } } setMcpServers(loadedMcpServers); } catch (err) { logError('Failed to load existing configuration', true, { context: { mcpConfigPath }, error: err instanceof Error ? err.message : String(err), }); } }); }, [mcpConfigPath]); const handleLocationComplete = (location) => { // Determine the base directory based on location const baseDir = location === 'project' ? process.cwd() : getConfigPath(); setMcpConfigPath(join(baseDir, '.mcp.json')); setStep('mcp'); }; const handleMcpComplete = (newMcpServers) => { setMcpServers(newMcpServers); setStep('summary'); }; const handleSave = () => { setStep('saving'); setError(null); try { // Build and save MCP config if (Object.keys(mcpServers).length > 0) { const mcpConfig = buildMcpConfigObject(mcpServers); const mcpDir = dirname(mcpConfigPath); if (!existsSync(mcpDir)) { mkdirSync(mcpDir, { recursive: true }); } writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8'); } setStep('complete'); // Don't auto-complete - wait for user to press Enter } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save configuration'); setStep('summary'); } }; const handleAddMcpServers = () => { setStep('mcp'); }; const handleCancel = () => { if (onCancel) { onCancel(); } }; const handleDeleteConfig = () => { setStep('confirm-delete'); }; const handleConfirmDelete = () => { try { if (existsSync(mcpConfigPath)) { unlinkSync(mcpConfigPath); logInfo(`Deleted configuration file: ${mcpConfigPath}`); } // Call onComplete to trigger reload onComplete(mcpConfigPath); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete configuration'); setStep('mcp'); } }; const openInEditor = () => { try { // Save current progress if (Object.keys(mcpServers).length > 0) { const mcpConfig = buildMcpConfigObject(mcpServers); const mcpDir = dirname(mcpConfigPath); if (!existsSync(mcpDir)) { mkdirSync(mcpDir, { recursive: true }); } writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8'); } // Detect editor (respect $EDITOR or $VISUAL environment variables) // Fall back to nano on Unix/Mac (much friendlier than vi!) // On Windows, use notepad const editor = process.env.EDITOR || process.env.VISUAL || (process.platform === 'win32' ? 'notepad' : 'nano'); // Show cursor and restore terminal for editor process.stdout.write('\x1B[?25h'); // Show cursor process.stdin.setRawMode?.(false); // Disable raw mode // Open MCP config in editor const result = spawnSync(editor, [mcpConfigPath], { stdio: 'inherit', // Give editor full control of terminal }); // Restore terminal state after editor closes process.stdin.setRawMode?.(true); // Re-enable raw mode process.stdout.write('\x1B[?25l'); // Hide cursor (Ink will manage it) if (result.status === 0) { // Reload config to get updated values let loadedMcpServers = {}; // Reload MCP config if (existsSync(mcpConfigPath)) { try { const editedContent = readFileSync(mcpConfigPath, 'utf-8'); const editedConfig = JSON.parse(editedContent); loadedMcpServers = editedConfig.mcpServers || {}; } catch (parseErr) { setError(parseErr instanceof Error ? `Invalid JSON: ${parseErr.message}` : 'Failed to parse edited configuration'); setStep('summary'); return; } } setMcpServers(loadedMcpServers); // Return to summary to review changes setStep('summary'); setError(null); } else { setError('Editor exited with an error. Changes may not be saved.'); setStep('summary'); } } catch (err) { // Restore terminal state on error process.stdin.setRawMode?.(true); process.stdout.write('\x1B[?25l'); setError(err instanceof Error ? `Failed to open editor: ${err.message}` : 'Failed to open editor'); setStep('summary'); } }; // Handle global keyboard shortcuts useInput((input, key) => { // In complete step, wait for Enter to finish if (step === 'complete' && key.return) { onComplete(mcpConfigPath); return; } // Escape - cancel/exit wizard completely if (key.escape) { if (onCancel) { onCancel(); } return; } // Ctrl+E to open editor (available after location is chosen) if (key.ctrl && input === 'e' && mcpConfigPath && (step === 'mcp' || step === 'summary')) { openInEditor(); } }); const renderStep = () => { switch (step) { case 'location': { return (_jsx(LocationStep, { projectDir: projectDir, onComplete: handleLocationComplete, onBack: onCancel, configFileName: ".mcp.json" })); } case 'mcp': { return (_jsx(McpStep, { existingServers: mcpServers, onComplete: handleMcpComplete, onBack: () => setStep('location'), onDelete: handleDeleteConfig, configExists: existsSync(mcpConfigPath) })); } case 'summary': { return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: colors.primary, children: "Configuration Summary" }) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "Config file:" }), _jsx(Text, { color: colors.success, children: mcpConfigPath })] }), Object.keys(mcpServers).length > 0 ? (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.secondary, children: ["MCP Servers (", Object.keys(mcpServers).length, "):"] }), Object.entries(mcpServers).map(([key, server]) => (_jsxs(Text, { color: colors.success, children: ["\u2022 ", server.name, " (", server.transport, ")"] }, key)))] })) : (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.warning, children: "No MCP servers configured" }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "\u2022 Enter: Save configuration" }), _jsx(Text, { color: colors.secondary, children: "\u2022 Shift+Tab: Go back" }), _jsx(Text, { color: colors.secondary, children: "\u2022 Esc: Cancel" })] })] })); } case 'confirm-delete': { const deleteOptions = [ { label: 'Yes, delete the file', value: 'yes' }, { label: 'No, go back', value: 'no' }, ]; return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: colors.error, children: "Delete Configuration?" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Are you sure you want to delete", ' ', _jsx(Text, { color: colors.warning, children: mcpConfigPath }), "?"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This action cannot be undone." }) }), _jsx(SelectInput, { items: deleteOptions, onSelect: (item) => { if (item.value === 'yes') { handleConfirmDelete(); } else { setStep('mcp'); } } })] })); } case 'saving': { return (_jsx(Box, { flexDirection: "column", children: _jsx(Box, { children: _jsxs(Text, { color: colors.success, children: [_jsx(Spinner, { type: "dots" }), " Saving configuration..."] }) }) })); } case 'complete': { return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.success, bold: true, children: "\u2713 Configuration saved!" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Saved to:" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [" ", mcpConfigPath] }) }), _jsx(Box, { children: _jsx(Text, { color: colors.secondary, children: "Press Enter to continue" }) })] })); } default: { return null; } } }; return (_jsxs(TitledBoxWithPreferences, { title: "MCP Server Configuration", width: boxWidth, borderColor: colors.primary, paddingX: 2, paddingY: 1, flexDirection: "column", marginBottom: 1, children: [error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: colors.error, children: ["Error: ", error] }) })), renderStep(), (step === 'location' || step === 'mcp' || step === 'summary') && (isNarrow ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "Esc: Exit wizard" }), _jsx(Text, { color: colors.secondary, children: "Shift+Tab: Go back" }), mcpConfigPath && (_jsx(Text, { color: colors.secondary, children: "Ctrl+E: Edit manually" }))] })) : (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.secondary, children: ["Esc: Exit wizard | Shift+Tab: Go back", mcpConfigPath && ' | Ctrl+E: Edit manually'] }) }))), step === 'summary' && (_jsx(SummaryStepActions, { onSave: handleSave, onAddMcpServers: handleAddMcpServers, onCancel: handleCancel }))] })); } function SummaryStepActions({ onSave, onAddMcpServers, onCancel, }) { useInput((_input, key) => { if (key.shift && key.tab) { onAddMcpServers(); } else if (key.return) { onSave(); } else if (key.escape) { onCancel(); } }); return null; } //# sourceMappingURL=mcp-wizard.js.map