viruagent-cli 0.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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # viruagent-cli
2
+
3
+ **AI 에이전트가 블로그를 쓰고, 태그를 만들고, 발행까지 자동으로 처리하는 CLI 도구**
4
+
5
+ 사람이 아닌 **AI 에이전트를 위해** 설계되었습니다.
6
+
7
+ ## 설치
8
+
9
+ `install.md` 파일의 내용을 AI 에이전트에게 그대로 복사해서 보여주세요. 나머지는 에이전트가 알아서 합니다.
10
+
11
+ ## 사용법
12
+
13
+ 설치가 끝나면 에이전트에게 자연어로 요청하세요.
14
+
15
+ ```
16
+ "티스토리에 블로그 글 써줘"
17
+ ```
18
+
19
+ 그러면 에이전트가 로그인 확인 → 카테고리 조회 → 글 작성 → 태그 생성 → 검증 → 발행까지 알아서 처리합니다.
20
+
21
+ ## 지원 도구
22
+
23
+ | 도구 | 상태 |
24
+ |---|---|
25
+ | Claude Code | 지원 |
26
+ | bash 실행 가능한 모든 AI 에이전트 | 지원 |
27
+
28
+ ## 지원 플랫폼
29
+
30
+ | 플랫폼 | 상태 |
31
+ |---|---|
32
+ | Tistory | 지원 |
33
+ | Naver | 예정 |
34
+
35
+ ## 왜 MCP가 아니라 CLI인가?
36
+
37
+ | | MCP | CLI |
38
+ |---|---|---|
39
+ | 설정 | 복잡 (서버 실행, 클라이언트 연결) | `npx` 한 줄 |
40
+ | 호환성 | 특정 클라이언트 종속 | bash 실행 가능한 모든 에이전트 |
41
+ | 확장성 | 프로토콜 제약 | 자유로운 파이프라인 |
42
+
43
+ ## 글 커스터마이징
44
+
45
+ 스킬 파일(`~/.claude/commands/viruagent.md`)을 직접 수정하면 글 구조, 길이, 스타일을 변경할 수 있습니다.
46
+
47
+ | 항목 | 수정 위치 | 예시 |
48
+ |---|---|---|
49
+ | 글 길이 | `Length` 값 | `500~800` (짧은 글), `3000~4000` (긴 글) |
50
+ | 톤 | `Tone` 값 | `Formal`, `Casual and friendly` |
51
+ | 태그 수 | `Generate exactly N tags` | 5개 → 10개 |
52
+ | 이미지 | `--image-upload-limit` | 2장 → 3장, 또는 비활성화 |
53
+ | 인용 스타일 | `blockquote data-ke-style` | `style1` (세로선), `style2` (박스), `style3` (큰따옴표) |
54
+ | 소제목 | `Do NOT use <h3>` 규칙 제거 | h2만 → h2+h3 혼용 |
55
+
56
+ 수정 후 재설치 불필요. 다음 호출부터 바로 적용됩니다.
57
+
58
+ ## 요구사항
59
+
60
+ - Node.js >= 18
61
+
62
+ ## License
63
+
64
+ MIT
package/bin/index.js ADDED
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const { runCommand } = require('../src/runner');
5
+
6
+ const VERSION = require('../package.json').version;
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name('viruagent-cli')
12
+ .description('AI-agent-optimized CLI for blog publishing')
13
+ .version(VERSION);
14
+
15
+ // --spec at root level
16
+ program
17
+ .option('--spec', 'Output full command schema as JSON');
18
+
19
+ // Global options
20
+ const addProviderOption = (cmd) =>
21
+ cmd.option('--provider <name>', 'Provider name (tistory or naver)', 'tistory');
22
+
23
+ const addDryRunOption = (cmd) =>
24
+ cmd.option('--dry-run', 'Validate params without executing', false);
25
+
26
+ // --- Commands ---
27
+
28
+ const statusCmd = program
29
+ .command('status')
30
+ .description('Check provider login status');
31
+ addProviderOption(statusCmd);
32
+ statusCmd.action((opts) => execute('status', opts));
33
+
34
+ program
35
+ .command('auth-status')
36
+ .description('Alias for status')
37
+ .copyInheritedSettings(program)
38
+ .action((opts) => execute('auth-status', { provider: 'tistory', ...opts }));
39
+ addProviderOption(program.commands[program.commands.length - 1]);
40
+
41
+ const loginCmd = program
42
+ .command('login')
43
+ .description('Authenticate with a provider');
44
+ addProviderOption(loginCmd);
45
+ addDryRunOption(loginCmd);
46
+ loginCmd
47
+ .option('--username <username>', 'Account username')
48
+ .option('--password <password>', 'Account password')
49
+ .option('--headless', 'Run browser in headless mode', false)
50
+ .option('--manual', 'Use manual login mode', false)
51
+ .option('--two-factor-code <code>', '2FA verification code')
52
+ .action((opts) => execute('login', opts));
53
+
54
+ const publishCmd = program
55
+ .command('publish')
56
+ .description('Publish a blog post');
57
+ addProviderOption(publishCmd);
58
+ addDryRunOption(publishCmd);
59
+ publishCmd
60
+ .option('--title <title>', 'Post title', '')
61
+ .option('--content <html>', 'Post content as HTML string')
62
+ .option('--content-file <path>', 'Path to HTML content file')
63
+ .option('--visibility <level>', 'Post visibility (public, private)', 'public')
64
+ .option('--category <id>', 'Category ID (integer)')
65
+ .option('--tags <tags>', 'Comma-separated tags', '')
66
+ .option('--thumbnail <url>', 'Thumbnail image URL')
67
+ .option('--related-image-keywords <keywords>', 'Comma-separated image search keywords')
68
+ .option('--image-urls <urls>', 'Comma-separated image URLs')
69
+ .option('--image-upload-limit <n>', 'Max images to upload', '1')
70
+ .option('--minimum-image-count <n>', 'Minimum required images', '1')
71
+ .option('--no-auto-upload-images', 'Disable automatic image uploading')
72
+ .option('--no-enforce-system-prompt', 'Disable system prompt enforcement')
73
+ .action((opts) => execute('publish', opts));
74
+
75
+ const saveDraftCmd = program
76
+ .command('save-draft')
77
+ .description('Save a post as draft');
78
+ addProviderOption(saveDraftCmd);
79
+ addDryRunOption(saveDraftCmd);
80
+ saveDraftCmd
81
+ .option('--title <title>', 'Post title', '')
82
+ .option('--content <html>', 'Post content as HTML string')
83
+ .option('--content-file <path>', 'Path to HTML content file')
84
+ .option('--tags <tags>', 'Comma-separated tags', '')
85
+ .option('--category <id>', 'Category ID (integer)')
86
+ .option('--related-image-keywords <keywords>', 'Comma-separated image search keywords')
87
+ .option('--image-urls <urls>', 'Comma-separated image URLs')
88
+ .option('--image-upload-limit <n>', 'Max images to upload', '1')
89
+ .option('--minimum-image-count <n>', 'Minimum required images', '1')
90
+ .option('--no-auto-upload-images', 'Disable automatic image uploading')
91
+ .option('--no-enforce-system-prompt', 'Disable system prompt enforcement')
92
+ .action((opts) => execute('save-draft', opts));
93
+
94
+ const listCategoriesCmd = program
95
+ .command('list-categories')
96
+ .description('List available categories');
97
+ addProviderOption(listCategoriesCmd);
98
+ listCategoriesCmd.action((opts) => execute('list-categories', opts));
99
+
100
+ const listPostsCmd = program
101
+ .command('list-posts')
102
+ .description('List recent posts');
103
+ addProviderOption(listPostsCmd);
104
+ listPostsCmd
105
+ .option('--limit <n>', 'Number of posts to retrieve', '20')
106
+ .action((opts) => execute('list-posts', opts));
107
+
108
+ const readPostCmd = program
109
+ .command('read-post')
110
+ .description('Read a specific post');
111
+ addProviderOption(readPostCmd);
112
+ readPostCmd
113
+ .option('--post-id <id>', 'Post ID to read')
114
+ .option('--include-draft', 'Include draft posts', false)
115
+ .argument('[postId]', 'Post ID (alternative to --post-id)')
116
+ .action((postIdArg, opts) => {
117
+ if (postIdArg && !opts.postId) opts.postId = postIdArg;
118
+ execute('read-post', opts);
119
+ });
120
+
121
+ const logoutCmd = program
122
+ .command('logout')
123
+ .description('Log out from a provider');
124
+ addProviderOption(logoutCmd);
125
+ addDryRunOption(logoutCmd);
126
+ logoutCmd.action((opts) => execute('logout', opts));
127
+
128
+ const listProvidersCmd = program
129
+ .command('list-providers')
130
+ .description('List supported providers');
131
+ listProvidersCmd.action((opts) => execute('list-providers', opts));
132
+
133
+ const installSkillCmd = program
134
+ .command('install-skill')
135
+ .description('Install viruagent skill for Claude Code / Codex')
136
+ .option('--target <dir>', 'Target directory for skill file', '')
137
+ .action((opts) => execute('install-skill', opts));
138
+
139
+ // --- Spec generation ---
140
+
141
+ function extractSpec(cmd) {
142
+ const spec = {
143
+ name: cmd.name(),
144
+ description: cmd.description(),
145
+ args: [],
146
+ options: [],
147
+ };
148
+
149
+ for (const arg of cmd.registeredArguments || []) {
150
+ spec.args.push({
151
+ name: arg.name(),
152
+ required: arg.required,
153
+ description: arg.description,
154
+ });
155
+ }
156
+
157
+ for (const opt of cmd.options) {
158
+ if (opt.long === '--version') continue;
159
+ if (opt.long === '--spec') continue;
160
+ spec.options.push({
161
+ flags: opt.flags,
162
+ description: opt.description,
163
+ required: opt.required,
164
+ default: opt.defaultValue,
165
+ });
166
+ }
167
+
168
+ return spec;
169
+ }
170
+
171
+ function generateFullSpec() {
172
+ const commands = {};
173
+ for (const cmd of program.commands) {
174
+ commands[cmd.name()] = extractSpec(cmd);
175
+ }
176
+ return {
177
+ name: program.name(),
178
+ version: VERSION,
179
+ description: program.description(),
180
+ commands,
181
+ };
182
+ }
183
+
184
+ // --- Execution ---
185
+
186
+ function output(obj, exitCode = 0) {
187
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
188
+ process.exit(exitCode);
189
+ }
190
+
191
+ async function execute(command, opts) {
192
+ try {
193
+ const result = await runCommand(command, opts);
194
+ output({ ok: true, data: result });
195
+ } catch (err) {
196
+ const errorCode = err.code || 'UNKNOWN_ERROR';
197
+ const response = {
198
+ ok: false,
199
+ error: errorCode,
200
+ message: err.message,
201
+ };
202
+ if (err.hint) response.hint = err.hint;
203
+ output(response, 1);
204
+ }
205
+ }
206
+
207
+ // Handle --spec before parse
208
+ const rawArgs = process.argv.slice(2);
209
+
210
+ if (rawArgs.includes('--spec')) {
211
+ const specIdx = rawArgs.indexOf('--spec');
212
+ const commandName = rawArgs.find((a, i) => i !== specIdx && !a.startsWith('-'));
213
+
214
+ if (commandName) {
215
+ const cmd = program.commands.find((c) => c.name() === commandName);
216
+ if (!cmd) {
217
+ output({ ok: false, error: 'UNKNOWN_COMMAND', message: `Unknown command: ${commandName}` }, 1);
218
+ } else {
219
+ output({ ok: true, data: extractSpec(cmd) });
220
+ }
221
+ } else {
222
+ output({ ok: true, data: generateFullSpec() });
223
+ }
224
+ } else {
225
+ // Suppress commander's default error output
226
+ program.exitOverride();
227
+ program.configureOutput({
228
+ writeOut: () => {},
229
+ writeErr: () => {},
230
+ });
231
+
232
+ try {
233
+ program.parse();
234
+ } catch (err) {
235
+ if (err.code === 'commander.unknownCommand') {
236
+ const unknownCmd = rawArgs.find((a) => !a.startsWith('-'));
237
+ output({
238
+ ok: false,
239
+ error: 'UNKNOWN_COMMAND',
240
+ message: `Unknown command: ${unknownCmd}`,
241
+ hint: 'viruagent-cli --spec',
242
+ }, 1);
243
+ } else if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
244
+ process.exit(0);
245
+ } else {
246
+ output({
247
+ ok: false,
248
+ error: 'INVALID_ARGS',
249
+ message: err.message,
250
+ hint: 'viruagent-cli --spec',
251
+ }, 1);
252
+ }
253
+ }
254
+
255
+ // If no command given
256
+ if (!rawArgs.length) {
257
+ output({
258
+ ok: false,
259
+ error: 'MISSING_COMMAND',
260
+ message: 'No command provided',
261
+ hint: 'viruagent-cli --spec',
262
+ }, 1);
263
+ }
264
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "viruagent-cli",
3
+ "version": "0.2.0",
4
+ "description": "AI-agent-optimized CLI for blog publishing (Tistory, Naver)",
5
+ "private": false,
6
+ "type": "commonjs",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "ai-agent",
10
+ "cli",
11
+ "blog",
12
+ "tistory",
13
+ "naver",
14
+ "publishing",
15
+ "automation",
16
+ "llm",
17
+ "ai-tools"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/greekr4/viruagent-cli.git"
22
+ },
23
+ "homepage": "https://github.com/greekr4/viruagent-cli#readme",
24
+ "bin": {
25
+ "viruagent-cli": "bin/index.js"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "src/",
30
+ "skills/",
31
+ "AGENTS.md",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "start": "node bin/index.js"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "dependencies": {
42
+ "commander": "^14.0.3",
43
+ "playwright": "^1.58.2"
44
+ }
45
+ }
@@ -0,0 +1,175 @@
1
+ ---
2
+ name: viruagent
3
+ description: Publish blog posts to Tistory (and Naver) via viruagent-cli. Handles login, content creation, tag generation, image handling, and publishing.
4
+ triggers:
5
+ - 블로그
6
+ - 티스토리
7
+ - tistory
8
+ - blog post
9
+ - 블로그 글
10
+ - 블로그 발행
11
+ - 블로그 써줘
12
+ - publish blog
13
+ - naver blog
14
+ - 네이버 블로그
15
+ ---
16
+
17
+ # viruagent — Blog Publishing Skill
18
+
19
+ You are a blog publishing agent using `viruagent-cli`. Always use `npx viruagent-cli` to execute commands.
20
+
21
+ ## Step 1: Discover CLI capabilities
22
+
23
+ ```bash
24
+ npx viruagent-cli --spec
25
+ ```
26
+
27
+ All responses are JSON: `{ "ok": true, "data": {...} }` on success, `{ "ok": false, "error": "...", "message": "...", "hint": "..." }` on failure.
28
+
29
+ ## Step 2: Check authentication
30
+
31
+ ```bash
32
+ npx viruagent-cli status --provider tistory
33
+ ```
34
+
35
+ If not logged in, authenticate:
36
+
37
+ ```bash
38
+ npx viruagent-cli login --provider tistory --username <user> --password <pass> --headless
39
+ ```
40
+
41
+ If 2FA is required (response contains `pending_2fa`), ask the user to approve the login on their mobile device (Kakao app notification), then retry the status check.
42
+
43
+ ## Step 3: Get categories (for publish)
44
+
45
+ ```bash
46
+ npx viruagent-cli list-categories --provider tistory
47
+ ```
48
+
49
+ Ask the user which category to use if not specified.
50
+
51
+ ## Step 4: Create content and tags
52
+
53
+ When the user asks to write a blog post:
54
+
55
+ 1. **Write the content** in HTML format following the structure below
56
+ 2. **Generate exactly 5 tags** relevant to the post topic, in the same language as the content
57
+ 3. **Validate with dry-run** before publishing
58
+
59
+ ### Blog Post Structure (MUST FOLLOW)
60
+
61
+ Every post must follow this structure. Write in the same language as the user's request.
62
+
63
+ ```html
64
+ <!-- 1. Hook (blockquote style2 for topic quote) -->
65
+ <blockquote data-ke-style="style2">[One impactful sentence about the topic]</blockquote>
66
+ <p data-ke-size="size16">&nbsp;</p>
67
+
68
+ <!-- 2. Introduction (what this post covers, what the reader will learn) -->
69
+ <p>[Brief overview — set expectations clearly]</p>
70
+ <p data-ke-size="size16">&nbsp;</p>
71
+
72
+ <!-- 3. Body (2~4 sections with h2/h3, short paragraphs, lists) -->
73
+ <!-- Use <p data-ke-size="size16">&nbsp;</p> between sections for spacing -->
74
+ <h2>[Section 1 Title — keyword-rich]</h2>
75
+ <p>[Short paragraph, max 2~3 sentences]</p>
76
+ <ul>
77
+ <li>[Key point]</li>
78
+ <li>[Key point]</li>
79
+ </ul>
80
+
81
+ <h2>[Section 2 Title]</h2>
82
+ <p>[Short paragraph]</p>
83
+
84
+ <!-- 4. Summary / Key Takeaways -->
85
+ <h2>핵심 정리</h2>
86
+ <ul>
87
+ <li>[Takeaway 1]</li>
88
+ <li>[Takeaway 2]</li>
89
+ <li>[Takeaway 3]</li>
90
+ </ul>
91
+
92
+ <!-- 5. Closing (CTA or next step) -->
93
+ <p>[Closing sentence — encourage action, share, or further reading]</p>
94
+ ```
95
+
96
+ ### Writing Rules
97
+
98
+ - **Title**: Include the primary keyword. 10~20 characters. Short and impactful.
99
+ - **Paragraphs**: Max 2~3 sentences each. Break long ideas into multiple paragraphs for readability.
100
+ - **Spacing**: Use `<p data-ke-size="size16">&nbsp;</p>` between sections for line breaks (Tistory-specific).
101
+ - **Hook**: Always use `<blockquote data-ke-style="style2">` for the opening topic quote.
102
+ - **Lists**: Use `<ul>` or `<ol>` for 3+ items. Easier to scan.
103
+ - **Subheadings**: Use `<h2>` for ALL section titles. Do NOT use `<h3>`. Keep heading sizes consistent.
104
+ - **Tone**: Conversational but informative. Avoid jargon unless the audience expects it.
105
+ - **Length**: 1500~2000 characters (한글 기준) for standard posts. Do NOT exceed 2000 characters.
106
+ - **SEO**: Primary keyword in title, first paragraph, and at least one `<h2>`. Don't keyword-stuff.
107
+
108
+ ```bash
109
+ npx viruagent-cli publish \
110
+ --provider tistory \
111
+ --title "Post Title" \
112
+ --content "<h2>...</h2><p>...</p>" \
113
+ --category <id> \
114
+ --tags "tag1,tag2,tag3,tag4,tag5" \
115
+ --visibility public \
116
+ --related-image-keywords "keyword1,keyword2" \
117
+ --image-upload-limit 2 \
118
+ --minimum-image-count 1 \
119
+ --dry-run
120
+ ```
121
+
122
+ ## Step 5: Publish
123
+
124
+ ```bash
125
+ npx viruagent-cli publish \
126
+ --provider tistory \
127
+ --title "Post Title" \
128
+ --content "<h2>...</h2><p>...</p>" \
129
+ --category <id> \
130
+ --tags "tag1,tag2,tag3,tag4,tag5" \
131
+ --visibility public \
132
+ --related-image-keywords "keyword1,keyword2" \
133
+ --image-upload-limit 2 \
134
+ --minimum-image-count 1
135
+ ```
136
+
137
+ For drafts, use `save-draft` instead of `publish`.
138
+
139
+ ### Image Rules (MUST FOLLOW)
140
+
141
+ - **Always** include `--related-image-keywords` with 2~3 keywords relevant to the post topic
142
+ - **Always** set `--image-upload-limit 2` and `--minimum-image-count 1`
143
+ - Keywords should be in English for better image search results
144
+ - Never use `--no-auto-upload-images` unless the user explicitly asks
145
+
146
+ ## Step 6: Verify
147
+
148
+ ```bash
149
+ npx viruagent-cli list-posts --provider tistory --limit 1
150
+ ```
151
+
152
+ Confirm the post was published and share the result with the user.
153
+
154
+ ## Error Recovery
155
+
156
+ | Error | Action |
157
+ |---|---|
158
+ | `NOT_LOGGED_IN` / `SESSION_EXPIRED` | Run `login` again |
159
+ | `MISSING_CONTENT` | Ensure `--content` or `--content-file` is provided |
160
+ | `PROVIDER_NOT_FOUND` | Check with `list-providers` |
161
+ | `INVALID_POST_ID` | Verify post ID with `list-posts` |
162
+
163
+ ## Other Commands
164
+
165
+ - `npx viruagent-cli read-post --post-id <id>` — Read a specific post
166
+ - `npx viruagent-cli list-posts --limit 10` — List recent posts
167
+ - `npx viruagent-cli logout --provider tistory` — End session
168
+
169
+ ## Important Notes
170
+
171
+ - Always use `--dry-run` before actual publish to validate parameters
172
+ - Content must be valid HTML
173
+ - Tags: exactly 5, comma-separated, matching post language
174
+ - Default provider is `tistory`
175
+ - For `--content-file`, use absolute paths
@@ -0,0 +1,50 @@
1
+ const createNaverProvider = () => {
2
+ const unavailable = (operation) => ({
3
+ provider: 'naver',
4
+ ready: false,
5
+ operation,
6
+ message: 'Naver provider is not implemented yet. Use tistory first.',
7
+ });
8
+
9
+ return {
10
+ id: 'naver',
11
+ name: 'Naver',
12
+
13
+ async authStatus() {
14
+ return unavailable('auth_status');
15
+ },
16
+
17
+ async login() {
18
+ return unavailable('login');
19
+ },
20
+
21
+ async publish() {
22
+ return unavailable('publish');
23
+ },
24
+
25
+ async saveDraft() {
26
+ return unavailable('saveDraft');
27
+ },
28
+
29
+ async listCategories() {
30
+ return unavailable('listCategories');
31
+ },
32
+
33
+ async listPosts() {
34
+ return unavailable('listPosts');
35
+ },
36
+
37
+ async getPost() {
38
+ return unavailable('getPost');
39
+ },
40
+
41
+ async logout() {
42
+ return {
43
+ provider: 'naver',
44
+ loggedOut: true,
45
+ };
46
+ },
47
+ };
48
+ };
49
+
50
+ module.exports = createNaverProvider;