feat: TypeScript installation wizard with @clack/prompts TUI (#1)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-02-21 18:25:51 +00:00
committed by jason.woltje
parent e3ec3e32e5
commit 6a84f7e210
56 changed files with 20647 additions and 31 deletions

83
src/runtime/detector.ts Normal file
View File

@@ -0,0 +1,83 @@
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
import type { RuntimeName } from '../types.js';
export interface RuntimeInfo {
name: RuntimeName;
label: string;
installed: boolean;
path?: string;
version?: string;
installHint: string;
}
const RUNTIME_DEFS: Record<
RuntimeName,
{ label: string; command: string; versionFlag: string; installHint: string }
> = {
claude: {
label: 'Claude Code',
command: 'claude',
versionFlag: '--version',
installHint: 'npm install -g @anthropic-ai/claude-code',
},
codex: {
label: 'Codex',
command: 'codex',
versionFlag: '--version',
installHint: 'npm install -g @openai/codex',
},
opencode: {
label: 'OpenCode',
command: 'opencode',
versionFlag: 'version',
installHint: 'See https://opencode.ai for install instructions',
},
};
export function detectRuntime(name: RuntimeName): RuntimeInfo {
const def = RUNTIME_DEFS[name];
const isWindows = platform() === 'win32';
const whichCmd = isWindows
? `where ${def.command} 2>nul`
: `which ${def.command} 2>/dev/null`;
try {
const path = execSync(whichCmd, {
encoding: 'utf-8',
timeout: 5000,
})
.trim()
.split('\n')[0];
let version: string | undefined;
try {
version = execSync(`${def.command} ${def.versionFlag} 2>/dev/null`, {
encoding: 'utf-8',
timeout: 5000,
}).trim();
} catch {
// Version detection is optional
}
return {
name,
label: def.label,
installed: true,
path,
version,
installHint: def.installHint,
};
} catch {
return {
name,
label: def.label,
installed: false,
installHint: def.installHint,
};
}
}
export function getInstallInstructions(name: RuntimeName): string {
return RUNTIME_DEFS[name].installHint;
}

12
src/runtime/installer.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { RuntimeName } from '../types.js';
import { getInstallInstructions } from './detector.js';
export function formatInstallInstructions(name: RuntimeName): string {
const hint = getInstallInstructions(name);
const labels: Record<RuntimeName, string> = {
claude: 'Claude Code',
codex: 'Codex',
opencode: 'OpenCode',
};
return `To install ${labels[name]}:\n ${hint}`;
}

112
src/runtime/mcp-config.ts Normal file
View File

@@ -0,0 +1,112 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import type { RuntimeName } from '../types.js';
const MCP_ENTRY = {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
};
export function configureMcpForRuntime(runtime: RuntimeName): void {
switch (runtime) {
case 'claude':
return configureClaudeMcp();
case 'codex':
return configureCodexMcp();
case 'opencode':
return configureOpenCodeMcp();
}
}
function ensureDir(filePath: string): void {
mkdirSync(dirname(filePath), { recursive: true });
}
function configureClaudeMcp(): void {
const settingsPath = join(homedir(), '.claude', 'settings.json');
ensureDir(settingsPath);
let data: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
data = JSON.parse(readFileSync(settingsPath, 'utf-8'));
} catch {
// Start fresh if corrupt
}
}
if (
!data.mcpServers ||
typeof data.mcpServers !== 'object' ||
Array.isArray(data.mcpServers)
) {
data.mcpServers = {};
}
(data.mcpServers as Record<string, unknown>)['sequential-thinking'] =
MCP_ENTRY;
writeFileSync(
settingsPath,
JSON.stringify(data, null, 2) + '\n',
'utf-8',
);
}
function configureCodexMcp(): void {
const configPath = join(homedir(), '.codex', 'config.toml');
ensureDir(configPath);
let content = '';
if (existsSync(configPath)) {
content = readFileSync(configPath, 'utf-8');
// Remove existing sequential-thinking section
content = content
.replace(
/\[mcp_servers\.(sequential-thinking|sequential_thinking)\][\s\S]*?(?=\n\[|$)/g,
'',
)
.trim();
}
content +=
'\n\n[mcp_servers.sequential-thinking]\n' +
'command = "npx"\n' +
'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]\n';
writeFileSync(configPath, content, 'utf-8');
}
function configureOpenCodeMcp(): void {
const configPath = join(
homedir(),
'.config',
'opencode',
'config.json',
);
ensureDir(configPath);
let data: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
data = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
// Start fresh
}
}
if (!data.mcp || typeof data.mcp !== 'object' || Array.isArray(data.mcp)) {
data.mcp = {};
}
(data.mcp as Record<string, unknown>)['sequential-thinking'] = {
type: 'local',
command: ['npx', '-y', '@modelcontextprotocol/server-sequential-thinking'],
enabled: true,
};
writeFileSync(
configPath,
JSON.stringify(data, null, 2) + '\n',
'utf-8',
);
}