@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
JavaScript
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