triflux 2.4.6 → 2.4.7

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/bin/triflux.mjs CHANGED
@@ -169,6 +169,12 @@ function cmdSetup() {
169
169
  "hud-qos-status.mjs"
170
170
  );
171
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
+
172
178
  // 스킬 동기화 (~/.claude/skills/{name}/SKILL.md)
173
179
  const skillsSrc = join(PKG_ROOT, "skills");
174
180
  const skillsDst = join(CLAUDE_DIR, "skills");
@@ -326,6 +332,11 @@ function cmdDoctor(options = {}) {
326
332
  join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
327
333
  "hud-qos-status.mjs"
328
334
  );
335
+ syncFile(
336
+ join(PKG_ROOT, "scripts", "notion-read.mjs"),
337
+ join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
338
+ "notion-read.mjs"
339
+ );
329
340
  // 스킬 동기화
330
341
  const fSkillsSrc = join(PKG_ROOT, "skills");
331
342
  const fSkillsDst = join(CLAUDE_DIR, "skills");
@@ -790,6 +801,7 @@ ${updateNotice}
790
801
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
791
802
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 버전으로 업데이트${RESET}
792
803
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
804
+ ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
793
805
  ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
794
806
 
795
807
  ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
@@ -819,6 +831,14 @@ switch (cmd) {
819
831
  }
820
832
  case "update": cmdUpdate(); break;
821
833
  case "list": case "ls": cmdList(); break;
834
+ case "notion-read": case "nr": {
835
+ const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
836
+ const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
837
+ try {
838
+ execSync(`"${process.execPath}" "${scriptPath}" ${nrArgs}`, { stdio: "inherit", timeout: 660000 });
839
+ } catch (e) { process.exit(e.status || 1); }
840
+ break;
841
+ }
822
842
  case "version": case "--version": case "-v": cmdVersion(); break;
823
843
  case "help": case "--help": case "-h": cmdHelp(); break;
824
844
  default:
@@ -437,9 +437,13 @@ function renderAlignedRows(rows) {
437
437
  function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
438
438
  const ctx = getContextPercent(stdin);
439
439
 
440
- // Claude 5h/1w
441
- const cF = claudeUsage ? clampPercent(claudeUsage.fiveHourPercent ?? 0) : null;
442
- const cW = claudeUsage ? clampPercent(claudeUsage.weeklyPercent ?? 0) : null;
440
+ // Claude 5h/1w (reset 과거면 0으로 표시)
441
+ const cF = claudeUsage
442
+ ? (isResetPast(claudeUsage.fiveHourResetsAt) ? 0 : clampPercent(claudeUsage.fiveHourPercent ?? 0))
443
+ : null;
444
+ const cW = claudeUsage
445
+ ? (isResetPast(claudeUsage.weeklyResetsAt) ? 0 : clampPercent(claudeUsage.weeklyPercent ?? 0))
446
+ : null;
443
447
  const cVal = cF != null
444
448
  ? `${colorByProvider(cF, `${cF}`, claudeOrange)}${dim("/")}${colorByProvider(cW, `${cW}`, claudeOrange)}`
445
449
  : dim("--/--");
@@ -838,7 +842,7 @@ function formatResetRemaining(isoOrUnix) {
838
842
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
839
843
  if (isNaN(d.getTime())) return "";
840
844
  const diffMs = d.getTime() - Date.now();
841
- if (diffMs <= 0) return "0h00m";
845
+ if (diffMs <= 0) return "";
842
846
  const totalMinutes = Math.floor(diffMs / 60000);
843
847
  const totalHours = Math.floor(totalMinutes / 60);
844
848
  const minutes = totalMinutes % 60;
@@ -856,7 +860,7 @@ function formatResetRemainingDayHour(isoOrUnix) {
856
860
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
857
861
  if (isNaN(d.getTime())) return "";
858
862
  const diffMs = d.getTime() - Date.now();
859
- if (diffMs <= 0) return "0d0h";
863
+ if (diffMs <= 0) return "";
860
864
  const totalMinutes = Math.floor(diffMs / 60000);
861
865
  const days = Math.floor(totalMinutes / (60 * 24));
862
866
  const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
@@ -941,6 +945,7 @@ function getGeminiEmail() {
941
945
  // ============================================================================
942
946
  function getCodexRateLimits() {
943
947
  const now = new Date();
948
+ let todayHasFiles = false;
944
949
  for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
945
950
  const d = new Date(now.getTime() - dayOffset * 86_400_000);
946
951
  const sessDir = join(
@@ -953,6 +958,7 @@ function getCodexRateLimits() {
953
958
  let files;
954
959
  try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
955
960
  catch { continue; }
961
+ if (dayOffset === 0 && files.length > 0) todayHasFiles = true;
956
962
  for (const file of files) {
957
963
  try {
958
964
  const content = readFileSync(join(sessDir, file), "utf-8");
@@ -978,6 +984,8 @@ function getCodexRateLimits() {
978
984
  if (Object.keys(buckets).length > 0) return buckets;
979
985
  } catch { /* 파일 읽기 실패 무시 */ }
980
986
  }
987
+ // 오늘 세션 파일이 존재하지만 rate_limits가 없으면 어제 stale 데이터로 폴백하지 않음
988
+ if (todayHasFiles && dayOffset === 0) return null;
981
989
  }
982
990
  return null;
983
991
  }
@@ -1134,7 +1142,7 @@ function readCodexRateLimitSnapshot() {
1134
1142
 
1135
1143
  function refreshCodexRateLimitsCache() {
1136
1144
  const buckets = getCodexRateLimits();
1137
- if (!buckets) return null;
1145
+ // buckets null이어도 캐시 갱신 (stale 데이터 제거)
1138
1146
  writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
1139
1147
  return buckets;
1140
1148
  }
@@ -1257,8 +1265,8 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1257
1265
  const svSuffix = `${dim("sv:")}${svStr}`;
1258
1266
 
1259
1267
  // API 실측 데이터 사용 (없으면 플레이스홀더)
1260
- const fiveHourPercent = claudeUsage?.fiveHourPercent ?? 0;
1261
- const weeklyPercent = claudeUsage?.weeklyPercent ?? 0;
1268
+ const fiveHourPercent = isResetPast(claudeUsage?.fiveHourResetsAt) ? 0 : (claudeUsage?.fiveHourPercent ?? 0);
1269
+ const weeklyPercent = isResetPast(claudeUsage?.weeklyResetsAt) ? 0 : (claudeUsage?.weeklyPercent ?? 0);
1262
1270
  const fiveHourReset = claudeUsage?.fiveHourResetsAt
1263
1271
  ? formatResetRemaining(claudeUsage.fiveHourResetsAt)
1264
1272
  : "n/a";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "2.4.6",
3
+ "version": "2.4.7",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env node
2
+ // notion-read.mjs v1.1 — Notion 대형 페이지 리더 (Codex/Gemini/Claude MCP 위임)
3
+ //
4
+ // Codex/Gemini/Claude CLI에 설치된 Notion MCP를 활용하여 대형 페이지를 마크다운으로 추출.
5
+ // 폴백 체인: Codex(무료) → Gemini(무료) → Claude(최후) → 에러
6
+ //
7
+ // 사용법:
8
+ // node notion-read.mjs <notion-url-or-page-id> [옵션]
9
+ // tfx notion-read <notion-url-or-page-id> [옵션]
10
+ //
11
+ // 옵션:
12
+ // --output, -o <file> 결과 파일 저장 (기본: stdout)
13
+ // --timeout, -t <sec> CLI 타임아웃 (기본: 600)
14
+ // --cli, -c <codex|gemini> CLI 강제 지정 (기본: 자동 + 폴백)
15
+ // --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
16
+ // --guest notion-guest 통합 사용 (기본: notion)
17
+
18
+ import { execSync } from "child_process";
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from "fs";
20
+ import { join, dirname } from "path";
21
+ import { homedir, tmpdir } from "os";
22
+
23
+ const VERSION = "1.1";
24
+ const CLAUDE_DIR = join(homedir(), ".claude");
25
+ const MCP_CACHE = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
26
+ const LOG_FILE = join(CLAUDE_DIR, "logs", "cli-route-stats.jsonl");
27
+ const ACC_FILE = join(CLAUDE_DIR, "cache", "sv-accumulator.json");
28
+
29
+ // ── ANSI 색상 ──
30
+ const AMBER = "\x1b[38;5;214m";
31
+ const GREEN = "\x1b[38;5;82m";
32
+ const RED = "\x1b[38;5;196m";
33
+ const YELLOW = "\x1b[33m";
34
+ const DIM = "\x1b[2m";
35
+ const BOLD = "\x1b[1m";
36
+ const RESET = "\x1b[0m";
37
+ const GRAY = "\x1b[38;5;245m";
38
+
39
+ // ── URL 파싱 ──
40
+ function parseNotionUrl(input) {
41
+ // 32자리 hex (하이픈 없는 page_id)
42
+ if (/^[a-f0-9]{32}$/i.test(input)) {
43
+ return { pageId: input, blockId: null };
44
+ }
45
+ // UUID 형식
46
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) {
47
+ return { pageId: input.replace(/-/g, ""), blockId: null };
48
+ }
49
+ // URL에서 page_id + optional #block_id 추출
50
+ const urlMatch = input.match(/([a-f0-9]{32})(?:#([a-f0-9]{32}))?/i);
51
+ if (urlMatch) {
52
+ return { pageId: urlMatch[1], blockId: urlMatch[2] || null };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // ── MCP 가용성 확인 ──
58
+ function getNotionMcpClis(useGuest) {
59
+ const serverName = useGuest ? "notion-guest" : "notion";
60
+ const result = { codex: false, gemini: false };
61
+
62
+ if (!existsSync(MCP_CACHE)) return result;
63
+
64
+ try {
65
+ const inv = JSON.parse(readFileSync(MCP_CACHE, "utf8"));
66
+
67
+ if (inv.codex?.servers) {
68
+ result.codex = inv.codex.servers.some(
69
+ (s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
70
+ );
71
+ }
72
+ if (inv.gemini?.servers) {
73
+ result.gemini = inv.gemini.servers.some(
74
+ (s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
75
+ );
76
+ }
77
+ } catch {}
78
+
79
+ return result;
80
+ }
81
+
82
+ // ── CLI 존재 확인 ──
83
+ function cliExists(name) {
84
+ try {
85
+ const cmd = process.platform === "win32" ? `where ${name} 2>nul` : `which ${name} 2>/dev/null`;
86
+ const result = execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
87
+ return !!result.trim();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ // ── 프롬프트 생성 ──
94
+ function buildPrompt(pageId, blockId, depth, useGuest, includeComments) {
95
+ const mcpServer = useGuest ? "notion-guest" : "notion";
96
+ const targetBlock = blockId || pageId;
97
+ const blockNote = blockId
98
+ ? `\n시작 블록: ${blockId} — 이 블록과 그 하위 블록만 읽어라.`
99
+ : "";
100
+
101
+ return `Notion 페이지를 마크다운으로 추출하라.
102
+
103
+ 페이지 ID: ${pageId}${blockNote}
104
+
105
+ ## 실행 지침
106
+
107
+ ${mcpServer} MCP 서버의 도구를 사용하라.
108
+
109
+ ### 1단계: 페이지 메타데이터
110
+ 페이지 조회 도구를 호출하라 (page_id: "${pageId}").
111
+ 제목과 주요 속성을 기록하라.
112
+ 404 에러가 나면 ${useGuest ? "notion" : "notion-guest"} 서버로 재시도하라.
113
+
114
+ ### 2단계: 블록 읽기 (페이지네이션 필수)
115
+ 블록 자식 조회 도구를 호출하라 (block_id: "${targetBlock}", page_size: 100).
116
+
117
+ **페이지네이션 — 반드시 수행:**
118
+ - 응답의 has_more가 true이면, next_cursor를 start_cursor로 전달하여 반복 호출.
119
+ - has_more가 false가 될 때까지 계속 반복. 대형 페이지는 5-10회 이상 필요.
120
+ - 절대 첫 페이지만 읽고 멈추지 마라.
121
+
122
+ ### 3단계: 중첩 블록 재귀
123
+ 각 블록의 has_children이 true이면, 해당 block_id로 블록 자식 조회를 재귀 호출.
124
+ 최대 깊이: ${depth}단계. 깊이 초과 시 "[깊이 초과]" 표시.
125
+
126
+ ### 4단계: 댓글 수집${includeComments ? `
127
+ 페이지 및 블록 댓글을 수집하라.
128
+ - 댓글 조회 도구를 호출하라 (block_id: "${pageId}")로 페이지 전체 댓글을 가져와라.
129
+ - 응답의 has_more가 true이면 next_cursor로 반복.
130
+ - 각 댓글의 parent.type이 "block_id"이면 해당 블록의 인라인 댓글이다.
131
+ - parent.type이 "page_id"이면 페이지 레벨 토론 댓글이다.
132
+ - 404 에러 발생 시 댓글 권한이 없는 것이므로 건너뛰어라.` : `
133
+ 댓글 수집을 건너뛴다 (--comments 플래그 미지정).`}
134
+
135
+ ### 5단계: 마크다운 변환
136
+ - heading_1/2/3 → #/##/###
137
+ - paragraph → rich_text의 plain_text 연결
138
+ - bulleted_list_item → - 항목
139
+ - numbered_list_item → 1. 항목
140
+ - to_do → - [ ] 또는 - [x]
141
+ - toggle → **제목** + 하위 내용 들여쓰기
142
+ - code → \`\`\`언어 + 코드 + \`\`\`
143
+ - quote → > 인용
144
+ - callout → > 콜아웃
145
+ - table + table_row → 마크다운 테이블 (| 헤더 | ... |)
146
+ - image → ![](url)
147
+ - bookmark → [북마크](url)
148
+ - divider → ---
149
+ - column_list/column → 순서대로 출력
150
+ - child_page → [하위 페이지: 제목]
151
+ - child_database → [하위 DB: 제목]
152
+ - synced_block → 원본 내용 출력
153
+ - 기타 → [블록타입: 지원안됨]
154
+
155
+ ### 출력 규칙
156
+ - 페이지 제목을 # 헤더로 시작
157
+ - 모든 블록을 빠짐없이 순서대로 출력
158
+ - 읽기 실패 블록은 <!-- 읽기 실패: block_id --> 주석 남기기
159
+ - rich_text의 annotations (bold, italic, code, strikethrough) 반영
160
+ - 링크는 [텍스트](url) 형식${includeComments ? `
161
+ - 블록 인라인 댓글: 해당 블록 바로 아래에 > **[댓글]** @작성자: 내용 형식으로 삽입
162
+ - 페이지 토론 댓글: 문서 맨 끝에 ## 토론 섹션으로 모아서 출력
163
+ - 댓글의 rich_text도 마크다운으로 변환` : ""}
164
+ - 최종 결과만 출력 — 중간 과정 설명 불필요`;
165
+ }
166
+
167
+ // ── CLI 실행 (임시 파일 + execSync — Windows .cmd 호환) ──
168
+ function runWithCli(cliType, prompt, timeout) {
169
+ const cliName = cliType === "claude" ? "claude" : cliType === "codex" ? "codex" : "gemini";
170
+ if (!cliExists(cliName)) {
171
+ return { success: false, output: "", error: `${cliType} CLI 미설치`, cli: cliType };
172
+ }
173
+
174
+ // 프롬프트를 임시 파일에 저장 (shell escaping 회피)
175
+ const promptFile = join(tmpdir(), `notion-prompt-${Date.now()}.md`);
176
+ writeFileSync(promptFile, prompt, "utf8");
177
+ const promptPath = promptFile.replace(/\\/g, "/");
178
+
179
+ // CLI에 전달할 짧은 메타 프롬프트
180
+ const metaPrompt = `Read the file at ${promptPath} and execute all instructions in it exactly as described. Output only the final markdown result.`;
181
+
182
+ let cmd;
183
+ if (cliType === "codex") {
184
+ cmd = `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "${metaPrompt}"`;
185
+ } else if (cliType === "gemini") {
186
+ cmd = `gemini -m gemini-3-flash-preview -y --allowed-mcp-server-names notion,notion-guest --prompt "${metaPrompt}"`;
187
+ } else {
188
+ // Claude CLI — print 모드 (MCP 도구 자동 접근)
189
+ cmd = `claude -p "${metaPrompt}"`;
190
+ }
191
+
192
+ console.error(`${AMBER}▸${RESET} ${cliType}로 실행 중... (timeout: ${timeout}s)`);
193
+ const startTime = Date.now();
194
+
195
+ let stdout = "";
196
+ let stderr = "";
197
+ let exitCode = 0;
198
+
199
+ try {
200
+ stdout = execSync(cmd, {
201
+ encoding: "utf8",
202
+ timeout: (timeout + 30) * 1000,
203
+ maxBuffer: 10 * 1024 * 1024,
204
+ stdio: ["pipe", "pipe", "pipe"],
205
+ cwd: process.cwd(),
206
+ });
207
+ } catch (e) {
208
+ exitCode = e.status || (e.killed ? 124 : 1);
209
+ stdout = e.stdout || "";
210
+ stderr = e.stderr || "";
211
+ }
212
+
213
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
214
+
215
+ // 임시 파일 정리
216
+ try { unlinkSync(promptFile); } catch {}
217
+
218
+ // 실행 로그 기록
219
+ logExecution(cliType, exitCode, elapsed, timeout, stderr);
220
+
221
+ if (exitCode === 0 && stdout) {
222
+ return { success: true, output: stdout, cli: cliType, elapsed };
223
+ }
224
+
225
+ const isTimeout = exitCode === 124;
226
+ return {
227
+ success: false,
228
+ output: stdout,
229
+ error: isTimeout ? `timeout (${timeout}s)` : `exit ${exitCode}`,
230
+ stderr: stderr.slice(-500),
231
+ cli: cliType,
232
+ elapsed,
233
+ };
234
+ }
235
+
236
+ // ── Codex JSON-line 출력 정리 ──
237
+ function cleanCodexOutput(raw) {
238
+ const lines = raw.split(/\r?\n/);
239
+ const texts = [];
240
+
241
+ for (const line of lines) {
242
+ const trimmed = line.trim();
243
+ if (!trimmed) continue;
244
+
245
+ // JSON-line 형식이면 파싱
246
+ if (trimmed.startsWith("{")) {
247
+ try {
248
+ const obj = JSON.parse(trimmed);
249
+ if (["message", "completed", "output_text"].includes(obj.type)) {
250
+ const text = obj.text || obj.content || obj.output || "";
251
+ if (text) texts.push(text);
252
+ }
253
+ continue;
254
+ } catch {
255
+ // JSON 파싱 실패 → 일반 텍스트로 처리
256
+ }
257
+ }
258
+
259
+ texts.push(line);
260
+ }
261
+
262
+ return texts.join("\n");
263
+ }
264
+
265
+ // ── 실행 로그 (cli-route.sh 호환) ──
266
+ function logExecution(cliType, exitCode, elapsed, timeout, stderr) {
267
+ try {
268
+ const logDir = dirname(LOG_FILE);
269
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
270
+
271
+ const ts = new Date().toISOString();
272
+ const status = exitCode === 0 ? "success" : exitCode === 124 ? "timeout" : "failed";
273
+ const entry = JSON.stringify({
274
+ ts,
275
+ agent: "notion-read",
276
+ cli: cliType,
277
+ effort: cliType === "codex" ? "high" : cliType === "claude" ? "sonnet" : "flash",
278
+ run_mode: "fg",
279
+ opus_oversight: "false",
280
+ status,
281
+ exit_code: exitCode,
282
+ elapsed_sec: elapsed,
283
+ timeout_sec: timeout,
284
+ mcp_profile: "notion",
285
+ input_tokens: 0,
286
+ output_tokens: 0,
287
+ total_tokens: 0,
288
+ });
289
+ appendFileSync(LOG_FILE, entry + "\n");
290
+ } catch {}
291
+ }
292
+
293
+ // ── 메인 ──
294
+ function main() {
295
+ const args = process.argv.slice(2);
296
+
297
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
298
+ console.log(`
299
+ ${AMBER}${BOLD}notion-read${RESET} ${DIM}v${VERSION}${RESET}
300
+ ${GRAY}Notion 대형 페이지 리더 — Codex/Gemini MCP 위임${RESET}
301
+
302
+ ${BOLD}사용법${RESET}
303
+ tfx notion-read <notion-url-or-page-id> [옵션]
304
+
305
+ ${BOLD}옵션${RESET}
306
+ --output, -o <file> 결과 파일 저장 (기본: stdout)
307
+ --timeout, -t <sec> CLI 타임아웃 (기본: 600)
308
+ --cli, -c <codex|gemini|claude> CLI 강제 지정 (기본: 자동 + 폴백)
309
+ --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
310
+ --comments 블록/페이지 댓글 포함
311
+ --guest notion-guest 통합 사용
312
+
313
+ ${BOLD}폴백 체인${RESET}
314
+ Codex(무료) → Gemini(무료) → Claude(최후) → 에러
315
+
316
+ ${BOLD}예시${RESET}
317
+ tfx notion-read https://notion.so/Page-abc123def456...
318
+ tfx notion-read abc123def456... --output page.md --comments
319
+ tfx notion-read abc123def456... --cli gemini --timeout 900
320
+ tfx notion-read abc123def456... --guest --comments
321
+ `);
322
+ return;
323
+ }
324
+
325
+ // 인자 파싱
326
+ const input = args[0];
327
+ let outputFile = null;
328
+ let timeout = 600;
329
+ let forceCli = null;
330
+ let depth = 3;
331
+ let useGuest = false;
332
+ let includeComments = false;
333
+
334
+ for (let i = 1; i < args.length; i++) {
335
+ switch (args[i]) {
336
+ case "--output":
337
+ case "-o":
338
+ outputFile = args[++i];
339
+ break;
340
+ case "--timeout":
341
+ case "-t":
342
+ timeout = parseInt(args[++i]) || 600;
343
+ break;
344
+ case "--cli":
345
+ case "-c":
346
+ forceCli = args[++i];
347
+ break;
348
+ case "--depth":
349
+ case "-d":
350
+ depth = parseInt(args[++i]) || 3;
351
+ break;
352
+ case "--guest":
353
+ useGuest = true;
354
+ break;
355
+ case "--comments":
356
+ includeComments = true;
357
+ break;
358
+ }
359
+ }
360
+
361
+ // URL 파싱
362
+ const parsed = parseNotionUrl(input);
363
+ if (!parsed) {
364
+ console.error(`${RED}✗${RESET} 유효하지 않은 Notion URL/ID: ${input}`);
365
+ process.exit(1);
366
+ }
367
+
368
+ console.error(
369
+ `${AMBER}▸${RESET} 페이지: ${parsed.pageId}${parsed.blockId ? ` (블록: ${parsed.blockId})` : ""}`,
370
+ );
371
+ console.error(`${GRAY} 통합: ${useGuest ? "notion-guest" : "notion"} | 깊이: ${depth} | 댓글: ${includeComments ? "O" : "X"} | 타임아웃: ${timeout}s${RESET}`);
372
+
373
+ // MCP 가용성 확인
374
+ const mcpAvail = getNotionMcpClis(useGuest);
375
+ console.error(
376
+ `${GRAY} MCP: codex=${mcpAvail.codex ? "O" : "X"} gemini=${mcpAvail.gemini ? "O" : "X"}${RESET}`,
377
+ );
378
+
379
+ // MCP 미설치 안내
380
+ if (!mcpAvail.codex && !mcpAvail.gemini) {
381
+ console.error(`${YELLOW}!${RESET} Codex/Gemini에 Notion MCP 미설치.`);
382
+ console.error(`${GRAY} Codex: codex mcp add notion${RESET}`);
383
+ console.error(`${GRAY} Gemini: ~/.gemini/settings.json에 notion 서버 추가${RESET}`);
384
+ console.error(`${GRAY} 설치 후 tfx doctor --reset으로 캐시 갱신${RESET}`);
385
+ } else if (!mcpAvail.codex) {
386
+ console.error(`${GRAY} Codex에 Notion MCP 미설치: codex mcp add notion${RESET}`);
387
+ } else if (!mcpAvail.gemini) {
388
+ console.error(`${GRAY} Gemini에 Notion MCP 미설치: ~/.gemini/settings.json 확인${RESET}`);
389
+ }
390
+
391
+ // CLI 실행 순서 결정 (Codex → Gemini → Claude)
392
+ let cliOrder;
393
+ if (forceCli) {
394
+ cliOrder = [forceCli];
395
+ } else {
396
+ cliOrder = [];
397
+ if (mcpAvail.codex) cliOrder.push("codex");
398
+ if (mcpAvail.gemini) cliOrder.push("gemini");
399
+ // Claude는 항상 최종 폴백 (자체 Notion MCP — notion-guest 포함)
400
+ cliOrder.push("claude");
401
+ }
402
+
403
+ // 프롬프트 생성
404
+ const prompt = buildPrompt(parsed.pageId, parsed.blockId, depth, useGuest, includeComments);
405
+ // Claude 폴백용: notion/notion-guest 양쪽 시도 프롬프트
406
+ const claudePrompt = buildPrompt(parsed.pageId, parsed.blockId, depth, false, includeComments)
407
+ .replace(
408
+ "404 에러가 나면 notion-guest 서버로 재시도하라.",
409
+ "404 에러가 나면 반드시 notion-guest 서버로 재시도하라. notion, notion-guest, claude_ai_Notion 등 사용 가능한 모든 Notion MCP 서버를 시도하라.",
410
+ );
411
+
412
+ // 실행 + 폴백
413
+ let lastResult = null;
414
+ let notionAccessFailed = false;
415
+
416
+ for (const cli of cliOrder) {
417
+ const currentPrompt = cli === "claude" ? claudePrompt : prompt;
418
+ const result = runWithCli(cli, currentPrompt, timeout);
419
+ lastResult = result;
420
+
421
+ if (result.success) {
422
+ // Codex JSON-line 출력 정리
423
+ let output = cli === "codex" ? cleanCodexOutput(result.output) : result.output;
424
+ output = output.trim();
425
+
426
+ // 실패 마커 감지 (404, 접근 실패 등)
427
+ const isFailureOutput =
428
+ (output.includes("조회 실패") || output.includes("읽기 실패") || output.includes("not_found")) &&
429
+ output.length < 500;
430
+
431
+ if (output.length > 100 && !isFailureOutput) {
432
+ console.error(`${GREEN}✓${RESET} ${cli}로 성공 (${output.length} chars, ${result.elapsed}s)`);
433
+
434
+ if (outputFile) {
435
+ const outDir = dirname(outputFile);
436
+ if (outDir && outDir !== "." && !existsSync(outDir)) {
437
+ mkdirSync(outDir, { recursive: true });
438
+ }
439
+ writeFileSync(outputFile, output, "utf8");
440
+ console.error(`${GREEN}✓${RESET} 저장: ${outputFile}`);
441
+ } else {
442
+ console.log(output);
443
+ }
444
+ return;
445
+ }
446
+
447
+ // 404 접근 실패 감지
448
+ if (isFailureOutput) {
449
+ notionAccessFailed = true;
450
+ console.error(`${YELLOW}!${RESET} ${cli}: Notion 접근 실패 (404) — 폴백`);
451
+ if (!useGuest && cli !== "claude") {
452
+ console.error(`${GRAY} --guest 플래그로 notion-guest 통합 시도 가능${RESET}`);
453
+ }
454
+ } else {
455
+ console.error(`${YELLOW}!${RESET} ${cli}: 출력 부족 (${output.length} chars) — 폴백`);
456
+ }
457
+ } else {
458
+ console.error(`${YELLOW}!${RESET} ${cli} 실패: ${result.error}`);
459
+ if (result.stderr) {
460
+ console.error(`${GRAY} stderr: ${result.stderr.slice(0, 200)}${RESET}`);
461
+ }
462
+ }
463
+
464
+ // 다음 CLI로 폴백
465
+ const idx = cliOrder.indexOf(cli);
466
+ if (idx < cliOrder.length - 1) {
467
+ const next = cliOrder[idx + 1];
468
+ console.error(`${AMBER}▸${RESET} ${next}로 폴백 시도...`);
469
+ }
470
+ }
471
+
472
+ // 전체 실패
473
+ console.error(`${RED}✗${RESET} 모든 CLI 실패`);
474
+ if (notionAccessFailed) {
475
+ console.error(`${YELLOW}!${RESET} Notion 페이지 접근 권한 문제.`);
476
+ console.error(`${GRAY} 1. Notion에서 페이지 → ... → 연결(Connections)에서 통합 추가${RESET}`);
477
+ console.error(`${GRAY} 2. --guest 플래그로 notion-guest 통합 시도${RESET}`);
478
+ console.error(`${GRAY} 3. --cli claude로 Claude 직접 사용 (Claude에 접근 권한 있는 경우)${RESET}`);
479
+ }
480
+
481
+ // 부분 결과라도 출력
482
+ if (lastResult?.output) {
483
+ const partial = lastResult.output.trim();
484
+ if (partial.length > 10) {
485
+ const cleaned = lastResult.cli === "codex" ? cleanCodexOutput(partial) : partial;
486
+ if (outputFile) {
487
+ writeFileSync(outputFile, cleaned, "utf8");
488
+ console.error(`${YELLOW}!${RESET} 부분 결과 저장: ${outputFile}`);
489
+ } else {
490
+ console.log(cleaned);
491
+ }
492
+ }
493
+ }
494
+
495
+ process.exit(1);
496
+ }
497
+
498
+ main();