ethos-router 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/LICENSE +21 -0
- package/README.md +61 -0
- package/index.js +519 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 베오르 주식회사 (Veor Inc.)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# 에토스 라우터 명령줄 도구
|
|
2
|
+
|
|
3
|
+
키를 손으로 옮기지 않고 붙이는 길입니다. 한 번 로그인하면 코딩 도구가
|
|
4
|
+
에토스 라우터를 보게 됩니다.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm i -g ethos-router
|
|
8
|
+
|
|
9
|
+
ethos login # 브라우저에서 승인하면 키가 저장됩니다
|
|
10
|
+
ethos claude # Claude Code 를 에토스 라우터로 실행
|
|
11
|
+
ethos codex # Codex 를 에토스 라우터로 실행
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 명령
|
|
15
|
+
|
|
16
|
+
| 명령 | 하는 일 |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `ethos login` | 브라우저에서 승인하고 키를 받습니다 |
|
|
19
|
+
| `ethos logout` | 키를 정지하고 설정을 지웁니다 |
|
|
20
|
+
| `ethos whoami` | 어느 워크스페이스로 붙어 있는지 봅니다 |
|
|
21
|
+
| `ethos claude [인자…]` | Claude Code 를 에토스 라우터로 실행합니다 |
|
|
22
|
+
| `ethos codex [인자…]` | Codex 를 에토스 라우터로 실행합니다 |
|
|
23
|
+
| `ethos env` | 다른 도구에 넣을 환경변수를 찍습니다 |
|
|
24
|
+
| `ethos models [검색어]` | 쓸 수 있는 모델과 단가를 봅니다 |
|
|
25
|
+
| `ethos chat "질문"` | 한 번 물어보고 답과 든 비용을 받습니다 |
|
|
26
|
+
| `ethos balance` | 남은 크레딧을 봅니다 |
|
|
27
|
+
| `ethos usage` | 기간별 사용 내역을 봅니다 |
|
|
28
|
+
| `ethos doctor` | 어디가 막혔는지 점검합니다 |
|
|
29
|
+
|
|
30
|
+
## 키
|
|
31
|
+
|
|
32
|
+
`ethos login` 은 키를 **두 개** 받아 `~/.ethos/config.json` 에 0600 으로
|
|
33
|
+
저장합니다. 모델을 부르는 키와 사용 내역을 읽는 키가 따로입니다. 범위가
|
|
34
|
+
갈려 있어서, 하나가 새더라도 그 키로는 절반만 할 수 있습니다.
|
|
35
|
+
|
|
36
|
+
두 키는 콘솔의 API 키 목록에 `CLI` 로 뜹니다. 거기서 정지할 수 있고,
|
|
37
|
+
`ethos logout` 도 설정만 지우는 게 아니라 그 두 키를 정지시킵니다.
|
|
38
|
+
|
|
39
|
+
## 환경변수
|
|
40
|
+
|
|
41
|
+
| 이름 | 쓰임 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `ETHOS_BASE_URL` | 창구 주소. 시험 서버를 보실 때 바꿉니다 |
|
|
44
|
+
| `ETHOS_API_KEY` | 모델 호출 키. 설정 파일보다 우선합니다 |
|
|
45
|
+
| `ETHOS_USAGE_KEY` | 사용 조회 키. 설정 파일보다 우선합니다 |
|
|
46
|
+
|
|
47
|
+
브라우저가 없는 곳(CI)에서는 `ethos login --with-key <키>` 를 쓰거나
|
|
48
|
+
`ETHOS_API_KEY` 를 넣으세요.
|
|
49
|
+
|
|
50
|
+
## 사용 내역을 파일로
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
ethos usage --from 2026-09-01 --to 2026-09-30 --group 부서 --csv 사용내역서.csv
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
엑셀이 바로 여는 파일로 저장됩니다. 금액은 공급가액(부가세 별도)입니다.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
문의 [router@ethos.ai.kr](mailto:router@ethos.ai.kr) · 문서
|
|
61
|
+
<https://router.ethos.ai.kr/docs#cli>
|
package/index.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================
|
|
3
|
+
// 에토스 라우터 명령줄 도구.
|
|
4
|
+
//
|
|
5
|
+
// 하는 일은 하나다: **코딩 도구가 에토스 라우터를 보게 만든다.**
|
|
6
|
+
// Claude Code·Codex 같은 도구는 각자 정해진 환경변수로 창구 주소와 키를
|
|
7
|
+
// 읽는다. 그 값을 사람이 매번 export 하게 두면 키가 셸 기록에 남고,
|
|
8
|
+
// 기계마다 다르게 걸려서 "왜 나만 안 되냐"가 된다.
|
|
9
|
+
//
|
|
10
|
+
// 의존성이 없다. 설치가 곧 신뢰 비용이라, 키를 다루는 도구일수록
|
|
11
|
+
// 남의 코드를 끌고 오지 않는다. Node 18 의 fetch 만 쓴다.
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir, hostname } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const VERSION = "0.1.0";
|
|
20
|
+
const DEFAULT_BASE = "https://router.ethos.ai.kr";
|
|
21
|
+
const CONFIG_PATH = join(homedir(), ".ethos", "config.json");
|
|
22
|
+
|
|
23
|
+
// ── 출력 ──────────────────────────────────────────────────
|
|
24
|
+
// 색은 터미널일 때만. 파이프로 넘기면 제어문자가 파일에 섞인다.
|
|
25
|
+
const tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
26
|
+
const c = (n, s) => (tty ? `[${n}m${s}[0m` : String(s));
|
|
27
|
+
const bold = (s) => c(1, s), dim = (s) => c(2, s), green = (s) => c(32, s), red = (s) => c(31, s);
|
|
28
|
+
const out = (s = "") => process.stdout.write(s + "\n");
|
|
29
|
+
const err = (s) => process.stderr.write(s + "\n");
|
|
30
|
+
|
|
31
|
+
function die(msg, hint) {
|
|
32
|
+
err(red("✗ ") + msg);
|
|
33
|
+
if (hint) err(dim(" " + hint));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── 설정 ──────────────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// 키가 들어 있는 파일이다. 남이 못 읽게 0600 으로 쓴다. 홈 디렉터리가
|
|
40
|
+
// 공유되는 기계(학교·회사 공용 서버)에서 기본 권한이면 옆자리가 읽는다.
|
|
41
|
+
function loadConfig() {
|
|
42
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveConfig(cfg) {
|
|
46
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
47
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
48
|
+
try { chmodSync(CONFIG_PATH, 0o600); } catch { /* 윈도우는 권한 모델이 다르다 */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 창구 주소. 시험 서버나 사내 스테이징을 볼 때 바꾼다.
|
|
53
|
+
* (라우터 자체는 SaaS 전용이다 — docs/PLAN.md §8. 사내 설치용 스위치가 아니다.)
|
|
54
|
+
*/
|
|
55
|
+
function baseUrl(cfg = loadConfig()) {
|
|
56
|
+
return (process.env.ETHOS_BASE_URL || cfg.base_url || DEFAULT_BASE).replace(/\/+$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 키를 꺼낸다. 환경변수가 설정 파일을 이긴다 — CI 에서는 파일을 못 쓰고,
|
|
61
|
+
* 한 기계에서 계정을 바꿔 가며 돌릴 때도 환경변수가 이기는 쪽이 덜 놀랍다.
|
|
62
|
+
*/
|
|
63
|
+
function apiKey(cfg = loadConfig()) {
|
|
64
|
+
return process.env.ETHOS_API_KEY || cfg.api_key || "";
|
|
65
|
+
}
|
|
66
|
+
function usageKey(cfg = loadConfig()) {
|
|
67
|
+
return process.env.ETHOS_USAGE_KEY || cfg.usage_key || "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function needKey(cfg) {
|
|
71
|
+
const k = apiKey(cfg);
|
|
72
|
+
if (!k) die("로그인이 필요합니다.", "ethos login");
|
|
73
|
+
return k;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── 서버 ──────────────────────────────────────────────────
|
|
77
|
+
async function call(path, { method = "GET", body, key, base } = {}) {
|
|
78
|
+
const url = (base ?? baseUrl()) + path;
|
|
79
|
+
let res;
|
|
80
|
+
try {
|
|
81
|
+
res = await fetch(url, {
|
|
82
|
+
method,
|
|
83
|
+
headers: {
|
|
84
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
85
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
86
|
+
},
|
|
87
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
88
|
+
});
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// 연결 자체가 안 된 것과 서버가 거절한 것은 고칠 곳이 다르다
|
|
91
|
+
die(`${url} 에 연결하지 못했습니다.`, e.message);
|
|
92
|
+
}
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
let json = null;
|
|
95
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* 본문이 JSON 이 아닐 수 있다 */ }
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
const msg = json?.error?.message || text.slice(0, 200) || `HTTP ${res.status}`;
|
|
98
|
+
const e = new Error(msg);
|
|
99
|
+
e.status = res.status;
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
return { status: res.status, json, text };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
106
|
+
|
|
107
|
+
// ── login ─────────────────────────────────────────────────
|
|
108
|
+
//
|
|
109
|
+
// 브라우저에서 승인하고 터미널이 결과를 받아 간다. 키를 화면에서 복사해
|
|
110
|
+
// 붙여넣게 하지 않는 이유는, 그 순간 키가 셸 기록과 스크롤백에 남기 때문이다.
|
|
111
|
+
async function cmdLogin(args) {
|
|
112
|
+
const cfg = loadConfig();
|
|
113
|
+
const base = args.base || baseUrl(cfg);
|
|
114
|
+
|
|
115
|
+
// CI 에는 브라우저가 없다. 그때만 키를 직접 받는다.
|
|
116
|
+
if (args["with-key"]) {
|
|
117
|
+
cfg.base_url = base;
|
|
118
|
+
cfg.api_key = args["with-key"];
|
|
119
|
+
if (args["usage-key"]) cfg.usage_key = args["usage-key"];
|
|
120
|
+
saveConfig(cfg);
|
|
121
|
+
out(green("✓ ") + `키를 저장했습니다. ${dim(CONFIG_PATH)}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const label = args.label || hostname();
|
|
126
|
+
const { json: start } = await call("/cli/v1/device", { method: "POST", body: { label }, base });
|
|
127
|
+
|
|
128
|
+
out("");
|
|
129
|
+
out(" 브라우저에서 아래 주소를 열고 승인하세요.");
|
|
130
|
+
out(" " + bold(start.verification_url));
|
|
131
|
+
out("");
|
|
132
|
+
out(" 승인 코드 " + bold(start.user_code));
|
|
133
|
+
out(dim(" 화면의 코드가 이 코드와 같은지 꼭 확인하세요."));
|
|
134
|
+
out("");
|
|
135
|
+
|
|
136
|
+
await openBrowser(start.verification_url);
|
|
137
|
+
|
|
138
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
139
|
+
process.stdout.write(dim(" 승인을 기다리는 중…"));
|
|
140
|
+
while (Date.now() < deadline) {
|
|
141
|
+
await sleep(start.interval * 1000);
|
|
142
|
+
let r;
|
|
143
|
+
try {
|
|
144
|
+
r = await call(`/cli/v1/device?device_code=${encodeURIComponent(start.device_code)}`, { base });
|
|
145
|
+
} catch (e) {
|
|
146
|
+
out("");
|
|
147
|
+
die(e.message, "ethos login 을 다시 실행하세요.");
|
|
148
|
+
}
|
|
149
|
+
if (r.status === 200 && r.json?.status === "ready") {
|
|
150
|
+
out("");
|
|
151
|
+
cfg.base_url = base;
|
|
152
|
+
cfg.api_key = r.json.api_key;
|
|
153
|
+
cfg.usage_key = r.json.usage_key;
|
|
154
|
+
cfg.workspace = r.json.workspace;
|
|
155
|
+
saveConfig(cfg);
|
|
156
|
+
out(green("✓ ") + `${r.json.workspace.name} 으로 로그인했습니다.`);
|
|
157
|
+
out(dim(` 키는 ${CONFIG_PATH} 에 저장됩니다. 콘솔의 API 키 목록에서 언제든 정지할 수 있습니다.`));
|
|
158
|
+
out("");
|
|
159
|
+
out(" 다음으로");
|
|
160
|
+
out(" " + bold("ethos claude") + dim(" Claude Code 를 에토스 라우터로 실행"));
|
|
161
|
+
out(" " + bold("ethos models") + dim(" 쓸 수 있는 모델 보기"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
process.stdout.write(dim("."));
|
|
165
|
+
}
|
|
166
|
+
out("");
|
|
167
|
+
die("승인 시간이 지났습니다.", "ethos login 을 다시 실행하세요.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 브라우저를 대신 열어 준다. 안 열려도 주소를 이미 찍었으므로 실패해도 그냥 넘어간다 */
|
|
171
|
+
async function openBrowser(url) {
|
|
172
|
+
const cmd = process.platform === "darwin" ? "open"
|
|
173
|
+
: process.platform === "win32" ? "cmd"
|
|
174
|
+
: "xdg-open";
|
|
175
|
+
const argv = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
176
|
+
try {
|
|
177
|
+
const p = spawn(cmd, argv, { stdio: "ignore", detached: true });
|
|
178
|
+
p.on("error", () => {});
|
|
179
|
+
p.unref();
|
|
180
|
+
} catch { /* 브라우저가 없는 서버일 수 있다 */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── logout ────────────────────────────────────────────────
|
|
184
|
+
//
|
|
185
|
+
// 설정 파일만 지우면 키는 서버에서 살아 있다. 노트북을 잃어버렸을 때
|
|
186
|
+
// 실제로 접근이 끊겨야 하므로, 두 키를 각각 스스로 정지시킨다.
|
|
187
|
+
async function cmdLogout() {
|
|
188
|
+
const cfg = loadConfig();
|
|
189
|
+
const base = baseUrl(cfg);
|
|
190
|
+
let revoked = 0, failed = 0;
|
|
191
|
+
for (const k of [cfg.api_key, cfg.usage_key].filter(Boolean)) {
|
|
192
|
+
try { await call("/cli/v1/logout", { method: "POST", key: k, base }); revoked++; }
|
|
193
|
+
catch { failed++; }
|
|
194
|
+
}
|
|
195
|
+
try { rmSync(CONFIG_PATH); } catch { /* 없으면 그만 */ }
|
|
196
|
+
|
|
197
|
+
if (revoked) out(green("✓ ") + `키 ${revoked}개를 정지하고 설정을 지웠습니다.`);
|
|
198
|
+
else out(green("✓ ") + "설정을 지웠습니다.");
|
|
199
|
+
// 못 지운 키를 조용히 넘기면 살아 있는 키가 어딘가에 남는다
|
|
200
|
+
if (failed) {
|
|
201
|
+
err(dim(` 키 ${failed}개는 정지하지 못했습니다. 콘솔의 API 키 목록에서 직접 정지하세요.`));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 도구 실행 ──────────────────────────────────────────────
|
|
206
|
+
//
|
|
207
|
+
// 도구마다 읽는 환경변수가 다르다. 그 대응표가 여기 한 곳에 있다 —
|
|
208
|
+
// 문서와 코드에 나눠 적으면 도구가 변수 이름을 바꾼 날 한쪽만 고쳐진다.
|
|
209
|
+
const TOOLS = {
|
|
210
|
+
claude: {
|
|
211
|
+
bin: "claude",
|
|
212
|
+
install: "npm i -g @anthropic-ai/claude-code",
|
|
213
|
+
// 앤트로픽 형식 창구. 도구가 뒤에 /v1/messages 를 붙인다.
|
|
214
|
+
env: (key, base) => ({
|
|
215
|
+
ANTHROPIC_BASE_URL: `${base}/claude`,
|
|
216
|
+
ANTHROPIC_AUTH_TOKEN: key,
|
|
217
|
+
ANTHROPIC_API_KEY: key,
|
|
218
|
+
}),
|
|
219
|
+
},
|
|
220
|
+
codex: {
|
|
221
|
+
bin: "codex",
|
|
222
|
+
install: "npm i -g @openai/codex",
|
|
223
|
+
env: (key, base) => ({
|
|
224
|
+
OPENAI_BASE_URL: `${base}/v1`,
|
|
225
|
+
OPENAI_API_KEY: key,
|
|
226
|
+
}),
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
function toolEnv(name, key, base) {
|
|
231
|
+
return TOOLS[name].env(key, base);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function cmdRunTool(name, rest) {
|
|
235
|
+
const cfg = loadConfig();
|
|
236
|
+
const key = needKey(cfg);
|
|
237
|
+
const t = TOOLS[name];
|
|
238
|
+
const env = { ...process.env, ...toolEnv(name, key, baseUrl(cfg)) };
|
|
239
|
+
|
|
240
|
+
const child = spawn(t.bin, rest, { stdio: "inherit", env });
|
|
241
|
+
child.on("error", (e) => {
|
|
242
|
+
if (e.code === "ENOENT") {
|
|
243
|
+
die(`${t.bin} 이 설치돼 있지 않습니다.`, `${t.install} 로 설치한 뒤 다시 실행하세요.`);
|
|
244
|
+
}
|
|
245
|
+
die(e.message);
|
|
246
|
+
});
|
|
247
|
+
child.on("exit", (code, signal) => {
|
|
248
|
+
// 신호로 죽은 것을 0 으로 보고하면 스크립트가 성공으로 읽는다
|
|
249
|
+
process.exit(signal ? 1 : (code ?? 0));
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── env ───────────────────────────────────────────────────
|
|
254
|
+
//
|
|
255
|
+
// 우리가 모르는 도구를 쓰는 사람에게 필요한 것. 무엇을 넣는지 그대로
|
|
256
|
+
// 보여 주므로, 붙는지 안 붙는지를 우리에게 묻지 않고 스스로 확인할 수 있다.
|
|
257
|
+
function cmdEnv(args) {
|
|
258
|
+
const cfg = loadConfig();
|
|
259
|
+
const key = needKey(cfg);
|
|
260
|
+
const base = baseUrl(cfg);
|
|
261
|
+
const vars = { ...toolEnv("claude", key, base), ...toolEnv("codex", key, base) };
|
|
262
|
+
const shell = args.shell || (process.env.SHELL?.includes("fish") ? "fish" : "posix");
|
|
263
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
264
|
+
out(shell === "fish" ? `set -gx ${k} ${v}`
|
|
265
|
+
: shell === "powershell" ? `$env:${k} = "${v}"`
|
|
266
|
+
: `export ${k}=${v}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── models ────────────────────────────────────────────────
|
|
271
|
+
async function cmdModels(args) {
|
|
272
|
+
const term = (args._[0] || "").toLowerCase();
|
|
273
|
+
const { json: d } = await call("/v1/prices");
|
|
274
|
+
const rows = d.models.filter((m) =>
|
|
275
|
+
!term || m.id.toLowerCase().includes(term) || (m.name || "").toLowerCase().includes(term));
|
|
276
|
+
if (!rows.length) return out(dim(term ? `"${term}" 에 맞는 모델이 없습니다.` : "모델이 없습니다."));
|
|
277
|
+
|
|
278
|
+
const n = (v, d2 = 0) => v == null ? "" : Number(v).toLocaleString("ko-KR", { maximumFractionDigits: d2 });
|
|
279
|
+
const w = Math.min(46, Math.max(...rows.map((m) => m.id.length)));
|
|
280
|
+
out(dim(` 적용 환율 ${n(d.fx.usd_krw, 2)}원 · ${d.fx.date} 기준 · 금액은 공급가액(부가세 별도)`));
|
|
281
|
+
out("");
|
|
282
|
+
out(dim(` ${"모델".padEnd(w)} ${"입력/1M".padStart(11)} ${"출력/1M".padStart(11)}`));
|
|
283
|
+
for (const m of rows) {
|
|
284
|
+
// 값이 없으면 단위도 붙이지 않는다. "—원" 은 있는 척하는 빈칸이다
|
|
285
|
+
const i = m.unit === "token" ? m.input_per_1m_krw : m.per_unit_krw;
|
|
286
|
+
const o = m.unit === "token" ? m.output_per_1m_krw : null;
|
|
287
|
+
out(` ${m.id.padEnd(w)} ${(i == null ? "" : n(i) + "원").padStart(11)} ${(o == null ? "" : n(o) + "원").padStart(11)}`);
|
|
288
|
+
}
|
|
289
|
+
out("");
|
|
290
|
+
out(dim(` ${rows.length}개`));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── balance ───────────────────────────────────────────────
|
|
294
|
+
async function cmdBalance() {
|
|
295
|
+
const cfg = loadConfig();
|
|
296
|
+
const k = usageKey(cfg);
|
|
297
|
+
if (!k) die("잔액을 읽을 키가 없습니다.", "ethos login 을 다시 실행하세요.");
|
|
298
|
+
const { json: b } = await call("/usage/v1/billing", { key: k });
|
|
299
|
+
const n = (v) => Number(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
|
300
|
+
out("");
|
|
301
|
+
out(` 잔액 ${bold(n(b.credit?.balanceKrw ?? b.balanceKrw) + "원")} ${dim("공급가액 기준")}`);
|
|
302
|
+
if (b.month) out(` 이번 달 사용 ${n(b.month.costKrw)}원`);
|
|
303
|
+
out("");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── usage ─────────────────────────────────────────────────
|
|
307
|
+
async function cmdUsage(args) {
|
|
308
|
+
const cfg = loadConfig();
|
|
309
|
+
const k = usageKey(cfg);
|
|
310
|
+
if (!k) die("사용 내역을 읽을 키가 없습니다.", "ethos login 을 다시 실행하세요.");
|
|
311
|
+
|
|
312
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
313
|
+
const from = args.from || today.slice(0, 8) + "01";
|
|
314
|
+
const to = args.to || today;
|
|
315
|
+
const group = args.group === "usage_group" || args.group === "부서" ? "usage_group" : "model";
|
|
316
|
+
const q = `from=${from}&to=${to}&group_by=${group}`;
|
|
317
|
+
|
|
318
|
+
// 파일로 받으면 그대로 회계팀에 보낸다. 엑셀이 여는 형식이라 손댈 게 없다.
|
|
319
|
+
if (args.csv) {
|
|
320
|
+
const res = await fetch(`${baseUrl(cfg)}/usage/v1/statement?${q}&format=csv`, {
|
|
321
|
+
headers: { Authorization: `Bearer ${k}` },
|
|
322
|
+
});
|
|
323
|
+
if (!res.ok) die(`내려받지 못했습니다 (HTTP ${res.status})`);
|
|
324
|
+
writeFileSync(args.csv, Buffer.from(await res.arrayBuffer()));
|
|
325
|
+
out(green("✓ ") + `${args.csv} 로 저장했습니다.`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const { json: d } = await call(`/usage/v1/statement?${q}`, { key: k });
|
|
330
|
+
const n = (v, d2 = 0) => Number(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: d2 });
|
|
331
|
+
if (!d.rows.length) return out(dim(` ${from} ~ ${to} 사용 내역이 없습니다.`));
|
|
332
|
+
|
|
333
|
+
const w = Math.min(46, Math.max(...d.rows.map((r) => r.key.length), 6));
|
|
334
|
+
out("");
|
|
335
|
+
out(dim(` ${d.workspace.name} · ${from} ~ ${to} (KST)`));
|
|
336
|
+
out("");
|
|
337
|
+
out(dim(` ${(group === "model" ? "모델" : "부서·프로젝트").padEnd(w)} ${"요청".padStart(8)} ${"금액".padStart(12)}`));
|
|
338
|
+
for (const r of d.rows) {
|
|
339
|
+
out(` ${r.key.padEnd(w)} ${n(r.requests).padStart(8)} ${(n(r.cost_krw, 2) + "원").padStart(12)}`);
|
|
340
|
+
}
|
|
341
|
+
out(` ${"합계".padEnd(w)} ${n(d.total.requests).padStart(8)} ${bold((n(d.total.cost_krw, 2) + "원").padStart(12))}`);
|
|
342
|
+
out("");
|
|
343
|
+
out(dim(" " + d.note));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── chat ──────────────────────────────────────────────────
|
|
347
|
+
async function cmdChat(args) {
|
|
348
|
+
const cfg = loadConfig();
|
|
349
|
+
const key = needKey(cfg);
|
|
350
|
+
const prompt = args._.join(" ") || await readStdin();
|
|
351
|
+
if (!prompt.trim()) die("물어볼 내용이 없습니다.", 'ethos chat "안녕"');
|
|
352
|
+
|
|
353
|
+
const model = args.m || args.model || cfg.model || "anthropic/claude-sonnet-5";
|
|
354
|
+
const { json: r } = await call("/v1/chat/completions", {
|
|
355
|
+
method: "POST",
|
|
356
|
+
key,
|
|
357
|
+
body: { model, messages: [{ role: "user", content: prompt }] },
|
|
358
|
+
});
|
|
359
|
+
out(r.choices?.[0]?.message?.content ?? "");
|
|
360
|
+
// 얼마 썼는지를 그 자리에서 말한다. 나중에 청구서로 처음 아는 것과 다르다.
|
|
361
|
+
if (r.usage?.cost != null) {
|
|
362
|
+
err(dim(` ${model} · ${Number(r.usage.cost).toLocaleString("ko-KR", { maximumFractionDigits: 4 })}원`));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readStdin() {
|
|
367
|
+
if (process.stdin.isTTY) return Promise.resolve("");
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
let buf = "";
|
|
370
|
+
process.stdin.setEncoding("utf8");
|
|
371
|
+
process.stdin.on("data", (d) => { buf += d; });
|
|
372
|
+
process.stdin.on("end", () => resolve(buf));
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── doctor ────────────────────────────────────────────────
|
|
377
|
+
//
|
|
378
|
+
// "안 되는데요" 를 받았을 때 물어볼 것을 미리 다 찍어 준다.
|
|
379
|
+
// 어디가 막혔는지 사람이 알아야 우리에게 물을 것도 정확해진다.
|
|
380
|
+
async function cmdDoctor() {
|
|
381
|
+
const cfg = loadConfig();
|
|
382
|
+
const base = baseUrl(cfg);
|
|
383
|
+
const line = (ok, label, detail) =>
|
|
384
|
+
out(` ${ok ? green("✓") : red("✗")} ${label}${detail ? dim(" " + detail) : ""}`);
|
|
385
|
+
|
|
386
|
+
out("");
|
|
387
|
+
out(bold(" 에토스 라우터 점검"));
|
|
388
|
+
out("");
|
|
389
|
+
out(` 창구 주소 ${base}`);
|
|
390
|
+
out(` 설정 파일 ${existsSync(CONFIG_PATH) ? CONFIG_PATH : dim("없음")}`);
|
|
391
|
+
out("");
|
|
392
|
+
|
|
393
|
+
let ok = true;
|
|
394
|
+
try {
|
|
395
|
+
const { json: h } = await call("/health", { base });
|
|
396
|
+
line(true, "서버 연결", `버전 ${h.version ?? "?"}`);
|
|
397
|
+
} catch (e) { ok = false; line(false, "서버 연결", e.message); }
|
|
398
|
+
|
|
399
|
+
const k = apiKey(cfg);
|
|
400
|
+
if (!k) { ok = false; line(false, "로그인", "ethos login"); }
|
|
401
|
+
else {
|
|
402
|
+
try {
|
|
403
|
+
await call("/v1/models", { key: k, base });
|
|
404
|
+
line(true, "모델 호출 키", k.slice(0, 12) + "…");
|
|
405
|
+
} catch (e) { ok = false; line(false, "모델 호출 키", e.message); }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const uk = usageKey(cfg);
|
|
409
|
+
if (!uk) line(false, "사용 조회 키", "ethos login 을 다시 실행하면 같이 발급됩니다");
|
|
410
|
+
else {
|
|
411
|
+
try {
|
|
412
|
+
await call("/usage/v1/billing", { key: uk, base });
|
|
413
|
+
line(true, "사용 조회 키", uk.slice(0, 12) + "…");
|
|
414
|
+
} catch (e) { line(false, "사용 조회 키", e.message); }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 도구가 깔려 있는지는 실행해 봐야 안다. 없다고 고장은 아니므로 안내만 한다.
|
|
418
|
+
for (const [name, t] of Object.entries(TOOLS)) {
|
|
419
|
+
const found = await which(t.bin);
|
|
420
|
+
line(found, `${name} 설치`, found ? "" : t.install);
|
|
421
|
+
}
|
|
422
|
+
out("");
|
|
423
|
+
if (!ok) process.exitCode = 1;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function which(bin) {
|
|
427
|
+
return new Promise((resolve) => {
|
|
428
|
+
const p = spawn(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
|
|
429
|
+
p.on("error", () => resolve(false));
|
|
430
|
+
p.on("exit", (code) => resolve(code === 0));
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── whoami ────────────────────────────────────────────────
|
|
435
|
+
function cmdWhoami() {
|
|
436
|
+
const cfg = loadConfig();
|
|
437
|
+
if (!apiKey(cfg)) die("로그인이 필요합니다.", "ethos login");
|
|
438
|
+
out("");
|
|
439
|
+
out(` 워크스페이스 ${bold(cfg.workspace?.name ?? dim("알 수 없음"))}`);
|
|
440
|
+
out(` 창구 주소 ${baseUrl(cfg)}`);
|
|
441
|
+
// 키 전체를 찍지 않는다. 스크린샷과 화면 공유로 새어 나가는 자리다.
|
|
442
|
+
out(` 키 ${apiKey(cfg).slice(0, 12)}…`);
|
|
443
|
+
out("");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── 도움말 ────────────────────────────────────────────────
|
|
447
|
+
function help() {
|
|
448
|
+
out(`
|
|
449
|
+
${bold("에토스 라우터")} ${dim("v" + VERSION)}
|
|
450
|
+
|
|
451
|
+
${bold("ethos login")} 브라우저에서 승인하고 키를 받습니다
|
|
452
|
+
${bold("ethos logout")} 키를 정지하고 설정을 지웁니다
|
|
453
|
+
${bold("ethos whoami")} 어느 워크스페이스로 붙어 있는지 봅니다
|
|
454
|
+
|
|
455
|
+
${bold("ethos claude")} ${dim("[인자…]")} Claude Code 를 에토스 라우터로 실행합니다
|
|
456
|
+
${bold("ethos codex")} ${dim("[인자…]")} Codex 를 에토스 라우터로 실행합니다
|
|
457
|
+
${bold("ethos env")} 다른 도구에 넣을 환경변수를 찍습니다
|
|
458
|
+
${dim("--shell fish|powershell")}
|
|
459
|
+
|
|
460
|
+
${bold("ethos models")} ${dim("[검색어]")} 쓸 수 있는 모델과 단가를 봅니다
|
|
461
|
+
${bold("ethos chat")} ${dim('"질문"')} 한 번 물어보고 답을 받습니다 ${dim("-m 모델")}
|
|
462
|
+
${bold("ethos balance")} 남은 크레딧을 봅니다
|
|
463
|
+
${bold("ethos usage")} 기간별 사용 내역을 봅니다
|
|
464
|
+
${dim("--from 2026-09-01 --to 2026-09-08")}
|
|
465
|
+
${dim("--group 부서 --csv 사용내역서.csv")}
|
|
466
|
+
${bold("ethos doctor")} 어디가 막혔는지 점검합니다
|
|
467
|
+
|
|
468
|
+
${dim("시험 서버를 보려면")} ETHOS_BASE_URL ${dim("을 설정하세요.")}
|
|
469
|
+
${dim("CI 처럼 브라우저가 없는 곳에서는")} ethos login --with-key <키> ${dim("를 씁니다.")}
|
|
470
|
+
`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── 인자 ──────────────────────────────────────────────────
|
|
474
|
+
//
|
|
475
|
+
// --키 값 과 --키=값 을 둘 다 받는다. 둘 중 하나만 되면 안 되는 쪽을 쓴
|
|
476
|
+
// 사람은 "무시됐다" 는 것도 모른 채 기본값으로 돈다.
|
|
477
|
+
function parse(argv) {
|
|
478
|
+
const args = { _: [] };
|
|
479
|
+
for (let i = 0; i < argv.length; i++) {
|
|
480
|
+
const a = argv[i];
|
|
481
|
+
if (!a.startsWith("--")) { args._.push(a); continue; }
|
|
482
|
+
const eq = a.indexOf("=");
|
|
483
|
+
if (eq > 0) { args[a.slice(2, eq)] = a.slice(eq + 1); continue; }
|
|
484
|
+
const name = a.slice(2);
|
|
485
|
+
const next = argv[i + 1];
|
|
486
|
+
if (next && !next.startsWith("--")) { args[name] = next; i++; }
|
|
487
|
+
else args[name] = true;
|
|
488
|
+
}
|
|
489
|
+
return args;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function main() {
|
|
493
|
+
const argv = process.argv.slice(2);
|
|
494
|
+
const cmd = argv[0];
|
|
495
|
+
|
|
496
|
+
// 도구에 넘길 인자는 우리가 해석하지 않는다. claude --help 는 claude 의 것이다.
|
|
497
|
+
if (cmd in TOOLS) return await cmdRunTool(cmd, argv.slice(1));
|
|
498
|
+
|
|
499
|
+
const args = parse(argv.slice(1));
|
|
500
|
+
switch (cmd) {
|
|
501
|
+
case "login": return await cmdLogin(args);
|
|
502
|
+
case "logout": return await cmdLogout();
|
|
503
|
+
case "whoami": return cmdWhoami();
|
|
504
|
+
case "env": return cmdEnv(args);
|
|
505
|
+
case "models": return await cmdModels(args);
|
|
506
|
+
case "chat": return await cmdChat(args);
|
|
507
|
+
case "balance": return await cmdBalance();
|
|
508
|
+
case "usage": return await cmdUsage(args);
|
|
509
|
+
case "doctor": return await cmdDoctor();
|
|
510
|
+
case "-v": case "--version": case "version": return out(VERSION);
|
|
511
|
+
case undefined: case "help": case "-h": case "--help": return help();
|
|
512
|
+
default:
|
|
513
|
+
err(red("✗ ") + `모르는 명령입니다: ${cmd}`);
|
|
514
|
+
help();
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
main().catch((e) => die(e.message));
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ethos-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "에토스 라우터 명령줄 도구. 한 번 로그인하면 코딩 도구가 에토스 라우터를 보게 됩니다.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ethos": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.17"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"homepage": "https://router.ethos.ai.kr/docs#cli",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"llm",
|
|
21
|
+
"gateway",
|
|
22
|
+
"router",
|
|
23
|
+
"cli",
|
|
24
|
+
"korea"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/spacebar-corp/Ethos-router.git",
|
|
29
|
+
"directory": "cli"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"email": "router@ethos.ai.kr"
|
|
33
|
+
}
|
|
34
|
+
}
|