triflux 8.2.3 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tui/doctor.mjs ADDED
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ // tui/doctor.mjs — Interactive triflux doctor TUI
3
+ import { execFileSync } from "node:child_process";
4
+ import { existsSync, readFileSync, unlinkSync, readdirSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
9
+ clear, box, table, divider, label, ok, warn, fail, info,
10
+ select, confirm, spinner, sleep,
11
+ RESET, DIM, BOLD, CYAN, AMBER, GREEN, RED, YELLOW, WHITE, GRAY,
12
+ onExit, showCursor,
13
+ } from "./core.mjs";
14
+
15
+ const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
+ const CLAUDE_DIR = join(homedir(), ".claude");
17
+ const CACHE_DIR = join(CLAUDE_DIR, "cache");
18
+
19
+ const CACHE_FILES = [
20
+ { name: "claude-usage-cache.json", desc: "Claude 사용량" },
21
+ { name: ".claude-refresh-lock", desc: "리프레시 락" },
22
+ { name: "codex-rate-limits-cache.json", desc: "Codex 레이트 리밋" },
23
+ { name: "gemini-quota-cache.json", desc: "Gemini 쿼터" },
24
+ { name: "gemini-project-id.json", desc: "Gemini 프로젝트 ID" },
25
+ { name: "gemini-session-cache.json", desc: "Gemini 세션" },
26
+ { name: "gemini-rpm-tracker.json", desc: "Gemini RPM" },
27
+ { name: "sv-accumulator.json", desc: "절약량 누적" },
28
+ { name: "mcp-inventory.json", desc: "MCP 인벤토리" },
29
+ { name: "cli-issues.jsonl", desc: "CLI 이슈 로그" },
30
+ { name: "triflux-update-check.json", desc: "업데이트 체크" },
31
+ ];
32
+
33
+ // ── Run triflux doctor --json ──
34
+
35
+ function runDoctor(mode = "check") {
36
+ const args = [join(PKG_ROOT, "bin", "triflux.mjs"), "doctor", "--json"];
37
+ if (mode === "fix") args.push("--fix");
38
+
39
+ try {
40
+ const out = execFileSync(process.execPath, args, {
41
+ timeout: 30000,
42
+ encoding: "utf8",
43
+ windowsHide: true,
44
+ });
45
+ // Extract JSON from output (may have ANSI/text before it)
46
+ const jsonMatch = out.match(/\{[\s\S]*\}$/m);
47
+ if (jsonMatch) return JSON.parse(jsonMatch[0]);
48
+ } catch (e) {
49
+ if (e.stdout) {
50
+ const jsonMatch = e.stdout.match(/\{[\s\S]*\}$/m);
51
+ if (jsonMatch) return JSON.parse(jsonMatch[0]);
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // ── Display Results ──
58
+
59
+ function statusIcon(status) {
60
+ const map = {
61
+ ok: `${GREEN}✓${RESET}`,
62
+ missing: `${RED}✗${RESET}`,
63
+ partial: `${YELLOW}⚠${RESET}`,
64
+ warning: `${YELLOW}⚠${RESET}`,
65
+ optional_missing: `${GRAY}○${RESET}`,
66
+ };
67
+ return map[status] || `${GRAY}?${RESET}`;
68
+ }
69
+
70
+ function showReport(report) {
71
+ if (!report) {
72
+ fail("진단 결과를 가져올 수 없습니다.");
73
+ return;
74
+ }
75
+
76
+ console.log();
77
+ const statusColor = report.issue_count === 0 ? GREEN : report.issue_count <= 2 ? YELLOW : RED;
78
+ label("상태", `${statusColor}${report.issue_count === 0 ? "정상" : `${report.issue_count}개 이슈`}${RESET}`);
79
+ label("모드", report.mode);
80
+ console.log();
81
+
82
+ const headers = ["항목", "상태", "비고"];
83
+ const rows = (report.checks || []).map((c) => {
84
+ let note = "";
85
+ if (c.version) note = `v${c.version}`;
86
+ if (c.missing_profiles?.length) note = `누락: ${c.missing_profiles.join(", ")}`;
87
+ if (c.fix) note += note ? ` → ${c.fix}` : c.fix;
88
+ if (c.path && !c.fix) note = c.path;
89
+
90
+ const icon = c.status === "ok" ? statusIcon("ok") :
91
+ c.optional ? statusIcon("optional_missing") : statusIcon(c.status);
92
+
93
+ return [
94
+ `${icon} ${c.name}`,
95
+ c.status === "ok" ? `${GREEN}정상${RESET}` : `${RED}${c.status}${RESET}`,
96
+ note ? `${DIM}${note}${RESET}` : "",
97
+ ];
98
+ });
99
+
100
+ if (rows.length > 0) table(headers, rows);
101
+
102
+ // Actions (from fix/reset mode)
103
+ if (report.actions?.length > 0) {
104
+ console.log();
105
+ info(`수행된 작업: ${report.actions.length}개`);
106
+ for (const action of report.actions) {
107
+ const icon = action.status === "ok" ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
108
+ console.log(` ${icon} ${action.type}: ${action.name || action.path || ""}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ // ── Cache Management ──
114
+
115
+ function getCacheStatus() {
116
+ const results = [];
117
+ for (const { name, desc } of CACHE_FILES) {
118
+ const fp = join(CACHE_DIR, name);
119
+ if (existsSync(fp)) {
120
+ let size = 0;
121
+ try { size = readFileSync(fp).length; } catch {}
122
+ let hasError = false;
123
+ try {
124
+ const parsed = JSON.parse(readFileSync(fp, "utf8"));
125
+ hasError = !!parsed.error;
126
+ } catch { hasError = true; }
127
+ results.push({ name, desc, exists: true, size, hasError });
128
+ } else {
129
+ results.push({ name, desc, exists: false, size: 0, hasError: false });
130
+ }
131
+ }
132
+ return results;
133
+ }
134
+
135
+ function showCacheStatus() {
136
+ const caches = getCacheStatus();
137
+ const existing = caches.filter((c) => c.exists);
138
+
139
+ if (existing.length === 0) {
140
+ info("캐시 파일 없음 (깨끗한 상태)");
141
+ return;
142
+ }
143
+
144
+ console.log();
145
+ const headers = ["캐시", "크기", "상태"];
146
+ const rows = existing.map((c) => [
147
+ c.desc,
148
+ c.size < 1024 ? `${c.size}B` : `${(c.size / 1024).toFixed(1)}KB`,
149
+ c.hasError ? `${RED}에러${RESET}` : `${GREEN}정상${RESET}`,
150
+ ]);
151
+ table(headers, rows);
152
+ }
153
+
154
+ async function selectiveReset() {
155
+ const caches = getCacheStatus().filter((c) => c.exists);
156
+ if (caches.length === 0) {
157
+ info("삭제할 캐시 파일이 없습니다.");
158
+ return;
159
+ }
160
+
161
+ const options = [
162
+ { label: "전체 삭제", hint: `${caches.length}개 파일` },
163
+ { label: "에러 캐시만 삭제", hint: "손상된 파일만" },
164
+ { label: "선택 삭제", hint: "하나씩 선택" },
165
+ { label: "취소", hint: "" },
166
+ ];
167
+
168
+ const choice = await select("삭제 방식", options);
169
+ if (!choice || choice.index === 3) return;
170
+
171
+ let targets = [];
172
+ if (choice.index === 0) {
173
+ targets = caches;
174
+ } else if (choice.index === 1) {
175
+ targets = caches.filter((c) => c.hasError);
176
+ if (targets.length === 0) {
177
+ info("에러 상태의 캐시가 없습니다.");
178
+ return;
179
+ }
180
+ } else {
181
+ for (const c of caches) {
182
+ const del = await confirm(`${c.desc} (${c.name}) 삭제?`, c.hasError);
183
+ if (del) targets.push(c);
184
+ }
185
+ }
186
+
187
+ if (targets.length === 0) return;
188
+
189
+ if (!(await confirm(`${targets.length}개 캐시 파일을 삭제하시겠습니까?`))) return;
190
+
191
+ let deleted = 0;
192
+ for (const c of targets) {
193
+ try {
194
+ unlinkSync(join(CACHE_DIR, c.name));
195
+ ok(`삭제: ${c.desc}`);
196
+ deleted++;
197
+ } catch (e) {
198
+ fail(`삭제 실패: ${c.desc} — ${e.message}`);
199
+ }
200
+ }
201
+
202
+ ok(`${BOLD}${deleted}개${RESET} 캐시 파일 삭제 완료`);
203
+ }
204
+
205
+ // ── Orphan Teams ──
206
+
207
+ async function checkOrphanTeams() {
208
+ const teamsDir = join(CLAUDE_DIR, "teams");
209
+ if (!existsSync(teamsDir)) {
210
+ info("teams 디렉토리 없음");
211
+ return;
212
+ }
213
+
214
+ const entries = readdirSync(teamsDir).filter((e) => !e.startsWith("."));
215
+ if (entries.length === 0) {
216
+ ok("잔존 팀 없음");
217
+ return;
218
+ }
219
+
220
+ warn(`${entries.length}개 팀 세션 발견`);
221
+ for (const e of entries) {
222
+ console.log(` ${DIM}${e}${RESET}`);
223
+ }
224
+
225
+ if (await confirm("잔존 팀 정리를 시도하시겠습니까?", false)) {
226
+ const spin = spinner("팀 정리 중...");
227
+ try {
228
+ // Delegate to triflux's cleanup
229
+ execFileSync(process.execPath, [join(PKG_ROOT, "bin", "triflux.mjs"), "doctor", "--fix"], {
230
+ timeout: 30000, stdio: "ignore", windowsHide: true,
231
+ });
232
+ spin.stop();
233
+ ok("팀 정리 완료");
234
+ } catch {
235
+ spin.stop();
236
+ warn("팀 정리 실패 — 수동 삭제가 필요할 수 있습니다");
237
+ }
238
+ }
239
+ }
240
+
241
+ // ── Main Menu ──
242
+
243
+ const MENU = [
244
+ { label: "진단 (Diagnose)", hint: "읽기 전용 검사" },
245
+ { label: "수정 (Fix)", hint: "자동 수정 + 진단" },
246
+ { label: "캐시 관리 (Cache)", hint: "캐시 조회/선택 삭제" },
247
+ { label: "팀 세션 정리 (Teams)", hint: "잔존 팀 감지/정리" },
248
+ { label: "전체 초기화 (Reset)", hint: "캐시 전체 삭제 + 재생성" },
249
+ { label: "종료", hint: "Ctrl+C" },
250
+ ];
251
+
252
+ async function main() {
253
+ onExit(() => {});
254
+ clear();
255
+
256
+ while (true) {
257
+ box("triflux Doctor", 46);
258
+ console.log();
259
+
260
+ const choice = await select("작업 선택", MENU);
261
+ if (!choice || choice.index === 5) {
262
+ console.log();
263
+ info("종료합니다.");
264
+ showCursor();
265
+ break;
266
+ }
267
+
268
+ console.log();
269
+
270
+ switch (choice.index) {
271
+ case 0: {
272
+ const spin = spinner("진단 중...");
273
+ const report = runDoctor("check");
274
+ spin.stop();
275
+ showReport(report);
276
+ break;
277
+ }
278
+
279
+ case 1: {
280
+ if (!(await confirm("자동 수정을 실행하시겠습니까?"))) break;
281
+ const spin = spinner("수정 + 진단 중...");
282
+ const report = runDoctor("fix");
283
+ spin.stop();
284
+ showReport(report);
285
+ break;
286
+ }
287
+
288
+ case 2: {
289
+ showCacheStatus();
290
+ console.log();
291
+ await selectiveReset();
292
+ break;
293
+ }
294
+
295
+ case 3: {
296
+ await checkOrphanTeams();
297
+ break;
298
+ }
299
+
300
+ case 4: {
301
+ if (!(await confirm(`${RED}전체 캐시를 초기화${RESET}하시겠습니까?`, false))) break;
302
+ const spin = spinner("초기화 + 재생성 중...");
303
+ try {
304
+ execFileSync(process.execPath,
305
+ [join(PKG_ROOT, "bin", "triflux.mjs"), "doctor", "--reset"],
306
+ { timeout: 60000, encoding: "utf8", windowsHide: true }
307
+ );
308
+ spin.stop();
309
+ ok("전체 초기화 + 재생성 완료");
310
+ } catch {
311
+ spin.stop();
312
+ warn("초기화 중 일부 실패 — triflux doctor --reset으로 재시도");
313
+ }
314
+ break;
315
+ }
316
+ }
317
+
318
+ console.log();
319
+ divider(46);
320
+ }
321
+ }
322
+
323
+ main().catch((e) => {
324
+ showCursor();
325
+ console.error(e);
326
+ process.exit(1);
327
+ });
package/tui/setup.mjs ADDED
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+ // tui/setup.mjs — Interactive triflux setup wizard TUI
3
+ import { execFileSync } from "node:child_process";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
9
+ clear, box, table, divider, label, ok, warn, fail, info,
10
+ select, confirm, spinner, sleep,
11
+ RESET, DIM, BOLD, CYAN, AMBER, GREEN, RED, YELLOW, WHITE, GRAY,
12
+ onExit, showCursor,
13
+ } from "./core.mjs";
14
+
15
+ const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
+ const CLAUDE_DIR = join(homedir(), ".claude");
17
+ const CODEX_DIR = join(homedir(), ".codex");
18
+ const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
19
+
20
+ // ── Step Definitions ──
21
+
22
+ const STEPS = [
23
+ { id: "sync", name: "파일 동기화", desc: "스크립트/HUD/스킬을 ~/.claude/에 배포" },
24
+ { id: "hud", name: "HUD 설정", desc: "settings.json에 statusLine 등록" },
25
+ { id: "profiles", name: "Codex 프로파일", desc: "필수 프로파일 생성/확인" },
26
+ { id: "cli", name: "CLI 진단", desc: "Codex/Gemini/Claude CLI 확인" },
27
+ { id: "mcp", name: "MCP 서버 확인", desc: "MCP 서버 인벤토리 점검" },
28
+ ];
29
+
30
+ // ── Step Implementations ──
31
+
32
+ function stepSync() {
33
+ try {
34
+ const out = execFileSync(process.execPath,
35
+ [join(PKG_ROOT, "bin", "triflux.mjs"), "setup", "--json"],
36
+ { timeout: 30000, encoding: "utf8", windowsHide: true }
37
+ );
38
+ return { ok: true, detail: "파일 동기화 완료" };
39
+ } catch (e) {
40
+ return { ok: false, detail: e.message };
41
+ }
42
+ }
43
+
44
+ function stepHud() {
45
+ try {
46
+ if (!existsSync(SETTINGS_PATH)) {
47
+ return { ok: false, detail: "settings.json 미존재", action: "create" };
48
+ }
49
+
50
+ const raw = readFileSync(SETTINGS_PATH, "utf8");
51
+ let settings;
52
+ try { settings = JSON.parse(raw); } catch {
53
+ return { ok: false, detail: "settings.json 파싱 실패", action: "fix" };
54
+ }
55
+
56
+ const hudScript = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
57
+ const nodePath = process.execPath;
58
+
59
+ // Check if statusLine already configured correctly
60
+ if (settings.statusLine?.command?.includes("hud-qos-status")) {
61
+ return { ok: true, detail: "statusLine 이미 설정됨", current: settings.statusLine };
62
+ }
63
+
64
+ return {
65
+ ok: false,
66
+ detail: settings.statusLine ? "statusLine이 다른 HUD를 가리킴" : "statusLine 미설정",
67
+ action: "configure",
68
+ current: settings.statusLine || null,
69
+ target: {
70
+ type: "command",
71
+ command: `"${nodePath}" "${hudScript}"`,
72
+ },
73
+ };
74
+ } catch (e) {
75
+ return { ok: false, detail: e.message };
76
+ }
77
+ }
78
+
79
+ function stepProfiles() {
80
+ const configPath = join(CODEX_DIR, "config.toml");
81
+ if (!existsSync(configPath)) {
82
+ return { ok: false, detail: "config.toml 미존재", action: "skip" };
83
+ }
84
+
85
+ const content = readFileSync(configPath, "utf8");
86
+ const required = ["codex53_high", "codex53_xhigh", "spark53_low"];
87
+ const missing = required.filter((name) => {
88
+ const re = new RegExp(`^\\[profiles\\.${name}\\]`, "m");
89
+ return !re.test(content);
90
+ });
91
+
92
+ if (missing.length === 0) {
93
+ return { ok: true, detail: `필수 프로파일 ${required.length}개 확인됨` };
94
+ }
95
+
96
+ return { ok: false, detail: `누락: ${missing.join(", ")}`, missing, action: "create" };
97
+ }
98
+
99
+ function stepCli() {
100
+ const results = [];
101
+ for (const [name, installCmd] of [
102
+ ["codex", "npm i -g @openai/codex"],
103
+ ["gemini", "npm i -g @google/gemini-cli"],
104
+ ["claude", "Claude Code 설치"],
105
+ ]) {
106
+ try {
107
+ execFileSync(process.platform === "win32" ? "where" : "which", [name], {
108
+ timeout: 5000, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
109
+ });
110
+ results.push({ name, found: true });
111
+ } catch {
112
+ results.push({ name, found: false, install: installCmd });
113
+ }
114
+ }
115
+ const allFound = results.every((r) => r.found);
116
+ return { ok: allFound, results, detail: allFound ? "모든 CLI 확인됨" : "일부 CLI 미설치" };
117
+ }
118
+
119
+ function stepMcp() {
120
+ const inventoryPath = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
121
+ if (!existsSync(inventoryPath)) {
122
+ return { ok: false, detail: "MCP 인벤토리 미존재", action: "rebuild" };
123
+ }
124
+ try {
125
+ const inventory = JSON.parse(readFileSync(inventoryPath, "utf8"));
126
+ const count = Object.keys(inventory.servers || inventory).length;
127
+ return { ok: true, detail: `${count}개 MCP 서버 등록됨` };
128
+ } catch {
129
+ return { ok: false, detail: "MCP 인벤토리 파싱 실패", action: "rebuild" };
130
+ }
131
+ }
132
+
133
+ const STEP_RUNNERS = { sync: stepSync, hud: stepHud, profiles: stepProfiles, cli: stepCli, mcp: stepMcp };
134
+
135
+ // ── UI ──
136
+
137
+ function showStepResult(step, result) {
138
+ if (result.ok) {
139
+ ok(`${step.name}: ${result.detail}`);
140
+ } else {
141
+ warn(`${step.name}: ${result.detail}`);
142
+ }
143
+ }
144
+
145
+ async function runWizard() {
146
+ console.log();
147
+ info(`${STEPS.length}개 단계를 순서대로 실행합니다.`);
148
+ console.log();
149
+
150
+ const results = {};
151
+
152
+ for (let i = 0; i < STEPS.length; i++) {
153
+ const step = STEPS[i];
154
+ const progress = `${DIM}[${i + 1}/${STEPS.length}]${RESET}`;
155
+
156
+ console.log(` ${progress} ${BOLD}${step.name}${RESET} ${DIM}— ${step.desc}${RESET}`);
157
+
158
+ const spin = spinner(`${step.name} 실행 중...`);
159
+ const result = STEP_RUNNERS[step.id]();
160
+ spin.stop();
161
+
162
+ showStepResult(step, result);
163
+ results[step.id] = result;
164
+
165
+ // Handle fixable issues
166
+ if (!result.ok && result.action) {
167
+ await handleStepFix(step, result);
168
+ }
169
+
170
+ console.log();
171
+ }
172
+
173
+ return results;
174
+ }
175
+
176
+ async function handleStepFix(step, result) {
177
+ switch (step.id) {
178
+ case "hud": {
179
+ if (result.action === "configure") {
180
+ if (result.current) {
181
+ warn(`현재 statusLine: ${JSON.stringify(result.current)}`);
182
+ if (!(await confirm("triflux HUD로 덮어쓰시겠습니까?", false))) return;
183
+ } else {
184
+ if (!(await confirm("statusLine을 설정하시겠습니까?"))) return;
185
+ }
186
+ try {
187
+ const raw = readFileSync(SETTINGS_PATH, "utf8");
188
+ const settings = JSON.parse(raw);
189
+ settings.statusLine = result.target;
190
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
191
+ ok("statusLine 설정 완료");
192
+ } catch (e) {
193
+ fail(`설정 실패: ${e.message}`);
194
+ }
195
+ }
196
+ break;
197
+ }
198
+
199
+ case "profiles": {
200
+ if (result.action === "create" && result.missing) {
201
+ if (await confirm(`누락된 프로파일 ${result.missing.length}개를 생성하시겠습니까?`)) {
202
+ try {
203
+ execFileSync(process.execPath,
204
+ [join(PKG_ROOT, "bin", "triflux.mjs"), "setup"],
205
+ { timeout: 15000, stdio: "ignore", windowsHide: true }
206
+ );
207
+ ok("프로파일 생성 완료");
208
+ } catch {
209
+ warn("프로파일 생성 실패 — triflux setup으로 재시도");
210
+ }
211
+ }
212
+ }
213
+ break;
214
+ }
215
+
216
+ case "mcp": {
217
+ if (result.action === "rebuild") {
218
+ if (await confirm("MCP 인벤토리를 재생성하시겠습니까?")) {
219
+ const mcpCheck = join(PKG_ROOT, "scripts", "mcp-check.mjs");
220
+ if (existsSync(mcpCheck)) {
221
+ try {
222
+ execFileSync(process.execPath, [mcpCheck], { timeout: 15000, stdio: "ignore", windowsHide: true });
223
+ ok("MCP 인벤토리 재생성 완료");
224
+ } catch {
225
+ warn("MCP 인벤토리 재생성 실패 — 다음 세션에서 자동 재시도");
226
+ }
227
+ }
228
+ }
229
+ }
230
+ break;
231
+ }
232
+ }
233
+ }
234
+
235
+ async function runSelective() {
236
+ const options = STEPS.map((s) => ({
237
+ label: s.name,
238
+ hint: s.desc,
239
+ }));
240
+
241
+ const picked = await select("실행할 단계", options);
242
+ if (!picked) return;
243
+
244
+ const step = STEPS[picked.index];
245
+ console.log();
246
+ const spin = spinner(`${step.name} 실행 중...`);
247
+ const result = STEP_RUNNERS[step.id]();
248
+ spin.stop();
249
+
250
+ showStepResult(step, result);
251
+
252
+ if (!result.ok && result.action) {
253
+ await handleStepFix(step, result);
254
+ }
255
+
256
+ // CLI step has extra detail
257
+ if (step.id === "cli" && result.results) {
258
+ console.log();
259
+ for (const r of result.results) {
260
+ if (r.found) ok(`${r.name}: 설치됨`);
261
+ else warn(`${r.name}: 미설치 → ${DIM}${r.install}${RESET}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ function showSummary(results) {
267
+ console.log();
268
+ divider(46);
269
+ box("Setup 완료", 46);
270
+ console.log();
271
+
272
+ const headers = ["항목", "상태"];
273
+ const rows = STEPS.map((s) => {
274
+ const r = results[s.id];
275
+ if (!r) return [s.name, `${GRAY}건너뜀${RESET}`];
276
+ return [
277
+ s.name,
278
+ r.ok ? `${GREEN}✓ 정상${RESET}` : `${YELLOW}⚠ ${r.detail}${RESET}`,
279
+ ];
280
+ });
281
+
282
+ table(headers, rows);
283
+
284
+ const issues = Object.values(results).filter((r) => r && !r.ok).length;
285
+ console.log();
286
+ if (issues === 0) {
287
+ ok(`${BOLD}모든 항목 정상${RESET} — 세션을 재시작하면 적용됩니다`);
288
+ } else {
289
+ warn(`${issues}개 항목에 주의가 필요합니다`);
290
+ info("/tfx-doctor --fix 로 자동 수정을 시도하세요");
291
+ }
292
+ }
293
+
294
+ // ── Main Menu ──
295
+
296
+ const MENU = [
297
+ { label: "전체 설정 (Full Setup)", hint: "5단계 순서 실행" },
298
+ { label: "단계별 선택 (Selective)", hint: "특정 단계만 실행" },
299
+ { label: "현재 상태 확인 (Status)", hint: "설정 없이 진단만" },
300
+ { label: "종료", hint: "Ctrl+C" },
301
+ ];
302
+
303
+ async function main() {
304
+ onExit(() => {});
305
+ clear();
306
+
307
+ while (true) {
308
+ box("triflux Setup Wizard", 46);
309
+ console.log();
310
+
311
+ const choice = await select("작업 선택", MENU);
312
+ if (!choice || choice.index === 3) {
313
+ console.log();
314
+ info("종료합니다.");
315
+ showCursor();
316
+ break;
317
+ }
318
+
319
+ console.log();
320
+
321
+ switch (choice.index) {
322
+ case 0: {
323
+ const results = await runWizard();
324
+ showSummary(results);
325
+ break;
326
+ }
327
+
328
+ case 1: {
329
+ await runSelective();
330
+ break;
331
+ }
332
+
333
+ case 2: {
334
+ info("현재 상태를 확인합니다...");
335
+ console.log();
336
+ const results = {};
337
+ for (const step of STEPS) {
338
+ if (step.id === "sync") continue; // sync는 상태 확인 불가
339
+ const result = STEP_RUNNERS[step.id]();
340
+ showStepResult(step, result);
341
+ results[step.id] = result;
342
+ if (step.id === "cli" && result.results) {
343
+ for (const r of result.results) {
344
+ if (r.found) ok(` ${r.name}: 설치됨`);
345
+ else warn(` ${r.name}: 미설치`);
346
+ }
347
+ }
348
+ }
349
+ break;
350
+ }
351
+ }
352
+
353
+ console.log();
354
+ divider(46);
355
+ }
356
+ }
357
+
358
+ main().catch((e) => {
359
+ showCursor();
360
+ console.error(e);
361
+ process.exit(1);
362
+ });