viruagent 1.0.1 → 1.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  const readline = require('readline');
2
2
  const chalk = require('chalk');
3
- const { generatePost, revisePost, chat, MODELS, loadConfig } = require('./lib/ai');
3
+ const { generatePost, revisePost, chat, runAgent, MODELS, loadConfig } = require('./lib/ai');
4
4
  const { initBlog, getBlogName, publishPost, saveDraft, getPosts, getCategories, VISIBILITY } = require('./lib/tistory');
5
5
 
6
6
  const TONES = loadConfig().tones.map((t) => t.name);
@@ -63,23 +63,32 @@ const selectMenu = (items, title = '선택하세요') =>
63
63
  // 설정 저장/로드
64
64
  const fs = require('fs');
65
65
  const path = require('path');
66
- const SETTINGS_PATH = path.join(__dirname, '..', 'config', 'settings.json');
66
+ const os = require('os');
67
+ const CONFIG_DIR = path.join(os.homedir(), '.viruagent');
68
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
67
69
 
68
70
  const DEFAULTS = { category: 0, visibility: 20, model: 'gpt-4o-mini', tone: loadConfig().defaultTone };
69
71
 
70
72
  const visLabel = (v) => (v === 20 ? '공개 발행' : v === 15 ? '보호 발행' : '비공개 발행');
71
73
 
74
+ const ensureConfigDir = () => {
75
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
76
+ };
77
+
72
78
  const loadSettings = () => {
73
79
  try {
74
- return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) };
80
+ return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) };
75
81
  } catch {
76
82
  return { ...DEFAULTS };
77
83
  }
78
84
  };
79
85
 
80
86
  const saveSettings = () => {
87
+ ensureConfigDir();
88
+ const current = loadSettings();
81
89
  const { category, visibility, model, tone } = state;
82
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify({ category, visibility, model, tone }, null, 2));
90
+ const merged = { ...current, category, visibility, model, tone };
91
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2));
83
92
  };
84
93
 
85
94
  const saved = loadSettings();
@@ -115,7 +124,8 @@ const animateBanner = async () => {
115
124
  const text = figlet.textSync('ViruAgent', { font: 'ANSI Shadow' });
116
125
  const lines = text.split('\n');
117
126
  const totalLines = lines.length;
118
- const sub = ' 대화형 티스토리 블로그 에이전트 v1.0';
127
+ const { version } = require('../package.json');
128
+ const sub = ` 대화형 티스토리 블로그 에이전트 v${version}`;
119
129
 
120
130
  console.log();
121
131
 
@@ -152,8 +162,10 @@ const showBootStep = async (msg, asyncFn, minMs = 800) => {
152
162
 
153
163
  const withSpinner = async (message, asyncFn) => {
154
164
  let i = 0;
165
+ const cols = process.stdout.columns || 80;
166
+ const truncated = message.length + 2 > cols ? message.slice(0, cols - 5) + '...' : message;
155
167
  const timer = setInterval(() => {
156
- process.stdout.write(`\r${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${message}`);
168
+ process.stdout.write(`\r\x1B[K${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${truncated}`);
157
169
  }, 80);
158
170
  try {
159
171
  return await asyncFn();
@@ -177,7 +189,7 @@ const COMMANDS = [
177
189
  '/help',
178
190
  '/exit',
179
191
  ];
180
- const SET_KEYS = ['category', 'visibility', 'model', 'tone'];
192
+ const SET_KEYS = ['category', 'visibility', 'model', 'tone', 'api'];
181
193
 
182
194
  const completer = (line) => {
183
195
  // /set model <값> 자동완성
@@ -234,7 +246,7 @@ const COMMAND_HINTS = {
234
246
  '/draft': '/draft',
235
247
  '/list': '/list',
236
248
  '/categories': '/categories',
237
- '/set': '/set <category|visibility|model|tone>',
249
+ '/set': '/set <category|visibility|model|tone|api>',
238
250
  '/login': '/login',
239
251
  '/logout': '/logout',
240
252
  '/help': '/help',
@@ -517,8 +529,53 @@ const commands = {
517
529
  log.dim('취소됨');
518
530
  }
519
531
  }
532
+ } else if (key === 'api') {
533
+ const keys = loadApiKeys();
534
+ const mask = (v) => v ? `${v.slice(0, 8)}${'*'.repeat(8)}` : chalk.dim('(미설정)');
535
+
536
+ console.log();
537
+ log.title('API Key 설정');
538
+ console.log(` OpenAI: ${mask(keys.OPENAI_API_KEY)}`);
539
+ console.log(` Unsplash: ${mask(keys.UNSPLASH_ACCESS_KEY)}`);
540
+ console.log();
541
+
542
+ rl.pause();
543
+ const apiOptions = ['OpenAI API Key', 'Unsplash Access Key', '취소'];
544
+ const idx = await selectMenu(apiOptions, 'API Key 선택 (↑↓ 이동, Enter 선택, Esc 취소)');
545
+ rl.resume();
546
+
547
+ if (idx === 0) {
548
+ rl.pause();
549
+ const newKey = await askQuestion(chalk.cyan(' 새 OpenAI API Key: '));
550
+ rl.resume();
551
+ if (newKey) {
552
+ saveApiKeys({ OPENAI_API_KEY: newKey });
553
+ log.success('OpenAI API Key가 저장되었습니다.');
554
+ log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
555
+ } else {
556
+ log.dim('변경 없음');
557
+ }
558
+ } else if (idx === 1) {
559
+ rl.pause();
560
+ const newKey = await askQuestion(chalk.cyan(' 새 Unsplash Access Key (삭제하려면 빈 값): '));
561
+ rl.resume();
562
+ if (newKey) {
563
+ saveApiKeys({ UNSPLASH_ACCESS_KEY: newKey });
564
+ log.success('Unsplash Access Key가 저장되었습니다.');
565
+ log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
566
+ } else if (keys.UNSPLASH_ACCESS_KEY) {
567
+ saveApiKeys({ UNSPLASH_ACCESS_KEY: '' });
568
+ log.success('Unsplash Access Key가 삭제되었습니다.');
569
+ log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
570
+ } else {
571
+ log.dim('변경 없음');
572
+ }
573
+ } else {
574
+ log.dim('취소됨');
575
+ }
576
+ return;
520
577
  } else {
521
- return log.warn('사용법: /set category | /set visibility | /set model | /set tone');
578
+ return log.warn('사용법: /set category | /set visibility | /set model | /set tone | /set api');
522
579
  }
523
580
  saveSettings();
524
581
  },
@@ -538,12 +595,14 @@ ${chalk.cyan('/set category')} 카테고리 설정
538
595
  ${chalk.cyan('/set visibility')} 공개설정
539
596
  ${chalk.cyan('/set model')} AI 모델 선택
540
597
  ${chalk.cyan('/set tone')} 글쓰기 톤 설정
598
+ ${chalk.cyan('/set api')} API Key 관리 (OpenAI, Unsplash)
541
599
  ${chalk.cyan('/login')} 티스토리 로그인
542
600
  ${chalk.cyan('/logout')} 로그아웃 (세션 삭제)
543
601
  ${chalk.cyan('/help')} 도움말
544
602
  ${chalk.cyan('/exit')} 종료
545
603
 
546
- 슬래시 없이 입력하면 AI 자유 대화 (주제 논의, 아이디어 등)
604
+ 슬래시 없이 자연어로 입력하면 AI 자율적으로 도구를 호출합니다.
605
+ ${chalk.dim('예: "AI 트렌드로 글 써줘", "서론을 더 흥미롭게 수정해줘", "발행해줘"')}
547
606
  `);
548
607
  },
549
608
 
@@ -601,12 +660,57 @@ const handleInput = async (input) => {
601
660
  log.warn(`알 수 없는 명령어: /${cmd}. /help로 확인하세요.`);
602
661
  }
603
662
  } else {
604
- // 자유 대화
605
- state.chatHistory.push({ role: 'user', content: trimmed });
663
+ // 에이전트 루프 (자연어 → 자율 도구 호출)
606
664
  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`);
665
+ // Braille 도트 애니메이션 (텍스트 없이)
666
+ const DOT_FRAMES = ['', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
667
+ const DOT_COLORS = [chalk.cyan, chalk.blue, chalk.magenta, chalk.blue];
668
+ let spinnerTimer = null;
669
+ let spinnerIdx = 0;
670
+
671
+ const startSpinner = () => {
672
+ stopSpinner();
673
+ spinnerTimer = setInterval(() => {
674
+ const colorFn = DOT_COLORS[Math.floor(spinnerIdx / 3) % DOT_COLORS.length];
675
+ process.stdout.write(`\r\x1B[K ${colorFn(DOT_FRAMES[spinnerIdx++ % DOT_FRAMES.length])}`);
676
+ }, 80);
677
+ };
678
+
679
+ const stopSpinner = () => {
680
+ if (spinnerTimer) {
681
+ clearInterval(spinnerTimer);
682
+ spinnerTimer = null;
683
+ process.stdout.write('\r\x1B[K');
684
+ }
685
+ };
686
+
687
+ startSpinner();
688
+
689
+ const reply = await runAgent(trimmed, {
690
+ state,
691
+ publishPost: publishPost,
692
+ onToolCall: () => {},
693
+ onToolResult: (name, result) => {
694
+ stopSpinner();
695
+ if (result?.error) {
696
+ log.warn(result.error);
697
+ } else if (name === 'generate_post' && result?.title) {
698
+ log.success(`글 생성 완료: "${result.title}"`);
699
+ } else if (name === 'edit_post' && result?.title) {
700
+ log.success(`수정 완료: "${result.title}"`);
701
+ } else if (name === 'publish_post' && result?.url) {
702
+ log.success(`발행 완료! ${result.url}`);
703
+ } else if (name === 'set_category' && result?.category) {
704
+ log.success(`카테고리 설정: ${result.category}`);
705
+ } else if (name === 'set_visibility' && result?.visibility) {
706
+ log.success(`공개설정: ${result.visibility}`);
707
+ }
708
+ startSpinner();
709
+ },
710
+ });
711
+
712
+ stopSpinner();
713
+ console.log(`\n${chalk.blue('AI')}\n${reply}\n`);
610
714
  } catch (e) {
611
715
  log.error(`대화 실패: ${e.message}`);
612
716
  }
@@ -622,37 +726,91 @@ const askQuestion = (query) =>
622
726
  });
623
727
  });
624
728
 
729
+ const loadApiKeys = () => {
730
+ const settings = loadSettings();
731
+ return {
732
+ OPENAI_API_KEY: settings.openaiApiKey || '',
733
+ UNSPLASH_ACCESS_KEY: settings.unsplashAccessKey || '',
734
+ };
735
+ };
736
+
737
+ const saveApiKeys = (keys) => {
738
+ ensureConfigDir();
739
+ const current = loadSettings();
740
+ if (keys.OPENAI_API_KEY !== undefined) current.openaiApiKey = keys.OPENAI_API_KEY || undefined;
741
+ if (keys.UNSPLASH_ACCESS_KEY !== undefined) current.unsplashAccessKey = keys.UNSPLASH_ACCESS_KEY || undefined;
742
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(current, null, 2));
743
+ // 환경 변수에 반영
744
+ if (keys.OPENAI_API_KEY) process.env.OPENAI_API_KEY = keys.OPENAI_API_KEY;
745
+ if (keys.UNSPLASH_ACCESS_KEY) process.env.UNSPLASH_ACCESS_KEY = keys.UNSPLASH_ACCESS_KEY;
746
+ };
747
+
748
+ const applyApiKeys = () => {
749
+ const keys = loadApiKeys();
750
+ if (keys.OPENAI_API_KEY) process.env.OPENAI_API_KEY = keys.OPENAI_API_KEY;
751
+ if (keys.UNSPLASH_ACCESS_KEY) process.env.UNSPLASH_ACCESS_KEY = keys.UNSPLASH_ACCESS_KEY;
752
+ };
753
+
625
754
  const setupEnv = async () => {
626
- const envPath = path.join(__dirname, '..', '.env');
627
- if (fs.existsSync(envPath)) return;
755
+ applyApiKeys();
756
+ if (process.env.OPENAI_API_KEY) return;
628
757
 
629
758
  console.log();
630
759
  log.title('━━━ 초기 설정 ━━━');
631
760
  console.log();
632
- log.info('.env 파일이 없습니다. API 키를 설정합니다.\n');
761
+ log.info('OpenAI API Key가 설정되지 않았습니다.\n');
762
+ log.dim(' https://platform.openai.com/api-keys 에서 발급받을 수 있습니다.\n');
633
763
 
634
- const openaiKey = await askQuestion(chalk.cyan(' OpenAI API Key (필수): '));
764
+ const openaiKey = await askQuestion(chalk.cyan(' OpenAI API Key: '));
635
765
  if (!openaiKey) {
636
766
  log.error('OpenAI API Key는 필수입니다. 프로그램을 종료합니다.');
637
767
  process.exit(1);
638
768
  }
639
769
 
640
- const unsplashKey = await askQuestion(chalk.cyan(' Unsplash Access Key (선택, Enter로 건너뛰기): '));
770
+ // OpenAI Key 검증
771
+ try {
772
+ const res = await fetch('https://api.openai.com/v1/models', {
773
+ headers: { Authorization: `Bearer ${openaiKey}` },
774
+ });
775
+ if (!res.ok) {
776
+ log.error('OpenAI API Key가 유효하지 않습니다. 키를 확인 후 다시 시도하세요.');
777
+ process.exit(1);
778
+ }
779
+ log.success('OpenAI API Key 확인 완료!');
780
+ } catch {
781
+ log.error('OpenAI 서버에 연결할 수 없습니다. 네트워크를 확인하세요.');
782
+ process.exit(1);
783
+ }
641
784
 
642
- const lines = [`OPENAI_API_KEY=${openaiKey}`];
643
- if (unsplashKey) lines.push(`UNSPLASH_ACCESS_KEY=${unsplashKey}`);
785
+ const unsplashKey = await askQuestion(chalk.cyan(' Unsplash Access Key (선택, Enter로 건너뛰기): '));
644
786
 
645
- fs.writeFileSync(envPath, lines.join('\n') + '\n');
646
- log.success('.env 파일이 생성되었습니다!\n');
787
+ if (unsplashKey) {
788
+ try {
789
+ const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
790
+ headers: { Authorization: `Client-ID ${unsplashKey}` },
791
+ });
792
+ if (!res.ok) {
793
+ log.warn('Unsplash Key가 유효하지 않습니다. 건너뜁니다.');
794
+ } else {
795
+ log.success('Unsplash Access Key 확인 완료!');
796
+ }
797
+ } catch {
798
+ log.warn('Unsplash 서버에 연결할 수 없습니다. 건너뜁니다.');
799
+ }
800
+ }
647
801
 
648
- // 생성된 .env 즉시 로드
649
- require('dotenv').config({ path: envPath });
802
+ saveApiKeys({
803
+ OPENAI_API_KEY: openaiKey,
804
+ ...(unsplashKey && { UNSPLASH_ACCESS_KEY: unsplashKey }),
805
+ });
806
+ log.success(`설정이 저장되었습니다! (${CONFIG_PATH})\n`);
807
+ if (!unsplashKey) log.dim(' Unsplash는 나중에 /set api 에서 설정할 수 있습니다.\n');
650
808
  };
651
809
 
652
810
  const main = async () => {
653
811
  await animateBanner();
654
812
 
655
- // .env 파일 없으면 초기 설정
813
+ // API Key 없으면 초기 설정
656
814
  await setupEnv();
657
815
 
658
816
  // 세션 체크 — 로그인 필수
@@ -672,28 +830,36 @@ const main = async () => {
672
830
  // 부팅 시퀀스
673
831
  console.log(chalk.dim(' 시스템 초기화 중...\n'));
674
832
 
675
- await showBootStep(
676
- 'OpenAI 연결',
677
- async () => {
678
- const res = await fetch('https://api.openai.com/v1/models', {
679
- headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
680
- });
681
- if (!res.ok) throw new Error('키가 유효하지 않습니다');
682
- },
683
- 1000,
684
- );
685
-
686
- if (process.env.UNSPLASH_ACCESS_KEY) {
833
+ try {
687
834
  await showBootStep(
688
- 'Unsplash 연결',
835
+ 'OpenAI 연결',
689
836
  async () => {
690
- const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
691
- headers: { Authorization: `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}` },
837
+ const res = await fetch('https://api.openai.com/v1/models', {
838
+ headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
692
839
  });
693
840
  if (!res.ok) throw new Error('키가 유효하지 않습니다');
694
841
  },
695
842
  1000,
696
843
  );
844
+ } catch (e) {
845
+ log.warn(` OpenAI 연결 실패: ${e.message}\n ${chalk.dim('/set api 명령어로 키를 확인하세요.')}`);
846
+ }
847
+
848
+ if (process.env.UNSPLASH_ACCESS_KEY) {
849
+ try {
850
+ await showBootStep(
851
+ 'Unsplash 연결',
852
+ async () => {
853
+ const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
854
+ headers: { Authorization: `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}` },
855
+ });
856
+ if (!res.ok) throw new Error('키가 유효하지 않습니다');
857
+ },
858
+ 1000,
859
+ );
860
+ } catch (e) {
861
+ log.warn(` Unsplash 연결 실패: ${e.message}\n ${chalk.dim('/set api 명령어로 키를 확인하세요.')}`);
862
+ }
697
863
  }
698
864
 
699
865
  let blogOk = false;
package/src/cli-post.js CHANGED
@@ -1,7 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const path = require('path');
4
- require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+
7
+ // ~/.viruagent/config.json에서 API 키 로드
8
+ const configPath = path.join(os.homedir(), '.viruagent', 'config.json');
9
+ try {
10
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
11
+ if (config.openaiApiKey) process.env.OPENAI_API_KEY = config.openaiApiKey;
12
+ if (config.unsplashAccessKey) process.env.UNSPLASH_ACCESS_KEY = config.unsplashAccessKey;
13
+ } catch {}
14
+
5
15
 
6
16
  const { generatePost, loadConfig } = require('./lib/ai');
7
17
  const { initBlog, publishPost, saveDraft, getCategories, VISIBILITY } = require('./lib/tistory');