Files
AriWeb-Suite/hal/ai/providers.mjs
T

232 lines
31 KiB
JavaScript

/**
* hal/ai/providers.mjs — 모델 공급자 어댑터(0.3.0). 엔진(hal/ai/engine.mjs)은 공급자와 무관한 블록 형식으로 말하고, 여기서 공급자별 HTTP 형식으로 바꾼다.
*
* 공급자
* - anthropic : Messages API(POST {base}/messages, GET {base}/models). API 키는 `x-api-key`, OAuth 는 `Authorization: Bearer`.
* - openai : OpenAI 호환 Chat Completions(POST {base}/chat/completions, GET {base}/models). base_url 을 바꾸면 호환 공급자도 쓴다. 키·토큰은 Bearer.
* - local-cli : 서버에 설치된 CLI 의 **로그인 세션** — Claude Code(`claude -p … --output-format json`) 또는 OpenAI Codex(`codex exec --json -s read-only …`). [0.3.1] 설치 위치를 자동 탐색한다(아래).
* CLI 에는 HAL 도구를 넘기지 않는다(대화·분석 전용). 세션 파일·자격은 HAL 이 읽지도 전달하지도 않는다. 환경 변수는 PATH·HOME 등 최소만 넘긴다(Vault 키 등 HAL 환경 비노출).
*
* 공통 블록 형식
* messages: [{ role:'user'|'assistant', content:[ {type:'text',text} | {type:'tool_use',id,name,input} | {type:'tool_result',toolUseId,name,ok,output} ] }]
* 응답: { blocks:[text|tool_use], usage:{inputTokens,outputTokens}, stopReason, model }
*
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
*/
import { spawn, execFile } from 'node:child_process';
import { accessSync, constants, statSync, realpathSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
export class ProviderError extends Error { constructor(code, message, { status = 502, detail } = {}) { super(message); this.code = code; this.status = status; this.detail = detail; } }
/**
* 공급자 프리셋(0.3.2). kind 가 실제 HTTP 형식(anthropic·openai·gemini)이고, 프리셋은 기본 주소·인증 방식·사전 준비 안내(guide)·OAuth 프리셋을 더한다.
* - anthropic(Claude) · openai(ChatGPT) · gemini(Google) · openrouter(OpenRouter, OpenAI 호환 고정 주소, PKCE 로 키 발급) · other(기타 OpenAI 호환 서비스, 기본 주소 필수) · local-cli
*/
export const PROVIDERS = {
anthropic: { label: 'Claude(Anthropic)', kind: 'anthropic', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true, guide: { keyUrl: 'https://console.anthropic.com/settings/keys', steps: ['Anthropic Console 에서 API 키를 발급받아 아래에 붙여 넣으세요(저장 뒤 다시 표시하지 않음).', '기본 모델 예: claude-opus-5-5 · claude-sonnet-5-5 — "확인"을 누르면 실제 모델 목록을 받아 옵니다.'], oauth: 'Anthropic 은 공개 OAuth 클라이언트를 제공하지 않습니다 — 자체 OAuth 중계가 있을 때만 authorize/token 주소와 client id 를 적으세요.' } },
openai: { label: 'ChatGPT(OpenAI)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true, guide: { keyUrl: 'https://platform.openai.com/api-keys', steps: ['OpenAI Platform 에서 API 키를 발급받아 붙여 넣으세요.', 'ChatGPT 구독 로그인(Codex)은 "로컬 CLI 로그인 세션"으로 연결합니다.'], oauth: 'OpenAI API 는 공개 OAuth 를 제공하지 않습니다 — 자체 중계가 있을 때만 사용.' } },
gemini: { label: 'Gemini(Google)', kind: 'gemini', authKinds: ['api_key', 'oauth'], defaultBase: 'https://generativelanguage.googleapis.com/v1beta', tools: true, guide: { keyUrl: 'https://aistudio.google.com/app/apikey', steps: ['Google AI Studio 에서 API 키를 만들어 붙여 넣으세요.', '기본 모델 예: gemini-2.5-pro · gemini-2.5-flash.'], oauth: 'Google Cloud Console 에서 OAuth 클라이언트(웹 애플리케이션)를 만들고 승인된 리디렉션 URI 에 HAL 콜백 주소를 등록한 뒤 client id·secret 을 적으세요. Generative Language API 가 켜져 있어야 합니다. 저장하면 Google 로그인 창이 자동으로 열립니다.' }, oauthPreset: { authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', scopes: ['https://www.googleapis.com/auth/generative-language'], extra: { access_type: 'offline', prompt: 'consent' } } },
openrouter: { label: 'OpenRouter', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://openrouter.ai/api/v1', tools: true, guide: { keyUrl: 'https://openrouter.ai/keys', steps: ['OpenRouter 키를 붙여 넣거나, OAuth(PKCE)를 고르면 저장 직후 OpenRouter 로그인 창이 열리고 승인 뒤 키가 자동 발급·저장됩니다.', '모델 이름은 공급자/모델 형식(예: anthropic/claude-sonnet-4.5, openai/gpt-5).'], oauth: 'OpenRouter OAuth(PKCE)는 설정값이 필요 없습니다 — 저장하면 로그인 창이 자동으로 열립니다.' }, oauthPreset: { mode: 'openrouter_pkce', authorizeUrl: 'https://openrouter.ai/auth', tokenUrl: 'https://openrouter.ai/api/v1/auth/keys', scopes: [] } },
other: { label: '기타 API 서비스(OpenAI 호환)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: null, tools: true, guide: { keyUrl: null, steps: ['OpenAI Chat Completions 호환 API(예: Groq, Together, Mistral, DeepSeek, 자체 vLLM)의 기본 주소(…/v1)와 키를 적으세요.', '도구 호출(function calling)을 지원하는 모델이어야 HAL 도구를 쓸 수 있습니다.'], oauth: '서비스가 OAuth2 authorization code(PKCE) 를 지원할 때 authorize/token 주소·client id·scope 를 적으세요.' } },
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', kind: 'cli', authKinds: ['login'], defaultBase: null, tools: false, guide: { keyUrl: null, steps: ['HAL 서버에 설치된 CLI(claude·codex)를 자동 탐색합니다. 로그인이 안 되어 있으면 "텍스트 셸"에서 login 을 실행하세요(브라우저 인증 URL 과 코드 입력).', 'CLI 연결에는 HAL MCP 서버 설정이 자동으로 붙어 읽기 도구를 바로 쓰고, 쓰기·고위험 도구는 승인 요청으로 올라갑니다.'], oauth: null } },
};
export const providerKind = (provider) => (PROVIDERS[provider] || {}).kind || provider;
const base = (conn) => String(conn.baseUrl || (PROVIDERS[conn.provider] || {}).defaultBase || '').replace(/\/+$/, '');
const fetchOf = (d) => d.fetch || fetch;
async function readJson(r) { try { return await r.json(); } catch (_) { return null; } }
function authHeaders(conn, secret) {
if (!secret) throw new ProviderError('no_credential', '연결에 자격(API 키 또는 OAuth 토큰)이 없습니다', { status: 409 });
const kind = providerKind(conn.provider);
if (kind === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' };
if (kind === 'gemini') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}` } : { 'x-goog-api-key': secret.value };
const h = { Authorization: `Bearer ${secret.value}` }; if (conn.provider === 'openrouter') { h['HTTP-Referer'] = 'https://hal.ariweb.net'; h['X-Title'] = 'Ariweb HAL'; } return h;
}
function providerFail(status, j) { const e = j && j.error ? (typeof j.error === 'string' ? j.error : `${j.error.type || j.error.code || ''}: ${j.error.message || ''}`) : ''; return new ProviderError(`provider_${status}`, `공급자 오류 ${status}${e ? ' ' + e : ''}`.trim(), { status: status === 401 || status === 403 ? 502 : 502 }); }
/** 사용 가능한 모델 목록(실제 공급자 응답). local-cli 는 실행 파일 확인으로 대신한다. */
export async function listModels(d, conn, secret) {
if (conn.provider === 'local-cli') { const info = await cliInfo(conn); if (info.loggedIn === false) throw new ProviderError('cli_not_logged_in', `${info.label}(${info.bin})에 로그인되어 있지 않습니다 — 서버(HAL 실행 계정)에서 '${info.loginCommand}' 을 실행하세요. HAL 이 대신 로그인할 수 없습니다`, { status: 502 }); return { models: info.models.length ? info.models : [conn.model].filter(Boolean), cliVersion: info.version, loggedIn: info.loggedIn, cli: info }; }
const r = await fetchOf(d)(`${base(conn)}/models`, { method: 'GET', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000) });
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j);
const models = providerKind(conn.provider) === 'gemini' ? ((j && j.models) || []).map((m) => String(m.name || '').replace(/^models\//, '')).filter(Boolean) : ((j && j.data) || []).map((m) => m.id).filter(Boolean);
return { models };
}
/** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096, mcp = false }) {
const kind = providerKind(conn.provider);
if (kind === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
if (kind === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
if (kind === 'gemini') return chatGemini(d, conn, secret, { system, messages, tools, maxTokens });
if (kind === 'cli') return chatCli(d, conn, { system, messages, mcp });
throw new ProviderError('bad_provider', `알 수 없는 공급자: ${conn.provider}`, { status: 400 });
}
/** 공급자 형식 이름: toolDefs(format) 에 넘길 값 */
export const toolFormat = (provider) => ({ anthropic: 'anthropic', openai: 'openai', gemini: 'gemini' }[providerKind(provider)] || 'anthropic');
// ---------------------------------------------------------------- Gemini(generateContent)
/** Gemini functionDeclarations 는 OpenAPI 부분집합(type 은 대문자, additionalProperties 없음) */
export function geminiSchema(sc) {
if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required;
if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out;
}
function toGemini(messages) {
const out = [];
for (const m of messages) {
const parts = []; for (const b of m.content) { if (b.type === 'text' && b.text) parts.push({ text: b.text }); else if (b.type === 'tool_use') parts.push({ functionCall: { name: b.name, args: b.input || {} } }); else if (b.type === 'tool_result') { let resp; try { resp = JSON.parse(String(b.output)); if (!resp || typeof resp !== 'object' || Array.isArray(resp)) resp = { output: resp }; } catch (_) { resp = { output: String(b.output ?? '') }; } if (b.ok === false) resp = { error: true, ...resp }; parts.push({ functionResponse: { name: b.name, response: resp } }); } }
if (!parts.length) continue; const role = m.role === 'assistant' ? 'model' : 'user'; const last = out[out.length - 1]; if (last && last.role === role) last.parts.push(...parts); else out.push({ role, parts });
}
if (out.length && out[0].role !== 'user') out.unshift({ role: 'user', parts: [{ text: '(대화 시작)' }] });
return out;
}
async function chatGemini(d, conn, secret, { system, messages, tools, maxTokens }) {
const body = { systemInstruction: { parts: [{ text: system }] }, contents: toGemini(messages), generationConfig: { maxOutputTokens: maxTokens } }; if (tools.length) body.tools = [{ functionDeclarations: tools }];
const r = await fetchOf(d)(`${base(conn)}/models/${encodeURIComponent(conn.model)}:generateContent`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) });
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); const cand = j && j.candidates && j.candidates[0]; if (!cand || !cand.content) throw new ProviderError('bad_response', j && j.promptFeedback ? `공급자가 응답을 막았습니다(${j.promptFeedback.blockReason || 'blocked'})` : '공급자 응답 형식이 올바르지 않습니다');
const blocks = []; let n = 0; for (const p of cand.content.parts || []) { if (p.text) blocks.push({ type: 'text', text: String(p.text) }); if (p.functionCall) blocks.push({ type: 'tool_use', id: `gfc_${Date.now().toString(36)}_${n++}`, name: p.functionCall.name, input: p.functionCall.args || {} }); }
return { blocks, usage: { inputTokens: j.usageMetadata?.promptTokenCount ?? null, outputTokens: j.usageMetadata?.candidatesTokenCount ?? null }, stopReason: cand.finishReason || null, model: conn.model };
}
// ---------------------------------------------------------------- Anthropic
function toAnthropic(messages) {
const out = [];
for (const m of messages) {
const content = m.content.map((b) => b.type === 'text' ? { type: 'text', text: b.text } : b.type === 'tool_use' ? { type: 'tool_use', id: b.id, name: b.name, input: b.input || {} } : { type: 'tool_result', tool_use_id: b.toolUseId, content: String(b.output ?? ''), is_error: b.ok === false }).filter((b) => b.type !== 'text' || b.text);
if (!content.length) continue;
const role = m.content.some((b) => b.type === 'tool_result') ? 'user' : m.role;
const last = out[out.length - 1]; if (last && last.role === role) last.content.push(...content); else out.push({ role, content }); // 같은 역할이 이어지면 합친다(교대 규칙)
}
if (out.length && out[0].role !== 'user') out.unshift({ role: 'user', content: [{ type: 'text', text: '(대화 시작)' }] });
return out;
}
async function chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens }) {
const body = { model: conn.model, max_tokens: maxTokens, system, messages: toAnthropic(messages) }; if (tools.length) body.tools = tools;
const r = await fetchOf(d)(`${base(conn)}/messages`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) });
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); if (!j || !Array.isArray(j.content)) throw new ProviderError('bad_response', '공급자 응답 형식이 올바르지 않습니다');
const blocks = j.content.map((b) => b.type === 'text' ? { type: 'text', text: b.text || '' } : b.type === 'tool_use' ? { type: 'tool_use', id: b.id, name: b.name, input: b.input || {} } : null).filter(Boolean);
return { blocks, usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: j.stop_reason || null, model: j.model || conn.model };
}
// ---------------------------------------------------------------- OpenAI 호환
function toOpenai(system, messages) {
const out = [{ role: 'system', content: system }];
for (const m of messages) {
const tr = m.content.filter((b) => b.type === 'tool_result'); for (const b of tr) out.push({ role: 'tool', tool_call_id: b.toolUseId, content: String(b.output ?? '') });
const text = m.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n'); const calls = m.content.filter((b) => b.type === 'tool_use');
if (m.role === 'assistant') { if (text || calls.length) out.push({ role: 'assistant', content: text || null, ...(calls.length ? { tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.input || {}) } })) } : {}) }); }
else if (text) out.push({ role: 'user', content: text });
}
return out;
}
async function chatOpenai(d, conn, secret, { system, messages, tools, maxTokens }) {
const body = { model: conn.model, max_tokens: maxTokens, messages: toOpenai(system, messages) }; if (tools.length) body.tools = tools;
const r = await fetchOf(d)(`${base(conn)}/chat/completions`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) });
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); const msg = j && j.choices && j.choices[0] && j.choices[0].message; if (!msg) throw new ProviderError('bad_response', '공급자 응답 형식이 올바르지 않습니다');
const blocks = []; if (msg.content) blocks.push({ type: 'text', text: String(msg.content) });
for (const c of msg.tool_calls || []) { let input = {}; try { input = c.function?.arguments ? JSON.parse(c.function.arguments) : {}; } catch (_) { input = { __parse_error: String(c.function?.arguments || '').slice(0, 200) }; } blocks.push({ type: 'tool_use', id: c.id, name: c.function?.name, input }); }
return { blocks, usage: { inputTokens: j.usage?.prompt_tokens ?? null, outputTokens: j.usage?.completion_tokens ?? null }, stopReason: j.choices[0].finish_reason || null, model: j.model || conn.model };
}
// ---------------------------------------------------------------- 로컬 CLI(로그인 세션): claude(Claude Code) · codex(OpenAI Codex CLI)
/**
* [0.3.1] 자동 탐색. HAL 서비스(systemd)의 PATH 에는 사용자 설치 위치(~/.local/bin 등)가 없어 0.3.0 은 bear 에 설치된 CLI 를 찾지 못했다.
* 탐색 순서: 환경 변수(CLAUDE_BIN·CODEX_BIN) → HAL_CLI_DIRS(있으면 이것만) → HOME 의 알려진 설치 위치(~/.local/bin, ~/.npm-global/bin, ~/.claude/local, ~/.codex/packages/standalone/current/bin, ~/node_modules/.bin)
* → /usr/local/bin·/usr/bin → PATH. HOME 은 HAL 실행 계정(HAL_CLI_HOME 으로 바꿀 수 있다 — 테스트용). 심볼릭 링크(~/.local/bin/claude → ~/.local/share/claude/versions/…)도 실행 가능하면 인정.
* 로그인 상태: claude 는 `claude auth status`(JSON loggedIn), codex 는 `codex login status`("Logged in …"). 로그인 안 됨은 HAL 이 대신할 수 없다 — 서버에서 그 계정으로 `claude login` / `codex login`.
*/
export const CLI_TOOLS = {
claude: { label: 'Claude Code CLI', defaultModel: 'claude-opus-5-5', loginCommand: 'claude login', envBin: 'CLAUDE_BIN' },
codex: { label: 'OpenAI Codex CLI', defaultModel: 'gpt-5.5', loginCommand: 'codex login', envBin: 'CODEX_BIN' },
};
const cliHome = () => process.env.HAL_CLI_HOME || homedir();
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. PATH 에는 탐색 위치를 더해 CLI 가 자기 보조 실행 파일(node 등)을 찾게 한다. */
export const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch (_) { return false; } };
export function cliSearchDirs() {
const h = cliHome(); const forced = (process.env.HAL_CLI_DIRS || '').split(':').filter(Boolean); if (forced.length) return forced;
return [...new Set([join(h, '.local', 'bin'), join(h, '.npm-global', 'bin'), join(h, '.claude', 'local'), join(h, '.codex', 'packages', 'standalone', 'current', 'bin'), join(h, 'node_modules', '.bin'), '/usr/local/bin', '/usr/bin', ...(process.env.PATH || '').split(':').filter(Boolean)])];
}
export function findCliBin(tool) {
const t = CLI_TOOLS[tool]; if (!t) return null; const env = process.env[t.envBin]; if (env && isExec(env)) return env;
for (const d of cliSearchDirs()) { const p = join(d, tool); if (isExec(p)) return p; } return null;
}
const runCli = (bin, args, timeout = 20000) => new Promise((resolve) => execFile(bin, args, { env: cliEnv(), timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') })));
/** 로그인 상태. loggedIn: true/false/null(알 수 없음 — 막지 않는다) */
export async function cliLoginStatus(tool, bin) {
if (tool === 'claude') { const r = await runCli(bin, ['auth', 'status']); try { const j = JSON.parse(r.stdout.trim()); return { loggedIn: 'loggedIn' in j ? !!j.loggedIn : null, method: j.authMethod || null }; } catch (_) { return { loggedIn: null, method: null }; } }
if (tool === 'codex') { const r = await runCli(bin, ['login', 'status']); const t = r.stdout + '\n' + r.stderr; if (/not logged in/i.test(t)) return { loggedIn: false, method: null }; if (/logged in/i.test(t)) return { loggedIn: true, method: /chatgpt/i.test(t) ? 'chatgpt' : /api key/i.test(t) ? 'api_key' : null }; return { loggedIn: null, method: null }; }
return { loggedIn: null, method: null };
}
/** codex 가 로그인 상태로 실행될 때 백엔드 모델 목록을 ~/.codex/models_cache.json 에 둔다(Webmaker engine-codex 와 같은 해석). 숨김(visibility=hide)은 뺀다. */
export function codexModels() { try { const j = JSON.parse(readFileSync(join(cliHome(), '.codex', 'models_cache.json'), 'utf8')); const ms = Array.isArray(j) ? j : (j.models || []); return ms.filter((m) => m && m.slug && m.visibility !== 'hide').map((m) => m.slug); } catch (_) { return []; } }
export function codexDefaultModel() { try { const m = readFileSync(join(cliHome(), '.codex', 'config.toml'), 'utf8').match(/^model\s*=\s*"([^"]+)"/m); return m ? m[1] : null; } catch (_) { return null; } }
export function cliDefaultModel(tool) { if (tool === 'codex') return codexDefaultModel() || codexModels()[0] || CLI_TOOLS.codex.defaultModel; return CLI_TOOLS.claude.defaultModel; }
/** 연결 설정의 CLI 종류: cli.tool → 실행 파일 이름 → claude */
export function cliToolOf(conn) { const c = conn.cli || {}; if (c.tool && CLI_TOOLS[c.tool]) return c.tool; if (c.bin) { const b = basename(c.bin); if (CLI_TOOLS[b]) return b; } return 'claude'; }
export const cliBin = (conn) => (conn.cli && conn.cli.bin) || findCliBin(cliToolOf(conn)) || null;
/** 서버에서 찾은 CLI 전부(버전·로그인·기본 모델·모델 목록). AI 연동 화면과 /ops/api/ai/cli 가 쓴다. */
export async function discoverClis() {
const clis = [];
for (const tool of Object.keys(CLI_TOOLS)) {
const bin = findCliBin(tool); if (!bin) continue; const t = CLI_TOOLS[tool];
const v = await runCli(bin, ['--version'], 15000); const login = await cliLoginStatus(tool, bin); let realpath = bin; try { realpath = realpathSync(bin); } catch (_) {}
clis.push({ tool, label: t.label, bin, realpath, version: v.err ? null : v.stdout.trim().split('\n')[0], versionError: v.err ? (v.err.code || v.err.message) : null, loggedIn: login.loggedIn, loginMethod: login.method, defaultModel: cliDefaultModel(tool), models: tool === 'codex' ? codexModels() : [], loginHint: login.loggedIn === false ? `서버(HAL 실행 계정)에서 '${t.loginCommand}' 을 실행해 로그인하세요 — HAL 이 대신 로그인할 수 없습니다` : null });
}
return { clis, searched: cliSearchDirs(), home: cliHome() };
}
export async function cliInfo(conn) {
const tool = cliToolOf(conn); const t = CLI_TOOLS[tool]; const bin = cliBin(conn);
if (!bin || !isExec(bin)) throw new ProviderError('cli_unavailable', `${t.label} 실행 파일을 찾지 못했습니다(탐색 위치: ${cliSearchDirs().slice(0, 7).join(', ')}). 서버에 설치하거나 연결 설정에 절대 경로를 적으세요.`);
const v = await runCli(bin, ['--version'], 15000); if (v.err) throw new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${v.err.code || v.err.message}`);
const login = await cliLoginStatus(tool, bin);
return { tool, label: t.label, bin, version: v.stdout.trim().split('\n')[0], loggedIn: login.loggedIn, loginMethod: login.method, models: tool === 'codex' ? codexModels() : [], defaultModel: cliDefaultModel(tool), loginCommand: t.loginCommand };
}
/** 대화 기록을 CLI 프롬프트 글로 편다(도구 없음). */
function transcriptText(messages) {
return messages.map((m) => m.content.filter((b) => b.type === 'text').map((b) => `${m.role === 'assistant' ? '[나]' : '[운영자/참여자]'} ${b.text}`).join('\n')).filter(Boolean).join('\n\n');
}
/** 자식 프로세스 실행 공통: 프로세스 그룹으로 띄워 시간 초과 때 통째로 끝낸다. */
function spawnCollect(bin, args, { cwd, timeoutMs }) {
return new Promise((resolve, reject) => {
let child; try { child = spawn(bin, args, { cwd, env: cliEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: true }); } catch (e) { return reject(new ProviderError('cli_unavailable', `CLI 실행 실패: ${e.message}`)); }
let out = ''; let err = ''; const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGTERM'); } catch (_) {} }, timeoutMs);
child.stdout.on('data', (c) => { out += c.toString(); if (out.length > 4 * 1024 * 1024) out = out.slice(-4 * 1024 * 1024); }); child.stderr.on('data', (c) => { err += c.toString(); if (err.length > 20000) err = err.slice(-20000); });
child.on('error', (e) => { clearTimeout(timer); reject(new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${e.code || e.message}`)); });
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }); });
});
}
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
/** [0.3.2] HAL MCP 서버 설정(로컬 CLI 연결에 자동으로 붙이고, AI 연동 화면에서 복사용으로도 보인다). 비밀 없음 — 데이터 경로만. */
export function mcpConfig(d) {
const index = join(ROOT, 'hal', 'index.mjs'); const env = { ARIWEB_HAL_DATA: d.config.dataDir };
const claude = { mcpServers: { hal: { command: process.execPath, args: [index, 'mcp'], env } } };
const codexArgs = ['-c', `mcp_servers.hal.command=${JSON.stringify(process.execPath)}`, '-c', `mcp_servers.hal.args=${JSON.stringify([index, 'mcp'])}`, '-c', `mcp_servers.hal.env={ARIWEB_HAL_DATA=${JSON.stringify(d.config.dataDir)}}`];
const codexToml = `[mcp_servers.hal]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([index, 'mcp'])}\n\n[mcp_servers.hal.env]\nARIWEB_HAL_DATA = ${JSON.stringify(d.config.dataDir)}\n`;
return { claude, codexArgs, codexToml, claudeCommand: `claude --mcp-config <hal-mcp.json 경로> --strict-mcp-config --allowedTools mcp__hal`, note: 'HAL MCP 서버는 읽기 도구만 바로 실행하고, 쓰기·고위험 도구는 운영자 승인 요청으로 올립니다(hal_action_status 로 확인).' };
}
function chatCli(d, conn, { system, messages, mcp }) {
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
return tool === 'codex' ? chatCodex(d, conn, bin, { system, messages, mcp }) : chatClaude(d, conn, bin, { system, messages, mcp });
}
async function chatClaude(d, conn, bin, { system, messages, mcp }) {
const prompt = transcriptText(messages) + '\n\n위 대화의 마지막 발언에 답하라.';
const args = ['-p', prompt, '--output-format', 'json', '--model', conn.model || CLI_TOOLS.claude.defaultModel, '--append-system-prompt', system, '--no-session-persistence', ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
let mcpFile = null; if (mcp) { const dir = mkdtempSync(join(tmpdir(), 'hal-mcp-')); mcpFile = join(dir, 'hal-mcp.json'); writeFileSync(mcpFile, JSON.stringify(mcpConfig(d).claude), { mode: 0o600 }); args.push('--mcp-config', mcpFile, '--strict-mcp-config', '--allowedTools', 'mcp__hal'); } // [0.3.2] HAL 도구를 MCP 로 바로 쓰게 한다
let res; try { res = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { if (mcpFile) { try { rmSync(dirname(mcpFile), { recursive: true, force: true }); } catch (_) {} } }
const { code, out, err } = res;
let j = null; const lines = out.trim().split('\n'); for (let i = lines.length - 1; i >= 0 && !j; i--) { try { const x = JSON.parse(lines[i]); if (x && (x.type === 'result' || x.result !== undefined)) j = x; } catch (_) {} }
if (code !== 0 && !j) throw new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`);
if (!j) throw new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다');
if (j.is_error) { const msg = String(j.result || '오류'); throw new ProviderError(/authenticate|login|OAuth/i.test(msg) ? 'cli_not_logged_in' : 'cli_failed', `Claude Code CLI 오류: ${msg}${/authenticate|login|OAuth/i.test(msg) ? ` — 서버에서 '${CLI_TOOLS.claude.loginCommand}' 로 다시 로그인하세요` : ''}`); }
return { blocks: [{ type: 'text', text: String(j.result ?? j.text ?? '') }], usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: 'end_turn', model: conn.model, sessionId: j.session_id || null };
}
/** codex exec --json: 읽기 전용 샌드박스·임시 작업 폴더·세션 비보존. JSONL 의 item.completed(agent_message) 가 답, turn.completed.usage 가 사용량. */
async function chatCodex(d, conn, bin, { system, messages, mcp }) {
const prompt = `${system}\n\n---\n${transcriptText(messages)}\n\n위 대화의 마지막 발언에 답하라.`; const dir = mkdtempSync(join(tmpdir(), 'hal-codex-'));
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), ...(mcp ? mcpConfig(d).codexArgs : []), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])]; // [0.3.2] -c mcp_servers.hal.* 로 HAL 도구 제공
let res; try { res = await spawnCollect(bin, args, { cwd: dir, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { try { rmSync(dir, { recursive: true, force: true }); } catch (_) {} }
let text = ''; let usage = { inputTokens: null, outputTokens: null }; let errText = '';
for (const line of res.out.split('\n')) { let ev; try { ev = JSON.parse(line); } catch (_) { continue; } if (!ev || typeof ev !== 'object') continue; if (ev.type === 'item.completed' && ev.item && ev.item.type === 'agent_message' && ev.item.text) text = String(ev.item.text).trim(); else if (ev.type === 'turn.completed' && ev.usage) usage = { inputTokens: ev.usage.input_tokens ?? null, outputTokens: ev.usage.output_tokens ?? null }; else if (ev.type === 'error') errText = String(ev.message || ev.error || JSON.stringify(ev)).slice(0, 300); }
if (!text) { const tail = errText || res.err.trim().split('\n').pop() || `종료 코드 ${res.code}`; throw new ProviderError(/login|auth|unauthorized|401/i.test(tail) ? 'cli_not_logged_in' : 'cli_failed', `Codex CLI 오류: ${tail}${/login|auth|unauthorized|401/i.test(tail) ? ` — 서버에서 '${CLI_TOOLS.codex.loginCommand}' 을 실행하세요` : ''}`); }
return { blocks: [{ type: 'text', text }], usage, stopReason: 'end_turn', model: conn.model };
}