bizrouter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # bizrouter CLI
2
+
3
+ 쓰던 코딩 에이전트를 그대로 두고, 명령 앞에 `bizrouter` 만 붙이면 BizRouter 로 연결됩니다.
4
+
5
+ ```bash
6
+ npm install -g bizrouter # 또는: curl -fsSL https://bizrouter.ai/install.sh | bash
7
+ bizrouter login # 브라우저에서 API 키를 발급받아 붙여 넣습니다 (한 번만)
8
+ bizrouter claude # Claude Code
9
+ bizrouter codex # Codex CLI
10
+ bizrouter opencode # OpenCode
11
+ bizrouter hermes # Hermes
12
+ ```
13
+
14
+ 설치 없이 바로 써 볼 수도 있습니다: `npx bizrouter claude`
15
+
16
+ ## 무엇을 해 주나
17
+
18
+ | 도구 | 실행 시 하는 일 | 설정 파일 변경 |
19
+ | --- | --- | --- |
20
+ | Claude Code | `ANTHROPIC_BASE_URL=https://api.bizrouter.ai/claude`, 토큰, 빈 `ANTHROPIC_API_KEY`, 모델 선택창 자동 채움(`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`)을 넘기고, 사용자 settings.json 의 다른 게이트웨이 설정이 이기지 못하도록 명령행 settings 로 고정 | 없음 |
21
+ | Codex CLI | `-c` 오버라이드로 BizRouter provider(Responses API)와 모델·추론 강도 지정 | 없음 |
22
+ | OpenCode | `OPENCODE_CONFIG_CONTENT` 로 이 키가 쓸 수 있는 모델 전체가 담긴 `bizrouter` provider 를 주입 | 없음 |
23
+ | Hermes | `custom` provider(`CUSTOM_BASE_URL`/`CUSTOM_API_KEY`)와 모델을 이번 실행에만 지정 | 없음 |
24
+
25
+ 우리 옵션 뒤의 인자는 도구에 그대로 전달됩니다.
26
+
27
+ ```bash
28
+ bizrouter claude --model anthropic/claude-opus-5 -p "실패하는 테스트 고쳐줘"
29
+ bizrouter codex --model openai/gpt-5.5 --reasoning-effort high exec "README 요약해줘"
30
+ bizrouter opencode --model google/gemini-3.5-pro
31
+ bizrouter claude --dry-run # 실행 대신 넘겨줄 환경 변수와 명령을 보여줍니다
32
+ ```
33
+
34
+ ## 관리 명령
35
+
36
+ ```bash
37
+ bizrouter models [검색어] # 이 키로 쓸 수 있는 모델·컨텍스트·원화 단가
38
+ bizrouter doctor # 키·연결·설치·충돌 점검
39
+ bizrouter setup claude|codex|opencode # 도구 설정 파일에 BizRouter 를 영구 등록 (백업 생성)
40
+ bizrouter auth / logout / env / update
41
+ ```
42
+
43
+ `BIZROUTER_API_KEY` 환경 변수가 있으면 저장된 키보다 우선합니다(CI·컨테이너). 설정은 `~/.bizrouter/`(`BIZROUTER_CONFIG_DIR` 로 변경) 에 권한 600 으로 저장됩니다.
44
+
45
+ ## 알아둘 것
46
+
47
+ - Claude Code 는 Anthropic 전용 필드(`thinking`, `cache_control`)를 항상 보내므로 Claude 모델만 실행할 수 있습니다. GPT·Gemini 는 Codex·OpenCode·Hermes 로 실행하세요.
48
+ - 요구 사항: Node.js 18 이상. 각 코딩 에이전트는 따로 설치돼 있어야 합니다.
49
+
50
+ 문서: https://bizrouter.ai/docs/cli
package/dist/api.js ADDED
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { apiBase, ensureCacheDir, readJsonFile, writePrivateJson } from './config.js';
4
+ import { CliError } from './ui.js';
5
+ const CACHE_TTL_MS = 10 * 60 * 1000;
6
+ const REQUEST_TIMEOUT_MS = 8000;
7
+ function keyHash(apiKey) {
8
+ return createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
9
+ }
10
+ function cachePath(apiKey) {
11
+ return join(ensureCacheDir(), `models-${keyHash(apiKey)}.json`);
12
+ }
13
+ export class ApiError extends CliError {
14
+ status;
15
+ constructor(status, message, hint) {
16
+ super(message, { hint });
17
+ this.status = status;
18
+ }
19
+ }
20
+ async function requestJson(path, apiKey) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
23
+ let response;
24
+ try {
25
+ response = await fetch(`${apiBase()}${path}`, {
26
+ headers: { Authorization: `Bearer ${apiKey}`, 'User-Agent': 'bizrouter-cli' },
27
+ redirect: 'manual',
28
+ signal: controller.signal,
29
+ });
30
+ }
31
+ catch (error) {
32
+ const reason = error instanceof Error && error.name === 'AbortError' ? '응답이 8초 안에 오지 않았습니다' : String(error);
33
+ throw new CliError(`BizRouter API(${apiBase()})에 연결할 수 없습니다: ${reason}`, {
34
+ hint: '네트워크나 프록시 설정을 확인하세요. 사내망이면 api.bizrouter.ai 로 나가는 HTTPS 가 열려 있어야 합니다.',
35
+ });
36
+ }
37
+ finally {
38
+ clearTimeout(timer);
39
+ }
40
+ if (response.status === 401 || response.status === 403) {
41
+ throw new ApiError(response.status, 'API 키가 유효하지 않습니다.', '`bizrouter login` 으로 키를 다시 저장하세요.');
42
+ }
43
+ if (!response.ok) {
44
+ throw new ApiError(response.status, `BizRouter API 가 ${response.status} 를 돌려주었습니다.`);
45
+ }
46
+ return response.json();
47
+ }
48
+ /**
49
+ * Load the model catalog the API key may use. Results are cached for ten
50
+ * minutes per key so that launching a harness does not wait on the network
51
+ * every time; `force` bypasses the cache.
52
+ */
53
+ export async function fetchCatalog(apiKey, options = {}) {
54
+ const path = cachePath(apiKey);
55
+ const cached = readJsonFile(path);
56
+ const fresh = cached && Date.now() - Date.parse(cached.fetched_at) < CACHE_TTL_MS;
57
+ if (cached && fresh && !options.force) {
58
+ return { models: cached.models, exchange_rate: cached.exchange_rate, fetched_at: cached.fetched_at, from_cache: true };
59
+ }
60
+ try {
61
+ const body = (await requestJson('/v1/models', apiKey));
62
+ const models = Array.isArray(body.models) ? body.models : [];
63
+ const exchange_rate = Number(body.exchange_rate ?? 0);
64
+ const fetched_at = new Date().toISOString();
65
+ writePrivateJson(path, { key_hash: keyHash(apiKey), fetched_at, models, exchange_rate });
66
+ return { models, exchange_rate, fetched_at, from_cache: false };
67
+ }
68
+ catch (error) {
69
+ if (cached && options.allowStale && !(error instanceof ApiError && (error.status === 401 || error.status === 403))) {
70
+ return { models: cached.models, exchange_rate: cached.exchange_rate, fetched_at: cached.fetched_at, from_cache: true };
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+ /** Chat-capable, text-producing models: what a coding agent can actually drive. */
76
+ export function chatModels(models) {
77
+ return models.filter((m) => (m.api_type === 'chat_completions' || m.api_type === 'responses') && m.output_modalities.includes('text'));
78
+ }
79
+ export function findModel(models, id) {
80
+ return models.find((m) => m.id === id);
81
+ }
package/dist/args.js ADDED
@@ -0,0 +1,64 @@
1
+ export const REASONING_EFFORTS = ['max', 'xhigh', 'high', 'medium', 'low', 'minimal', 'none'];
2
+ export class ArgError extends Error {
3
+ }
4
+ /**
5
+ * Parse launcher flags. Like Ori, our own flags come first; the first token we
6
+ * do not recognise (or `--`) ends our parsing and everything after it goes to
7
+ * the harness untouched, so `bizrouter claude -p "hi"` still works.
8
+ */
9
+ export function parseLaunchArgs(argv) {
10
+ const out = { closedNetwork: false, dryRun: false, help: false, passthrough: [] };
11
+ let i = 0;
12
+ const takeValue = (flag) => {
13
+ const value = argv[i + 1];
14
+ if (value === undefined || value.startsWith('-'))
15
+ throw new ArgError(`${flag} 뒤에 값이 필요합니다.`);
16
+ i += 2;
17
+ return value;
18
+ };
19
+ while (i < argv.length) {
20
+ const token = argv[i];
21
+ if (token === '--') {
22
+ out.passthrough.push(...argv.slice(i + 1));
23
+ return out;
24
+ }
25
+ const eq = token.indexOf('=');
26
+ const name = token.startsWith('--') && eq > 0 ? token.slice(0, eq) : token;
27
+ const inline = token.startsWith('--') && eq > 0 ? token.slice(eq + 1) : undefined;
28
+ const value = (flag) => (inline !== undefined ? ((i += 1), inline) : takeValue(flag));
29
+ switch (name) {
30
+ case '--model':
31
+ case '-m':
32
+ out.model = value(name);
33
+ continue;
34
+ case '--reasoning-effort': {
35
+ const v = value(name);
36
+ if (!REASONING_EFFORTS.includes(v)) {
37
+ throw new ArgError(`--reasoning-effort 값은 ${REASONING_EFFORTS.join(', ')} 중 하나여야 합니다.`);
38
+ }
39
+ out.reasoningEffort = v;
40
+ continue;
41
+ }
42
+ case '--closed-network':
43
+ out.closedNetwork = true;
44
+ i += 1;
45
+ continue;
46
+ case '--dry-run':
47
+ out.dryRun = true;
48
+ i += 1;
49
+ continue;
50
+ case '--help':
51
+ case '-h':
52
+ out.help = true;
53
+ i += 1;
54
+ continue;
55
+ default:
56
+ out.passthrough.push(...argv.slice(i));
57
+ return out;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ export function hasFlag(args, ...flags) {
63
+ return args.some((a) => flags.some((f) => a === f || a.startsWith(`${f}=`)));
64
+ }
@@ -0,0 +1,18 @@
1
+ import { spawn } from 'node:child_process';
2
+ /** Best-effort browser open; returns false when nothing could be started. */
3
+ export function openInBrowser(url) {
4
+ const [cmd, args] = process.platform === 'darwin'
5
+ ? ['open', [url]]
6
+ : process.platform === 'win32'
7
+ ? ['cmd', ['/c', 'start', '', url]]
8
+ : ['xdg-open', [url]];
9
+ try {
10
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
11
+ child.on('error', () => undefined);
12
+ child.unref();
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
@@ -0,0 +1,44 @@
1
+ import { chatModels } from './api.js';
2
+ /** Used when the catalog cannot be reached and the user pinned nothing. */
3
+ export const FALLBACK_DEFAULT = {
4
+ codex: 'openai/gpt-5.5',
5
+ opencode: 'anthropic/claude-sonnet-5',
6
+ hermes: 'anthropic/claude-sonnet-5',
7
+ };
8
+ export function isAnthropicModelId(id) {
9
+ return /claude|anthropic/i.test(id);
10
+ }
11
+ /**
12
+ * Pick a sensible default model per harness from the models this key may use.
13
+ * Claude Code keeps its own alias defaults (sonnet/opus), so it returns
14
+ * undefined and lets the family mapping on the gateway decide.
15
+ */
16
+ export function pickDefaultModel(harness, models) {
17
+ if (harness === 'claude')
18
+ return undefined;
19
+ const chat = chatModels(models);
20
+ const ids = chat.map((m) => m.id);
21
+ const first = (pred) => ids.find(pred);
22
+ if (harness === 'codex') {
23
+ return (first((id) => id.startsWith('openai/') && /codex/i.test(id)) ??
24
+ first((id) => id.startsWith('openai/gpt-')) ??
25
+ first(isAnthropicModelId) ??
26
+ ids[0]);
27
+ }
28
+ return (first((id) => id === 'anthropic/claude-sonnet-5') ??
29
+ first((id) => id.startsWith('anthropic/claude-sonnet')) ??
30
+ first(isAnthropicModelId) ??
31
+ first((id) => id.startsWith('openai/gpt-')) ??
32
+ ids[0]);
33
+ }
34
+ /** Return the closest catalog ids for an unknown id, for error hints. */
35
+ export function suggestModels(models, wanted, limit = 5) {
36
+ const needle = wanted.toLowerCase().split('/').pop() ?? wanted.toLowerCase();
37
+ const tokens = needle.split(/[-_.\s]+/).filter(Boolean);
38
+ return chatModels(models)
39
+ .map((m) => ({ id: m.id, score: tokens.filter((t) => m.id.toLowerCase().includes(t)).length }))
40
+ .filter((x) => x.score > 0)
41
+ .sort((a, b) => b.score - a.score)
42
+ .slice(0, limit)
43
+ .map((x) => x.id);
44
+ }
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { fetchCatalog, chatModels } from '../api.js';
3
+ import { apiBase, ENV_KEY_NAME, HARNESSES, resolveCredential } from '../config.js';
4
+ import { binaryVersion } from '../spawn.js';
5
+ import { c, maskKey, print } from '../ui.js';
6
+ import { claudeSettingsPath, codexConfigPath, opencodeConfigPath } from './setup.js';
7
+ const ICON = { ok: c.green('✔'), warn: c.yellow('⚠'), fail: c.red('✖') };
8
+ async function probe(url, apiKey) {
9
+ const controller = new AbortController();
10
+ const timer = setTimeout(() => controller.abort(), 6000);
11
+ try {
12
+ const started = Date.now();
13
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, redirect: 'manual', signal: controller.signal });
14
+ return `${res.status} (${Date.now() - started}ms)`;
15
+ }
16
+ catch (error) {
17
+ return `연결 실패: ${error instanceof Error ? (error.name === 'AbortError' ? '6초 초과' : error.message) : String(error)}`;
18
+ }
19
+ finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
23
+ /** Environment and config that would override what the launchers set. */
24
+ export function conflictFindings(env, files) {
25
+ const findings = [];
26
+ const base = apiBase();
27
+ if (env.ANTHROPIC_API_KEY) {
28
+ findings.push({
29
+ level: 'warn',
30
+ text: `ANTHROPIC_API_KEY 가 셸에 설정돼 있습니다 (${maskKey(env.ANTHROPIC_API_KEY)}).`,
31
+ hint: '`bizrouter claude` 는 실행 시 빈 값으로 덮어쓰므로 동작에는 문제가 없지만, `claude` 를 직접 실행하면 Anthropic 으로 바로 갑니다.',
32
+ });
33
+ }
34
+ if (env.ANTHROPIC_BASE_URL && !env.ANTHROPIC_BASE_URL.startsWith(base)) {
35
+ findings.push({ level: 'warn', text: `ANTHROPIC_BASE_URL 이 다른 주소를 가리킵니다: ${env.ANTHROPIC_BASE_URL}`, hint: '`bizrouter claude` 는 이 값을 덮어씁니다.' });
36
+ }
37
+ if (env.OPENAI_BASE_URL) {
38
+ findings.push({ level: 'warn', text: `OPENAI_BASE_URL 이 설정돼 있습니다: ${env.OPENAI_BASE_URL}`, hint: 'Codex 는 provider 설정이 우선이라 영향이 없지만, 다른 도구가 이 값을 읽을 수 있습니다.' });
39
+ }
40
+ if (files.claudeSettings) {
41
+ try {
42
+ const parsed = JSON.parse(files.claudeSettings);
43
+ const keys = Object.keys(parsed.env ?? {}).filter((k) => k.startsWith('ANTHROPIC_') || k.startsWith('CLAUDE_CODE_'));
44
+ const foreignBase = parsed.env?.ANTHROPIC_BASE_URL && !parsed.env.ANTHROPIC_BASE_URL.startsWith(base);
45
+ if (foreignBase) {
46
+ findings.push({
47
+ level: 'warn',
48
+ text: `Claude Code settings.json 의 env 가 다른 게이트웨이를 가리킵니다: ${parsed.env?.ANTHROPIC_BASE_URL}`,
49
+ hint: '`bizrouter claude` 는 명령행 settings 로 덮어쓰지만, `claude` 단독 실행은 그 주소로 갑니다. `bizrouter setup claude` 로 갈아탈 수 있습니다.',
50
+ });
51
+ }
52
+ else if (keys.length) {
53
+ findings.push({ level: 'ok', text: `Claude Code settings.json 에 env ${keys.length}개가 있고 BizRouter 와 충돌하지 않습니다.` });
54
+ }
55
+ }
56
+ catch {
57
+ findings.push({ level: 'warn', text: 'Claude Code settings.json 을 JSON 으로 읽을 수 없습니다.', hint: 'Claude Code 자체도 이 파일을 못 읽습니다. 문법을 확인하세요.' });
58
+ }
59
+ }
60
+ if (files.codexConfig) {
61
+ const provider = /^\s*model_provider\s*=\s*"([^"]+)"/m.exec(files.codexConfig)?.[1];
62
+ if (provider && provider !== 'bizrouter') {
63
+ findings.push({ level: 'ok', text: `Codex config.toml 의 기본 provider 는 "${provider}" 입니다.`, hint: '`bizrouter codex` 는 -c 로 덮어쓰므로 그대로 두어도 됩니다. `codex` 단독으로도 BizRouter 를 쓰려면 `bizrouter setup codex`.' });
64
+ }
65
+ }
66
+ return findings;
67
+ }
68
+ export async function doctorCommand() {
69
+ const findings = [];
70
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
71
+ findings.push(nodeMajor >= 18
72
+ ? { level: 'ok', text: `Node.js ${process.versions.node}` }
73
+ : { level: 'fail', text: `Node.js ${process.versions.node} — 18 이상이 필요합니다.` });
74
+ const cred = resolveCredential();
75
+ if (cred.source === 'none') {
76
+ findings.push({ level: 'fail', text: '인증 정보가 없습니다.', hint: `\`bizrouter login\` 또는 ${ENV_KEY_NAME} 환경 변수.` });
77
+ }
78
+ else {
79
+ const where = cred.source === 'env' ? `환경 변수 ${ENV_KEY_NAME}` : `저장 파일 ${cred.path}`;
80
+ findings.push({ level: 'ok', text: `인증 출처: ${where} · ${maskKey(cred.apiKey ?? '')}` });
81
+ try {
82
+ const catalog = await fetchCatalog(cred.apiKey ?? '', { force: true });
83
+ findings.push({ level: 'ok', text: `키 유효 · 사용 가능한 채팅 모델 ${chatModels(catalog.models).length}개 (전체 ${catalog.models.length}개)` });
84
+ }
85
+ catch (error) {
86
+ findings.push({ level: 'fail', text: error instanceof Error ? error.message : String(error) });
87
+ }
88
+ const base = apiBase();
89
+ findings.push({ level: 'ok', text: `GET ${base}/v1/models → ${await probe(`${base}/v1/models?limit=1`, cred.apiKey ?? '')}` });
90
+ const claudeModels = await probe(`${base}/claude/v1/models?limit=1`, cred.apiKey ?? '');
91
+ findings.push(claudeModels.startsWith('200')
92
+ ? { level: 'ok', text: `GET ${base}/claude/v1/models → ${claudeModels} (Claude Code /model 선택창 자동 채움)` }
93
+ : { level: 'warn', text: `GET ${base}/claude/v1/models → ${claudeModels}`, hint: 'Claude Code 의 /model 선택창이 내장 목록으로만 채워집니다.' });
94
+ }
95
+ for (const h of HARNESSES) {
96
+ const version = binaryVersion(h);
97
+ findings.push(version ? { level: 'ok', text: `${label(h)}: ${version}` } : { level: 'warn', text: `${label(h)}: 설치되어 있지 않습니다.` });
98
+ }
99
+ const claudeSettings = existsSync(claudeSettingsPath()) ? readFileSync(claudeSettingsPath(), 'utf8') : undefined;
100
+ const codexConfig = existsSync(codexConfigPath()) ? readFileSync(codexConfigPath(), 'utf8') : undefined;
101
+ findings.push(...conflictFindings(process.env, { claudeSettings, codexConfig }));
102
+ if (existsSync(opencodeConfigPath())) {
103
+ findings.push({ level: 'ok', text: `OpenCode 설정 파일: ${opencodeConfigPath()}` });
104
+ }
105
+ for (const f of findings) {
106
+ print(`${ICON[f.level]} ${f.text}`);
107
+ if (f.hint)
108
+ print(` ${c.dim(f.hint)}`);
109
+ }
110
+ const failed = findings.some((f) => f.level === 'fail');
111
+ print();
112
+ print(failed ? c.red('해결해야 할 항목이 있습니다.') : c.green('BizRouter 연결 준비가 끝났습니다.'));
113
+ return failed ? 1 : 0;
114
+ }
115
+ function label(h) {
116
+ return { claude: 'Claude Code (claude)', codex: 'Codex CLI (codex)', opencode: 'OpenCode (opencode)', hermes: 'Hermes (hermes)' }[h];
117
+ }
@@ -0,0 +1,97 @@
1
+ import { openInBrowser } from '../browser.js';
2
+ import { fetchCatalog, chatModels } from '../api.js';
3
+ import { ENV_KEY_NAME, KEYS_PAGE_PATH, consoleUrl, credentialsPath, looksLikeApiKey, resolveCredential, saveCredential, clearCredential, } from '../config.js';
4
+ import { readSecret } from '../prompt.js';
5
+ import { c, CliError, fail, info, maskKey, ok, print, warn } from '../ui.js';
6
+ export function parseLoginFlags(argv) {
7
+ const flags = { withKey: false, noBrowser: false };
8
+ for (const a of argv) {
9
+ if (a === '--with-key')
10
+ flags.withKey = true;
11
+ else if (a === '--no-browser')
12
+ flags.noBrowser = true;
13
+ else if (a === '-h' || a === '--help')
14
+ continue;
15
+ else
16
+ throw new CliError(`알 수 없는 옵션입니다: ${a}`, { hint: '사용법: bizrouter login [--with-key] [--no-browser]' });
17
+ }
18
+ return flags;
19
+ }
20
+ export async function loginCommand(argv) {
21
+ const flags = parseLoginFlags(argv);
22
+ const keysUrl = `${consoleUrl()}${KEYS_PAGE_PATH}`;
23
+ const current = resolveCredential();
24
+ if (current.source === 'env') {
25
+ warn(`${ENV_KEY_NAME} 환경 변수가 설정돼 있어 저장된 키보다 우선합니다 (${maskKey(current.apiKey ?? '')}).`);
26
+ }
27
+ if (!flags.withKey) {
28
+ print(`${c.bold('BizRouter 에 로그인합니다.')}`);
29
+ print(`1. 브라우저에서 API 키 페이지를 엽니다: ${c.cyan(keysUrl)}`);
30
+ print('2. 「API 키 만들기」로 키를 발급하거나 기존 키를 복사합니다.');
31
+ print('3. 아래에 키를 붙여 넣습니다. 입력한 글자는 화면에 보이지 않습니다.');
32
+ print();
33
+ if (!flags.noBrowser) {
34
+ if (!openInBrowser(keysUrl))
35
+ info('브라우저를 자동으로 열 수 없어 주소만 표시합니다.');
36
+ }
37
+ }
38
+ const entered = await readSecret(`API 키 (${c.dim('sk-br-v1-…')}): `);
39
+ if (!entered)
40
+ throw new CliError('키가 입력되지 않았습니다.');
41
+ if (!looksLikeApiKey(entered)) {
42
+ throw new CliError('BizRouter API 키 형식이 아닙니다. 키는 sk-br- 로 시작합니다.', {
43
+ hint: `${keysUrl} 에서 발급한 키를 그대로 붙여 넣으세요.`,
44
+ });
45
+ }
46
+ info('키를 확인하는 중…');
47
+ try {
48
+ const catalog = await fetchCatalog(entered, { force: true });
49
+ const path = saveCredential(entered);
50
+ ok(`키를 저장했습니다: ${path} (권한 600)`);
51
+ ok(`이 키로 쓸 수 있는 모델 ${chatModels(catalog.models).length}개를 확인했습니다.`);
52
+ print();
53
+ print(`이제 바로 실행할 수 있습니다:`);
54
+ print(` ${c.bold('bizrouter claude')} Claude Code`);
55
+ print(` ${c.bold('bizrouter codex')} Codex CLI`);
56
+ print(` ${c.bold('bizrouter opencode')} OpenCode`);
57
+ print(` ${c.bold('bizrouter hermes')} Hermes`);
58
+ return 0;
59
+ }
60
+ catch (error) {
61
+ fail(error instanceof Error ? error.message : String(error));
62
+ if (error instanceof CliError && error.hint)
63
+ info(error.hint);
64
+ return 1;
65
+ }
66
+ }
67
+ export async function authCommand() {
68
+ const cred = resolveCredential();
69
+ if (cred.source === 'none') {
70
+ fail('저장된 BizRouter 인증 정보가 없습니다.');
71
+ info(`\`bizrouter login\` 을 실행하거나 ${ENV_KEY_NAME} 환경 변수를 설정하세요.`);
72
+ return 1;
73
+ }
74
+ const where = cred.source === 'env' ? `환경 변수 ${ENV_KEY_NAME}` : `저장된 파일 ${cred.path}`;
75
+ info(`인증 출처: ${where} · 키 ${maskKey(cred.apiKey ?? '')}`);
76
+ try {
77
+ const catalog = await fetchCatalog(cred.apiKey ?? '', { force: true });
78
+ ok(`유효한 키입니다. 사용 가능한 모델 ${chatModels(catalog.models).length}개.`);
79
+ return 0;
80
+ }
81
+ catch (error) {
82
+ fail(error instanceof Error ? error.message : String(error));
83
+ if (error instanceof CliError && error.hint)
84
+ info(error.hint);
85
+ return 1;
86
+ }
87
+ }
88
+ export function logoutCommand() {
89
+ const removed = clearCredential();
90
+ if (removed)
91
+ ok(`저장된 키를 지웠습니다: ${credentialsPath()}`);
92
+ else
93
+ info('저장된 키가 없습니다.');
94
+ if (process.env[ENV_KEY_NAME])
95
+ warn(`${ENV_KEY_NAME} 환경 변수는 그대로 남아 있습니다. 셸에서 unset 하세요.`);
96
+ return 0;
97
+ }
@@ -0,0 +1,57 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { ENV_KEY_NAME } from '../config.js';
3
+ import { c, info, print } from '../ui.js';
4
+ import { requireApiKey } from './shared.js';
5
+ export const VERSION = '0.1.0';
6
+ /** `eval "$(bizrouter env)"` exports the saved key for tools configured by `bizrouter setup`. */
7
+ export function envCommand() {
8
+ const key = requireApiKey();
9
+ print(`export ${ENV_KEY_NAME}=${shellQuote(key)}`);
10
+ return 0;
11
+ }
12
+ function shellQuote(value) {
13
+ return `'${value.replace(/'/g, `'\\''`)}'`;
14
+ }
15
+ export function updateCommand() {
16
+ const viaBun = /[\\/]\.bun[\\/]/.test(process.argv[1] ?? '');
17
+ const [cmd, args] = viaBun ? ['bun', ['add', '-g', 'bizrouter@latest']] : ['npm', ['install', '-g', 'bizrouter@latest']];
18
+ info(`${cmd} ${args.join(' ')}`);
19
+ return new Promise((resolve) => {
20
+ const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' });
21
+ child.on('error', () => resolve(1));
22
+ child.on('exit', (code) => resolve(code ?? 1));
23
+ });
24
+ }
25
+ export function helpText() {
26
+ return `${c.bold('bizrouter')} — 쓰던 코딩 에이전트를 BizRouter 로 연결합니다. (v${VERSION})
27
+
28
+ ${c.bold('시작하기')}
29
+ bizrouter login 브라우저에서 API 키를 발급받아 저장합니다 (한 번만)
30
+ bizrouter claude Claude Code 를 BizRouter 로 실행합니다
31
+ bizrouter codex Codex CLI 를 BizRouter 로 실행합니다
32
+ bizrouter opencode OpenCode 를 BizRouter 로 실행합니다
33
+ bizrouter hermes Hermes 를 BizRouter 로 실행합니다
34
+
35
+ ${c.bold('실행 옵션')} (실행 명령 뒤, 도구 자체 옵션 앞에 씁니다)
36
+ --model <ID> BizRouter 모델 ID (예: anthropic/claude-sonnet-5, openai/gpt-5.5)
37
+ --reasoning-effort <단계> minimal · low · medium · high · xhigh (Codex·OpenCode)
38
+ --closed-network Claude Code 의 부수 트래픽(버전 확인·텔레메트리)을 끕니다
39
+ --dry-run 실행하지 않고 넘겨줄 환경 변수와 명령만 보여줍니다
40
+ 나머지 옵션은 그대로 도구에 전달됩니다. 예: bizrouter claude -p "테스트 고쳐줘"
41
+
42
+ ${c.bold('관리')}
43
+ bizrouter models [검색어] 이 키로 쓸 수 있는 모델과 원화 단가
44
+ bizrouter setup <도구> claude · codex · opencode 설정 파일에 BizRouter 를 영구 등록
45
+ bizrouter doctor 연결·키·설치·충돌 점검
46
+ bizrouter auth 지금 어떤 키가 쓰이는지 확인
47
+ bizrouter env eval "$(bizrouter env)" 로 ${ENV_KEY_NAME} 내보내기
48
+ bizrouter logout 저장된 키 삭제
49
+ bizrouter update CLI 최신 버전으로 업데이트
50
+
51
+ ${c.bold('환경 변수')}
52
+ ${ENV_KEY_NAME} 저장된 키보다 우선합니다 (CI·컨테이너용)
53
+ BIZROUTER_CONFIG_DIR 설정 폴더 (기본 ~/.bizrouter)
54
+
55
+ 문서: https://bizrouter.ai/docs/cli
56
+ `;
57
+ }
@@ -0,0 +1,57 @@
1
+ import { chatModels, fetchCatalog } from '../api.js';
2
+ import { CliError, c, formatKrw, padEnd, print } from '../ui.js';
3
+ import { requireApiKey } from './shared.js';
4
+ export function parseModelsFlags(argv) {
5
+ const flags = { json: false, all: false, refresh: false };
6
+ for (const a of argv) {
7
+ if (a === '--json')
8
+ flags.json = true;
9
+ else if (a === '--all')
10
+ flags.all = true;
11
+ else if (a === '--refresh')
12
+ flags.refresh = true;
13
+ else if (a.startsWith('-'))
14
+ throw new CliError(`알 수 없는 옵션입니다: ${a}`, { hint: '사용법: bizrouter models [검색어] [--all] [--json] [--refresh]' });
15
+ else
16
+ flags.query = a;
17
+ }
18
+ return flags;
19
+ }
20
+ export function filterModels(models, flags) {
21
+ const base = flags.all ? models : chatModels(models);
22
+ if (!flags.query)
23
+ return base;
24
+ const q = flags.query.toLowerCase();
25
+ return base.filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q));
26
+ }
27
+ function formatContext(tokens) {
28
+ if (tokens >= 1_000_000)
29
+ return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
30
+ if (tokens >= 1000)
31
+ return `${Math.round(tokens / 1000)}K`;
32
+ return String(tokens);
33
+ }
34
+ export async function modelsCommand(argv) {
35
+ const flags = parseModelsFlags(argv);
36
+ const apiKey = requireApiKey();
37
+ const catalog = await fetchCatalog(apiKey, { force: flags.refresh, allowStale: true });
38
+ const rows = filterModels(catalog.models, flags);
39
+ if (flags.json) {
40
+ print(JSON.stringify(rows, null, 2));
41
+ return 0;
42
+ }
43
+ if (rows.length === 0) {
44
+ print(flags.query ? `「${flags.query}」에 맞는 모델이 없습니다.` : '이 키로 쓸 수 있는 모델이 없습니다.');
45
+ return 1;
46
+ }
47
+ const idWidth = Math.min(44, Math.max(...rows.map((m) => m.id.length), 8));
48
+ print(c.dim(`${padEnd('모델 ID', idWidth)} ${padEnd('컨텍스트', 9)} ${padEnd('입력/1M', 10)} ${padEnd('출력/1M', 10)} 유형`));
49
+ for (const m of rows) {
50
+ const type = flags.all ? m.api_type : m.supported_parameters.includes('tools') ? '도구 호출' : '';
51
+ print(`${padEnd(m.id, idWidth)} ${padEnd(formatContext(m.context_length), 9)} ${padEnd(formatKrw(m.input_price_per_1m_krw), 10)} ${padEnd(formatKrw(m.output_price_per_1m_krw), 10)} ${c.dim(type)}`);
52
+ }
53
+ print();
54
+ print(c.dim(`${rows.length}개 · 원화 단가는 100만 토큰당 · 환율 ${catalog.exchange_rate}원/달러 · ${catalog.from_cache ? '캐시(최대 10분)' : '지금 조회'}`));
55
+ print(c.dim('예: bizrouter codex --model openai/gpt-5.5 · bizrouter claude --model anthropic/claude-opus-5'));
56
+ return 0;
57
+ }