viruagent 1.0.0 → 1.1.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/README.md CHANGED
@@ -28,22 +28,21 @@
28
28
  **필요한 것**: Node.js 18+, OpenAI API Key
29
29
 
30
30
  ```bash
31
- git clone https://github.com/your-username/viruagent.git
32
- cd viruagent
33
- npm install
34
- cp .env.example .env
35
- # .env 파일 편집
36
- npm start
31
+ npm install -g viruagent
32
+ viruagent
37
33
  ```
38
34
 
39
- ### 환경 변수
35
+ 또는 설치 없이 바로 실행:
40
36
 
41
- | 변수 | 필수 | 설명 |
42
- |------|------|------|
43
- | `OPENAI_API_KEY` | O | OpenAI API 키 |
44
- | `UNSPLASH_ACCESS_KEY` | X | Unsplash API 키 (없으면 이미지 삽입 건너뜀) |
37
+ ```bash
38
+ npx viruagent
39
+ ```
40
+
41
+ 최초 실행 시 OpenAI API Key를 입력하면 `~/.viruagent/config.json`에 자동 저장됩니다.
42
+ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다.
45
43
 
46
- Unsplash API 키는 [unsplash.com/developers](https://unsplash.com/developers)에서 무료로 발급받을 수 있습니다. (시간당 50회 제한)
44
+ - OpenAI API Key: [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
45
+ - Unsplash API Key (선택): [unsplash.com/developers](https://unsplash.com/developers) (시간당 50회 제한)
47
46
 
48
47
  ---
49
48
 
@@ -103,7 +102,7 @@ node src/cli-post.js --topic "주제" --draft
103
102
 
104
103
  ## Unsplash 이미지 연동
105
104
 
106
- 글 생성 시 본문에 `<!-- IMAGE: keyword -->` 플레이스홀더가 자동 삽입됩니다. `UNSPLASH_ACCESS_KEY`가 설정되어 있으면:
105
+ 글 생성 시 본문에 `<!-- IMAGE: keyword -->` 플레이스홀더가 자동 삽입됩니다. Unsplash API Key가 설정되어 있으면 (`/set api`):
107
106
 
108
107
  1. 키워드로 Unsplash에서 이미지 검색
109
108
  2. 이미지 다운로드 → 티스토리에 업로드
package/package.json CHANGED
@@ -1,21 +1,27 @@
1
1
  {
2
2
  "name": "viruagent",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "AI 기반 티스토리 블로그 자동 발행 CLI 도구",
5
5
  "main": "src/agent.js",
6
6
  "bin": {
7
- "viruagent": "./bin/viruagent.js"
7
+ "viruagent": "bin/viruagent.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node --no-deprecation src/agent.js",
11
11
  "test": "echo \"Error: no test specified\" && exit 1"
12
12
  },
13
- "keywords": ["tistory", "blog", "openai", "cli", "ai"],
13
+ "keywords": [
14
+ "tistory",
15
+ "blog",
16
+ "openai",
17
+ "cli",
18
+ "ai"
19
+ ],
14
20
  "author": "tkman",
15
21
  "license": "MIT",
16
22
  "repository": {
17
23
  "type": "git",
18
- "url": "https://github.com/greekr4/Viruagent.git"
24
+ "url": "git+https://github.com/greekr4/Viruagent.git"
19
25
  },
20
26
  "homepage": "https://github.com/greekr4/Viruagent#readme",
21
27
  "engines": {
@@ -24,7 +30,6 @@
24
30
  "type": "commonjs",
25
31
  "dependencies": {
26
32
  "chalk": "^4.1.2",
27
- "dotenv": "^16.4.7",
28
33
  "oh-my-logo": "^0.4.0",
29
34
  "openai": "^4.77.0",
30
35
  "playwright": "^1.58.2"
package/src/agent.js CHANGED
@@ -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
 
@@ -177,7 +187,7 @@ const COMMANDS = [
177
187
  '/help',
178
188
  '/exit',
179
189
  ];
180
- const SET_KEYS = ['category', 'visibility', 'model', 'tone'];
190
+ const SET_KEYS = ['category', 'visibility', 'model', 'tone', 'api'];
181
191
 
182
192
  const completer = (line) => {
183
193
  // /set model <값> 자동완성
@@ -234,7 +244,7 @@ const COMMAND_HINTS = {
234
244
  '/draft': '/draft',
235
245
  '/list': '/list',
236
246
  '/categories': '/categories',
237
- '/set': '/set <category|visibility|model|tone>',
247
+ '/set': '/set <category|visibility|model|tone|api>',
238
248
  '/login': '/login',
239
249
  '/logout': '/logout',
240
250
  '/help': '/help',
@@ -517,8 +527,53 @@ const commands = {
517
527
  log.dim('취소됨');
518
528
  }
519
529
  }
530
+ } else if (key === 'api') {
531
+ const keys = loadApiKeys();
532
+ const mask = (v) => v ? `${v.slice(0, 8)}${'*'.repeat(8)}` : chalk.dim('(미설정)');
533
+
534
+ console.log();
535
+ log.title('API Key 설정');
536
+ console.log(` OpenAI: ${mask(keys.OPENAI_API_KEY)}`);
537
+ console.log(` Unsplash: ${mask(keys.UNSPLASH_ACCESS_KEY)}`);
538
+ console.log();
539
+
540
+ rl.pause();
541
+ const apiOptions = ['OpenAI API Key', 'Unsplash Access Key', '취소'];
542
+ const idx = await selectMenu(apiOptions, 'API Key 선택 (↑↓ 이동, Enter 선택, Esc 취소)');
543
+ rl.resume();
544
+
545
+ if (idx === 0) {
546
+ rl.pause();
547
+ const newKey = await askQuestion(chalk.cyan(' 새 OpenAI API Key: '));
548
+ rl.resume();
549
+ if (newKey) {
550
+ saveApiKeys({ OPENAI_API_KEY: newKey });
551
+ log.success('OpenAI API Key가 저장되었습니다.');
552
+ log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
553
+ } else {
554
+ log.dim('변경 없음');
555
+ }
556
+ } else if (idx === 1) {
557
+ rl.pause();
558
+ const newKey = await askQuestion(chalk.cyan(' 새 Unsplash Access Key (삭제하려면 빈 값): '));
559
+ rl.resume();
560
+ if (newKey) {
561
+ saveApiKeys({ UNSPLASH_ACCESS_KEY: newKey });
562
+ log.success('Unsplash Access Key가 저장되었습니다.');
563
+ log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
564
+ } else if (keys.UNSPLASH_ACCESS_KEY) {
565
+ saveApiKeys({ UNSPLASH_ACCESS_KEY: '' });
566
+ log.success('Unsplash Access Key가 삭제되었습니다.');
567
+ log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
568
+ } else {
569
+ log.dim('변경 없음');
570
+ }
571
+ } else {
572
+ log.dim('취소됨');
573
+ }
574
+ return;
520
575
  } else {
521
- return log.warn('사용법: /set category | /set visibility | /set model | /set tone');
576
+ return log.warn('사용법: /set category | /set visibility | /set model | /set tone | /set api');
522
577
  }
523
578
  saveSettings();
524
579
  },
@@ -538,6 +593,7 @@ ${chalk.cyan('/set category')} 카테고리 설정
538
593
  ${chalk.cyan('/set visibility')} 공개설정
539
594
  ${chalk.cyan('/set model')} AI 모델 선택
540
595
  ${chalk.cyan('/set tone')} 글쓰기 톤 설정
596
+ ${chalk.cyan('/set api')} API Key 관리 (OpenAI, Unsplash)
541
597
  ${chalk.cyan('/login')} 티스토리 로그인
542
598
  ${chalk.cyan('/logout')} 로그아웃 (세션 삭제)
543
599
  ${chalk.cyan('/help')} 도움말
@@ -613,9 +669,102 @@ const handleInput = async (input) => {
613
669
  }
614
670
  };
615
671
 
672
+ const askQuestion = (query) =>
673
+ new Promise((resolve) => {
674
+ const tmpRl = readline.createInterface({ input: process.stdin, output: process.stdout });
675
+ tmpRl.question(query, (answer) => {
676
+ tmpRl.close();
677
+ resolve(answer.trim());
678
+ });
679
+ });
680
+
681
+ const loadApiKeys = () => {
682
+ const settings = loadSettings();
683
+ return {
684
+ OPENAI_API_KEY: settings.openaiApiKey || '',
685
+ UNSPLASH_ACCESS_KEY: settings.unsplashAccessKey || '',
686
+ };
687
+ };
688
+
689
+ const saveApiKeys = (keys) => {
690
+ ensureConfigDir();
691
+ const current = loadSettings();
692
+ if (keys.OPENAI_API_KEY !== undefined) current.openaiApiKey = keys.OPENAI_API_KEY || undefined;
693
+ if (keys.UNSPLASH_ACCESS_KEY !== undefined) current.unsplashAccessKey = keys.UNSPLASH_ACCESS_KEY || undefined;
694
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(current, null, 2));
695
+ // 환경 변수에 반영
696
+ if (keys.OPENAI_API_KEY) process.env.OPENAI_API_KEY = keys.OPENAI_API_KEY;
697
+ if (keys.UNSPLASH_ACCESS_KEY) process.env.UNSPLASH_ACCESS_KEY = keys.UNSPLASH_ACCESS_KEY;
698
+ };
699
+
700
+ const applyApiKeys = () => {
701
+ const keys = loadApiKeys();
702
+ if (keys.OPENAI_API_KEY) process.env.OPENAI_API_KEY = keys.OPENAI_API_KEY;
703
+ if (keys.UNSPLASH_ACCESS_KEY) process.env.UNSPLASH_ACCESS_KEY = keys.UNSPLASH_ACCESS_KEY;
704
+ };
705
+
706
+ const setupEnv = async () => {
707
+ applyApiKeys();
708
+ if (process.env.OPENAI_API_KEY) return;
709
+
710
+ console.log();
711
+ log.title('━━━ 초기 설정 ━━━');
712
+ console.log();
713
+ log.info('OpenAI API Key가 설정되지 않았습니다.\n');
714
+ log.dim(' https://platform.openai.com/api-keys 에서 발급받을 수 있습니다.\n');
715
+
716
+ const openaiKey = await askQuestion(chalk.cyan(' OpenAI API Key: '));
717
+ if (!openaiKey) {
718
+ log.error('OpenAI API Key는 필수입니다. 프로그램을 종료합니다.');
719
+ process.exit(1);
720
+ }
721
+
722
+ // OpenAI Key 검증
723
+ try {
724
+ const res = await fetch('https://api.openai.com/v1/models', {
725
+ headers: { Authorization: `Bearer ${openaiKey}` },
726
+ });
727
+ if (!res.ok) {
728
+ log.error('OpenAI API Key가 유효하지 않습니다. 키를 확인 후 다시 시도하세요.');
729
+ process.exit(1);
730
+ }
731
+ log.success('OpenAI API Key 확인 완료!');
732
+ } catch {
733
+ log.error('OpenAI 서버에 연결할 수 없습니다. 네트워크를 확인하세요.');
734
+ process.exit(1);
735
+ }
736
+
737
+ const unsplashKey = await askQuestion(chalk.cyan(' Unsplash Access Key (선택, Enter로 건너뛰기): '));
738
+
739
+ if (unsplashKey) {
740
+ try {
741
+ const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
742
+ headers: { Authorization: `Client-ID ${unsplashKey}` },
743
+ });
744
+ if (!res.ok) {
745
+ log.warn('Unsplash Key가 유효하지 않습니다. 건너뜁니다.');
746
+ } else {
747
+ log.success('Unsplash Access Key 확인 완료!');
748
+ }
749
+ } catch {
750
+ log.warn('Unsplash 서버에 연결할 수 없습니다. 건너뜁니다.');
751
+ }
752
+ }
753
+
754
+ saveApiKeys({
755
+ OPENAI_API_KEY: openaiKey,
756
+ ...(unsplashKey && { UNSPLASH_ACCESS_KEY: unsplashKey }),
757
+ });
758
+ log.success(`설정이 저장되었습니다! (${CONFIG_PATH})\n`);
759
+ if (!unsplashKey) log.dim(' Unsplash는 나중에 /set api 에서 설정할 수 있습니다.\n');
760
+ };
761
+
616
762
  const main = async () => {
617
763
  await animateBanner();
618
764
 
765
+ // API Key 없으면 초기 설정
766
+ await setupEnv();
767
+
619
768
  // 세션 체크 — 로그인 필수
620
769
  while (!fs.existsSync(path.join(__dirname, '..', 'data', 'session.json'))) {
621
770
  log.warn('세션 파일(session.json)이 없습니다. 티스토리 로그인이 필요합니다.');
@@ -633,28 +782,36 @@ const main = async () => {
633
782
  // 부팅 시퀀스
634
783
  console.log(chalk.dim(' 시스템 초기화 중...\n'));
635
784
 
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) {
785
+ try {
648
786
  await showBootStep(
649
- 'Unsplash 연결',
787
+ 'OpenAI 연결',
650
788
  async () => {
651
- const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
652
- headers: { Authorization: `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}` },
789
+ const res = await fetch('https://api.openai.com/v1/models', {
790
+ headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
653
791
  });
654
792
  if (!res.ok) throw new Error('키가 유효하지 않습니다');
655
793
  },
656
794
  1000,
657
795
  );
796
+ } catch (e) {
797
+ log.warn(` OpenAI 연결 실패: ${e.message}\n ${chalk.dim('/set api 명령어로 키를 확인하세요.')}`);
798
+ }
799
+
800
+ if (process.env.UNSPLASH_ACCESS_KEY) {
801
+ try {
802
+ await showBootStep(
803
+ 'Unsplash 연결',
804
+ async () => {
805
+ const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
806
+ headers: { Authorization: `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}` },
807
+ });
808
+ if (!res.ok) throw new Error('키가 유효하지 않습니다');
809
+ },
810
+ 1000,
811
+ );
812
+ } catch (e) {
813
+ log.warn(` Unsplash 연결 실패: ${e.message}\n ${chalk.dim('/set api 명령어로 키를 확인하세요.')}`);
814
+ }
658
815
  }
659
816
 
660
817
  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');
package/src/lib/ai.js CHANGED
@@ -1,17 +1,16 @@
1
1
  const OpenAI = require('openai');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') });
5
-
6
- if (!process.env.OPENAI_API_KEY) {
7
- console.error('\x1b[31m✗ OPENAI_API_KEY 설정되지 않았습니다.\x1b[0m');
8
- console.error('\x1b[33m 1. 프로젝트 루트에 .env 파일을 생성하세요');
9
- console.error(' 2. OPENAI_API_KEY=sk-... 형식으로 키를 입력하세요');
10
- console.error(' 3. https://platform.openai.com/api-keys 에서 키를 발급받을 있습니다\x1b[0m');
11
- process.exit(1);
12
- }
13
-
14
- const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
4
+ let client;
5
+ const getClient = () => {
6
+ if (!client) {
7
+ if (!process.env.OPENAI_API_KEY) {
8
+ throw new Error('OPENAI_API_KEY가 설정되지 않았습니다. /set api 로 키를 설정하세요.');
9
+ }
10
+ client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
11
+ }
12
+ return client;
13
+ };
15
14
 
16
15
  const { replaceImagePlaceholders } = require('./unsplash');
17
16
  const { createLogger } = require('./logger');
@@ -19,7 +18,7 @@ const aiLog = createLogger('ai');
19
18
 
20
19
  const handleApiError = (e) => {
21
20
  if (e?.status === 401 || e?.code === 'invalid_api_key') {
22
- throw new Error('OpenAI API 키가 유효하지 않습니다. .env 파일의 OPENAI_API_KEY를 확인하세요.');
21
+ throw new Error('OpenAI API 키가 유효하지 않습니다. /set api 키를 확인하세요.');
23
22
  }
24
23
  if (e?.status === 429) {
25
24
  throw new Error('API 요청 한도를 초과했습니다. 잠시 후 다시 시도하거나 요금제를 확인하세요.');
@@ -77,7 +76,7 @@ const generatePost = async (topic, options = {}) => {
77
76
 
78
77
  let res;
79
78
  try {
80
- res = await client.chat.completions.create({
79
+ res = await getClient().chat.completions.create({
81
80
  model,
82
81
  messages: [
83
82
  { role: 'system', content: config.systemPrompt },
@@ -136,7 +135,7 @@ const revisePost = async (content, instruction, model) => {
136
135
 
137
136
  let res;
138
137
  try {
139
- res = await client.chat.completions.create({
138
+ res = await getClient().chat.completions.create({
140
139
  model,
141
140
  messages: [
142
141
  { role: 'system', content: config.systemPrompt },
@@ -174,7 +173,7 @@ const chat = async (messages, model) => {
174
173
 
175
174
  let res;
176
175
  try {
177
- res = await client.chat.completions.create({
176
+ res = await getClient().chat.completions.create({
178
177
  model,
179
178
  messages: [
180
179
  { role: 'system', content: '당신은 블로그 글쓰기를 돕는 AI 어시스턴트입니다. 주제 논의, 아이디어 브레인스토밍, 글 구조 제안 등을 도와줍니다. 한국어로 대화하세요.' },
@@ -1,9 +1,7 @@
1
- const path = require('path');
2
- require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') });
3
1
  const { createLogger } = require('./logger');
4
2
  const log = createLogger('unsplash');
5
3
 
6
- const UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY;
4
+ const getUnsplashKey = () => process.env.UNSPLASH_ACCESS_KEY;
7
5
 
8
6
  /**
9
7
  * Unsplash에서 키워드로 이미지 검색
@@ -11,12 +9,12 @@ const UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY;
11
9
  * @returns {Promise<Object|null>} { url, alt, credit, link } 또는 null
12
10
  */
13
11
  const searchImage = async (keyword) => {
14
- if (!UNSPLASH_ACCESS_KEY) return null;
12
+ if (!getUnsplashKey()) return null;
15
13
 
16
14
  try {
17
15
  const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(keyword)}&per_page=1&orientation=landscape`;
18
16
  const res = await fetch(url, {
19
- headers: { Authorization: `Client-ID ${UNSPLASH_ACCESS_KEY}` },
17
+ headers: { Authorization: `Client-ID ${getUnsplashKey()}` },
20
18
  });
21
19
 
22
20
  if (!res.ok) {
@@ -75,8 +73,8 @@ const replaceImagePlaceholders = async (html, options = {}) => {
75
73
  let thumbnailUrl = null;
76
74
  let thumbnailKage = null;
77
75
 
78
- if (!UNSPLASH_ACCESS_KEY) {
79
- log.warn('UNSPLASH_ACCESS_KEY 미설정 — 이미지 처리 건너뜀');
76
+ if (!getUnsplashKey()) {
77
+ log.warn('getUnsplashKey() 미설정 — 이미지 처리 건너뜀');
80
78
  return { html, thumbnailUrl, thumbnailKage };
81
79
  }
82
80
 
package/.env.example DELETED
@@ -1,2 +0,0 @@
1
- OPENAI_API_KEY=sk-your-api-key-here
2
- UNSPLASH_ACCESS_KEY=