#!/usr/bin/env node // kimi-eyes setup wizard — interactive one-time configuration. // Zero-dependency: raw-mode stdin input + native fetch. // // Flow: choose protocol → base URL → API key → pick multimodal model → verify → write config. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { callVLM, modelsEndpoint, ONE_PX_PNG_BASE64, modelCapabilityTag } from './mcp/vision.mjs'; // Directory that contains this script — used for the /plugins install hint. // Works both when run locally and when run via `npx kimi-eyes setup` from the npm package. const __dirname = path.dirname(fileURLToPath(import.meta.url)); // --------------------------------------------------------------------------- // Raw-mode line input (supports masked echo) // --------------------------------------------------------------------------- let pipeLines = null; function ensurePipeLines() { if (!pipeLines) { pipeLines = new Promise((resolve, reject) => { let s = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => (s += c)); process.stdin.on('end', () => resolve(s.split(/\r?\n/))); process.stdin.on('error', reject); process.stdin.resume(); }); } return pipeLines; } function ask(query, { hidden = false } = {}) { const stdin = process.stdin; if (!stdin.isTTY) { // Non-TTY fallback (piped input in tests/CI): plain line read, no masking. process.stdout.write(query); return ensurePipeLines().then((lines) => { if (lines.length === 0) { console.error('\n[setup] 输入已结束,向导中止。'); process.exit(1); } return lines.shift() ?? ''; }); } process.stdout.write(query); stdin.setRawMode(true); stdin.resume(); return new Promise((resolve) => { let val = ''; let esc = false; const cleanup = () => { stdin.setRawMode(false); stdin.removeListener('data', onData); process.stdout.write('\n'); }; const onData = (chunk) => { for (const ch of chunk.toString('utf8')) { if (esc) { if ((ch >= 'A' && ch <= 'D') || ch === '~') esc = false; continue; } if (ch === '\u001b') { esc = true; continue; } if (ch === '\r' || ch === '\n') { cleanup(); resolve(val); return; } if (ch === '\u0003') process.exit(130); if (ch === '\u007f' || ch === '\b') { if (val.length > 0) { val = val.slice(0, -1); process.stdout.write('\b \b'); } continue; } val += ch; process.stdout.write(hidden ? '*' : ch); } }; stdin.on('data', onData); }); } async function askRequired(query, { hidden = false, validate = null } = {}) { for (;;) { const v = (await ask(query, { hidden })).trim(); if (!v) { console.log(' 输入不能为空,请重试。'); continue; } if (validate) { const err = validate(v); if (err) { console.log(` ${err}`); continue; } } return v; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const VISION_HINTS = /(vision|visual|vlm|multimodal|4o|omni|glm-4v|qwen-vl|qwen2.*?vl|internvl|minicpm|gpt-4v|gpt-5|claude-3|claude-sonnet|claude-opus|gemini)/i; async function fetchModels(protocol, baseUrl, apiKey) { const url = modelsEndpoint(baseUrl, protocol); const headers = protocol === 'anthropic' ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' } : { Authorization: `Bearer ${apiKey}` }; const res = await fetch(url, { headers, signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const list = Array.isArray(data?.data) ? data.data.map((m) => m.id || m.name).filter((x) => typeof x === 'string' && x) : []; if (list.length === 0) throw new Error('接口未返回任何模型'); return [...new Set(list)]; } async function chooseTool() { console.log(' 选择目标工具:'); console.log(' [1] KimiCode(插件方式,需手动选协议)'); console.log(' [2] Claude Code(MCP 方式,协议固定 Anthropic)'); console.log(' [3] Codex(MCP 方式,协议固定 OpenAI)'); for (;;) { const v = (await ask(' 输入序号 [1/2/3]: ')).trim(); if (v === '1') return 'kimicode'; if (v === '2') return 'claudecode'; if (v === '3') return 'codex'; console.log(' 无效输入,请输入 1、2 或 3。'); } } async function chooseProtocol() { console.log(' 选择视觉 API 协议:'); console.log(' [1] OpenAI 兼容 (Chat Completions)'); console.log(' [2] Anthropic (Messages API)'); for (;;) { const v = (await ask(' 输入序号 [1/2]: ')).trim(); if (v === '1') return 'openai'; if (v === '2') return 'anthropic'; console.log(' 无效输入,请输入 1 或 2。'); } } async function chooseModel(protocol, baseUrl, apiKey) { console.log(' 拉取模型列表 ...'); let models = null; try { models = await fetchModels(protocol, baseUrl, apiKey); } catch (err) { console.log(` 拉取模型列表失败 (${err.message}),将改为手动输入模型名。`); } if (models) { const shown = models.slice(0, 50); console.log( ` 可用模型 (✓视觉=支持识图 / 文本=纯文本 / ★疑似=未收录按关键词猜测): ` + `${models.length > 50 ? `显示前 50 / 共 ${models.length}` : ''}` ); shown.forEach((m, i) => { const tag = modelCapabilityTag(m, VISION_HINTS); console.log(` [${String(i + 1).padStart(3)}] ${m}${tag ? ` ${tag}` : ''}`); }); console.log(' 输入序号选择模型,或直接输入自定义模型名。'); for (;;) { const v = (await ask(' 模型: ')).trim(); if (v === '') { console.log(' 输入不能为空。'); continue; } const idx = Number.parseInt(v, 10); if (!Number.isNaN(idx) && idx >= 1 && idx <= shown.length) return shown[idx - 1]; return v; // treat as custom model name } } return askRequired(' 模型名: '); } async function verifyModel(cfg) { console.log(' 视觉验证:发送 1×1 测试图到模型 ...'); try { const text = await callVLM( { imageBase64: ONE_PX_PNG_BASE64, mediaType: 'image/png', prompt: 'Reply with the single word OK.' }, cfg ); console.log(` ✓ 通过,模型可接收图片 (返回: ${text.slice(0, 60)})`); return true; } catch (err) { console.log(` ✗ 失败: ${err.message.slice(0, 200)}`); return false; } } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { const configPath = path.join( process.env.KIMI_EYES_HOME || path.join(os.homedir(), 'kimi-eyes'), 'config.json' ); console.log('=== Kimi Eyes 配置向导 ==='); console.log(`配置将写入: ${configPath}\n`); const tool = await chooseTool(); let protocol; if (tool === 'claudecode') { console.log(' Claude Code 目标:协议固定为 Anthropic (Messages API)\n'); protocol = 'anthropic'; } else if (tool === 'codex') { console.log(' Codex 目标:协议固定为 OpenAI (Chat Completions)\n'); protocol = 'openai'; } else { protocol = await chooseProtocol(); } const baseUrl = await askRequired(' 视觉 API BaseUrl (如 https://api.openai.com/v1): ', { validate: (v) => (/^https?:\/\//i.test(v) ? null : 'BaseUrl 必须以 http:// 或 https:// 开头。'), }); const apiKey = await askRequired(' API Key: ', { hidden: true }); const model = await chooseModel(protocol, baseUrl, apiKey); const cfg = { protocol, baseUrl, apiKey, model, tool }; console.log(''); for (let attempt = 0; attempt < 3; attempt++) { if (await verifyModel(cfg)) break; if (attempt === 2) { console.log(' 连续验证失败,跳过验证(配置仍将写入)。'); break; } const act = (await ask(' [r] 重试 [c] 换模型 [s] 跳过验证: ')).trim().toLowerCase(); if (act === 'c') { cfg.model = await chooseModel(protocol, baseUrl, apiKey); continue; } if (act === 's') break; // 'r' or anything else → retry the loop } const mainModel = (await ask(' 当前主模型名(可选,用于触发判断;留空跳过): ')).trim(); if (mainModel) cfg.mainModel = mainModel; const dir = path.dirname(configPath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); if (process.platform !== 'win32') fs.chmodSync(configPath, 0o600); console.log(''); console.log(`✓ 配置已写入: ${configPath}`); console.log(` tool : ${cfg.tool}`); console.log(` protocol: ${cfg.protocol}`); console.log(` baseUrl : ${cfg.baseUrl}`); console.log(` model : ${cfg.model}`); if (cfg.mainModel) console.log(` mainModel: ${cfg.mainModel} (触发判断用)`); console.log(''); console.log('下一步:'); if (tool === 'kimicode') { console.log(` 1. 安装插件: /plugins install ${__dirname}`); console.log(' (国内镜像: /plugins install https://git.codingplan.site/admin/kimi-eyes/archive/main.zip)'); console.log(' 2. 启用插件: /reload'); } else if (tool === 'claudecode') { console.log(' 1. 挂载 MCP(全局): claude mcp add --scope user kimi-eyes -- node <插件目录>/mcp/server.mjs'); console.log(' npx 通用版: claude mcp add --scope user kimi-eyes -- npx --prefer-online -y -p kimi-eyes kimi-eyes-mcp'); console.log(' 2. 验证: claude mcp list'); } else { console.log(' 1. 挂载 MCP: codex mcp add kimi-eyes -- node <插件目录>/mcp/server.mjs'); console.log(' npx 通用版: codex mcp add kimi-eyes -- npx --prefer-online -y -p kimi-eyes kimi-eyes-mcp'); console.log(' 2. 验证: codex mcp list'); } console.log(' 3. 开始使用: 非多模态模型下 @图片路径 或 截图后提问'); } main().catch((err) => { console.error(`\n[setup] 出错: ${err.message}`); process.exit(1); });