docs: prepare v1 milestone release
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
// ChatGPT MCP Server — Local Setup Helper
|
||||
// A simple interactive onboarding script. No external dependencies.
|
||||
// Usage: npm run setup
|
||||
|
||||
import readline from "node:readline";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import tty from "node:tty";
|
||||
|
||||
const SUPPORTED_PROVIDERS = ["openai", "manual", "ollama"];
|
||||
|
||||
const ROOT_DIR = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function maskedInput(prompt) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
// Detect if stdin is a TTY (interactive terminal).
|
||||
const isTTY = process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
|
||||
|
||||
if (!isTTY) {
|
||||
console.log("\n⚠ Non-interactive terminal detected — input will be visible.");
|
||||
}
|
||||
|
||||
rl.question(prompt + ": ", (answer) => resolve(answer));
|
||||
});
|
||||
}
|
||||
|
||||
function getProviderDefault() {
|
||||
const envPath = path.join(ROOT_DIR, ".env");
|
||||
if (fs.existsSync(envPath)) {
|
||||
const content = fs.readFileSync(envPath, "utf-8");
|
||||
const match = content.match(/^CHATGPT_MCP_PROVIDER\s*=\s*(\S+)/im);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function promptProvider() {
|
||||
const existing = getProviderDefault();
|
||||
console.log("\nSelect a provider:");
|
||||
console.log(" [1] openai — Automated second-opinion queries (requires API key)");
|
||||
console.log(" [2] manual — Copy-paste prompts into ChatGPT Web (no API key needed)");
|
||||
console.log(" [3] ollama — Local AI via Ollama (no API key needed)");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const hint = existing && SUPPORTED_PROVIDERS.includes(existing) ? ` (default: ${existing})` : "";
|
||||
|
||||
rl.question(`\nProvider${hint}: `, (input) => {
|
||||
rl.close();
|
||||
|
||||
// Allow default by empty input
|
||||
if (!input.trim()) {
|
||||
return resolve(existing || "openai");
|
||||
}
|
||||
|
||||
const map = { 1: "openai", 2: "manual", 3: "ollama" };
|
||||
const selected = map[input.trim()];
|
||||
if (SUPPORTED_PROVIDERS.includes(selected)) {
|
||||
return resolve(selected);
|
||||
}
|
||||
|
||||
console.log(`\nInvalid selection: ${input.trim()}. Try again.\n`);
|
||||
return resolve(promptProvider()); // retry
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promptRequired(label, defaultVal) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function ask() {
|
||||
const hint = defaultVal ? ` (default: ${defaultVal})` : "";
|
||||
rl.question(`${label}${hint}: `, (answer) => {
|
||||
if (defaultVal && !answer.trim()) {
|
||||
rl.close();
|
||||
return resolve(defaultVal);
|
||||
}
|
||||
if (!answer.trim()) {
|
||||
console.log(" This field is required. Please enter a value.");
|
||||
return ask();
|
||||
}
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
}
|
||||
ask();
|
||||
});
|
||||
}
|
||||
|
||||
function promptOptional(label, defaultVal) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const hint = defaultVal ? ` (default: ${defaultVal})` : "";
|
||||
rl.question(`${label}${hint}: `, (answer) => {
|
||||
rl.close();
|
||||
if (!answer.trim() && defaultVal) {
|
||||
return resolve(defaultVal);
|
||||
}
|
||||
return resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promptConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question(`${message} [y/N]: `, (answer) => {
|
||||
rl.close();
|
||||
resolve(["y", "yes"].includes(answer.trim().toLowerCase()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── .env generation ───────────────────────────────────────
|
||||
|
||||
function buildEnvContent(provider, config) {
|
||||
const lines = [];
|
||||
|
||||
// Provider section header
|
||||
lines.push(`# Chat provider: "openai", "manual", or "ollama"`);
|
||||
lines.push(`CHATGPT_MCP_PROVIDER=${provider}`);
|
||||
lines.push("");
|
||||
|
||||
if (provider === "openai") {
|
||||
lines.push("# OpenAI provider settings");
|
||||
lines.push(`OPENAI_API_KEY=${config.openaiApiKey || ""}`);
|
||||
if (config.openaiModel) lines.push(`OPENAI_MODEL=${config.openaiModel}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (provider === "ollama") {
|
||||
lines.push("# Ollama provider settings");
|
||||
if (config.ollamaBaseUrl) lines.push(`OLLAMA_BASE_URL=${config.ollamaBaseUrl}`);
|
||||
if (config.ollamaModel) lines.push(`OLLAMA_MODEL=${config.ollamaModel}`);
|
||||
if (config.ollamaTemperature !== undefined && config.ollamaTemperature !== null)
|
||||
lines.push(`OLLAMA_TEMPERATURE=${config.ollamaTemperature}`);
|
||||
if (config.ollamaTimeout !== undefined && config.ollamaTimeout !== null)
|
||||
lines.push(`OLLAMA_TIMEOUT=${config.ollamaTimeout}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Append existing .env content that doesn't conflict with our keys.
|
||||
const envPath = path.join(ROOT_DIR, ".env");
|
||||
if (fs.existsSync(envPath)) {
|
||||
const existingContent = fs.readFileSync(envPath, "utf-8");
|
||||
const existingKeys = new Set([
|
||||
"CHATGPT_MCP_PROVIDER",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_MODEL",
|
||||
"OLLAMA_BASE_URL",
|
||||
"OLLAMA_MODEL",
|
||||
"OLLAMA_TEMPERATURE",
|
||||
"OLLAMA_TIMEOUT",
|
||||
]);
|
||||
|
||||
const otherLines = existingContent
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
if (line.trim().startsWith("#")) return true;
|
||||
const keyMatch = line.match(/^(\w+)/);
|
||||
if (!keyMatch) return true;
|
||||
return !existingKeys.has(keyMatch[1]);
|
||||
})
|
||||
// Deduplicate: keep last occurrence of each non-comment line
|
||||
.reduceRight((acc, line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && acc.includes(trimmed)) return acc;
|
||||
if (trimmed === "") return acc;
|
||||
return [line, ...acc];
|
||||
}, []);
|
||||
|
||||
if (otherLines.length > 0) {
|
||||
lines.push("# ── Other settings (preserved from existing .env) ──");
|
||||
lines.push(...otherLines);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure trailing newline
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ── Claude config generation ──────────────────────────────
|
||||
|
||||
function buildClaudeConfig() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"chatgpt-mcp": {
|
||||
command: "npm",
|
||||
args: ["start"],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Exports for testing ─────────────────────────────────
|
||||
|
||||
export { SUPPORTED_PROVIDERS, buildEnvContent, buildClaudeConfig };
|
||||
|
||||
// ── Main flow ─────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log("\n╔══════════════════════════════════════════════╗");
|
||||
console.log("║ ChatGPT MCP Server — Local Setup Helper ║");
|
||||
console.log("╚══════════════════════════════════════════════╝\n");
|
||||
|
||||
// Step 1: Provider selection
|
||||
const provider = await promptProvider();
|
||||
console.log(`\n✓ Selected provider: ${provider}`);
|
||||
|
||||
// Step 2: Provider-specific config prompts
|
||||
let config;
|
||||
if (provider === "openai") {
|
||||
config = { openaiApiKey: "", openaiModel: null };
|
||||
// Mask the API key input by disabling terminal echo temporarily.
|
||||
const isTTY = process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
|
||||
if (isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
|
||||
const rlKey = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
config.openaiApiKey = await new Promise((resolve) => {
|
||||
console.log("\nOPENAI_API_KEY (required — not shown as you type):");
|
||||
rlKey.question(" Key: ", resolve);
|
||||
});
|
||||
|
||||
if (isTTY) process.stdin.setRawMode(false);
|
||||
rlKey.close();
|
||||
process.stdout.write("\n"); // newline after hidden input
|
||||
|
||||
config.openaiModel = await promptOptional("OPENAI_MODEL", "gpt-5.1");
|
||||
}
|
||||
|
||||
if (provider === "ollama") {
|
||||
config = {};
|
||||
config.ollamaBaseUrl = await promptOptional("OLLAMA_BASE_URL", "http://localhost:11434");
|
||||
config.ollamaModel = await promptRequired("OLLAMA_MODEL", "qwen3.6:35b-a3b");
|
||||
config.ollamaTemperature = await promptOptional("OLLAMA_TEMPERATURE", "0.2");
|
||||
config.ollamaTimeout = await promptOptional("OLLAMA_TIMEOUT", "60");
|
||||
}
|
||||
|
||||
// manual provider — no prompts needed (config is empty object)
|
||||
if (provider === "manual") {
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Step 3: Generate and confirm .env
|
||||
const envContent = buildEnvContent(provider, config);
|
||||
console.log("\n" + "─".repeat(52));
|
||||
console.log("The following will be written to .env:\n");
|
||||
process.stdout.write(envContent);
|
||||
console.log("─".repeat(52) + "\n");
|
||||
|
||||
const confirmEnv = await promptConfirm("Overwrite .env with this configuration?");
|
||||
if (!confirmEnv) {
|
||||
console.log("\n✗ Setup cancelled. No changes were made.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const envPath = path.join(ROOT_DIR, ".env");
|
||||
fs.writeFileSync(envPath, envContent, "utf-8");
|
||||
console.log("✓ .env updated.\n");
|
||||
|
||||
// Step 4: Optionally create Claude Code config
|
||||
const claudeDir = path.join(ROOT_DIR, ".claude");
|
||||
const claudeConfigPath = path.join(claudeDir, "settings.local.json");
|
||||
|
||||
let confirmClaude = false;
|
||||
if (fs.existsSync(claudeConfigPath)) {
|
||||
console.log(`⚠ ${claudeConfigPath} already exists.`);
|
||||
confirmClaude = await promptConfirm("Overwrite it?");
|
||||
} else {
|
||||
console.log(".claude/ directory not found — will be created.");
|
||||
// Check if .claude dir itself exists but settings doesn't
|
||||
if (!fs.existsSync(claudeDir)) {
|
||||
console.log("Creating .claude/ directory...");
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
}
|
||||
confirmClaude = await promptConfirm("Create .claude/settings.local.json?");
|
||||
}
|
||||
|
||||
if (confirmClaude) {
|
||||
if (!fs.existsSync(claudeDir)) {
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(claudeConfigPath, buildClaudeConfig() + "\n", "utf-8");
|
||||
console.log("✓ .claude/settings.local.json created.\n");
|
||||
} else {
|
||||
console.log(".claude/settings.local.json was skipped.\n");
|
||||
}
|
||||
|
||||
// Done
|
||||
console.log("╔══════════════════════════════════════════════╗");
|
||||
console.log("║ Setup complete! ║");
|
||||
console.log("╚══════════════════════════════════════════════╝\n");
|
||||
console.log("Next steps:");
|
||||
console.log(" npm test — Run the test suite");
|
||||
console.log(" npm start — Start the MCP server");
|
||||
console.log(` CHATGPT_MCP_PROVIDER=${provider} (active provider)`);
|
||||
console.log("");
|
||||
|
||||
if (provider === "ollama") {
|
||||
console.log("Tip: verify your Ollama setup with: ollama list");
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`Setup failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user