viruagent 1.0.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/src/agent.js ADDED
@@ -0,0 +1,689 @@
1
+ const readline = require('readline');
2
+ const chalk = require('chalk');
3
+ const { generatePost, revisePost, chat, MODELS, loadConfig } = require('./lib/ai');
4
+ const { initBlog, getBlogName, publishPost, saveDraft, getPosts, getCategories, VISIBILITY } = require('./lib/tistory');
5
+
6
+ const TONES = loadConfig().tones.map((t) => t.name);
7
+
8
+ /**
9
+ * 화살표 키로 항목을 선택하는 인터랙티브 메뉴
10
+ * @param {string[]} items - 선택지 목록
11
+ * @param {string} [title='선택하세요'] - 상단 제목
12
+ * @returns {Promise<number>} 선택된 인덱스 (-1이면 취소)
13
+ */
14
+ const selectMenu = (items, title = '선택하세요') =>
15
+ new Promise((resolve) => {
16
+ let cursor = 0;
17
+
18
+ const render = () => {
19
+ // 이전 출력 지우기
20
+ process.stdout.write(`\x1B[${items.length + 1}A\x1B[J`);
21
+ draw();
22
+ };
23
+
24
+ const draw = () => {
25
+ console.log(chalk.bold(title));
26
+ items.forEach((item, i) => {
27
+ const prefix = i === cursor ? chalk.green('❯ ') : ' ';
28
+ const text = i === cursor ? chalk.green.bold(item) : chalk.dim(item);
29
+ console.log(`${prefix}${text}`);
30
+ });
31
+ };
32
+
33
+ draw();
34
+
35
+ const onKeypress = (_, key) => {
36
+ if (!key) return;
37
+
38
+ if (key.name === 'up') {
39
+ cursor = (cursor - 1 + items.length) % items.length;
40
+ render();
41
+ } else if (key.name === 'down') {
42
+ cursor = (cursor + 1) % items.length;
43
+ render();
44
+ } else if (key.name === 'return') {
45
+ cleanup();
46
+ resolve(cursor);
47
+ } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
48
+ cleanup();
49
+ resolve(-1);
50
+ }
51
+ };
52
+
53
+ const cleanup = () => {
54
+ process.stdin.removeListener('keypress', onKeypress);
55
+ };
56
+
57
+ readline.emitKeypressEvents(process.stdin);
58
+ if (process.stdin.isTTY && !process.stdin.isRaw) process.stdin.setRawMode(true);
59
+ process.stdin.resume();
60
+ process.stdin.on('keypress', onKeypress);
61
+ });
62
+
63
+ // 설정 저장/로드
64
+ const fs = require('fs');
65
+ const path = require('path');
66
+ const SETTINGS_PATH = path.join(__dirname, '..', 'config', 'settings.json');
67
+
68
+ const DEFAULTS = { category: 0, visibility: 20, model: 'gpt-4o-mini', tone: loadConfig().defaultTone };
69
+
70
+ const visLabel = (v) => (v === 20 ? '공개 발행' : v === 15 ? '보호 발행' : '비공개 발행');
71
+
72
+ const loadSettings = () => {
73
+ try {
74
+ return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) };
75
+ } catch {
76
+ return { ...DEFAULTS };
77
+ }
78
+ };
79
+
80
+ const saveSettings = () => {
81
+ const { category, visibility, model, tone } = state;
82
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify({ category, visibility, model, tone }, null, 2));
83
+ };
84
+
85
+ const saved = loadSettings();
86
+
87
+ // 상태
88
+ const state = {
89
+ draft: null, // { title, content, tags }
90
+ categories: {}, // { name: id }
91
+ category: saved.category,
92
+ visibility: saved.visibility,
93
+ model: saved.model,
94
+ tone: saved.tone,
95
+ chatHistory: [],
96
+ };
97
+
98
+ const log = {
99
+ info: (msg) => console.log(chalk.cyan(`ℹ ${msg}`)),
100
+ success: (msg) => console.log(chalk.green(`✓ ${msg}`)),
101
+ error: (msg) => console.log(chalk.red(`✗ ${msg}`)),
102
+ warn: (msg) => console.log(chalk.yellow(`⚠ ${msg}`)),
103
+ title: (msg) => console.log(chalk.bold.magenta(msg)),
104
+ dim: (msg) => console.log(chalk.dim(msg)),
105
+ };
106
+
107
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
108
+
109
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
110
+
111
+ const animateBanner = async () => {
112
+ const figlet = require('figlet');
113
+ const gradient = require('gradient-string');
114
+
115
+ const text = figlet.textSync('ViruAgent', { font: 'ANSI Shadow' });
116
+ const lines = text.split('\n');
117
+ const totalLines = lines.length;
118
+ const sub = ' 대화형 티스토리 블로그 에이전트 v1.0';
119
+
120
+ console.log();
121
+
122
+ // 라인별 드롭 애니메이션
123
+ for (let i = 0; i < totalLines; i++) {
124
+ console.log(gradient.pastel(lines[i]));
125
+ await sleep(40);
126
+ }
127
+
128
+ // 서브타이틀
129
+ await sleep(100);
130
+ const cols = process.stdout.columns || 80;
131
+ const pad = Math.max(0, Math.floor((cols - sub.length) / 2));
132
+ console.log(' '.repeat(pad) + chalk.dim(sub));
133
+ console.log();
134
+ };
135
+
136
+ const showBootStep = async (msg, asyncFn, minMs = 800) => {
137
+ let i = 0;
138
+ const timer = setInterval(() => {
139
+ process.stdout.write(`\r ${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${chalk.dim(msg)}`);
140
+ }, 80);
141
+ try {
142
+ const [result] = await Promise.all([asyncFn(), sleep(minMs)]);
143
+ clearInterval(timer);
144
+ process.stdout.write(`\r ${chalk.green('✓')} ${msg}\n`);
145
+ return result;
146
+ } catch (e) {
147
+ clearInterval(timer);
148
+ process.stdout.write(`\r ${chalk.red('✗')} ${msg}\n`);
149
+ throw e;
150
+ }
151
+ };
152
+
153
+ const withSpinner = async (message, asyncFn) => {
154
+ let i = 0;
155
+ const timer = setInterval(() => {
156
+ process.stdout.write(`\r${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${message}`);
157
+ }, 80);
158
+ try {
159
+ return await asyncFn();
160
+ } finally {
161
+ clearInterval(timer);
162
+ process.stdout.write('\r\x1B[K');
163
+ }
164
+ };
165
+
166
+ const COMMANDS = [
167
+ '/write',
168
+ '/edit',
169
+ '/preview',
170
+ '/publish',
171
+ '/draft',
172
+ '/list',
173
+ '/categories',
174
+ '/set',
175
+ '/login',
176
+ '/logout',
177
+ '/help',
178
+ '/exit',
179
+ ];
180
+ const SET_KEYS = ['category', 'visibility', 'model', 'tone'];
181
+
182
+ const completer = (line) => {
183
+ // /set model <값> 자동완성
184
+ if (line.match(/^\/set\s+model\s+/)) {
185
+ const partial = line.split(/\s+/).pop();
186
+ const hits = MODELS.filter((m) => m.startsWith(partial));
187
+ return [hits.length ? hits : MODELS, partial];
188
+ }
189
+
190
+ // /set visibility <값> 자동완성
191
+ if (line.match(/^\/set\s+visibility\s+/)) {
192
+ const opts = ['공개', '보호', '비공개'];
193
+ const partial = line.split(/\s+/).pop();
194
+ const hits = opts.filter((v) => v.startsWith(partial));
195
+ return [hits.length ? hits : opts, partial];
196
+ }
197
+
198
+ // /set tone <값> 자동완성
199
+ if (line.match(/^\/set\s+tone\s+/)) {
200
+ const partial = line.split(/\s+/).pop();
201
+ const hits = TONES.filter((t) => t.startsWith(partial));
202
+ return [hits.length ? hits : TONES, partial];
203
+ }
204
+
205
+ // /set category <이름> 자동완성
206
+ if (line.match(/^\/set\s+category\s+/)) {
207
+ const partial = line.replace(/^\/set\s+category\s+/, '');
208
+ const names = Object.keys(state.categories);
209
+ const hits = names.filter((n) => n.startsWith(partial));
210
+ return [hits.length ? hits : names, partial];
211
+ }
212
+
213
+ // /set <키> 자동완성
214
+ if (line.match(/^\/set\s+/)) {
215
+ const partial = line.split(/\s+/).pop();
216
+ const hits = SET_KEYS.filter((k) => k.startsWith(partial));
217
+ return [hits.length ? hits : SET_KEYS, partial];
218
+ }
219
+
220
+ // / 커맨드 자동완성
221
+ if (line.startsWith('/')) {
222
+ const hits = COMMANDS.filter((c) => c.startsWith(line.split(/\s+/)[0]));
223
+ return [hits.length ? hits : COMMANDS, line.split(/\s+/)[0]];
224
+ }
225
+
226
+ return [[], line];
227
+ };
228
+
229
+ const COMMAND_HINTS = {
230
+ '/write': '/write <주제>',
231
+ '/edit': '/edit <수정 지시>',
232
+ '/preview': '/preview',
233
+ '/publish': '/publish',
234
+ '/draft': '/draft',
235
+ '/list': '/list',
236
+ '/categories': '/categories',
237
+ '/set': '/set <category|visibility|model|tone>',
238
+ '/login': '/login',
239
+ '/logout': '/logout',
240
+ '/help': '/help',
241
+ '/exit': '/exit',
242
+ };
243
+
244
+ const getHint = (line) => {
245
+ if (!line || !line.startsWith('/')) return '';
246
+
247
+ const parts = line.split(/\s+/);
248
+ const cmd = parts[0];
249
+
250
+ // /set 서브커맨드 힌트
251
+ if (cmd === '/set' && parts.length >= 2) {
252
+ const subKey = parts[1];
253
+ if (parts.length === 2) {
254
+ // /set 이후 키 입력 중
255
+ const match = SET_KEYS.find((k) => k.startsWith(subKey) && k !== subKey);
256
+ if (match) return match.slice(subKey.length);
257
+ }
258
+ if (parts.length === 3 && subKey === 'model') {
259
+ const partial = parts[2];
260
+ const match = MODELS.find((m) => m.startsWith(partial) && m !== partial);
261
+ if (match) return match.slice(partial.length);
262
+ }
263
+ if (parts.length === 3 && subKey === 'tone') {
264
+ const partial = parts[2];
265
+ const match = TONES.find((t) => t.startsWith(partial) && t !== partial);
266
+ if (match) return match.slice(partial.length);
267
+ }
268
+ return '';
269
+ }
270
+
271
+ // 정확히 매칭되는 커맨드가 있으면 전체 힌트
272
+ if (COMMAND_HINTS[cmd] && cmd === line) {
273
+ const hint = COMMAND_HINTS[cmd];
274
+ return hint.slice(line.length);
275
+ }
276
+
277
+ // 부분 입력이면 첫 번째 매칭 커맨드 추천
278
+ const match = COMMANDS.find((c) => c.startsWith(cmd) && c !== cmd);
279
+ if (match) {
280
+ const hint = COMMAND_HINTS[match] || match;
281
+ return hint.slice(line.length);
282
+ }
283
+
284
+ return '';
285
+ };
286
+
287
+ const rl = readline.createInterface({
288
+ input: process.stdin,
289
+ output: process.stdout,
290
+ completer,
291
+ });
292
+
293
+ // 키 입력마다 ghost hint 표시
294
+ process.stdin.on('keypress', () => {
295
+ // nextTick으로 rl.line이 업데이트된 후 실행
296
+ process.nextTick(() => {
297
+ const line = rl.line;
298
+ const hint = getHint(line);
299
+
300
+ // 현재 커서 위치 저장 → 잔상 지우기 → 힌트 출력 → 커서 복원
301
+ process.stdout.write(`\x1B[s\x1B[K${hint ? chalk.dim(hint) : ''}\x1B[u`);
302
+ });
303
+ });
304
+
305
+ const drawStatusBar = () => {
306
+ const cols = process.stdout.columns || 80;
307
+ const vis = visLabel(state.visibility);
308
+ const cat = getCategoryName();
309
+ const parts = [
310
+ chalk.bgBlue.white(` ${state.model} `),
311
+ chalk.bgMagenta.white(` ${getBlogName() || '미연결'} `),
312
+ chalk.bgHex('#6A0DAD').white(` ${cat} `),
313
+ chalk.bgHex('#D4A017').black(` ${vis} `),
314
+ chalk.bgRed.white(` ${state.tone} `),
315
+ state.draft ? chalk.bgYellow.black(` 초안: ${state.draft.title.slice(0, 20)} `) : '',
316
+ ].filter(Boolean);
317
+ const bar = parts.join(chalk.dim(' │ '));
318
+ console.log(chalk.dim('─'.repeat(cols)));
319
+ console.log(bar);
320
+ console.log(chalk.dim('─'.repeat(cols)));
321
+ };
322
+
323
+ const prompt = () => {
324
+ drawStatusBar();
325
+ return new Promise((resolve) => rl.question(chalk.bold.green('viruagent> '), resolve));
326
+ };
327
+
328
+ const stripHtml = (html) =>
329
+ html
330
+ .replace(/<[^>]+>/g, '')
331
+ .replace(/&nbsp;/g, ' ')
332
+ .replace(/&lt;/g, '<')
333
+ .replace(/&gt;/g, '>')
334
+ .replace(/&amp;/g, '&');
335
+
336
+ // 커맨드 핸들러
337
+ const commands = {
338
+ async write(args) {
339
+ const topic = args.join(' ');
340
+ if (!topic) return log.warn('사용법: /write <주제>');
341
+
342
+ try {
343
+ state.draft = await withSpinner(`"${topic}" 주제로 글을 생성하는 중...`, () =>
344
+ generatePost(topic, { model: state.model, tone: state.tone }),
345
+ );
346
+ log.success(`글 생성 완료: "${state.draft.title}"`);
347
+ log.dim(`태그: ${state.draft.tags}`);
348
+ log.dim('미리보기: /preview | 수정: /edit <지시> | 발행: /publish');
349
+ } catch (e) {
350
+ log.error(`글 생성 실패: ${e.message}`);
351
+ }
352
+ },
353
+
354
+ async edit(args) {
355
+ if (!state.draft) return log.warn('초안이 없습니다. /write로 먼저 생성하세요.');
356
+ const instruction = args.join(' ');
357
+ if (!instruction) return log.warn('사용법: /edit <수정 지시>');
358
+
359
+ try {
360
+ const result = await withSpinner('글을 수정하는 중...', () =>
361
+ revisePost(state.draft.content, instruction, state.model),
362
+ );
363
+ state.draft.title = result.title;
364
+ state.draft.content = result.content;
365
+ state.draft.tags = result.tags;
366
+ log.success('수정 완료!');
367
+ log.dim('미리보기: /preview | 추가 수정: /edit <지시>');
368
+ } catch (e) {
369
+ log.error(`수정 실패: ${e.message}`);
370
+ }
371
+ },
372
+
373
+ preview() {
374
+ if (!state.draft) return log.warn('초안이 없습니다.');
375
+
376
+ console.log('');
377
+ log.title(`━━━ ${state.draft.title} ━━━`);
378
+ console.log('');
379
+ console.log(stripHtml(state.draft.content));
380
+ console.log('');
381
+ log.dim(`태그: ${state.draft.tags}`);
382
+ log.dim(`카테고리: ${getCategoryName()} | 공개설정: ${visLabel(state.visibility)}`);
383
+ console.log('');
384
+ },
385
+
386
+ async publish() {
387
+ if (!state.draft) return log.warn('초안이 없습니다.');
388
+
389
+ log.info('발행하는 중...');
390
+ try {
391
+ const result = await publishPost({
392
+ title: state.draft.title,
393
+ content: state.draft.content,
394
+ visibility: state.visibility,
395
+ category: state.category,
396
+ tag: state.draft.tags,
397
+ thumbnail: state.draft.thumbnailKage || null,
398
+ });
399
+ log.success(`발행 완료! ${result.entryUrl || ''}`);
400
+ state.draft = null;
401
+ } catch (e) {
402
+ log.error(`발행 실패: ${e.message}`);
403
+ }
404
+ },
405
+
406
+ async draft() {
407
+ if (!state.draft) return log.warn('초안이 없습니다.');
408
+
409
+ log.info('임시저장하는 중...');
410
+ try {
411
+ await saveDraft({ title: state.draft.title, content: state.draft.content });
412
+ log.success('임시저장 완료!');
413
+ } catch (e) {
414
+ log.error(`임시저장 실패: ${e.message}`);
415
+ }
416
+ },
417
+
418
+ async list() {
419
+ try {
420
+ const data = await getPosts();
421
+ log.title(`글 목록 (총 ${data.totalCount}개)`);
422
+ data.items?.forEach((p) => {
423
+ const vis = p.visibility === 'PUBLIC' ? '공개' : p.visibility === 'PROTECTED' ? '보호' : '비공개';
424
+ console.log(` ${chalk.dim(`[${p.id}]`)} ${p.title} ${chalk.dim(`(${vis})`)}`);
425
+ });
426
+ } catch (e) {
427
+ log.error(`목록 조회 실패: ${e.message}`);
428
+ }
429
+ },
430
+
431
+ categories() {
432
+ if (!Object.keys(state.categories).length) return log.warn('카테고리가 없습니다.');
433
+
434
+ log.title('카테고리 목록');
435
+ Object.entries(state.categories).forEach(([name, id]) => {
436
+ const marker = id === state.category ? chalk.green(' ← 현재') : '';
437
+ console.log(` ${chalk.dim(id)} → ${name}${marker}`);
438
+ });
439
+ },
440
+
441
+ async set(args) {
442
+ const [key, ...rest] = args;
443
+ const value = rest.join(' ');
444
+
445
+ if (key === 'category') {
446
+ const names = Object.keys(state.categories);
447
+ if (!names.length) return log.warn('카테고리가 없습니다.');
448
+
449
+ if (value) {
450
+ // 직접 이름 지정
451
+ const id = state.categories[value];
452
+ if (id !== undefined) {
453
+ state.category = id;
454
+ log.success(`카테고리 설정: ${value} (${id})`);
455
+ } else {
456
+ log.warn(`"${value}" 카테고리를 찾을 수 없습니다. /categories로 확인하세요.`);
457
+ }
458
+ } else {
459
+ // 인터랙티브 선택
460
+ rl.pause();
461
+ const idx = await selectMenu(names, '카테고리 선택 (↑↓ 이동, Enter 선택, Esc 취소)');
462
+ rl.resume();
463
+ if (idx >= 0) {
464
+ const name = names[idx];
465
+ state.category = state.categories[name];
466
+ log.success(`카테고리 설정: ${name} (${state.categories[name]})`);
467
+ } else {
468
+ log.dim('취소됨');
469
+ }
470
+ }
471
+ } else if (key === 'visibility') {
472
+ const visMap = { 공개: 20, 보호: 15, 비공개: 0 };
473
+ if (visMap[value] !== undefined) {
474
+ state.visibility = visMap[value];
475
+ log.success(`공개설정: ${visLabel(state.visibility)}`);
476
+ } else {
477
+ // 인터랙티브 선택
478
+ const visOptions = ['공개', '보호', '비공개'];
479
+ rl.pause();
480
+ const idx = await selectMenu(visOptions, '공개설정 선택 (↑↓ 이동, Enter 선택, Esc 취소)');
481
+ rl.resume();
482
+ if (idx >= 0) {
483
+ state.visibility = [20, 15, 0][idx];
484
+ log.success(`공개설정: ${visLabel(state.visibility)}`);
485
+ } else {
486
+ log.dim('취소됨');
487
+ }
488
+ }
489
+ } else if (key === 'model') {
490
+ if (value && MODELS.includes(value)) {
491
+ state.model = value;
492
+ log.success(`모델 설정: ${value}`);
493
+ } else {
494
+ // 인터랙티브 선택
495
+ rl.pause();
496
+ const idx = await selectMenu(MODELS, '모델 선택 (↑↓ 이동, Enter 선택, Esc 취소)');
497
+ rl.resume();
498
+ if (idx >= 0) {
499
+ state.model = MODELS[idx];
500
+ log.success(`모델 설정: ${state.model}`);
501
+ } else {
502
+ log.dim('취소됨');
503
+ }
504
+ }
505
+ } else if (key === 'tone') {
506
+ if (value && TONES.includes(value)) {
507
+ state.tone = value;
508
+ log.success(`톤 설정: ${value}`);
509
+ } else {
510
+ rl.pause();
511
+ const idx = await selectMenu(TONES, '톤 선택 (↑↓ 이동, Enter 선택, Esc 취소)');
512
+ rl.resume();
513
+ if (idx >= 0) {
514
+ state.tone = TONES[idx];
515
+ log.success(`톤 설정: ${state.tone}`);
516
+ } else {
517
+ log.dim('취소됨');
518
+ }
519
+ }
520
+ } else {
521
+ return log.warn('사용법: /set category | /set visibility | /set model | /set tone');
522
+ }
523
+ saveSettings();
524
+ },
525
+
526
+ help() {
527
+ console.log(`
528
+ ${chalk.bold('ViruAgent 명령어')}
529
+
530
+ ${chalk.cyan('/write <주제>')} AI가 블로그 글 초안 생성
531
+ ${chalk.cyan('/edit <지시>')} 현재 초안 수정
532
+ ${chalk.cyan('/preview')} 현재 초안 미리보기
533
+ ${chalk.cyan('/publish')} 글 발행
534
+ ${chalk.cyan('/draft')} 임시저장
535
+ ${chalk.cyan('/list')} 글 목록 조회
536
+ ${chalk.cyan('/categories')} 카테고리 목록
537
+ ${chalk.cyan('/set category')} 카테고리 설정
538
+ ${chalk.cyan('/set visibility')} 공개설정
539
+ ${chalk.cyan('/set model')} AI 모델 선택
540
+ ${chalk.cyan('/set tone')} 글쓰기 톤 설정
541
+ ${chalk.cyan('/login')} 티스토리 로그인
542
+ ${chalk.cyan('/logout')} 로그아웃 (세션 삭제)
543
+ ${chalk.cyan('/help')} 도움말
544
+ ${chalk.cyan('/exit')} 종료
545
+
546
+ 슬래시 없이 입력하면 AI와 자유 대화 (주제 논의, 아이디어 등)
547
+ `);
548
+ },
549
+
550
+ logout() {
551
+ const sessionPath = path.join(__dirname, '..', 'data', 'session.json');
552
+ if (fs.existsSync(sessionPath)) {
553
+ fs.unlinkSync(sessionPath);
554
+ log.success('세션이 삭제되었습니다.');
555
+ } else {
556
+ log.warn('세션 파일이 없습니다.');
557
+ }
558
+ state.categories = {};
559
+ state.category = 0;
560
+ log.info('로그아웃되었습니다. 프로그램을 종료합니다.');
561
+ process.exit(0);
562
+ },
563
+
564
+ async login() {
565
+ log.info('브라우저를 열어 로그인합니다...');
566
+ try {
567
+ const { execSync } = require('child_process');
568
+ execSync('node lib/login.js', { cwd: __dirname, stdio: 'inherit' });
569
+ log.success('로그인 완료!');
570
+ await initBlog();
571
+ log.success(`블로그 감지: ${getBlogName()}`);
572
+ state.categories = await getCategories();
573
+ log.success(`${Object.keys(state.categories).length}개 카테고리 로드 완료`);
574
+ } catch (e) {
575
+ log.error(`로그인 실패: ${e.message}`);
576
+ }
577
+ },
578
+
579
+ exit() {
580
+ log.info('안녕히 가세요!');
581
+ rl.close();
582
+ process.exit(0);
583
+ },
584
+ };
585
+
586
+ const getCategoryName = () => {
587
+ const entry = Object.entries(state.categories).find(([, id]) => id === state.category);
588
+ return entry ? entry[0] : '없음';
589
+ };
590
+
591
+ const handleInput = async (input) => {
592
+ const trimmed = input.trim();
593
+ if (!trimmed) return;
594
+
595
+ if (trimmed.startsWith('/')) {
596
+ const [cmd, ...args] = trimmed.slice(1).split(/\s+/);
597
+ const handler = commands[cmd];
598
+ if (handler) {
599
+ await handler(args);
600
+ } else {
601
+ log.warn(`알 수 없는 명령어: /${cmd}. /help로 확인하세요.`);
602
+ }
603
+ } else {
604
+ // 자유 대화
605
+ state.chatHistory.push({ role: 'user', content: trimmed });
606
+ try {
607
+ const reply = await withSpinner('생각하는 중...', () => chat(state.chatHistory, state.model));
608
+ state.chatHistory.push({ role: 'assistant', content: reply });
609
+ console.log(`\n${chalk.blue('AI')} ${reply}\n`);
610
+ } catch (e) {
611
+ log.error(`대화 실패: ${e.message}`);
612
+ }
613
+ }
614
+ };
615
+
616
+ const main = async () => {
617
+ await animateBanner();
618
+
619
+ // 세션 체크 — 로그인 필수
620
+ while (!fs.existsSync(path.join(__dirname, '..', 'data', 'session.json'))) {
621
+ log.warn('세션 파일(session.json)이 없습니다. 티스토리 로그인이 필요합니다.');
622
+ log.info('브라우저를 열어 로그인합니다...');
623
+ try {
624
+ const { execSync } = require('child_process');
625
+ execSync('node lib/login.js', { cwd: __dirname, stdio: 'inherit' });
626
+ log.success('로그인 완료!');
627
+ } catch (e) {
628
+ log.error(`로그인 실패: ${e.message}`);
629
+ log.warn('다시 시도합니다...\n');
630
+ }
631
+ }
632
+
633
+ // 부팅 시퀀스
634
+ console.log(chalk.dim(' 시스템 초기화 중...\n'));
635
+
636
+ await showBootStep(
637
+ 'OpenAI 연결',
638
+ async () => {
639
+ const res = await fetch('https://api.openai.com/v1/models', {
640
+ headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
641
+ });
642
+ if (!res.ok) throw new Error('키가 유효하지 않습니다');
643
+ },
644
+ 1000,
645
+ );
646
+
647
+ if (process.env.UNSPLASH_ACCESS_KEY) {
648
+ await showBootStep(
649
+ 'Unsplash 연결',
650
+ async () => {
651
+ const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
652
+ headers: { Authorization: `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}` },
653
+ });
654
+ if (!res.ok) throw new Error('키가 유효하지 않습니다');
655
+ },
656
+ 1000,
657
+ );
658
+ }
659
+
660
+ let blogOk = false;
661
+ try {
662
+ await showBootStep(
663
+ '티스토리 연결',
664
+ async () => {
665
+ await initBlog();
666
+ state.categories = await getCategories();
667
+ },
668
+ 1200,
669
+ );
670
+ blogOk = true;
671
+ } catch (e) {
672
+ if (!blogOk) log.warn(` 티스토리 연결 실패: ${e.message}\n ${chalk.dim('/login 명령어로 다시 로그인하세요.')}`);
673
+ }
674
+
675
+ console.log();
676
+ console.log(chalk.dim(' /help로 명령어 확인 | 자유롭게 대화하며 글을 작성하세요'));
677
+ console.log();
678
+
679
+ // 메인 루프
680
+ while (true) {
681
+ const input = await prompt();
682
+ await handleInput(input);
683
+ }
684
+ };
685
+
686
+ main().catch((e) => {
687
+ log.error(`치명적 오류: ${e.message}`);
688
+ process.exit(1);
689
+ });