triflux 3.1.0-dev.5 → 3.2.0-dev.10

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.
Files changed (52) hide show
  1. package/README.ko.md +26 -18
  2. package/README.md +26 -18
  3. package/bin/triflux.mjs +1614 -1077
  4. package/hooks/hooks.json +12 -0
  5. package/hooks/keyword-rules.json +354 -0
  6. package/hub/bridge.mjs +371 -193
  7. package/hub/hitl.mjs +45 -31
  8. package/hub/pipe.mjs +457 -0
  9. package/hub/router.mjs +422 -161
  10. package/hub/server.mjs +429 -344
  11. package/hub/store.mjs +388 -314
  12. package/hub/team/cli-team-common.mjs +348 -0
  13. package/hub/team/cli-team-control.mjs +393 -0
  14. package/hub/team/cli-team-start.mjs +516 -0
  15. package/hub/team/cli-team-status.mjs +269 -0
  16. package/hub/team/cli.mjs +99 -0
  17. package/hub/team/dashboard.mjs +267 -0
  18. package/hub/team/native-supervisor.mjs +300 -0
  19. package/hub/team/native.mjs +62 -0
  20. package/hub/team/nativeProxy.mjs +534 -0
  21. package/hub/team/orchestrator.mjs +166 -0
  22. package/hub/team/pane.mjs +138 -0
  23. package/hub/team/psmux.mjs +297 -0
  24. package/hub/team/session.mjs +608 -0
  25. package/hub/team/shared.mjs +13 -0
  26. package/hub/team/staleState.mjs +299 -0
  27. package/hub/tools.mjs +140 -53
  28. package/hub/workers/claude-worker.mjs +446 -0
  29. package/hub/workers/codex-mcp.mjs +414 -0
  30. package/hub/workers/factory.mjs +18 -0
  31. package/hub/workers/gemini-worker.mjs +349 -0
  32. package/hub/workers/interface.mjs +41 -0
  33. package/hud/hud-qos-status.mjs +1789 -1732
  34. package/package.json +6 -2
  35. package/scripts/__tests__/keyword-detector.test.mjs +234 -0
  36. package/scripts/hub-ensure.mjs +83 -0
  37. package/scripts/keyword-detector.mjs +272 -0
  38. package/scripts/keyword-rules-expander.mjs +521 -0
  39. package/scripts/lib/keyword-rules.mjs +168 -0
  40. package/scripts/psmux-steering-prototype.sh +368 -0
  41. package/scripts/run.cjs +62 -0
  42. package/scripts/setup.mjs +189 -7
  43. package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
  44. package/scripts/tfx-route-worker.mjs +161 -0
  45. package/scripts/tfx-route.sh +943 -508
  46. package/skills/tfx-auto/SKILL.md +90 -564
  47. package/skills/tfx-auto-codex/SKILL.md +77 -0
  48. package/skills/tfx-codex/SKILL.md +1 -4
  49. package/skills/tfx-doctor/SKILL.md +1 -0
  50. package/skills/tfx-gemini/SKILL.md +1 -4
  51. package/skills/tfx-multi/SKILL.md +296 -0
  52. package/skills/tfx-setup/SKILL.md +1 -4
package/bin/triflux.mjs CHANGED
@@ -1,1077 +1,1614 @@
1
- #!/usr/bin/env node
2
- // triflux CLI — setup, doctor, version
3
- import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync } from "fs";
4
- import { join, dirname } from "path";
5
- import { homedir } from "os";
6
- import { execSync, spawn } from "child_process";
7
-
8
- const PKG_ROOT = dirname(dirname(new URL(import.meta.url).pathname)).replace(/^\/([A-Z]:)/, "$1");
9
- const CLAUDE_DIR = join(homedir(), ".claude");
10
- const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
11
-
12
- // ── 색상 체계 (triflux brand: amber/orange accent) ──
13
- const CYAN = "\x1b[36m";
14
- const GREEN = "\x1b[32m";
15
- const RED = "\x1b[31m";
16
- const YELLOW = "\x1b[33m";
17
- const DIM = "\x1b[2m";
18
- const BOLD = "\x1b[1m";
19
- const RESET = "\x1b[0m";
20
- const AMBER = "\x1b[38;5;214m";
21
- const BLUE = "\x1b[38;5;39m";
22
- const WHITE_BRIGHT = "\x1b[97m";
23
- const GRAY = "\x1b[38;5;245m";
24
- const GREEN_BRIGHT = "\x1b[38;5;82m";
25
- const RED_BRIGHT = "\x1b[38;5;196m";
26
-
27
- // ── 브랜드 요소 ──
28
- const BRAND = `${AMBER}${BOLD}triflux${RESET}`;
29
- const VER = `${DIM}v${PKG.version}${RESET}`;
30
- const LINE = `${GRAY}${"".repeat(48)}${RESET}`;
31
- const DOT = `${GRAY}·${RESET}`;
32
-
33
- // ── 유틸리티 ──
34
-
35
- function ok(msg) { console.log(` ${GREEN_BRIGHT}✓${RESET} ${msg}`); }
36
- function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
37
- function fail(msg) { console.log(` ${RED_BRIGHT}✗${RESET} ${msg}`); }
38
- function info(msg) { console.log(` ${GRAY}${msg}${RESET}`); }
39
- function section(title) { console.log(`\n ${AMBER}▸${RESET} ${BOLD}${title}${RESET}`); }
40
-
41
- function which(cmd) {
42
- try {
43
- const result = execSync(
44
- process.platform === "win32" ? `where ${cmd} 2>nul` : `which ${cmd} 2>/dev/null`,
45
- { encoding: "utf8", timeout: 5000 }
46
- ).trim();
47
- return result.split(/\r?\n/)[0] || null;
48
- } catch { return null; }
49
- }
50
-
51
- function whichInShell(cmd, shell) {
52
- const cmds = {
53
- bash: `bash -c "source ~/.bashrc 2>/dev/null && command -v ${cmd} 2>/dev/null"`,
54
- cmd: `cmd /c where ${cmd} 2>nul`,
55
- pwsh: `pwsh -NoProfile -c "(Get-Command ${cmd} -EA SilentlyContinue).Source"`,
56
- };
57
- const command = cmds[shell];
58
- if (!command) return null;
59
- try {
60
- const result = execSync(command, {
61
- encoding: "utf8",
62
- timeout: 8000,
63
- stdio: ["pipe", "pipe", "ignore"],
64
- }).trim();
65
- return result.split(/\r?\n/)[0] || null;
66
- } catch { return null; }
67
- }
68
-
69
- function checkShellAvailable(shell) {
70
- const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
71
- try {
72
- execSync(cmds[shell], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
73
- return true;
74
- } catch { return false; }
75
- }
76
-
77
- function getVersion(filePath) {
78
- try {
79
- const content = readFileSync(filePath, "utf8");
80
- const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
81
- return match ? match[1] : null;
82
- } catch { return null; }
83
- }
84
-
85
- function syncFile(src, dst, label) {
86
- const dstDir = dirname(dst);
87
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
88
-
89
- if (!existsSync(src)) {
90
- fail(`${label}: 소스 파일 없음 (${src})`);
91
- return false;
92
- }
93
-
94
- const srcVer = getVersion(src);
95
- const dstVer = existsSync(dst) ? getVersion(dst) : null;
96
-
97
- if (!existsSync(dst)) {
98
- copyFileSync(src, dst);
99
- try { chmodSync(dst, 0o755); } catch {}
100
- ok(`${label}: 설치됨 ${srcVer ? `(v${srcVer})` : ""}`);
101
- return true;
102
- }
103
-
104
- const srcContent = readFileSync(src, "utf8");
105
- const dstContent = readFileSync(dst, "utf8");
106
- if (srcContent !== dstContent) {
107
- copyFileSync(src, dst);
108
- try { chmodSync(dst, 0o755); } catch {}
109
- const verInfo = (srcVer && dstVer && srcVer !== dstVer)
110
- ? `(v${dstVer} v${srcVer})`
111
- : srcVer ? `(v${srcVer}, 내용 변경)` : "(내용 변경)";
112
- ok(`${label}: 업데이트됨 ${verInfo}`);
113
- return true;
114
- }
115
-
116
- ok(`${label}: 최신 상태 ${srcVer ? `(v${srcVer})` : ""}`);
117
- return false;
118
- }
119
-
120
- // ── 크로스 셸 진단 ──
121
-
122
- function checkCliCrossShell(cmd, installHint) {
123
- const shells = process.platform === "win32" ? ["bash", "cmd", "pwsh"] : ["bash"];
124
- let anyFound = false;
125
- let bashMissing = false;
126
-
127
- for (const shell of shells) {
128
- if (!checkShellAvailable(shell)) {
129
- info(`${shell}: ${DIM}셸 없음 (건너뜀)${RESET}`);
130
- continue;
131
- }
132
- const p = whichInShell(cmd, shell);
133
- if (p) {
134
- ok(`${shell}: ${p}`);
135
- anyFound = true;
136
- } else {
137
- fail(`${shell}: 미발견`);
138
- if (shell === "bash") bashMissing = true;
139
- }
140
- }
141
-
142
- if (!anyFound) {
143
- info(`미설치 (선택사항) — ${installHint}`);
144
- info("없으면 Claude 네이티브 에이전트로 fallback");
145
- return 1;
146
- }
147
- if (bashMissing) {
148
- warn("bash에서 미발견 tfx-route.sh 실행 불가");
149
- info('→ ~/.bashrc에 추가: export PATH="$PATH:$APPDATA/npm"');
150
- return 1;
151
- }
152
- return 0;
153
- }
154
-
155
- // ── 명령어 ──
156
-
157
- function cmdSetup() {
158
- console.log(`\n${BOLD}triflux setup${RESET}\n`);
159
-
160
- syncFile(
161
- join(PKG_ROOT, "scripts", "tfx-route.sh"),
162
- join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
163
- "tfx-route.sh"
164
- );
165
-
166
- syncFile(
167
- join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
168
- join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
169
- "hud-qos-status.mjs"
170
- );
171
-
172
- syncFile(
173
- join(PKG_ROOT, "scripts", "notion-read.mjs"),
174
- join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
175
- "notion-read.mjs"
176
- );
177
-
178
- syncFile(
179
- join(PKG_ROOT, "scripts", "tfx-route-post.mjs"),
180
- join(CLAUDE_DIR, "scripts", "tfx-route-post.mjs"),
181
- "tfx-route-post.mjs"
182
- );
183
-
184
- syncFile(
185
- join(PKG_ROOT, "scripts", "tfx-batch-stats.mjs"),
186
- join(CLAUDE_DIR, "scripts", "tfx-batch-stats.mjs"),
187
- "tfx-batch-stats.mjs"
188
- );
189
-
190
- // 스킬 동기화 (~/.claude/skills/{name}/SKILL.md)
191
- const skillsSrc = join(PKG_ROOT, "skills");
192
- const skillsDst = join(CLAUDE_DIR, "skills");
193
- if (existsSync(skillsSrc)) {
194
- let skillCount = 0;
195
- let skillTotal = 0;
196
- for (const name of readdirSync(skillsSrc)) {
197
- const src = join(skillsSrc, name, "SKILL.md");
198
- const dst = join(skillsDst, name, "SKILL.md");
199
- if (!existsSync(src)) continue;
200
- skillTotal++;
201
-
202
- const dstDir = dirname(dst);
203
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
204
-
205
- if (!existsSync(dst)) {
206
- copyFileSync(src, dst);
207
- skillCount++;
208
- } else {
209
- const srcContent = readFileSync(src, "utf8");
210
- const dstContent = readFileSync(dst, "utf8");
211
- if (srcContent !== dstContent) {
212
- copyFileSync(src, dst);
213
- skillCount++;
214
- }
215
- }
216
- }
217
- if (skillCount > 0) {
218
- ok(`스킬: ${skillCount}/${skillTotal}개 업데이트됨`);
219
- } else {
220
- ok(`스킬: ${skillTotal}개 최신 상태`);
221
- }
222
- }
223
-
224
- // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
225
- if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
226
- const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
227
- autoRegisterMcp(defaultHubUrl);
228
- console.log("");
229
- }
230
-
231
- // HUD statusLine 설정
232
- console.log(`${CYAN}[HUD 설정]${RESET}`);
233
- const settingsPath = join(CLAUDE_DIR, "settings.json");
234
- const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
235
-
236
- if (existsSync(hudPath)) {
237
- try {
238
- let settings = {};
239
- if (existsSync(settingsPath)) {
240
- settings = JSON.parse(readFileSync(settingsPath, "utf8"));
241
- }
242
-
243
- const currentCmd = settings.statusLine?.command || "";
244
- if (currentCmd.includes("hud-qos-status.mjs")) {
245
- ok("statusLine 이미 설정됨");
246
- } else {
247
- const nodePath = process.execPath.replace(/\\/g, "/");
248
- const hudForward = hudPath.replace(/\\/g, "/");
249
- const nodeRef = nodePath.includes(" ") ? `"${nodePath}"` : nodePath;
250
- const hudRef = hudForward.includes(" ") ? `"${hudForward}"` : hudForward;
251
-
252
- if (currentCmd) {
253
- warn(`기존 statusLine 덮어쓰기: ${currentCmd}`);
254
- }
255
-
256
- settings.statusLine = {
257
- type: "command",
258
- command: `${nodeRef} ${hudRef}`,
259
- };
260
-
261
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
262
- ok("statusLine 설정 완료 — 세션 재시작 후 HUD 표시");
263
- }
264
- } catch (e) {
265
- fail(`settings.json 처리 실패: ${e.message}`);
266
- }
267
- } else {
268
- warn("HUD 파일 없음 — 먼저 파일 동기화 필요");
269
- }
270
-
271
- console.log(`\n${DIM}설치 위치: ${CLAUDE_DIR}${RESET}\n`);
272
- }
273
-
274
- function cmdDoctor(options = {}) {
275
- const { fix = false, reset = false } = options;
276
- const modeLabel = reset ? ` ${RED}--reset${RESET}` : fix ? ` ${YELLOW}--fix${RESET}` : "";
277
- console.log(`\n ${AMBER}${BOLD}⬡ triflux doctor${RESET} ${VER}${modeLabel}\n`);
278
- console.log(` ${LINE}`);
279
-
280
- // ── reset 모드: 캐시 전체 초기화 ──
281
- if (reset) {
282
- section("Cache Reset");
283
- const cacheDir = join(CLAUDE_DIR, "cache");
284
- const resetFiles = [
285
- "claude-usage-cache.json",
286
- ".claude-refresh-lock",
287
- "codex-rate-limits-cache.json",
288
- "gemini-quota-cache.json",
289
- "gemini-project-id.json",
290
- "gemini-session-cache.json",
291
- "gemini-rpm-tracker.json",
292
- "sv-accumulator.json",
293
- "mcp-inventory.json",
294
- "cli-issues.jsonl",
295
- "triflux-update-check.json",
296
- ];
297
- let cleared = 0;
298
- for (const name of resetFiles) {
299
- const fp = join(cacheDir, name);
300
- if (existsSync(fp)) {
301
- try { unlinkSync(fp); cleared++; ok(`삭제됨: ${name}`); }
302
- catch (e) { fail(`삭제 실패: ${name} ${e.message}`); }
303
- }
304
- }
305
- if (cleared === 0) {
306
- ok("삭제할 캐시 파일 없음 (이미 깨끗함)");
307
- } else {
308
- console.log("");
309
- ok(`${BOLD}${cleared}개${RESET} 캐시 파일 초기화 완료`);
310
- }
311
- // 캐시 즉시 재생성
312
- console.log("");
313
- section("Cache Rebuild");
314
- const mcpCheck = join(PKG_ROOT, "scripts", "mcp-check.mjs");
315
- if (existsSync(mcpCheck)) {
316
- try {
317
- execSync(`"${process.execPath}" "${mcpCheck}"`, { timeout: 15000, stdio: "ignore" });
318
- ok("MCP 인벤토리 재생성됨");
319
- } catch { warn("MCP 인벤토리 재생성 실패 — 다음 세션에서 자동 재시도"); }
320
- }
321
- const hudScript = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
322
- if (existsSync(hudScript)) {
323
- try {
324
- execSync(`"${process.execPath}" "${hudScript}" --refresh-claude-usage`, { timeout: 20000, stdio: "ignore" });
325
- ok("Claude 사용량 캐시 재생성됨");
326
- } catch { warn("Claude 사용량 캐시 재생성 실패 — 다음 API 호출 시 자동 생성"); }
327
- try {
328
- execSync(`"${process.execPath}" "${hudScript}" --refresh-codex-rate-limits`, { timeout: 15000, stdio: "ignore" });
329
- ok("Codex 레이트 리밋 캐시 재생성됨");
330
- } catch { warn("Codex 레이트 리밋 캐시 재생성 실패"); }
331
- try {
332
- execSync(`"${process.execPath}" "${hudScript}" --refresh-gemini-quota`, { timeout: 15000, stdio: "ignore" });
333
- ok("Gemini 쿼터 캐시 재생성됨");
334
- } catch { warn("Gemini 쿼터 캐시 재생성 실패"); }
335
- }
336
- console.log(`\n ${LINE}`);
337
- console.log(` ${GREEN_BRIGHT}${BOLD}✓ 캐시 초기화 + 재생성 완료${RESET}\n`);
338
- return;
339
- }
340
-
341
- // ── fix 모드: 파일 동기화 + 캐시 정리 후 진단 ──
342
- if (fix) {
343
- section("Auto Fix");
344
- syncFile(
345
- join(PKG_ROOT, "scripts", "tfx-route.sh"),
346
- join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
347
- "tfx-route.sh"
348
- );
349
- syncFile(
350
- join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
351
- join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
352
- "hud-qos-status.mjs"
353
- );
354
- syncFile(
355
- join(PKG_ROOT, "scripts", "notion-read.mjs"),
356
- join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
357
- "notion-read.mjs"
358
- );
359
- // 스킬 동기화
360
- const fSkillsSrc = join(PKG_ROOT, "skills");
361
- const fSkillsDst = join(CLAUDE_DIR, "skills");
362
- if (existsSync(fSkillsSrc)) {
363
- let sc = 0, st = 0;
364
- for (const name of readdirSync(fSkillsSrc)) {
365
- const src = join(fSkillsSrc, name, "SKILL.md");
366
- const dst = join(fSkillsDst, name, "SKILL.md");
367
- if (!existsSync(src)) continue;
368
- st++;
369
- const dstDir = dirname(dst);
370
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
371
- if (!existsSync(dst)) { copyFileSync(src, dst); sc++; }
372
- else if (readFileSync(src, "utf8") !== readFileSync(dst, "utf8")) { copyFileSync(src, dst); sc++; }
373
- }
374
- if (sc > 0) ok(`스킬: ${sc}/${st}개 업데이트됨`);
375
- else ok(`스킬: ${st}개 최신 상태`);
376
- }
377
- // 에러/스테일 캐시 정리
378
- const fCacheDir = join(CLAUDE_DIR, "cache");
379
- const staleNames = ["claude-usage-cache.json", ".claude-refresh-lock", "codex-rate-limits-cache.json"];
380
- let cleaned = 0;
381
- for (const name of staleNames) {
382
- const fp = join(fCacheDir, name);
383
- if (!existsSync(fp)) continue;
384
- try {
385
- const parsed = JSON.parse(readFileSync(fp, "utf8"));
386
- if (parsed.error || name.startsWith(".")) { unlinkSync(fp); cleaned++; ok(`에러 캐시 정리: ${name}`); }
387
- } catch { try { unlinkSync(fp); cleaned++; ok(`손상된 캐시 정리: ${name}`); } catch {} }
388
- }
389
- if (cleaned === 0) info("에러 캐시 없음");
390
- console.log(`\n ${LINE}`);
391
- info("수정 완료 — 아래 진단 결과를 확인하세요");
392
- console.log("");
393
- }
394
-
395
- let issues = 0;
396
-
397
- // 1. tfx-route.sh
398
- section("tfx-route.sh");
399
- const routeSh = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
400
- if (existsSync(routeSh)) {
401
- const ver = getVersion(routeSh);
402
- ok(`설치됨 ${ver ? `${DIM}v${ver}${RESET}` : ""}`);
403
- } else {
404
- fail("미설치 — tfx setup 실행 필요");
405
- issues++;
406
- }
407
-
408
- // 2. HUD
409
- section("HUD");
410
- const hud = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
411
- if (existsSync(hud)) {
412
- ok("설치됨");
413
- } else {
414
- warn("미설치 ${GRAY}(선택사항)${RESET}");
415
- }
416
-
417
- // 3. Codex CLI
418
- section(`Codex CLI ${WHITE_BRIGHT}●${RESET}`);
419
- issues += checkCliCrossShell("codex", "npm install -g @openai/codex");
420
- if (which("codex")) {
421
- if (process.env.OPENAI_API_KEY) {
422
- ok("OPENAI_API_KEY 설정됨");
423
- } else {
424
- warn(`OPENAI_API_KEY 미설정 ${GRAY}(Pro 구독이면 불필요)${RESET}`);
425
- }
426
- }
427
-
428
- // 4. Gemini CLI
429
- section(`Gemini CLI ${BLUE}●${RESET}`);
430
- issues += checkCliCrossShell("gemini", "npm install -g @google/gemini-cli");
431
- if (which("gemini")) {
432
- if (process.env.GEMINI_API_KEY) {
433
- ok("GEMINI_API_KEY 설정됨");
434
- } else {
435
- warn(`GEMINI_API_KEY 미설정 ${GRAY}(gemini auth login)${RESET}`);
436
- }
437
- }
438
-
439
- // 5. Claude Code
440
- section(`Claude Code ${AMBER}●${RESET}`);
441
- const claudePath = which("claude");
442
- if (claudePath) {
443
- ok("설치됨");
444
- } else {
445
- fail("미설치 (필수)");
446
- issues++;
447
- }
448
-
449
- // 6. 스킬 설치 상태
450
- section("Skills");
451
- const skillsSrc = join(PKG_ROOT, "skills");
452
- const skillsDst = join(CLAUDE_DIR, "skills");
453
- if (existsSync(skillsSrc)) {
454
- let installed = 0;
455
- let total = 0;
456
- const missing = [];
457
- for (const name of readdirSync(skillsSrc)) {
458
- if (!existsSync(join(skillsSrc, name, "SKILL.md"))) continue;
459
- total++;
460
- if (existsSync(join(skillsDst, name, "SKILL.md"))) {
461
- installed++;
462
- } else {
463
- missing.push(name);
464
- }
465
- }
466
- if (installed === total) {
467
- ok(`${installed}/${total}개 설치됨`);
468
- } else {
469
- warn(`${installed}/${total}개 설치됨 — 미설치: ${missing.join(", ")}`);
470
- info("triflux setup으로 동기화 가능");
471
- issues++;
472
- }
473
- }
474
-
475
- // 7. 플러그인 등록
476
- section("Plugin");
477
- const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
478
- if (existsSync(pluginsFile)) {
479
- const content = readFileSync(pluginsFile, "utf8");
480
- if (content.includes("triflux")) {
481
- ok("triflux 플러그인 등록됨");
482
- } else {
483
- warn("triflux 플러그인 미등록 — npm 단독 사용 중");
484
- info("플러그인 등록: /plugin marketplace add <repo-url>");
485
- }
486
- } else {
487
- info("플러그인 시스템 감지 안 됨 — npm 단독 사용");
488
- }
489
-
490
- // 8. MCP 인벤토리
491
- section("MCP Inventory");
492
- const mcpCache = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
493
- if (existsSync(mcpCache)) {
494
- try {
495
- const inv = JSON.parse(readFileSync(mcpCache, "utf8"));
496
- ok(`캐시 존재 (${inv.timestamp})`);
497
- if (inv.codex?.servers?.length) {
498
- const names = inv.codex.servers.map(s => s.name).join(", ");
499
- info(`Codex: ${inv.codex.servers.length}개 서버 (${names})`);
500
- }
501
- if (inv.gemini?.servers?.length) {
502
- const names = inv.gemini.servers.map(s => s.name).join(", ");
503
- info(`Gemini: ${inv.gemini.servers.length}개 서버 (${names})`);
504
- }
505
- } catch {
506
- warn("캐시 파일 파싱 실패");
507
- }
508
- } else {
509
- warn("캐시 없음 — 다음 세션 시작 시 자동 생성");
510
- info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
511
- }
512
-
513
- // 9. CLI 이슈 트래커
514
- section("CLI Issues");
515
- const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
516
- if (existsSync(issuesFile)) {
517
- try {
518
- const lines = readFileSync(issuesFile, "utf8").trim().split("\n").filter(Boolean);
519
- const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
520
- const unresolved = entries.filter(e => !e.resolved);
521
-
522
- if (unresolved.length === 0) {
523
- ok("미해결 이슈 없음");
524
- } else {
525
- // 패턴별 그룹핑
526
- const groups = {};
527
- for (const e of unresolved) {
528
- const key = `${e.cli}:${e.pattern}`;
529
- if (!groups[key]) groups[key] = { ...e, count: 0 };
530
- groups[key].count++;
531
- if (e.ts > groups[key].ts) { groups[key].ts = e.ts; groups[key].snippet = e.snippet; }
532
- }
533
-
534
- // 알려진 해결 버전 (패턴별 수정된 triflux 버전)
535
- const KNOWN_FIXES = {
536
- "gemini:deprecated_flag": "1.8.9", // -p --prompt
537
- };
538
-
539
- const currentVer = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
540
- let cleaned = 0;
541
-
542
- for (const [key, g] of Object.entries(groups)) {
543
- const fixVer = KNOWN_FIXES[key];
544
- if (fixVer && currentVer >= fixVer) {
545
- // 해결된 이슈 — 자동 정리
546
- cleaned += g.count;
547
- continue;
548
- }
549
- const age = Date.now() - g.ts;
550
- const ago = age < 3600000 ? `${Math.round(age / 60000)}분 전` :
551
- age < 86400000 ? `${Math.round(age / 3600000)}시간 전` :
552
- `${Math.round(age / 86400000)}일 전`;
553
- const sev = g.severity === "error" ? `${RED}ERROR${RESET}` : `${YELLOW}WARN${RESET}`;
554
- warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
555
- if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
556
- if (fixVer) info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
557
- issues++;
558
- }
559
-
560
- // 해결된 이슈 자동 정리
561
- if (cleaned > 0) {
562
- const remaining = entries.filter(e => {
563
- const key = `${e.cli}:${e.pattern}`;
564
- const fixVer = KNOWN_FIXES[key];
565
- return !(fixVer && currentVer >= fixVer);
566
- });
567
- writeFileSync(issuesFile, remaining.map(e => JSON.stringify(e)).join("\n") + (remaining.length ? "\n" : ""));
568
- ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
569
- }
570
- }
571
- } catch (e) {
572
- warn(`이슈 파일 읽기 실패: ${e.message}`);
573
- }
574
- } else {
575
- ok("이슈 로그 없음 (정상)");
576
- }
577
-
578
- // 결과
579
- console.log(`\n ${LINE}`);
580
- if (issues === 0) {
581
- console.log(` ${GREEN_BRIGHT}${BOLD}✓ 모든 검사 통과${RESET}\n`);
582
- } else {
583
- console.log(` ${YELLOW}${BOLD}⚠ ${issues}개 항목 확인 필요${RESET}\n`);
584
- }
585
- }
586
-
587
- function cmdUpdate() {
588
- const isDev = process.argv.includes("--dev");
589
- const tagLabel = isDev ? ` ${YELLOW}@dev${RESET}` : "";
590
- console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
591
-
592
- // 1. 설치 방식 감지
593
- const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
594
- let installMode = "unknown";
595
- let pluginPath = null;
596
-
597
- // 플러그인 모드 감지
598
- if (existsSync(pluginsFile)) {
599
- try {
600
- const plugins = JSON.parse(readFileSync(pluginsFile, "utf8"));
601
- for (const [key, entries] of Object.entries(plugins.plugins || {})) {
602
- if (key.startsWith("triflux")) {
603
- pluginPath = entries[0]?.installPath;
604
- installMode = "plugin";
605
- break;
606
- }
607
- }
608
- } catch {}
609
- }
610
-
611
- // PKG_ROOT가 플러그인 캐시 내에 있으면 플러그인 모드
612
- if (installMode === "unknown" && PKG_ROOT.includes(join(".claude", "plugins"))) {
613
- installMode = "plugin";
614
- pluginPath = PKG_ROOT;
615
- }
616
-
617
- // npm global 감지
618
- if (installMode === "unknown") {
619
- try {
620
- const npmList = execSync("npm list -g triflux --depth=0", {
621
- encoding: "utf8",
622
- timeout: 10000,
623
- stdio: ["pipe", "pipe", "ignore"],
624
- });
625
- if (npmList.includes("triflux")) installMode = "npm-global";
626
- } catch {}
627
- }
628
-
629
- // npm local 감지
630
- if (installMode === "unknown") {
631
- const localPkg = join(process.cwd(), "node_modules", "triflux");
632
- if (existsSync(localPkg)) installMode = "npm-local";
633
- }
634
-
635
- // git 저장소 직접 사용
636
- if (installMode === "unknown" && existsSync(join(PKG_ROOT, ".git"))) {
637
- installMode = "git-local";
638
- }
639
-
640
- info(`검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`);
641
-
642
- // 2. 설치 방식에 따라 업데이트
643
- const oldVer = PKG.version;
644
- let updated = false;
645
-
646
- try {
647
- switch (installMode) {
648
- case "plugin": {
649
- const gitDir = pluginPath || PKG_ROOT;
650
- const result = execSync("git pull", {
651
- encoding: "utf8",
652
- timeout: 30000,
653
- cwd: gitDir,
654
- }).trim();
655
- ok(`git pull — ${result}`);
656
- updated = true;
657
- break;
658
- }
659
- case "npm-global": {
660
- const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
661
- const result = execSync(npmCmd, {
662
- encoding: "utf8",
663
- timeout: 60000,
664
- stdio: ["pipe", "pipe", "ignore"],
665
- }).trim().split(/\r?\n/)[0];
666
- ok(`${isDev ? "npm install -g @dev" : "npm update -g"} — ${result || "완료"}`);
667
- updated = true;
668
- break;
669
- }
670
- case "npm-local": {
671
- const npmLocalCmd = isDev ? "npm install triflux@dev" : "npm update triflux";
672
- const result = execSync(npmLocalCmd, {
673
- encoding: "utf8",
674
- timeout: 60000,
675
- cwd: process.cwd(),
676
- stdio: ["pipe", "pipe", "ignore"],
677
- }).trim().split(/\r?\n/)[0];
678
- ok(`npm update ${result || "완료"}`);
679
- updated = true;
680
- break;
681
- }
682
- case "git-local": {
683
- const result = execSync("git pull", {
684
- encoding: "utf8",
685
- timeout: 30000,
686
- cwd: PKG_ROOT,
687
- }).trim();
688
- ok(`git pull — ${result}`);
689
- updated = true;
690
- break;
691
- }
692
- default:
693
- fail("설치 방식을 감지할 없음");
694
- info("수동 업데이트: cd <triflux-dir> && git pull");
695
- return;
696
- }
697
- } catch (e) {
698
- fail(`업데이트 실패: ${e.message}`);
699
- return;
700
- }
701
-
702
- // 3. setup 재실행 (tfx-route.sh, HUD, 스킬 동기화)
703
- if (updated) {
704
- console.log("");
705
- // 업데이트 후 새 버전 읽기
706
- let newVer = oldVer;
707
- try {
708
- const newPkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
709
- newVer = newPkg.version;
710
- } catch {}
711
-
712
- if (newVer !== oldVer) {
713
- ok(`버전: v${oldVer} → v${newVer}`);
714
- } else {
715
- ok(`버전: v${oldVer} (이미 최신)`);
716
- }
717
-
718
- // setup 재실행
719
- console.log("");
720
- info("setup 재실행 중...");
721
- cmdSetup();
722
- }
723
-
724
- console.log(`${GREEN}${BOLD}업데이트 완료${RESET}\n`);
725
- }
726
-
727
- function cmdList() {
728
- console.log(`\n ${AMBER}${BOLD}⬡ triflux list${RESET} ${VER}\n`);
729
- console.log(` ${LINE}`);
730
-
731
- const pluginSkills = join(PKG_ROOT, "skills");
732
- const installedSkills = join(CLAUDE_DIR, "skills");
733
-
734
- section("패키지 스킬");
735
- if (existsSync(pluginSkills)) {
736
- for (const name of readdirSync(pluginSkills).sort()) {
737
- const src = join(pluginSkills, name, "SKILL.md");
738
- if (!existsSync(src)) continue;
739
- const dst = join(installedSkills, name, "SKILL.md");
740
- const installed = existsSync(dst);
741
- if (installed) {
742
- console.log(` ${GREEN_BRIGHT}✓${RESET} ${BOLD}${name}${RESET}`);
743
- } else {
744
- console.log(` ${RED_BRIGHT}✗${RESET} ${DIM}${name}${RESET} ${GRAY}(미설치)${RESET}`);
745
- }
746
- }
747
- }
748
-
749
- section("사용자 스킬");
750
- const pkgNames = new Set(existsSync(pluginSkills) ? readdirSync(pluginSkills) : []);
751
- let userCount = 0;
752
- if (existsSync(installedSkills)) {
753
- for (const name of readdirSync(installedSkills).sort()) {
754
- if (pkgNames.has(name)) continue;
755
- const skill = join(installedSkills, name, "SKILL.md");
756
- if (!existsSync(skill)) continue;
757
- console.log(` ${AMBER}◆${RESET} ${name}`);
758
- userCount++;
759
- }
760
- }
761
- if (userCount === 0) console.log(` ${GRAY}없음${RESET}`);
762
-
763
- console.log(`\n ${LINE}`);
764
- console.log(` ${GRAY}${installedSkills}${RESET}\n`);
765
- }
766
-
767
- function cmdVersion() {
768
- const routeVer = getVersion(join(CLAUDE_DIR, "scripts", "tfx-route.sh"));
769
- const hudVer = getVersion(join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"));
770
- console.log(`\n ${AMBER}${BOLD}⬡ triflux${RESET} ${WHITE_BRIGHT}v${PKG.version}${RESET}`);
771
- if (routeVer) console.log(` ${GRAY}tfx-route${RESET} v${routeVer}`);
772
- if (hudVer) console.log(` ${GRAY}hud${RESET} v${hudVer}`);
773
- console.log("");
774
- }
775
-
776
- function checkForUpdate() {
777
- const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
778
- const cacheDir = dirname(cacheFile);
779
-
780
- // 캐시 확인 (1시간 이내면 캐시 사용)
781
- try {
782
- if (existsSync(cacheFile)) {
783
- const cache = JSON.parse(readFileSync(cacheFile, "utf8"));
784
- if (Date.now() - cache.timestamp < 3600000) {
785
- return cache.latest !== PKG.version ? cache.latest : null;
786
- }
787
- }
788
- } catch {}
789
-
790
- // npm registry 조회
791
- try {
792
- const result = execSync("npm view triflux version", {
793
- encoding: "utf8",
794
- timeout: 5000,
795
- stdio: ["pipe", "pipe", "ignore"],
796
- }).trim();
797
-
798
- if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
799
- writeFileSync(cacheFile, JSON.stringify({ latest: result, timestamp: Date.now() }));
800
-
801
- return result !== PKG.version ? result : null;
802
- } catch {
803
- return null;
804
- }
805
- }
806
-
807
- function cmdHelp() {
808
- const latestVer = checkForUpdate();
809
- const updateNotice = latestVer
810
- ? `\n ${YELLOW}${BOLD}↑ v${latestVer} 사용 가능${RESET} ${GRAY}npm update -g triflux${RESET}\n`
811
- : "";
812
-
813
- console.log(`
814
- ${AMBER}${BOLD}⬡ triflux${RESET} ${DIM}v${PKG.version}${RESET}
815
- ${GRAY}CLI-first multi-model orchestrator for Claude Code${RESET}
816
- ${updateNotice}
817
- ${LINE}
818
-
819
- ${BOLD}Commands${RESET}
820
-
821
- ${WHITE_BRIGHT}tfx setup${RESET} ${GRAY}파일 동기화 + HUD 설정${RESET}
822
- ${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
823
- ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
824
- ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
825
- ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 버전으로 업데이트${RESET}
826
- ${DIM} --dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
827
- ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
828
- ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
829
- ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
830
- ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
831
-
832
- ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
833
-
834
- ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행${RESET}
835
- ${WHITE_BRIGHT}/tfx-codex${RESET} ${GRAY}Codex 전용 모드${RESET}
836
- ${BLUE}/tfx-gemini${RESET} ${GRAY}Gemini 전용 모드${RESET}
837
- ${AMBER}/tfx-setup${RESET} ${GRAY}HUD 설정 + 진단${RESET}
838
- ${YELLOW}/tfx-doctor${RESET} ${GRAY}진단 + 수리 + 캐시 초기화${RESET}
839
-
840
- ${LINE}
841
- ${GRAY}github.com/tellang/triflux${RESET}
842
- `);
843
- }
844
-
845
- // ── hub 서브커맨드 ──
846
-
847
- const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
848
- const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
849
-
850
- // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
851
- function autoRegisterMcp(mcpUrl) {
852
- section("MCP 자동 등록");
853
-
854
- // Codex codex mcp add
855
- if (which("codex")) {
856
- try {
857
- // 이미 등록됐는지 확인
858
- const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
859
- if (list.includes("tfx-hub")) {
860
- ok("Codex: 이미 등록됨");
861
- } else {
862
- execSync(`codex mcp add tfx-hub --url ${mcpUrl}`, { timeout: 10000, stdio: "ignore" });
863
- ok("Codex: MCP 등록 완료");
864
- }
865
- } catch {
866
- // mcp list/add 미지원 → 설정 파일 직접 수정
867
- try {
868
- const codexDir = join(homedir(), ".codex");
869
- const configFile = join(codexDir, "config.json");
870
- let config = {};
871
- if (existsSync(configFile)) config = JSON.parse(readFileSync(configFile, "utf8"));
872
- if (!config.mcpServers) config.mcpServers = {};
873
- if (!config.mcpServers["tfx-hub"]) {
874
- config.mcpServers["tfx-hub"] = { url: mcpUrl };
875
- if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
876
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
877
- ok("Codex: config.json에 등록 완료");
878
- } else {
879
- ok("Codex: 이미 등록됨");
880
- }
881
- } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
882
- }
883
- } else {
884
- info("Codex: 미설치 (건너뜀)");
885
- }
886
-
887
- // Gemini — settings.json 직접 수정
888
- if (which("gemini")) {
889
- try {
890
- const geminiDir = join(homedir(), ".gemini");
891
- const settingsFile = join(geminiDir, "settings.json");
892
- let settings = {};
893
- if (existsSync(settingsFile)) settings = JSON.parse(readFileSync(settingsFile, "utf8"));
894
- if (!settings.mcpServers) settings.mcpServers = {};
895
- if (!settings.mcpServers["tfx-hub"]) {
896
- settings.mcpServers["tfx-hub"] = { url: mcpUrl };
897
- if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
898
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
899
- ok("Gemini: settings.json에 등록 완료");
900
- } else {
901
- ok("Gemini: 이미 등록됨");
902
- }
903
- } catch (e) { warn(`Gemini 등록 실패: ${e.message}`); }
904
- } else {
905
- info("Gemini: 미설치 (건너뜀)");
906
- }
907
-
908
- // Claude — 프로젝트 .mcp.json에 등록 (오케스트레이터용)
909
- try {
910
- const mcpJsonPath = join(PKG_ROOT, ".mcp.json");
911
- let mcpJson = {};
912
- if (existsSync(mcpJsonPath)) mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
913
- if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
914
- if (!mcpJson.mcpServers["tfx-hub"]) {
915
- mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
916
- writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
917
- ok("Claude: .mcp.json에 등록 완료");
918
- } else {
919
- ok("Claude: 이미 등록됨");
920
- }
921
- } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
922
- }
923
-
924
- function cmdHub() {
925
- const sub = process.argv[3] || "status";
926
-
927
- switch (sub) {
928
- case "start": {
929
- // 이미 실행 중인지 확인
930
- if (existsSync(HUB_PID_FILE)) {
931
- try {
932
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
933
- process.kill(info.pid, 0); // 프로세스 존재 확인
934
- console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
935
- return;
936
- } catch {
937
- // PID 파일 있지만 프로세스 없음 — 정리
938
- try { unlinkSync(HUB_PID_FILE); } catch {}
939
- }
940
- }
941
-
942
- const portArg = process.argv.indexOf("--port");
943
- const port = portArg !== -1 ? process.argv[portArg + 1] : "27888";
944
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
945
-
946
- if (!existsSync(serverPath)) {
947
- fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
948
- return;
949
- }
950
-
951
- const child = spawn(process.execPath, [serverPath], {
952
- env: { ...process.env, TFX_HUB_PORT: port },
953
- stdio: "ignore",
954
- detached: true,
955
- });
956
- child.unref();
957
-
958
- // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
959
- let started = false;
960
- const deadline = Date.now() + 3000;
961
- while (Date.now() < deadline) {
962
- if (existsSync(HUB_PID_FILE)) { started = true; break; }
963
- execSync("node -e \"setTimeout(()=>{},100)\"", { stdio: "ignore", timeout: 500 });
964
- }
965
-
966
- if (started) {
967
- const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
968
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
969
- console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
970
- console.log(` PID: ${hubInfo.pid}`);
971
- console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}`);
972
- console.log("");
973
- autoRegisterMcp(hubInfo.url);
974
- console.log("");
975
- } else {
976
- // 직접 포그라운드 모드로 안내
977
- console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
978
- console.log(` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`);
979
- }
980
- break;
981
- }
982
-
983
- case "stop": {
984
- if (!existsSync(HUB_PID_FILE)) {
985
- console.log(`\n ${DIM}hub 미실행${RESET}\n`);
986
- return;
987
- }
988
- try {
989
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
990
- process.kill(info.pid, "SIGTERM");
991
- try { unlinkSync(HUB_PID_FILE); } catch {}
992
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`);
993
- } catch (e) {
994
- try { unlinkSync(HUB_PID_FILE); } catch {}
995
- console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
996
- }
997
- break;
998
- }
999
-
1000
- case "status": {
1001
- if (!existsSync(HUB_PID_FILE)) {
1002
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
1003
- return;
1004
- }
1005
- try {
1006
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1007
- process.kill(info.pid, 0); // 생존 확인
1008
- const uptime = Date.now() - info.started;
1009
- const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
1010
- : uptime < 3600000 ? `${Math.round(uptime / 60000)}분`
1011
- : `${Math.round(uptime / 3600000)}시간`;
1012
-
1013
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
1014
- console.log(` URL: ${info.url}`);
1015
- console.log(` PID: ${info.pid}`);
1016
- console.log(` Uptime: ${uptimeStr}`);
1017
-
1018
- // HTTP 상태 조회 시도
1019
- try {
1020
- const statusUrl = info.url.replace("/mcp", "/status");
1021
- const result = execSync(`curl -s "${statusUrl}"`, { encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "ignore"] });
1022
- const data = JSON.parse(result);
1023
- if (data.hub) {
1024
- console.log(` State: ${data.hub.state}`);
1025
- }
1026
- if (data.sessions !== undefined) {
1027
- console.log(` Sessions: ${data.sessions}`);
1028
- }
1029
- } catch {}
1030
-
1031
- console.log("");
1032
- } catch {
1033
- try { unlinkSync(HUB_PID_FILE); } catch {}
1034
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1035
- }
1036
- break;
1037
- }
1038
-
1039
- default:
1040
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
1041
- console.log(` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`);
1042
- console.log(` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`);
1043
- console.log(` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`);
1044
- console.log(` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}\n`);
1045
- }
1046
- }
1047
-
1048
- // ── 메인 ──
1049
-
1050
- const cmd = process.argv[2] || "help";
1051
-
1052
- switch (cmd) {
1053
- case "setup": cmdSetup(); break;
1054
- case "doctor": {
1055
- const fix = process.argv.includes("--fix");
1056
- const reset = process.argv.includes("--reset");
1057
- cmdDoctor({ fix, reset });
1058
- break;
1059
- }
1060
- case "update": cmdUpdate(); break;
1061
- case "list": case "ls": cmdList(); break;
1062
- case "hub": cmdHub(); break;
1063
- case "notion-read": case "nr": {
1064
- const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
1065
- const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
1066
- try {
1067
- execSync(`"${process.execPath}" "${scriptPath}" ${nrArgs}`, { stdio: "inherit", timeout: 660000 });
1068
- } catch (e) { process.exit(e.status || 1); }
1069
- break;
1070
- }
1071
- case "version": case "--version": case "-v": cmdVersion(); break;
1072
- case "help": case "--help": case "-h": cmdHelp(); break;
1073
- default:
1074
- console.error(`알 수 없는 명령: ${cmd}`);
1075
- cmdHelp();
1076
- process.exit(1);
1077
- }
1
+ #!/usr/bin/env node
2
+ // triflux CLI — setup, doctor, version
3
+ import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, rmSync, statSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { homedir } from "os";
6
+ import { execSync, spawn } from "child_process";
7
+ import { fileURLToPath } from "url";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { detectMultiplexer, getSessionAttachedCount, killSession, listSessions, tmuxExec } from "../hub/team/session.mjs";
10
+ import { cleanupStaleOmcTeams, inspectStaleOmcTeams } from "../hub/team/staleState.mjs";
11
+
12
+ const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ const CLAUDE_DIR = join(homedir(), ".claude");
14
+ const CODEX_DIR = join(homedir(), ".codex");
15
+ const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
16
+ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
17
+
18
+ const REQUIRED_CODEX_PROFILES = [
19
+ {
20
+ name: "xhigh",
21
+ lines: [
22
+ 'model = "gpt-5.3-codex"',
23
+ 'model_reasoning_effort = "xhigh"',
24
+ ],
25
+ },
26
+ {
27
+ name: "spark_fast",
28
+ lines: [
29
+ 'model = "gpt-5.1-codex-mini"',
30
+ 'model_reasoning_effort = "low"',
31
+ ],
32
+ },
33
+ ];
34
+
35
+ // ── 색상 체계 (triflux brand: amber/orange accent) ──
36
+ const CYAN = "\x1b[36m";
37
+ const GREEN = "\x1b[32m";
38
+ const RED = "\x1b[31m";
39
+ const YELLOW = "\x1b[33m";
40
+ const DIM = "\x1b[2m";
41
+ const BOLD = "\x1b[1m";
42
+ const RESET = "\x1b[0m";
43
+ const AMBER = "\x1b[38;5;214m";
44
+ const BLUE = "\x1b[38;5;39m";
45
+ const WHITE_BRIGHT = "\x1b[97m";
46
+ const GRAY = "\x1b[38;5;245m";
47
+ const GREEN_BRIGHT = "\x1b[38;5;82m";
48
+ const RED_BRIGHT = "\x1b[38;5;196m";
49
+
50
+ // ── 브랜드 요소 ──
51
+ const BRAND = `${AMBER}${BOLD}triflux${RESET}`;
52
+ const VER = `${DIM}v${PKG.version}${RESET}`;
53
+ const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
54
+ const DOT = `${GRAY}·${RESET}`;
55
+ const STALE_TEAM_MAX_AGE_SEC = 3600;
56
+
57
+ // ── 유틸리티 ──
58
+
59
+ function ok(msg) { console.log(` ${GREEN_BRIGHT}✓${RESET} ${msg}`); }
60
+ function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
61
+ function fail(msg) { console.log(` ${RED_BRIGHT}✗${RESET} ${msg}`); }
62
+ function info(msg) { console.log(` ${GRAY}${msg}${RESET}`); }
63
+ function section(title) { console.log(`\n ${AMBER}▸${RESET} ${BOLD}${title}${RESET}`); }
64
+
65
+ function which(cmd) {
66
+ try {
67
+ const result = execSync(
68
+ process.platform === "win32" ? `where ${cmd} 2>nul` : `which ${cmd} 2>/dev/null`,
69
+ { encoding: "utf8", timeout: 5000 }
70
+ ).trim();
71
+ return result.split(/\r?\n/)[0] || null;
72
+ } catch { return null; }
73
+ }
74
+
75
+ function whichInShell(cmd, shell) {
76
+ const cmds = {
77
+ bash: `bash -c "source ~/.bashrc 2>/dev/null && command -v ${cmd} 2>/dev/null"`,
78
+ cmd: `cmd /c where ${cmd} 2>nul`,
79
+ pwsh: `pwsh -NoProfile -c "(Get-Command ${cmd} -EA SilentlyContinue).Source"`,
80
+ };
81
+ const command = cmds[shell];
82
+ if (!command) return null;
83
+ try {
84
+ const result = execSync(command, {
85
+ encoding: "utf8",
86
+ timeout: 8000,
87
+ stdio: ["pipe", "pipe", "ignore"],
88
+ }).trim();
89
+ return result.split(/\r?\n/)[0] || null;
90
+ } catch { return null; }
91
+ }
92
+
93
+ function isDevUpdateRequested(argv = process.argv) {
94
+ return argv.includes("--dev") || argv.includes("@dev") || argv.includes("dev");
95
+ }
96
+
97
+ function checkShellAvailable(shell) {
98
+ const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
99
+ try {
100
+ execSync(cmds[shell], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
101
+ return true;
102
+ } catch { return false; }
103
+ }
104
+
105
+ function getVersion(filePath) {
106
+ try {
107
+ const content = readFileSync(filePath, "utf8");
108
+ const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
109
+ return match ? match[1] : null;
110
+ } catch { return null; }
111
+ }
112
+
113
+ function parseSessionCreated(rawValue) {
114
+ const value = String(rawValue || "").trim();
115
+ if (!value) return null;
116
+
117
+ const numeric = Number(value);
118
+ if (Number.isFinite(numeric) && numeric > 0) {
119
+ return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric);
120
+ }
121
+
122
+ const parsed = Date.parse(value);
123
+ if (Number.isFinite(parsed)) {
124
+ return Math.floor(parsed / 1000);
125
+ }
126
+
127
+ const normalized = value.replace(/^(\d{2})-(\d{2})-(\d{2})(\s+)/, "20$1-$2-$3$4");
128
+ const reparsed = Date.parse(normalized);
129
+ if (Number.isFinite(reparsed)) {
130
+ return Math.floor(reparsed / 1000);
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ function formatElapsedAge(ageSec) {
137
+ if (!Number.isFinite(ageSec) || ageSec < 0) return "알 수 없음";
138
+ if (ageSec < 60) return `${ageSec}초`;
139
+ if (ageSec < 3600) return `${Math.floor(ageSec / 60)}분`;
140
+ if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}시간`;
141
+ return `${Math.floor(ageSec / 86400)}일`;
142
+ }
143
+
144
+ function readTeamSessionCreatedMap() {
145
+ const createdMap = new Map();
146
+
147
+ try {
148
+ const output = tmuxExec('list-sessions -F "#{session_name} #{session_created}"');
149
+ for (const line of output.split(/\r?\n/)) {
150
+ const trimmed = line.trim();
151
+ if (!trimmed) continue;
152
+
153
+ const firstSpace = trimmed.indexOf(" ");
154
+ if (firstSpace === -1) continue;
155
+
156
+ const sessionName = trimmed.slice(0, firstSpace);
157
+ const createdRaw = trimmed.slice(firstSpace + 1).trim();
158
+ const createdAt = parseSessionCreated(createdRaw);
159
+ createdMap.set(sessionName, {
160
+ createdAt,
161
+ createdRaw,
162
+ });
163
+ }
164
+ } catch {
165
+ // session_created 포맷을 읽지 못하면 stale 판정만 완화한다.
166
+ }
167
+
168
+ return createdMap;
169
+ }
170
+
171
+ function inspectTeamSessions() {
172
+ const mux = detectMultiplexer();
173
+ if (!mux) {
174
+ return { mux: null, sessions: [] };
175
+ }
176
+
177
+ const sessionNames = listSessions();
178
+ if (sessionNames.length === 0) {
179
+ return { mux, sessions: [] };
180
+ }
181
+
182
+ const createdMap = readTeamSessionCreatedMap();
183
+ const nowSec = Math.floor(Date.now() / 1000);
184
+ const sessions = sessionNames.map((sessionName) => {
185
+ const createdInfo = createdMap.get(sessionName) || { createdAt: null, createdRaw: "" };
186
+ const attachedCount = getSessionAttachedCount(sessionName);
187
+ const ageSec = createdInfo.createdAt == null ? null : Math.max(0, nowSec - createdInfo.createdAt);
188
+ const stale = ageSec != null && ageSec >= STALE_TEAM_MAX_AGE_SEC && attachedCount === 0;
189
+
190
+ return {
191
+ sessionName,
192
+ attachedCount,
193
+ ageSec,
194
+ createdAt: createdInfo.createdAt,
195
+ createdRaw: createdInfo.createdRaw,
196
+ stale,
197
+ };
198
+ });
199
+
200
+ return { mux, sessions };
201
+ }
202
+
203
+ async function cleanupStaleTeamSessions(staleSessions) {
204
+ let cleaned = 0;
205
+ let failed = 0;
206
+
207
+ for (const session of staleSessions) {
208
+ let removed = false;
209
+
210
+ for (let attempt = 1; attempt <= 3; attempt++) {
211
+ killSession(session.sessionName);
212
+ const stillAlive = listSessions().includes(session.sessionName);
213
+ if (!stillAlive) {
214
+ removed = true;
215
+ cleaned++;
216
+ ok(`stale 세션 정리: ${session.sessionName}`);
217
+ break;
218
+ }
219
+
220
+ if (attempt < 3) {
221
+ await delay(1000);
222
+ }
223
+ }
224
+
225
+ if (!removed) {
226
+ failed++;
227
+ fail(`세션 정리 실패: ${session.sessionName} — 수동 정리 필요`);
228
+ }
229
+ }
230
+
231
+ info(`${cleaned}개 stale 세션 정리 완료`);
232
+
233
+ return { cleaned, failed };
234
+ }
235
+
236
+ function escapeRegExp(value) {
237
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
238
+ }
239
+
240
+ function hasProfileSection(tomlContent, profileName) {
241
+ const section = `^\\[profiles\\.${escapeRegExp(profileName)}\\]\\s*$`;
242
+ return new RegExp(section, "m").test(tomlContent);
243
+ }
244
+
245
+ function ensureCodexProfiles() {
246
+ try {
247
+ if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
248
+
249
+ const original = existsSync(CODEX_CONFIG_PATH)
250
+ ? readFileSync(CODEX_CONFIG_PATH, "utf8")
251
+ : "";
252
+
253
+ let updated = original;
254
+ let added = 0;
255
+
256
+ for (const profile of REQUIRED_CODEX_PROFILES) {
257
+ if (hasProfileSection(updated, profile.name)) continue;
258
+
259
+ if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
260
+ if (updated.trim().length > 0) updated += "\n";
261
+ updated += `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
262
+ added++;
263
+ }
264
+
265
+ if (added > 0) {
266
+ writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
267
+ }
268
+
269
+ return { ok: true, added };
270
+ } catch (e) {
271
+ return { ok: false, added: 0, message: e.message };
272
+ }
273
+ }
274
+
275
+ function syncFile(src, dst, label) {
276
+ const dstDir = dirname(dst);
277
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
278
+
279
+ if (!existsSync(src)) {
280
+ fail(`${label}: 소스 파일 없음 (${src})`);
281
+ return false;
282
+ }
283
+
284
+ const srcVer = getVersion(src);
285
+ const dstVer = existsSync(dst) ? getVersion(dst) : null;
286
+
287
+ if (!existsSync(dst)) {
288
+ copyFileSync(src, dst);
289
+ try { chmodSync(dst, 0o755); } catch {}
290
+ ok(`${label}: 설치됨 ${srcVer ? `(v${srcVer})` : ""}`);
291
+ return true;
292
+ }
293
+
294
+ const srcContent = readFileSync(src, "utf8");
295
+ const dstContent = readFileSync(dst, "utf8");
296
+ if (srcContent !== dstContent) {
297
+ copyFileSync(src, dst);
298
+ try { chmodSync(dst, 0o755); } catch {}
299
+ const verInfo = (srcVer && dstVer && srcVer !== dstVer)
300
+ ? `(v${dstVer} → v${srcVer})`
301
+ : srcVer ? `(v${srcVer}, 내용 변경)` : "(내용 변경)";
302
+ ok(`${label}: 업데이트됨 ${verInfo}`);
303
+ return true;
304
+ }
305
+
306
+ ok(`${label}: 최신 상태 ${srcVer ? `(v${srcVer})` : ""}`);
307
+ return false;
308
+ }
309
+
310
+ // ── 크로스 셸 진단 ──
311
+
312
+ function checkCliCrossShell(cmd, installHint) {
313
+ const shells = process.platform === "win32" ? ["bash", "cmd", "pwsh"] : ["bash"];
314
+ let anyFound = false;
315
+ let bashMissing = false;
316
+
317
+ for (const shell of shells) {
318
+ if (!checkShellAvailable(shell)) {
319
+ info(`${shell}: ${DIM}셸 없음 (건너뜀)${RESET}`);
320
+ continue;
321
+ }
322
+ const p = whichInShell(cmd, shell);
323
+ if (p) {
324
+ ok(`${shell}: ${p}`);
325
+ anyFound = true;
326
+ } else {
327
+ fail(`${shell}: 미발견`);
328
+ if (shell === "bash") bashMissing = true;
329
+ }
330
+ }
331
+
332
+ if (!anyFound) {
333
+ info(`미설치 (선택사항) ${installHint}`);
334
+ info("없으면 Claude 네이티브 에이전트로 fallback");
335
+ return 1;
336
+ }
337
+ if (bashMissing) {
338
+ warn("bash에서 미발견 — tfx-route.sh 실행 불가");
339
+ info('→ ~/.bashrc에 추가: export PATH="$PATH:$APPDATA/npm"');
340
+ return 1;
341
+ }
342
+ return 0;
343
+ }
344
+
345
+ // ── 명령어 ──
346
+
347
+ function cmdSetup() {
348
+ console.log(`\n${BOLD}triflux setup${RESET}\n`);
349
+
350
+ syncFile(
351
+ join(PKG_ROOT, "scripts", "tfx-route.sh"),
352
+ join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
353
+ "tfx-route.sh"
354
+ );
355
+
356
+ syncFile(
357
+ join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
358
+ join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
359
+ "hud-qos-status.mjs"
360
+ );
361
+
362
+ syncFile(
363
+ join(PKG_ROOT, "scripts", "notion-read.mjs"),
364
+ join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
365
+ "notion-read.mjs"
366
+ );
367
+
368
+ syncFile(
369
+ join(PKG_ROOT, "scripts", "tfx-route-post.mjs"),
370
+ join(CLAUDE_DIR, "scripts", "tfx-route-post.mjs"),
371
+ "tfx-route-post.mjs"
372
+ );
373
+
374
+ syncFile(
375
+ join(PKG_ROOT, "scripts", "tfx-batch-stats.mjs"),
376
+ join(CLAUDE_DIR, "scripts", "tfx-batch-stats.mjs"),
377
+ "tfx-batch-stats.mjs"
378
+ );
379
+
380
+ // 스킬 동기화 (~/.claude/skills/{name}/SKILL.md)
381
+ const skillsSrc = join(PKG_ROOT, "skills");
382
+ const skillsDst = join(CLAUDE_DIR, "skills");
383
+ if (existsSync(skillsSrc)) {
384
+ let skillCount = 0;
385
+ let skillTotal = 0;
386
+ for (const name of readdirSync(skillsSrc)) {
387
+ const src = join(skillsSrc, name, "SKILL.md");
388
+ const dst = join(skillsDst, name, "SKILL.md");
389
+ if (!existsSync(src)) continue;
390
+ skillTotal++;
391
+
392
+ const dstDir = dirname(dst);
393
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
394
+
395
+ if (!existsSync(dst)) {
396
+ copyFileSync(src, dst);
397
+ skillCount++;
398
+ } else {
399
+ const srcContent = readFileSync(src, "utf8");
400
+ const dstContent = readFileSync(dst, "utf8");
401
+ if (srcContent !== dstContent) {
402
+ copyFileSync(src, dst);
403
+ skillCount++;
404
+ }
405
+ }
406
+ }
407
+ if (skillCount > 0) {
408
+ ok(`스킬: ${skillCount}/${skillTotal}개 업데이트됨`);
409
+ } else {
410
+ ok(`스킬: ${skillTotal}개 최신 상태`);
411
+ }
412
+ }
413
+
414
+ const codexProfileResult = ensureCodexProfiles();
415
+ if (!codexProfileResult.ok) {
416
+ warn(`Codex profiles 설정 실패: ${codexProfileResult.message}`);
417
+ } else if (codexProfileResult.added > 0) {
418
+ ok(`Codex profiles: ${codexProfileResult.added}개 추가됨 (~/.codex/config.toml)`);
419
+ } else {
420
+ ok("Codex profiles: 이미 준비됨");
421
+ }
422
+
423
+ // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
424
+ if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
425
+ const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
426
+ autoRegisterMcp(defaultHubUrl);
427
+ console.log("");
428
+ }
429
+
430
+ // HUD statusLine 설정
431
+ console.log(`${CYAN}[HUD 설정]${RESET}`);
432
+ const settingsPath = join(CLAUDE_DIR, "settings.json");
433
+ const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
434
+
435
+ if (existsSync(hudPath)) {
436
+ try {
437
+ let settings = {};
438
+ if (existsSync(settingsPath)) {
439
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
440
+ }
441
+
442
+ const currentCmd = settings.statusLine?.command || "";
443
+ if (currentCmd.includes("hud-qos-status.mjs")) {
444
+ ok("statusLine 이미 설정됨");
445
+ } else {
446
+ const nodePath = process.execPath.replace(/\\/g, "/");
447
+ const hudForward = hudPath.replace(/\\/g, "/");
448
+ const nodeRef = nodePath.includes(" ") ? `"${nodePath}"` : nodePath;
449
+ const hudRef = hudForward.includes(" ") ? `"${hudForward}"` : hudForward;
450
+
451
+ if (currentCmd) {
452
+ warn(`기존 statusLine 덮어쓰기: ${currentCmd}`);
453
+ }
454
+
455
+ settings.statusLine = {
456
+ type: "command",
457
+ command: `${nodeRef} ${hudRef}`,
458
+ };
459
+
460
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
461
+ ok("statusLine 설정 완료 — 세션 재시작 후 HUD 표시");
462
+ }
463
+ } catch (e) {
464
+ fail(`settings.json 처리 실패: ${e.message}`);
465
+ }
466
+ } else {
467
+ warn("HUD 파일 없음 — 먼저 파일 동기화 필요");
468
+ }
469
+
470
+ console.log(`\n${DIM}설치 위치: ${CLAUDE_DIR}${RESET}\n`);
471
+ }
472
+
473
+ async function cmdDoctor(options = {}) {
474
+ const { fix = false, reset = false } = options;
475
+ const modeLabel = reset ? ` ${RED}--reset${RESET}` : fix ? ` ${YELLOW}--fix${RESET}` : "";
476
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux doctor${RESET} ${VER}${modeLabel}\n`);
477
+ console.log(` ${LINE}`);
478
+
479
+ // ── reset 모드: 캐시 전체 초기화 ──
480
+ if (reset) {
481
+ section("Cache Reset");
482
+ const cacheDir = join(CLAUDE_DIR, "cache");
483
+ const resetFiles = [
484
+ "claude-usage-cache.json",
485
+ ".claude-refresh-lock",
486
+ "codex-rate-limits-cache.json",
487
+ "gemini-quota-cache.json",
488
+ "gemini-project-id.json",
489
+ "gemini-session-cache.json",
490
+ "gemini-rpm-tracker.json",
491
+ "sv-accumulator.json",
492
+ "mcp-inventory.json",
493
+ "cli-issues.jsonl",
494
+ "triflux-update-check.json",
495
+ ];
496
+ let cleared = 0;
497
+ for (const name of resetFiles) {
498
+ const fp = join(cacheDir, name);
499
+ if (existsSync(fp)) {
500
+ try { unlinkSync(fp); cleared++; ok(`삭제됨: ${name}`); }
501
+ catch (e) { fail(`삭제 실패: ${name} — ${e.message}`); }
502
+ }
503
+ }
504
+ if (cleared === 0) {
505
+ ok("삭제할 캐시 파일 없음 (이미 깨끗함)");
506
+ } else {
507
+ console.log("");
508
+ ok(`${BOLD}${cleared}개${RESET} 캐시 파일 초기화 완료`);
509
+ }
510
+ // 캐시 즉시 재생성
511
+ console.log("");
512
+ section("Cache Rebuild");
513
+ const mcpCheck = join(PKG_ROOT, "scripts", "mcp-check.mjs");
514
+ if (existsSync(mcpCheck)) {
515
+ try {
516
+ execSync(`"${process.execPath}" "${mcpCheck}"`, { timeout: 15000, stdio: "ignore" });
517
+ ok("MCP 인벤토리 재생성됨");
518
+ } catch { warn("MCP 인벤토리 재생성 실패 — 다음 세션에서 자동 재시도"); }
519
+ }
520
+ const hudScript = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
521
+ if (existsSync(hudScript)) {
522
+ try {
523
+ execSync(`"${process.execPath}" "${hudScript}" --refresh-claude-usage`, { timeout: 20000, stdio: "ignore" });
524
+ ok("Claude 사용량 캐시 재생성됨");
525
+ } catch { warn("Claude 사용량 캐시 재생성 실패 — 다음 API 호출 시 자동 생성"); }
526
+ try {
527
+ execSync(`"${process.execPath}" "${hudScript}" --refresh-codex-rate-limits`, { timeout: 15000, stdio: "ignore" });
528
+ ok("Codex 레이트 리밋 캐시 재생성됨");
529
+ } catch { warn("Codex 레이트 리밋 캐시 재생성 실패"); }
530
+ try {
531
+ execSync(`"${process.execPath}" "${hudScript}" --refresh-gemini-quota`, { timeout: 15000, stdio: "ignore" });
532
+ ok("Gemini 쿼터 캐시 재생성됨");
533
+ } catch { warn("Gemini 쿼터 캐시 재생성 실패"); }
534
+ }
535
+ console.log(`\n ${LINE}`);
536
+ console.log(` ${GREEN_BRIGHT}${BOLD}✓ 캐시 초기화 + 재생성 완료${RESET}\n`);
537
+ return;
538
+ }
539
+
540
+ // ── fix 모드: 파일 동기화 + 캐시 정리 후 진단 ──
541
+ if (fix) {
542
+ section("Auto Fix");
543
+ syncFile(
544
+ join(PKG_ROOT, "scripts", "tfx-route.sh"),
545
+ join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
546
+ "tfx-route.sh"
547
+ );
548
+ syncFile(
549
+ join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
550
+ join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
551
+ "hud-qos-status.mjs"
552
+ );
553
+ syncFile(
554
+ join(PKG_ROOT, "scripts", "notion-read.mjs"),
555
+ join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
556
+ "notion-read.mjs"
557
+ );
558
+ // 스킬 동기화
559
+ const fSkillsSrc = join(PKG_ROOT, "skills");
560
+ const fSkillsDst = join(CLAUDE_DIR, "skills");
561
+ if (existsSync(fSkillsSrc)) {
562
+ let sc = 0, st = 0;
563
+ for (const name of readdirSync(fSkillsSrc)) {
564
+ const src = join(fSkillsSrc, name, "SKILL.md");
565
+ const dst = join(fSkillsDst, name, "SKILL.md");
566
+ if (!existsSync(src)) continue;
567
+ st++;
568
+ const dstDir = dirname(dst);
569
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
570
+ if (!existsSync(dst)) { copyFileSync(src, dst); sc++; }
571
+ else if (readFileSync(src, "utf8") !== readFileSync(dst, "utf8")) { copyFileSync(src, dst); sc++; }
572
+ }
573
+ if (sc > 0) ok(`스킬: ${sc}/${st}개 업데이트됨`);
574
+ else ok(`스킬: ${st} 최신 상태`);
575
+ }
576
+ const profileFix = ensureCodexProfiles();
577
+ if (!profileFix.ok) {
578
+ warn(`Codex Profiles 자동 복구 실패: ${profileFix.message}`);
579
+ } else if (profileFix.added > 0) {
580
+ ok(`Codex Profiles: ${profileFix.added}개 추가됨`);
581
+ } else {
582
+ info("Codex Profiles: 이미 최신 상태");
583
+ }
584
+ // 에러/스테일 캐시 정리
585
+ const fCacheDir = join(CLAUDE_DIR, "cache");
586
+ const staleNames = ["claude-usage-cache.json", ".claude-refresh-lock", "codex-rate-limits-cache.json"];
587
+ let cleaned = 0;
588
+ for (const name of staleNames) {
589
+ const fp = join(fCacheDir, name);
590
+ if (!existsSync(fp)) continue;
591
+ try {
592
+ const parsed = JSON.parse(readFileSync(fp, "utf8"));
593
+ if (parsed.error || name.startsWith(".")) { unlinkSync(fp); cleaned++; ok(`에러 캐시 정리: ${name}`); }
594
+ } catch { try { unlinkSync(fp); cleaned++; ok(`손상된 캐시 정리: ${name}`); } catch {} }
595
+ }
596
+ if (cleaned === 0) info("에러 캐시 없음");
597
+ console.log(`\n ${LINE}`);
598
+ info("수정 완료 — 아래 진단 결과를 확인하세요");
599
+ console.log("");
600
+ }
601
+
602
+ let issues = 0;
603
+
604
+ // 1. tfx-route.sh
605
+ section("tfx-route.sh");
606
+ const routeSh = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
607
+ if (existsSync(routeSh)) {
608
+ const ver = getVersion(routeSh);
609
+ ok(`설치됨 ${ver ? `${DIM}v${ver}${RESET}` : ""}`);
610
+ } else {
611
+ fail("미설치 tfx setup 실행 필요");
612
+ issues++;
613
+ }
614
+
615
+ // 2. HUD
616
+ section("HUD");
617
+ const hud = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
618
+ if (existsSync(hud)) {
619
+ ok("설치됨");
620
+ } else {
621
+ warn("미설치 ${GRAY}(선택사항)${RESET}");
622
+ }
623
+
624
+ // 3. Codex CLI
625
+ section(`Codex CLI ${WHITE_BRIGHT}●${RESET}`);
626
+ issues += checkCliCrossShell("codex", "npm install -g @openai/codex");
627
+ if (which("codex")) {
628
+ if (process.env.OPENAI_API_KEY) {
629
+ ok("OPENAI_API_KEY 설정됨");
630
+ } else {
631
+ warn(`OPENAI_API_KEY 미설정 ${GRAY}(Pro 구독이면 불필요)${RESET}`);
632
+ }
633
+ }
634
+
635
+ // 4. Codex Profiles
636
+ section("Codex Profiles");
637
+ if (existsSync(CODEX_CONFIG_PATH)) {
638
+ const codexConfig = readFileSync(CODEX_CONFIG_PATH, "utf8");
639
+ for (const profile of REQUIRED_CODEX_PROFILES) {
640
+ if (hasProfileSection(codexConfig, profile.name)) {
641
+ ok(`${profile.name}: 정상`);
642
+ } else {
643
+ warn(`${profile.name}: 미설정`);
644
+ issues++;
645
+ }
646
+ }
647
+ } else {
648
+ warn("config.toml 미존재");
649
+ issues++;
650
+ }
651
+
652
+ // 5. Gemini CLI
653
+ section(`Gemini CLI ${BLUE}●${RESET}`);
654
+ issues += checkCliCrossShell("gemini", "npm install -g @google/gemini-cli");
655
+ if (which("gemini")) {
656
+ if (process.env.GEMINI_API_KEY) {
657
+ ok("GEMINI_API_KEY 설정됨");
658
+ } else {
659
+ warn(`GEMINI_API_KEY 미설정 ${GRAY}(gemini auth login)${RESET}`);
660
+ }
661
+ }
662
+
663
+ // 6. Claude Code
664
+ section(`Claude Code ${AMBER}●${RESET}`);
665
+ const claudePath = which("claude");
666
+ if (claudePath) {
667
+ ok("설치됨");
668
+ } else {
669
+ fail("미설치 (필수)");
670
+ issues++;
671
+ }
672
+
673
+ // 7. 스킬 설치 상태
674
+ section("Skills");
675
+ const skillsSrc = join(PKG_ROOT, "skills");
676
+ const skillsDst = join(CLAUDE_DIR, "skills");
677
+ if (existsSync(skillsSrc)) {
678
+ let installed = 0;
679
+ let total = 0;
680
+ const missing = [];
681
+ for (const name of readdirSync(skillsSrc)) {
682
+ if (!existsSync(join(skillsSrc, name, "SKILL.md"))) continue;
683
+ total++;
684
+ if (existsSync(join(skillsDst, name, "SKILL.md"))) {
685
+ installed++;
686
+ } else {
687
+ missing.push(name);
688
+ }
689
+ }
690
+ if (installed === total) {
691
+ ok(`${installed}/${total}개 설치됨`);
692
+ } else {
693
+ warn(`${installed}/${total}개 설치됨 미설치: ${missing.join(", ")}`);
694
+ info("triflux setup으로 동기화 가능");
695
+ issues++;
696
+ }
697
+ }
698
+
699
+ // 8. 플러그인 등록
700
+ section("Plugin");
701
+ const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
702
+ if (existsSync(pluginsFile)) {
703
+ const content = readFileSync(pluginsFile, "utf8");
704
+ if (content.includes("triflux")) {
705
+ ok("triflux 플러그인 등록됨");
706
+ } else {
707
+ warn("triflux 플러그인 미등록 — npm 단독 사용 중");
708
+ info("플러그인 등록: /plugin marketplace add <repo-url>");
709
+ }
710
+ } else {
711
+ info("플러그인 시스템 감지 안 됨 — npm 단독 사용");
712
+ }
713
+
714
+ // 9. MCP 인벤토리
715
+ section("MCP Inventory");
716
+ const mcpCache = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
717
+ if (existsSync(mcpCache)) {
718
+ try {
719
+ const inv = JSON.parse(readFileSync(mcpCache, "utf8"));
720
+ ok(`캐시 존재 (${inv.timestamp})`);
721
+ if (inv.codex?.servers?.length) {
722
+ const names = inv.codex.servers.map(s => s.name).join(", ");
723
+ info(`Codex: ${inv.codex.servers.length}개 서버 (${names})`);
724
+ }
725
+ if (inv.gemini?.servers?.length) {
726
+ const names = inv.gemini.servers.map(s => s.name).join(", ");
727
+ info(`Gemini: ${inv.gemini.servers.length}개 서버 (${names})`);
728
+ }
729
+ } catch {
730
+ warn("캐시 파일 파싱 실패");
731
+ }
732
+ } else {
733
+ warn("캐시 없음 — 다음 세션 시작 시 자동 생성");
734
+ info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
735
+ }
736
+
737
+ // 10. CLI 이슈 트래커
738
+ section("CLI Issues");
739
+ const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
740
+ if (existsSync(issuesFile)) {
741
+ try {
742
+ const lines = readFileSync(issuesFile, "utf8").trim().split("\n").filter(Boolean);
743
+ const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
744
+ const unresolved = entries.filter(e => !e.resolved);
745
+
746
+ if (unresolved.length === 0) {
747
+ ok("미해결 이슈 없음");
748
+ } else {
749
+ // 패턴별 그룹핑
750
+ const groups = {};
751
+ for (const e of unresolved) {
752
+ const key = `${e.cli}:${e.pattern}`;
753
+ if (!groups[key]) groups[key] = { ...e, count: 0 };
754
+ groups[key].count++;
755
+ if (e.ts > groups[key].ts) { groups[key].ts = e.ts; groups[key].snippet = e.snippet; }
756
+ }
757
+
758
+ // 알려진 해결 버전 (패턴별 수정된 triflux 버전)
759
+ const KNOWN_FIXES = {
760
+ "gemini:deprecated_flag": "1.8.9", // -p → --prompt
761
+ };
762
+
763
+ const currentVer = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
764
+ let cleaned = 0;
765
+
766
+ for (const [key, g] of Object.entries(groups)) {
767
+ const fixVer = KNOWN_FIXES[key];
768
+ if (fixVer && currentVer >= fixVer) {
769
+ // 해결된 이슈 자동 정리
770
+ cleaned += g.count;
771
+ continue;
772
+ }
773
+ const age = Date.now() - g.ts;
774
+ const ago = age < 3600000 ? `${Math.round(age / 60000)}분 전` :
775
+ age < 86400000 ? `${Math.round(age / 3600000)}시간 전` :
776
+ `${Math.round(age / 86400000)}일 전`;
777
+ const sev = g.severity === "error" ? `${RED}ERROR${RESET}` : `${YELLOW}WARN${RESET}`;
778
+ warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
779
+ if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
780
+ if (fixVer) info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
781
+ issues++;
782
+ }
783
+
784
+ // 해결된 이슈 자동 정리
785
+ if (cleaned > 0) {
786
+ const remaining = entries.filter(e => {
787
+ const key = `${e.cli}:${e.pattern}`;
788
+ const fixVer = KNOWN_FIXES[key];
789
+ return !(fixVer && currentVer >= fixVer);
790
+ });
791
+ writeFileSync(issuesFile, remaining.map(e => JSON.stringify(e)).join("\n") + (remaining.length ? "\n" : ""));
792
+ ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
793
+ }
794
+ }
795
+ } catch (e) {
796
+ warn(`이슈 파일 읽기 실패: ${e.message}`);
797
+ }
798
+ } else {
799
+ ok("이슈 로그 없음 (정상)");
800
+ }
801
+
802
+ // 11. Team Sessions
803
+ section("Team Sessions");
804
+ const teamSessionReport = inspectTeamSessions();
805
+ if (!teamSessionReport.mux) {
806
+ info("tmux/psmux 미감지 — 팀 세션 검사 건너뜀");
807
+ } else if (teamSessionReport.sessions.length === 0) {
808
+ ok(`활성 세션 없음 ${DIM}(${teamSessionReport.mux})${RESET}`);
809
+ } else {
810
+ info(`multiplexer: ${teamSessionReport.mux}`);
811
+
812
+ for (const session of teamSessionReport.sessions) {
813
+ const attachedLabel = session.attachedCount == null ? "?" : `${session.attachedCount}`;
814
+ const ageLabel = formatElapsedAge(session.ageSec);
815
+
816
+ if (session.stale) {
817
+ warn(`${session.sessionName}: stale 추정 (attach=${attachedLabel}, 경과=${ageLabel})`);
818
+ } else {
819
+ ok(`${session.sessionName}: 정상 (attach=${attachedLabel}, 경과=${ageLabel})`);
820
+ }
821
+
822
+ if (session.createdAt == null) {
823
+ info(`${session.sessionName}: session_created 파싱 실패${session.createdRaw ? ` (${session.createdRaw})` : ""}`);
824
+ }
825
+ }
826
+
827
+ const staleSessions = teamSessionReport.sessions.filter((session) => session.stale);
828
+ if (staleSessions.length > 0) {
829
+ if (fix) {
830
+ const cleanupResult = await cleanupStaleTeamSessions(staleSessions);
831
+ issues += cleanupResult.failed;
832
+ } else {
833
+ info("정리: tfx doctor --fix");
834
+ issues += staleSessions.length;
835
+ }
836
+ }
837
+ }
838
+
839
+ // 12. OMC stale team 상태
840
+ section("OMC Stale Teams");
841
+ const omcTeamReport = inspectStaleOmcTeams({
842
+ startDir: process.cwd(),
843
+ maxAgeMs: STALE_TEAM_MAX_AGE_SEC * 1000,
844
+ liveSessionNames: teamSessionReport.sessions.map((session) => session.sessionName),
845
+ });
846
+ if (!omcTeamReport.stateRoot) {
847
+ info(".omc/state 없음 — 검사 건너뜀");
848
+ } else if (omcTeamReport.entries.length === 0) {
849
+ ok(`stale team 없음 ${DIM}(${omcTeamReport.stateRoot})${RESET}`);
850
+ } else {
851
+ warn(`${omcTeamReport.entries.length}개 stale team 발견`);
852
+
853
+ for (const entry of omcTeamReport.entries) {
854
+ const ageLabel = formatElapsedAge(entry.ageSec);
855
+ const scopeLabel = entry.scope === "root" ? "root-state" : entry.sessionId;
856
+ warn(`${scopeLabel}: stale team (경과=${ageLabel}, 프로세스 없음)`);
857
+ if (entry.teamName) info(`팀: ${entry.teamName}`);
858
+ info(`파일: ${entry.stateFile}`);
859
+ }
860
+
861
+ if (fix) {
862
+ const cleanupResult = cleanupStaleOmcTeams(omcTeamReport.entries);
863
+ for (const result of cleanupResult.results) {
864
+ if (result.ok) {
865
+ ok(`stale team 정리: ${result.entry.scope === "root" ? "root-state" : result.entry.sessionId}`);
866
+ } else {
867
+ fail(`stale team 정리 실패: ${result.entry.scope === "root" ? "root-state" : result.entry.sessionId} — ${result.error.message}`);
868
+ }
869
+ }
870
+ issues += cleanupResult.failed;
871
+ } else {
872
+ info("정리: tfx doctor --fix");
873
+ issues += omcTeamReport.entries.length;
874
+ }
875
+ }
876
+
877
+ // 13. Orphan Teams
878
+ section("Orphan Teams");
879
+ const teamsDir = join(CLAUDE_DIR, "teams");
880
+ const tasksDir = join(CLAUDE_DIR, "tasks");
881
+ if (existsSync(teamsDir)) {
882
+ try {
883
+ const teamDirs = readdirSync(teamsDir).filter(d => {
884
+ try { return statSync(join(teamsDir, d)).isDirectory(); } catch { return false; }
885
+ });
886
+ if (teamDirs.length === 0) {
887
+ ok("잔존 없음");
888
+ } else {
889
+ warn(`${teamDirs.length}개 잔존 팀 발견: ${teamDirs.join(", ")}`);
890
+ if (fix) {
891
+ let cleaned = 0;
892
+ for (const d of teamDirs) {
893
+ try {
894
+ rmSync(join(teamsDir, d), { recursive: true, force: true });
895
+ cleaned++;
896
+ } catch {}
897
+ // 연관 tasks 디렉토리도 정리
898
+ const taskDir = join(tasksDir, d);
899
+ if (existsSync(taskDir)) {
900
+ try { rmSync(taskDir, { recursive: true, force: true }); } catch {}
901
+ }
902
+ }
903
+ ok(`${cleaned}개 잔존 정리 완료`);
904
+ } else {
905
+ info("정리: /tfx-doctor --fix 또는 수동 rm -rf ~/.claude/teams/{name}/");
906
+ issues++;
907
+ }
908
+ }
909
+ } catch (e) {
910
+ warn(`teams 디렉토리 읽기 실패: ${e.message}`);
911
+ }
912
+ } else {
913
+ ok("잔존 없음");
914
+ }
915
+
916
+ // 결과
917
+ console.log(`\n ${LINE}`);
918
+ if (issues === 0) {
919
+ console.log(` ${GREEN_BRIGHT}${BOLD}✓ 모든 검사 통과${RESET}\n`);
920
+ } else {
921
+ console.log(` ${YELLOW}${BOLD}⚠ ${issues}개 항목 확인 필요${RESET}\n`);
922
+ }
923
+ }
924
+
925
+ function cmdUpdate() {
926
+ const isDev = isDevUpdateRequested(process.argv);
927
+ const tagLabel = isDev ? ` ${YELLOW}--dev${RESET}` : "";
928
+ console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
929
+
930
+ // 1. 설치 방식 감지
931
+ const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
932
+ let installMode = "unknown";
933
+ let pluginPath = null;
934
+
935
+ // 플러그인 모드 감지
936
+ if (existsSync(pluginsFile)) {
937
+ try {
938
+ const plugins = JSON.parse(readFileSync(pluginsFile, "utf8"));
939
+ for (const [key, entries] of Object.entries(plugins.plugins || {})) {
940
+ if (key.startsWith("triflux")) {
941
+ pluginPath = entries[0]?.installPath;
942
+ installMode = "plugin";
943
+ break;
944
+ }
945
+ }
946
+ } catch {}
947
+ }
948
+
949
+ // PKG_ROOT가 플러그인 캐시 내에 있으면 플러그인 모드
950
+ if (installMode === "unknown" && PKG_ROOT.includes(join(".claude", "plugins"))) {
951
+ installMode = "plugin";
952
+ pluginPath = PKG_ROOT;
953
+ }
954
+
955
+ // npm global 감지
956
+ if (installMode === "unknown") {
957
+ try {
958
+ const npmList = execSync("npm list -g triflux --depth=0", {
959
+ encoding: "utf8",
960
+ timeout: 10000,
961
+ stdio: ["pipe", "pipe", "ignore"],
962
+ });
963
+ if (npmList.includes("triflux")) installMode = "npm-global";
964
+ } catch {}
965
+ }
966
+
967
+ // npm local 감지
968
+ if (installMode === "unknown") {
969
+ const localPkg = join(process.cwd(), "node_modules", "triflux");
970
+ if (existsSync(localPkg)) installMode = "npm-local";
971
+ }
972
+
973
+ // git 저장소 직접 사용
974
+ if (installMode === "unknown" && existsSync(join(PKG_ROOT, ".git"))) {
975
+ installMode = "git-local";
976
+ }
977
+
978
+ info(`검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`);
979
+
980
+ // 2. 설치 방식에 따라 업데이트
981
+ const oldVer = PKG.version;
982
+ let updated = false;
983
+ let stoppedHubInfo = null;
984
+
985
+ try {
986
+ switch (installMode) {
987
+ case "plugin": {
988
+ const gitDir = pluginPath || PKG_ROOT;
989
+ const result = execSync("git pull", {
990
+ encoding: "utf8",
991
+ timeout: 30000,
992
+ cwd: gitDir,
993
+ }).trim();
994
+ ok(`git pull ${result}`);
995
+ updated = true;
996
+ break;
997
+ }
998
+ case "npm-global": {
999
+ stoppedHubInfo = stopHubForUpdate();
1000
+ if (stoppedHubInfo?.pid) {
1001
+ info(`실행 중 hub 정지 (PID ${stoppedHubInfo.pid})`);
1002
+ }
1003
+ const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
1004
+ const result = execSync(npmCmd, {
1005
+ encoding: "utf8",
1006
+ timeout: 60000,
1007
+ stdio: ["pipe", "pipe", "ignore"],
1008
+ }).trim().split(/\r?\n/)[0];
1009
+ ok(`${isDev ? "npm install -g triflux@dev" : "npm update -g triflux"} — ${result || "완료"}`);
1010
+ updated = true;
1011
+ break;
1012
+ }
1013
+ case "npm-local": {
1014
+ const npmLocalCmd = isDev ? "npm install triflux@dev" : "npm update triflux";
1015
+ const result = execSync(npmLocalCmd, {
1016
+ encoding: "utf8",
1017
+ timeout: 60000,
1018
+ cwd: process.cwd(),
1019
+ stdio: ["pipe", "pipe", "ignore"],
1020
+ }).trim().split(/\r?\n/)[0];
1021
+ ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} ${result || "완료"}`);
1022
+ updated = true;
1023
+ break;
1024
+ }
1025
+ case "git-local": {
1026
+ const result = execSync("git pull", {
1027
+ encoding: "utf8",
1028
+ timeout: 30000,
1029
+ cwd: PKG_ROOT,
1030
+ }).trim();
1031
+ ok(`git pull — ${result}`);
1032
+ updated = true;
1033
+ break;
1034
+ }
1035
+ default:
1036
+ fail("설치 방식을 감지할 수 없음");
1037
+ info("수동 업데이트: cd <triflux-dir> && git pull");
1038
+ return;
1039
+ }
1040
+ } catch (e) {
1041
+ if (stoppedHubInfo && startHubAfterUpdate(stoppedHubInfo)) {
1042
+ info("업데이트 실패 hub 재기동 시도");
1043
+ }
1044
+ fail(`업데이트 실패: ${e.message}`);
1045
+ return;
1046
+ }
1047
+
1048
+ // 3. setup 재실행 (tfx-route.sh, HUD, 스킬 동기화)
1049
+ if (updated) {
1050
+ console.log("");
1051
+ // 업데이트 후 새 버전 읽기
1052
+ let newVer = oldVer;
1053
+ try {
1054
+ const newPkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
1055
+ newVer = newPkg.version;
1056
+ } catch {}
1057
+
1058
+ if (newVer !== oldVer) {
1059
+ ok(`버전: v${oldVer} → v${newVer}`);
1060
+ } else {
1061
+ ok(`버전: v${oldVer} (이미 최신)`);
1062
+ }
1063
+
1064
+ // setup 재실행
1065
+ console.log("");
1066
+ info("setup 재실행 중...");
1067
+ cmdSetup();
1068
+
1069
+ if (stoppedHubInfo) {
1070
+ if (startHubAfterUpdate(stoppedHubInfo)) info("hub 재기동 완료");
1071
+ else warn("hub 재기동 실패 `tfx hub start`로 수동 시작 필요");
1072
+ }
1073
+ }
1074
+
1075
+ console.log(`${GREEN}${BOLD}업데이트 완료${RESET}\n`);
1076
+ }
1077
+
1078
+ function cmdList() {
1079
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux list${RESET} ${VER}\n`);
1080
+ console.log(` ${LINE}`);
1081
+
1082
+ const pluginSkills = join(PKG_ROOT, "skills");
1083
+ const installedSkills = join(CLAUDE_DIR, "skills");
1084
+
1085
+ section("패키지 스킬");
1086
+ if (existsSync(pluginSkills)) {
1087
+ for (const name of readdirSync(pluginSkills).sort()) {
1088
+ const src = join(pluginSkills, name, "SKILL.md");
1089
+ if (!existsSync(src)) continue;
1090
+ const dst = join(installedSkills, name, "SKILL.md");
1091
+ const installed = existsSync(dst);
1092
+ if (installed) {
1093
+ console.log(` ${GREEN_BRIGHT}✓${RESET} ${BOLD}${name}${RESET}`);
1094
+ } else {
1095
+ console.log(` ${RED_BRIGHT}✗${RESET} ${DIM}${name}${RESET} ${GRAY}(미설치)${RESET}`);
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ section("사용자 스킬");
1101
+ const pkgNames = new Set(existsSync(pluginSkills) ? readdirSync(pluginSkills) : []);
1102
+ let userCount = 0;
1103
+ if (existsSync(installedSkills)) {
1104
+ for (const name of readdirSync(installedSkills).sort()) {
1105
+ if (pkgNames.has(name)) continue;
1106
+ const skill = join(installedSkills, name, "SKILL.md");
1107
+ if (!existsSync(skill)) continue;
1108
+ console.log(` ${AMBER}◆${RESET} ${name}`);
1109
+ userCount++;
1110
+ }
1111
+ }
1112
+ if (userCount === 0) console.log(` ${GRAY}없음${RESET}`);
1113
+
1114
+ console.log(`\n ${LINE}`);
1115
+ console.log(` ${GRAY}${installedSkills}${RESET}\n`);
1116
+ }
1117
+
1118
+ function cmdVersion() {
1119
+ const routeVer = getVersion(join(CLAUDE_DIR, "scripts", "tfx-route.sh"));
1120
+ const hudVer = getVersion(join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"));
1121
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux${RESET} ${WHITE_BRIGHT}v${PKG.version}${RESET}`);
1122
+ if (routeVer) console.log(` ${GRAY}tfx-route${RESET} v${routeVer}`);
1123
+ if (hudVer) console.log(` ${GRAY}hud${RESET} v${hudVer}`);
1124
+ console.log("");
1125
+ }
1126
+
1127
+ function checkForUpdate() {
1128
+ const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
1129
+ const cacheDir = dirname(cacheFile);
1130
+
1131
+ // 캐시 확인 (1시간 이내면 캐시 사용)
1132
+ try {
1133
+ if (existsSync(cacheFile)) {
1134
+ const cache = JSON.parse(readFileSync(cacheFile, "utf8"));
1135
+ if (Date.now() - cache.timestamp < 3600000) {
1136
+ return cache.latest !== PKG.version ? cache.latest : null;
1137
+ }
1138
+ }
1139
+ } catch {}
1140
+
1141
+ // npm registry 조회
1142
+ try {
1143
+ const result = execSync("npm view triflux version", {
1144
+ encoding: "utf8",
1145
+ timeout: 5000,
1146
+ stdio: ["pipe", "pipe", "ignore"],
1147
+ }).trim();
1148
+
1149
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
1150
+ writeFileSync(cacheFile, JSON.stringify({ latest: result, timestamp: Date.now() }));
1151
+
1152
+ return result !== PKG.version ? result : null;
1153
+ } catch {
1154
+ return null;
1155
+ }
1156
+ }
1157
+
1158
+ function cmdHelp() {
1159
+ const latestVer = checkForUpdate();
1160
+ const updateNotice = latestVer
1161
+ ? `\n ${YELLOW}${BOLD}↑ v${latestVer} 사용 가능${RESET} ${GRAY}npm update -g triflux${RESET}\n`
1162
+ : "";
1163
+
1164
+ console.log(`
1165
+ ${AMBER}${BOLD}⬡ triflux${RESET} ${DIM}v${PKG.version}${RESET}
1166
+ ${GRAY}CLI-first multi-model orchestrator for Claude Code${RESET}
1167
+ ${updateNotice}
1168
+ ${LINE}
1169
+
1170
+ ${BOLD}Commands${RESET}
1171
+
1172
+ ${WHITE_BRIGHT}tfx setup${RESET} ${GRAY}파일 동기화 + HUD 설정${RESET}
1173
+ ${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
1174
+ ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
1175
+ ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
1176
+ ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
1177
+ ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
1178
+ ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
1179
+ ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
1180
+ ${WHITE_BRIGHT}tfx multi${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
1181
+ ${WHITE_BRIGHT}tfx codex-team${RESET} ${GRAY}Codex 전용 팀 모드 (기본 lead/agents: codex)${RESET}
1182
+ ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
1183
+ ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
1184
+
1185
+ ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
1186
+
1187
+ ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행${RESET}
1188
+ ${WHITE_BRIGHT}/tfx-auto-codex${RESET} ${GRAY}Codex 리드 + Gemini 유지 (no-Claude-native)${RESET}
1189
+ ${WHITE_BRIGHT}/tfx-codex${RESET} ${GRAY}Codex 전용 모드${RESET}
1190
+ ${BLUE}/tfx-gemini${RESET} ${GRAY}Gemini 전용 모드${RESET}
1191
+ ${AMBER}/tfx-setup${RESET} ${GRAY}HUD 설정 + 진단${RESET}
1192
+ ${YELLOW}/tfx-doctor${RESET} ${GRAY}진단 + 수리 + 캐시 초기화${RESET}
1193
+
1194
+ ${LINE}
1195
+ ${GRAY}github.com/tellang/triflux${RESET}
1196
+ `);
1197
+ }
1198
+
1199
+ async function cmdCodexTeam() {
1200
+ const args = process.argv.slice(3);
1201
+ const sub = String(args[0] || "").toLowerCase();
1202
+ const passthrough = new Set([
1203
+ "status", "attach", "stop", "kill", "send", "list", "help", "--help", "-h",
1204
+ "tasks", "task", "focus", "interrupt", "control", "debug",
1205
+ ]);
1206
+
1207
+ if (sub === "help" || sub === "--help" || sub === "-h") {
1208
+ console.log(`
1209
+ ${AMBER}${BOLD}⬡ tfx codex-team${RESET}
1210
+
1211
+ ${WHITE_BRIGHT}tfx codex-team "작업"${RESET} ${GRAY}Codex 리드 + 워커 2개로 팀 시작${RESET}
1212
+ ${WHITE_BRIGHT}tfx codex-team --layout 1xN "작업"${RESET} ${GRAY}(세로 분할 컬럼)${RESET}
1213
+ ${WHITE_BRIGHT}tfx codex-team --layout Nx1 "작업"${RESET} ${GRAY}(가로 분할 스택)${RESET}
1214
+ ${WHITE_BRIGHT}tfx codex-team status${RESET}
1215
+ ${WHITE_BRIGHT}tfx codex-team debug --lines 30${RESET}
1216
+ ${WHITE_BRIGHT}tfx codex-team send N "msg"${RESET}
1217
+
1218
+ ${DIM}내부적으로 tfx multi을 호출하며, 시작 시 --lead codex --agents codex,codex를 기본 주입합니다.${RESET}
1219
+ `);
1220
+ return;
1221
+ }
1222
+
1223
+ const hasAgents = args.includes("--agents");
1224
+ const hasLead = args.includes("--lead");
1225
+ const hasLayout = args.includes("--layout");
1226
+ const isControl = passthrough.has(sub);
1227
+ const normalizedArgs = isControl && args.length ? [sub, ...args.slice(1)] : args;
1228
+ const inject = [];
1229
+ if (!isControl && !hasLead) inject.push("--lead", "codex");
1230
+ if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
1231
+ if (!isControl && !hasLayout) inject.push("--layout", "1xN");
1232
+ const forwarded = isControl ? normalizedArgs : [...inject, ...args];
1233
+
1234
+ const prevArgv = process.argv;
1235
+ const prevProfile = process.env.TFX_TEAM_PROFILE;
1236
+ process.env.TFX_TEAM_PROFILE = "codex-team";
1237
+ const { pathToFileURL } = await import("node:url");
1238
+ const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
1239
+ process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
1240
+ try {
1241
+ await cmdTeam();
1242
+ } finally {
1243
+ process.argv = prevArgv;
1244
+ if (typeof prevProfile === "string") process.env.TFX_TEAM_PROFILE = prevProfile;
1245
+ else delete process.env.TFX_TEAM_PROFILE;
1246
+ }
1247
+ }
1248
+
1249
+ // ── hub 서브커맨드 ──
1250
+
1251
+ const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
1252
+ const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
1253
+
1254
+ function sleepMs(ms) {
1255
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1256
+ }
1257
+
1258
+ function stopHubForUpdate() {
1259
+ if (!existsSync(HUB_PID_FILE)) return null;
1260
+ let info = null;
1261
+ try {
1262
+ info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1263
+ process.kill(info.pid, 0);
1264
+ } catch {
1265
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1266
+ return null;
1267
+ }
1268
+
1269
+ try {
1270
+ if (process.platform === "win32") {
1271
+ execSync(`taskkill /PID ${info.pid} /T /F`, {
1272
+ stdio: ["pipe", "pipe", "ignore"],
1273
+ timeout: 10000,
1274
+ });
1275
+ } else {
1276
+ process.kill(info.pid, "SIGTERM");
1277
+ }
1278
+ } catch {
1279
+ try { process.kill(info.pid, "SIGKILL"); } catch {}
1280
+ }
1281
+
1282
+ sleepMs(300);
1283
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1284
+ return info;
1285
+ }
1286
+
1287
+ function startHubAfterUpdate(info) {
1288
+ if (!info) return false;
1289
+ const serverPath = join(PKG_ROOT, "hub", "server.mjs");
1290
+ if (!existsSync(serverPath)) return false;
1291
+ const port = Number(info?.port) > 0 ? String(info.port) : String(process.env.TFX_HUB_PORT || "27888");
1292
+
1293
+ try {
1294
+ const child = spawn(process.execPath, [serverPath], {
1295
+ env: { ...process.env, TFX_HUB_PORT: port },
1296
+ stdio: "ignore",
1297
+ detached: true,
1298
+ });
1299
+ child.unref();
1300
+ return true;
1301
+ } catch {
1302
+ return false;
1303
+ }
1304
+ }
1305
+
1306
+ // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
1307
+ function autoRegisterMcp(mcpUrl) {
1308
+ section("MCP 자동 등록");
1309
+
1310
+ // Codex — codex mcp add
1311
+ if (which("codex")) {
1312
+ try {
1313
+ // 이미 등록됐는지 확인
1314
+ const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
1315
+ if (list.includes("tfx-hub")) {
1316
+ ok("Codex: 이미 등록됨");
1317
+ } else {
1318
+ execSync(`codex mcp add tfx-hub --url ${mcpUrl}`, { timeout: 10000, stdio: "ignore" });
1319
+ ok("Codex: MCP 등록 완료");
1320
+ }
1321
+ } catch {
1322
+ // mcp list/add 미지원 → 설정 파일 직접 수정
1323
+ try {
1324
+ const codexDir = join(homedir(), ".codex");
1325
+ const configFile = join(codexDir, "config.json");
1326
+ let config = {};
1327
+ if (existsSync(configFile)) config = JSON.parse(readFileSync(configFile, "utf8"));
1328
+ if (!config.mcpServers) config.mcpServers = {};
1329
+ if (!config.mcpServers["tfx-hub"]) {
1330
+ config.mcpServers["tfx-hub"] = { url: mcpUrl };
1331
+ if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
1332
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
1333
+ ok("Codex: config.json에 등록 완료");
1334
+ } else {
1335
+ ok("Codex: 이미 등록됨");
1336
+ }
1337
+ } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
1338
+ }
1339
+ } else {
1340
+ info("Codex: 미설치 (건너뜀)");
1341
+ }
1342
+
1343
+ // Gemini — settings.json 직접 수정
1344
+ if (which("gemini")) {
1345
+ try {
1346
+ const geminiDir = join(homedir(), ".gemini");
1347
+ const settingsFile = join(geminiDir, "settings.json");
1348
+ let settings = {};
1349
+ if (existsSync(settingsFile)) settings = JSON.parse(readFileSync(settingsFile, "utf8"));
1350
+ if (!settings.mcpServers) settings.mcpServers = {};
1351
+ if (!settings.mcpServers["tfx-hub"]) {
1352
+ settings.mcpServers["tfx-hub"] = { url: mcpUrl };
1353
+ if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
1354
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
1355
+ ok("Gemini: settings.json에 등록 완료");
1356
+ } else {
1357
+ ok("Gemini: 이미 등록됨");
1358
+ }
1359
+ } catch (e) { warn(`Gemini 등록 실패: ${e.message}`); }
1360
+ } else {
1361
+ info("Gemini: 미설치 (건너뜀)");
1362
+ }
1363
+
1364
+ // Claude — 프로젝트 .mcp.json에 등록 (오케스트레이터용)
1365
+ try {
1366
+ const mcpJsonPath = join(PKG_ROOT, ".mcp.json");
1367
+ let mcpJson = {};
1368
+ if (existsSync(mcpJsonPath)) mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
1369
+ if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
1370
+ if (!mcpJson.mcpServers["tfx-hub"]) {
1371
+ mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
1372
+ writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
1373
+ ok("Claude: .mcp.json에 등록 완료");
1374
+ } else {
1375
+ ok("Claude: 이미 등록됨");
1376
+ }
1377
+ } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
1378
+ }
1379
+
1380
+ async function cmdHub() {
1381
+ const sub = process.argv[3] || "status";
1382
+ const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
1383
+ const probePort = Number.isFinite(defaultPortRaw) && defaultPortRaw > 0 ? defaultPortRaw : 27888;
1384
+ const formatHostForUrl = (host) => host.includes(":") ? `[${host}]` : host;
1385
+ const probeHubStatus = async (host = "127.0.0.1", port = probePort, timeoutMs = 3000) => {
1386
+ try {
1387
+ const res = await fetch(`http://${formatHostForUrl(host)}:${port}/status`, {
1388
+ signal: AbortSignal.timeout(timeoutMs),
1389
+ });
1390
+ if (!res.ok) return null;
1391
+ const data = await res.json();
1392
+ return data?.hub ? data : null;
1393
+ } catch {
1394
+ return null;
1395
+ }
1396
+ };
1397
+ const recoverPidFile = (statusData, defaultHost = "127.0.0.1") => {
1398
+ const pid = Number(statusData?.pid);
1399
+ const port = Number(statusData?.port) || probePort;
1400
+ if (!Number.isFinite(pid) || pid <= 0) return;
1401
+ try {
1402
+ mkdirSync(HUB_PID_DIR, { recursive: true });
1403
+ writeFileSync(HUB_PID_FILE, JSON.stringify({
1404
+ pid,
1405
+ port,
1406
+ host: defaultHost,
1407
+ url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
1408
+ started: Date.now(),
1409
+ }));
1410
+ } catch {}
1411
+ };
1412
+
1413
+ switch (sub) {
1414
+ case "start": {
1415
+ // 이미 실행 중인지 확인
1416
+ if (existsSync(HUB_PID_FILE)) {
1417
+ try {
1418
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1419
+ process.kill(info.pid, 0); // 프로세스 존재 확인
1420
+ console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
1421
+ return;
1422
+ } catch {
1423
+ // PID 파일 있지만 프로세스 없음 — 정리
1424
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1425
+ }
1426
+ }
1427
+
1428
+ const portArg = process.argv.indexOf("--port");
1429
+ const port = portArg !== -1 ? process.argv[portArg + 1] : "27888";
1430
+ const serverPath = join(PKG_ROOT, "hub", "server.mjs");
1431
+
1432
+ if (!existsSync(serverPath)) {
1433
+ fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
1434
+ return;
1435
+ }
1436
+
1437
+ const child = spawn(process.execPath, [serverPath], {
1438
+ env: { ...process.env, TFX_HUB_PORT: port },
1439
+ stdio: "ignore",
1440
+ detached: true,
1441
+ });
1442
+ child.unref();
1443
+
1444
+ // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
1445
+ let started = false;
1446
+ const deadline = Date.now() + 3000;
1447
+ while (Date.now() < deadline) {
1448
+ if (existsSync(HUB_PID_FILE)) { started = true; break; }
1449
+ await new Promise((r) => setTimeout(r, 100));
1450
+ }
1451
+
1452
+ if (started) {
1453
+ const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1454
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
1455
+ console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
1456
+ console.log(` PID: ${hubInfo.pid}`);
1457
+ console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}`);
1458
+ console.log("");
1459
+ autoRegisterMcp(hubInfo.url);
1460
+ console.log("");
1461
+ } else {
1462
+ // 직접 포그라운드 모드로 안내
1463
+ console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
1464
+ console.log(` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`);
1465
+ }
1466
+ break;
1467
+ }
1468
+
1469
+ case "stop": {
1470
+ if (!existsSync(HUB_PID_FILE)) {
1471
+ const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
1472
+ || (probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500));
1473
+ if (probed && Number.isFinite(Number(probed.pid))) {
1474
+ try {
1475
+ process.kill(Number(probed.pid), "SIGTERM");
1476
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`);
1477
+ return;
1478
+ } catch {}
1479
+ }
1480
+ console.log(`\n ${DIM}hub 미실행${RESET}\n`);
1481
+ return;
1482
+ }
1483
+ try {
1484
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1485
+ process.kill(info.pid, "SIGTERM");
1486
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1487
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`);
1488
+ } catch (e) {
1489
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1490
+ console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
1491
+ }
1492
+ break;
1493
+ }
1494
+
1495
+ case "status": {
1496
+ if (!existsSync(HUB_PID_FILE)) {
1497
+ const probed = await probeHubStatus();
1498
+ if (!probed) {
1499
+ const fallback = probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500);
1500
+ if (fallback) {
1501
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`);
1502
+ console.log(` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`);
1503
+ if (fallback.pid !== undefined) console.log(` PID: ${fallback.pid}`);
1504
+ if (fallback.hub?.state) console.log(` State: ${fallback.hub.state}`);
1505
+ if (fallback.sessions !== undefined) console.log(` Sessions: ${fallback.sessions}`);
1506
+ recoverPidFile(fallback, "127.0.0.1");
1507
+ console.log("");
1508
+ return;
1509
+ }
1510
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
1511
+ return;
1512
+ }
1513
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`);
1514
+ console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1515
+ if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1516
+ if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1517
+ if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1518
+ recoverPidFile(probed, "127.0.0.1");
1519
+ console.log("");
1520
+ return;
1521
+ }
1522
+ try {
1523
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1524
+ process.kill(info.pid, 0); // 생존 확인
1525
+ const uptime = Date.now() - info.started;
1526
+ const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
1527
+ : uptime < 3600000 ? `${Math.round(uptime / 60000)}분`
1528
+ : `${Math.round(uptime / 3600000)}시간`;
1529
+
1530
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
1531
+ console.log(` URL: ${info.url}`);
1532
+ console.log(` PID: ${info.pid}`);
1533
+ console.log(` Uptime: ${uptimeStr}`);
1534
+
1535
+ // HTTP 상태 조회 시도
1536
+ try {
1537
+ const host = typeof info.host === "string" ? info.host : "127.0.0.1";
1538
+ const port = Number(info.port) || probePort;
1539
+ const data = await probeHubStatus(host, port, 3000);
1540
+ if (data.hub) {
1541
+ console.log(` State: ${data.hub.state}`);
1542
+ }
1543
+ if (data.sessions !== undefined) {
1544
+ console.log(` Sessions: ${data.sessions}`);
1545
+ }
1546
+ } catch {}
1547
+
1548
+ console.log("");
1549
+ } catch {
1550
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1551
+ const probed = await probeHubStatus();
1552
+ if (!probed) {
1553
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1554
+ break;
1555
+ }
1556
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`);
1557
+ console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
1558
+ if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
1559
+ if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
1560
+ if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
1561
+ recoverPidFile(probed, "127.0.0.1");
1562
+ console.log("");
1563
+ }
1564
+ break;
1565
+ }
1566
+
1567
+ default:
1568
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
1569
+ console.log(` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`);
1570
+ console.log(` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`);
1571
+ console.log(` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`);
1572
+ console.log(` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}\n`);
1573
+ }
1574
+ }
1575
+
1576
+ // ── 메인 ──
1577
+
1578
+ const cmd = process.argv[2] || "help";
1579
+
1580
+ switch (cmd) {
1581
+ case "setup": cmdSetup(); break;
1582
+ case "doctor": {
1583
+ const fix = process.argv.includes("--fix");
1584
+ const reset = process.argv.includes("--reset");
1585
+ await cmdDoctor({ fix, reset });
1586
+ break;
1587
+ }
1588
+ case "update": cmdUpdate(); break;
1589
+ case "list": case "ls": cmdList(); break;
1590
+ case "hub": await cmdHub(); break;
1591
+ case "multi": {
1592
+ const { pathToFileURL } = await import("node:url");
1593
+ const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
1594
+ await cmdTeam();
1595
+ break;
1596
+ }
1597
+ case "codex-team":
1598
+ await cmdCodexTeam();
1599
+ break;
1600
+ case "notion-read": case "nr": {
1601
+ const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
1602
+ const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
1603
+ try {
1604
+ execSync(`"${process.execPath}" "${scriptPath}" ${nrArgs}`, { stdio: "inherit", timeout: 660000 });
1605
+ } catch (e) { process.exit(e.status || 1); }
1606
+ break;
1607
+ }
1608
+ case "version": case "--version": case "-v": cmdVersion(); break;
1609
+ case "help": case "--help": case "-h": cmdHelp(); break;
1610
+ default:
1611
+ console.error(`알 수 없는 명령: ${cmd}`);
1612
+ cmdHelp();
1613
+ process.exit(1);
1614
+ }