viruagent-cli 0.5.0 → 0.6.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.
Files changed (37) hide show
  1. package/bin/index.js +26 -0
  2. package/package.json +1 -1
  3. package/skills/persona-blogger/SKILL.md +47 -0
  4. package/skills/persona-influencer-manager/SKILL.md +40 -0
  5. package/skills/persona-sns-marketer/SKILL.md +38 -0
  6. package/skills/recipe-blog-publish/SKILL.md +59 -0
  7. package/skills/recipe-cross-post/SKILL.md +41 -0
  8. package/skills/recipe-daily-engagement/SKILL.md +57 -0
  9. package/skills/recipe-engage-feed/SKILL.md +56 -0
  10. package/skills/recipe-grow-followers/SKILL.md +52 -0
  11. package/skills/va-insta/SKILL.md +64 -0
  12. package/skills/va-insta-comment/SKILL.md +59 -0
  13. package/skills/va-insta-dm/SKILL.md +51 -0
  14. package/skills/va-insta-feed/SKILL.md +72 -0
  15. package/skills/va-insta-follow/SKILL.md +42 -0
  16. package/skills/va-insta-like/SKILL.md +55 -0
  17. package/skills/va-insta-login/SKILL.md +57 -0
  18. package/skills/va-naver/SKILL.md +43 -0
  19. package/skills/va-naver-categories/SKILL.md +25 -0
  20. package/skills/va-naver-draft/SKILL.md +33 -0
  21. package/skills/va-naver-login/SKILL.md +58 -0
  22. package/skills/va-naver-posts/SKILL.md +38 -0
  23. package/skills/va-naver-publish/SKILL.md +97 -0
  24. package/skills/va-shared/SKILL.md +133 -0
  25. package/skills/va-tistory/SKILL.md +42 -0
  26. package/skills/va-tistory-categories/SKILL.md +37 -0
  27. package/skills/va-tistory-draft/SKILL.md +31 -0
  28. package/skills/va-tistory-login/SKILL.md +54 -0
  29. package/skills/va-tistory-posts/SKILL.md +38 -0
  30. package/skills/va-tistory-publish/SKILL.md +90 -0
  31. package/src/providers/insta/apiClient.js +38 -0
  32. package/src/providers/insta/index.js +223 -4
  33. package/src/runner.js +47 -16
  34. package/skills/viruagent-insta.md +0 -163
  35. package/skills/viruagent-naver.md +0 -122
  36. package/skills/viruagent-tistory.md +0 -117
  37. package/skills/viruagent.md +0 -77
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: va-tistory-login
3
+ version: 1.0.0
4
+ description: "Tistory: 카카오 계정으로 로그인 (2FA 지원)"
5
+ metadata:
6
+ category: "command"
7
+ provider: "tistory"
8
+ requires:
9
+ bins: ["viruagent-cli"]
10
+ cliHelp: "viruagent-cli login --help"
11
+ ---
12
+
13
+ # va-tistory-login — Tistory 로그인
14
+
15
+ ## 인증 상태 확인
16
+
17
+ ```bash
18
+ npx viruagent-cli status --provider tistory
19
+ ```
20
+
21
+ ## 로그인
22
+
23
+ ```bash
24
+ npx viruagent-cli login --provider tistory --username <kakao_id> --password <pass> --headless
25
+ ```
26
+
27
+ ### 옵션
28
+
29
+ | 플래그 | 설명 | 기본값 |
30
+ |--------|------|--------|
31
+ | `--username` | 카카오 아이디 | - |
32
+ | `--password` | 카카오 비밀번호 | - |
33
+ | `--headless` | 헤드리스 브라우저 모드 | false |
34
+ | `--two-factor-code` | 2FA 인증 코드 | - |
35
+
36
+ ### 2FA 처리
37
+
38
+ 로그인 결과가 `pending_2fa`인 경우:
39
+ 1. 사용자에게 카카오 앱에서 인증 승인 요청
40
+ 2. 승인 후 `status` 재확인
41
+
42
+ ### 환경변수
43
+
44
+ `TISTORY_USERNAME` / `TISTORY_PASSWORD` 설정 시 자동 사용.
45
+
46
+ ## 로그아웃
47
+
48
+ ```bash
49
+ npx viruagent-cli logout --provider tistory
50
+ ```
51
+
52
+ ## See Also
53
+
54
+ va-tistory, va-shared
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: va-tistory-posts
3
+ version: 1.0.0
4
+ description: "Tistory: 글 목록 조회 및 개별 글 읽기"
5
+ metadata:
6
+ category: "command"
7
+ provider: "tistory"
8
+ requires:
9
+ bins: ["viruagent-cli"]
10
+ cliHelp: "viruagent-cli list-posts --help"
11
+ ---
12
+
13
+ # va-tistory-posts — 글 목록 & 읽기
14
+
15
+ ## 글 목록
16
+
17
+ ```bash
18
+ npx viruagent-cli list-posts --provider tistory --limit 10
19
+ ```
20
+
21
+ | 플래그 | 설명 | 기본값 |
22
+ |--------|------|--------|
23
+ | `--limit` | 조회할 글 수 | 20 |
24
+
25
+ ## 글 읽기
26
+
27
+ ```bash
28
+ npx viruagent-cli read-post --provider tistory --post-id <id>
29
+ ```
30
+
31
+ | 플래그 | 설명 |
32
+ |--------|------|
33
+ | `--post-id` | 글 ID |
34
+ | `--include-draft` | 임시저장 포함 |
35
+
36
+ ## See Also
37
+
38
+ va-tistory, va-shared
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: va-tistory-publish
3
+ version: 1.0.0
4
+ description: "Tistory: 블로그 글 발행 (HTML 콘텐츠 + 이미지)"
5
+ metadata:
6
+ category: "command"
7
+ provider: "tistory"
8
+ requires:
9
+ bins: ["viruagent-cli"]
10
+ cliHelp: "viruagent-cli publish --help"
11
+ ---
12
+
13
+ # va-tistory-publish — Tistory 글 발행
14
+
15
+ ## 사용법
16
+
17
+ ```bash
18
+ npx viruagent-cli publish \
19
+ --provider tistory \
20
+ --title "Post Title" \
21
+ --content "<h2>...</h2><p>...</p>" \
22
+ --category <id> \
23
+ --tags "tag1,tag2,tag3,tag4,tag5" \
24
+ --visibility public \
25
+ --related-image-keywords "keyword1,keyword2" \
26
+ --image-upload-limit 2 \
27
+ --minimum-image-count 1
28
+ ```
29
+
30
+ ### 옵션
31
+
32
+ | 플래그 | 설명 | 기본값 |
33
+ |--------|------|--------|
34
+ | `--title` | 글 제목 | (필수) |
35
+ | `--content` | HTML 콘텐츠 | - |
36
+ | `--content-file` | HTML 파일 경로 (절대) | - |
37
+ | `--category` | 카테고리 ID | - |
38
+ | `--tags` | 쉼표 구분 태그 (5개) | - |
39
+ | `--visibility` | public / private | public |
40
+ | `--related-image-keywords` | 이미지 검색 키워드 (영어) | - |
41
+ | `--image-upload-limit` | 최대 이미지 수 | 1 |
42
+ | `--minimum-image-count` | 최소 이미지 수 | 1 |
43
+ | `--dry-run` | 파라미터만 검증 | false |
44
+
45
+ ## HTML 템플릿
46
+
47
+ ```html
48
+ <!-- 1. Hook -->
49
+ <blockquote data-ke-style="style2">[한 문장 임팩트]</blockquote>
50
+ <p data-ke-size="size16">&nbsp;</p>
51
+
52
+ <!-- 2. 서론 (2~3단락) -->
53
+ <p data-ke-size="size18">[맥락과 공감, 3~5문장]</p>
54
+ <p data-ke-size="size18">[이 글에서 다룰 내용]</p>
55
+ <p data-ke-size="size16">&nbsp;</p>
56
+
57
+ <!-- 3. 본문 (3~4 섹션) -->
58
+ <h2>[섹션 제목]</h2>
59
+ <p data-ke-size="size18">[3~5문장, 근거 포함]</p>
60
+ <p data-ke-size="size18">[분석과 시사점]</p>
61
+ <p data-ke-size="size16">&nbsp;</p>
62
+
63
+ <!-- 4. 핵심 정리 -->
64
+ <h2>핵심 정리</h2>
65
+ <ul>
66
+ <li>[핵심 1]</li>
67
+ <li>[핵심 2]</li>
68
+ <li>[핵심 3]</li>
69
+ </ul>
70
+ <p data-ke-size="size16">&nbsp;</p>
71
+
72
+ <!-- 5. 마무리 -->
73
+ <p data-ke-size="size18">[구체적 실행 제안]</p>
74
+ ```
75
+
76
+ ## 이미지 규칙
77
+
78
+ - `--related-image-keywords`에 영어 키워드 2~3개 항상 포함
79
+ - `--image-upload-limit 2`, `--minimum-image-count 1` 설정
80
+ - `--no-auto-upload-images`는 사용자 명시 요청 시만
81
+
82
+ ## 발행 후 검증
83
+
84
+ ```bash
85
+ npx viruagent-cli list-posts --provider tistory --limit 1
86
+ ```
87
+
88
+ ## See Also
89
+
90
+ va-tistory, va-tistory-draft, va-shared
@@ -545,6 +545,43 @@ const createInstaApiClient = ({ sessionPath }) => {
545
545
  return res.json();
546
546
  });
547
547
 
548
+ const sendDm = (recipientUserId, text) => withDelay('dm', async () => {
549
+ const cookies = getCookies();
550
+ const csrf = getCsrfToken();
551
+ const body = new URLSearchParams({
552
+ action: 'send_item',
553
+ recipient_users: `[[${recipientUserId}]]`,
554
+ client_context: `6${Date.now()}_${Math.floor(Math.random() * 1000000000)}`,
555
+ offline_threading_id: Date.now().toString(),
556
+ text,
557
+ });
558
+ const res = await fetch('https://i.instagram.com/api/v1/direct_v2/threads/broadcast/text/', {
559
+ method: 'POST',
560
+ headers: {
561
+ 'User-Agent': 'Instagram 317.0.0.34.109 Android (30/11; 420dpi; 1080x2220; samsung; SM-A515F; a51; exynos9611; en_US; 562940699)',
562
+ 'X-IG-App-ID': '567067343352427',
563
+ 'X-CSRFToken': csrf,
564
+ 'Content-Type': 'application/x-www-form-urlencoded',
565
+ Cookie: cookiesToHeader(cookies),
566
+ },
567
+ body: body.toString(),
568
+ redirect: 'manual',
569
+ });
570
+ const data = await res.json();
571
+ if (data.status !== 'ok') {
572
+ const errCode = data.content?.error_code;
573
+ if (errCode === 4415001) {
574
+ throw new Error('DM restricted: Account DM feature is limited. Please open Instagram in browser, go to Direct Messages, and resolve any pending prompts or challenges.');
575
+ }
576
+ throw new Error(`DM failed: ${JSON.stringify(data)}`);
577
+ }
578
+ return {
579
+ status: data.status,
580
+ threadId: data.payload?.thread_id || null,
581
+ itemId: data.payload?.item_id || null,
582
+ };
583
+ });
584
+
548
585
  const getMediaIdFromShortcode = async (shortcode) => {
549
586
  const detail = await getPostDetail(shortcode);
550
587
  return detail.id;
@@ -666,6 +703,7 @@ const createInstaApiClient = ({ sessionPath }) => {
666
703
  uploadPhoto,
667
704
  configurePost,
668
705
  publishPost,
706
+ sendDm,
669
707
  deletePost,
670
708
  resolveChallenge,
671
709
  resetState,
@@ -225,12 +225,33 @@ const createInstaProvider = ({ sessionPath }) => {
225
225
  });
226
226
  },
227
227
 
228
- async publish({ imageUrl, imagePath, caption = '' } = {}) {
228
+ async publish({ imageUrl, imagePath, caption = '', content, title, relatedImageKeywords = [], imageUrls = [] } = {}) {
229
229
  return withProviderSession(async () => {
230
- if (!imageUrl && !imagePath) {
231
- throw new Error('Either imageUrl or imagePath is required.');
230
+ // Use content as caption if caption is not provided
231
+ const finalCaption = caption || content || '';
232
+
233
+ // Resolve image: explicit imageUrl/imagePath > imageUrls > relatedImageKeywords search
234
+ let resolvedImageUrl = imageUrl;
235
+ if (!resolvedImageUrl && !imagePath) {
236
+ if (imageUrls.length > 0) {
237
+ resolvedImageUrl = imageUrls[0];
238
+ } else if (relatedImageKeywords.length > 0) {
239
+ const { buildKeywordImageCandidates } = require('../tistory/imageSources');
240
+ for (const keyword of relatedImageKeywords) {
241
+ const candidates = await buildKeywordImageCandidates(keyword);
242
+ if (candidates.length > 0) {
243
+ resolvedImageUrl = candidates[0];
244
+ break;
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ if (!resolvedImageUrl && !imagePath) {
251
+ throw new Error('No image found. Provide --image-urls, --related-image-keywords, or use imageUrl/imagePath.');
232
252
  }
233
- const result = await instaApi.publishPost({ imageUrl, imagePath, caption });
253
+
254
+ const result = await instaApi.publishPost({ imageUrl: resolvedImageUrl, imagePath, caption: finalCaption });
234
255
  return {
235
256
  provider: 'insta',
236
257
  mode: 'publish',
@@ -239,6 +260,204 @@ const createInstaProvider = ({ sessionPath }) => {
239
260
  });
240
261
  },
241
262
 
263
+ async sendDm({ username, threadId, text } = {}) {
264
+ const target = String(username || '').trim();
265
+ const tid = String(threadId || '').trim();
266
+ const msg = String(text || '').trim();
267
+ if (!target && !tid) throw new Error('username or threadId is required.');
268
+ if (!msg) throw new Error('text is required.');
269
+
270
+ const { chromium } = require('playwright');
271
+ const path = require('path');
272
+ const userDataDir = path.join(path.dirname(sessionPath), '..', 'browser-data', 'insta');
273
+ const fs = require('fs');
274
+ if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
275
+
276
+ // Determine DM URL
277
+ let dmUrl;
278
+ if (tid) {
279
+ dmUrl = `https://www.instagram.com/direct/t/${tid}/`;
280
+ } else {
281
+ dmUrl = `https://www.instagram.com/direct/new/`;
282
+ }
283
+
284
+ const context = await chromium.launchPersistentContext(userDataDir, {
285
+ headless: true,
286
+ viewport: { width: 1280, height: 800 },
287
+ });
288
+
289
+ try {
290
+ const page = context.pages()[0] || await context.newPage();
291
+
292
+ if (!tid && target) {
293
+ // New DM: go to new message, search for user
294
+ await page.goto('https://www.instagram.com/direct/new/', { waitUntil: 'domcontentloaded' });
295
+ await page.waitForTimeout(3000);
296
+
297
+ // Search for recipient
298
+ const searchInput = page.locator('input[name="queryBox"]').or(page.getByPlaceholder(/검색|Search/i));
299
+ await searchInput.first().waitFor({ timeout: 10000 });
300
+ await searchInput.first().fill(target);
301
+ await page.waitForTimeout(2000);
302
+
303
+ // Click the user result
304
+ const userResult = page.locator(`text=${target}`).first();
305
+ await userResult.click();
306
+ await page.waitForTimeout(1000);
307
+
308
+ // Click chat/next button
309
+ const chatBtn = page.getByRole('button', { name: /채팅|Chat|다음|Next/i });
310
+ await chatBtn.first().click();
311
+ await page.waitForTimeout(2000);
312
+ } else {
313
+ await page.goto(dmUrl, { waitUntil: 'domcontentloaded' });
314
+ await page.waitForTimeout(3000);
315
+ }
316
+
317
+ // Dismiss popups
318
+ try {
319
+ const btn = page.getByRole('button', { name: /나중에|Not Now/i });
320
+ if (await btn.first().isVisible({ timeout: 2000 })) await btn.first().click();
321
+ } catch {}
322
+
323
+ // Send message
324
+ const input = page.locator('[role="textbox"]').first();
325
+ await input.waitFor({ timeout: 10000 });
326
+ await input.click();
327
+ await page.keyboard.type(msg);
328
+ await page.waitForTimeout(500);
329
+ await page.keyboard.press('Enter');
330
+ await page.waitForTimeout(3000);
331
+
332
+ return {
333
+ provider: 'insta',
334
+ mode: 'dm',
335
+ to: target || tid,
336
+ text: msg,
337
+ status: 'ok',
338
+ };
339
+ } finally {
340
+ await context.close();
341
+ }
342
+ },
343
+
344
+ async listMessages({ threadId } = {}) {
345
+ const tid = String(threadId || '').trim();
346
+ if (!tid) throw new Error('threadId is required.');
347
+
348
+ const { chromium } = require('playwright');
349
+ const path = require('path');
350
+ const fs = require('fs');
351
+ const userDataDir = path.join(path.dirname(sessionPath), '..', 'browser-data', 'insta');
352
+ if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
353
+
354
+ const context = await chromium.launchPersistentContext(userDataDir, {
355
+ headless: true,
356
+ viewport: { width: 1280, height: 800 },
357
+ });
358
+
359
+ try {
360
+ const page = context.pages()[0] || await context.newPage();
361
+ await page.goto(`https://www.instagram.com/direct/t/${tid}/`, { waitUntil: 'domcontentloaded' });
362
+ await page.waitForTimeout(5000);
363
+
364
+ // Dismiss popups
365
+ try {
366
+ const btn = page.getByRole('button', { name: /나중에|Not Now/i });
367
+ if (await btn.first().isVisible({ timeout: 2000 })) await btn.first().click();
368
+ } catch {}
369
+ await page.waitForTimeout(1000);
370
+
371
+ // Extract messages from DOM
372
+ const messages = await page.evaluate(() => {
373
+ const result = [];
374
+ // Find message containers - Instagram uses div with role="row" or specific data attributes
375
+ const rows = document.querySelectorAll('div[role="row"]');
376
+ rows.forEach((row) => {
377
+ const textEl = row.querySelector('div[dir="auto"]');
378
+ if (!textEl) return;
379
+ const text = textEl.innerText?.trim();
380
+ if (!text) return;
381
+
382
+ // Determine if sent or received by checking position/style
383
+ const wrapper = row.closest('[class]');
384
+ const style = wrapper ? window.getComputedStyle(wrapper) : null;
385
+ const isSent = row.innerHTML.includes('rgb(99, 91, 255)') ||
386
+ row.innerHTML.includes('#635BFF') ||
387
+ row.querySelector('[style*="flex-end"]') !== null;
388
+
389
+ result.push({ text, isSent });
390
+ });
391
+ return result;
392
+ });
393
+
394
+ // If role="row" didn't work, try alternative extraction
395
+ if (messages.length === 0) {
396
+ const altMessages = await page.evaluate(() => {
397
+ const result = [];
398
+ const allDivs = document.querySelectorAll('div[dir="auto"]');
399
+ const seen = new Set();
400
+ allDivs.forEach((el) => {
401
+ const text = el.innerText?.trim();
402
+ if (!text || text.length > 500 || seen.has(text)) return;
403
+ // Skip UI elements
404
+ if (['메시지 입력...', '검색', 'Message...'].includes(text)) return;
405
+ if (el.closest('nav') || el.closest('header')) return;
406
+ seen.add(text);
407
+
408
+ // Check if element is in right-aligned (sent) bubble
409
+ const rect = el.getBoundingClientRect();
410
+ const isSent = rect.left > window.innerWidth / 2;
411
+
412
+ result.push({ text, isSent });
413
+ });
414
+ return result;
415
+ });
416
+ if (altMessages.length > 0) messages.push(...altMessages);
417
+ }
418
+
419
+ // Get thread participant name
420
+ const participant = await page.evaluate(() => {
421
+ const header = document.querySelector('header');
422
+ if (!header) return null;
423
+ const spans = header.querySelectorAll('span');
424
+ for (const s of spans) {
425
+ const t = s.innerText?.trim();
426
+ if (t && !['메시지', 'Direct', '뒤로'].includes(t) && t.length < 30) return t;
427
+ }
428
+ return null;
429
+ });
430
+
431
+ return {
432
+ provider: 'insta',
433
+ mode: 'messages',
434
+ threadId: tid,
435
+ participant,
436
+ totalCount: messages.length,
437
+ messages,
438
+ };
439
+ } finally {
440
+ await context.close();
441
+ }
442
+ },
443
+
444
+ async listComments({ postId } = {}) {
445
+ return withProviderSession(async () => {
446
+ const shortcode = String(postId || '').trim();
447
+ if (!shortcode) {
448
+ throw new Error('postId (shortcode) is required.');
449
+ }
450
+ const comments = await instaApi.getComments(shortcode);
451
+ return {
452
+ provider: 'insta',
453
+ mode: 'comments',
454
+ postId: shortcode,
455
+ totalCount: comments.length,
456
+ comments,
457
+ };
458
+ });
459
+ },
460
+
242
461
  async analyzePost({ postId } = {}) {
243
462
  return withProviderSession(async () => {
244
463
  const shortcode = String(postId || '').trim();
package/src/runner.js CHANGED
@@ -47,26 +47,36 @@ const runCommand = async (command, opts = {}) => {
47
47
 
48
48
  if (command === 'install-skill') {
49
49
  const skillsDir = path.resolve(__dirname, '..', 'skills');
50
- const skillFiles = ['viruagent.md', 'viruagent-tistory.md', 'viruagent-naver.md', 'viruagent-insta.md'];
51
-
52
50
  const targetDir = opts.target
53
51
  || path.join(os.homedir(), '.claude', 'commands');
54
- fs.mkdirSync(targetDir, { recursive: true });
55
-
56
- const installed = [];
57
- for (const file of skillFiles) {
58
- const src = path.join(skillsDir, file);
59
- if (!fs.existsSync(src)) continue;
60
- const dest = path.join(targetDir, file);
61
- fs.copyFileSync(src, dest);
62
- installed.push(dest);
63
- }
64
52
 
65
- if (installed.length === 0) {
66
- throw createError('FILE_NOT_FOUND', 'Skill files not found in package');
53
+ // Install only the main router skill as /viruagent
54
+ const routerSrc = path.join(skillsDir, 'va-shared', 'SKILL.md');
55
+ if (!fs.existsSync(routerSrc)) {
56
+ throw createError('FILE_NOT_FOUND', 'Router skill (va-shared/SKILL.md) not found');
67
57
  }
68
58
 
69
- return { installed: true, paths: installed, count: installed.length };
59
+ const destDir = path.join(targetDir, 'viruagent');
60
+ fs.mkdirSync(destDir, { recursive: true });
61
+ const dest = path.join(destDir, 'SKILL.md');
62
+ fs.copyFileSync(routerSrc, dest);
63
+
64
+ // Inject actual skills directory path into the installed SKILL.md
65
+ const skillsAbsPath = skillsDir;
66
+ let content = fs.readFileSync(dest, 'utf-8');
67
+ content = content.replace(
68
+ 'SKILLS_DIR: <viruagent-cli 설치 경로>/skills/',
69
+ `SKILLS_DIR: ${skillsAbsPath}/`
70
+ );
71
+ fs.writeFileSync(dest, content, 'utf-8');
72
+
73
+ return {
74
+ installed: true,
75
+ paths: [dest],
76
+ count: 1,
77
+ skillsDir: skillsAbsPath,
78
+ note: 'Only /viruagent is registered as a slash command. Sub-skills are loaded on demand from ' + skillsAbsPath,
79
+ };
70
80
  }
71
81
 
72
82
  const manager = createProviderManager();
@@ -110,7 +120,7 @@ const runCommand = async (command, opts = {}) => {
110
120
 
111
121
  case 'publish': {
112
122
  const content = readContent(opts);
113
- if (!content) {
123
+ if (!content && providerName !== 'insta') {
114
124
  throw createError('MISSING_CONTENT', 'publish requires --content or --content-file', 'viruagent-cli publish --spec');
115
125
  }
116
126
  return withProvider(() =>
@@ -231,6 +241,27 @@ const runCommand = async (command, opts = {}) => {
231
241
  }
232
242
  return withProvider(() => provider.unlikeComment({ commentId: opts.commentId }))();
233
243
 
244
+ case 'send-dm':
245
+ if (!opts.username && !opts.threadId) {
246
+ throw createError('MISSING_PARAM', 'send-dm requires --username or --thread-id');
247
+ }
248
+ if (!opts.text) {
249
+ throw createError('MISSING_PARAM', 'send-dm requires --text');
250
+ }
251
+ return withProvider(() => provider.sendDm({ username: opts.username, threadId: opts.threadId, text: opts.text }))();
252
+
253
+ case 'list-messages':
254
+ if (!opts.threadId) {
255
+ throw createError('MISSING_PARAM', 'list-messages requires --thread-id');
256
+ }
257
+ return withProvider(() => provider.listMessages({ threadId: opts.threadId }))();
258
+
259
+ case 'list-comments':
260
+ if (!opts.postId) {
261
+ throw createError('MISSING_PARAM', 'list-comments requires --post-id');
262
+ }
263
+ return withProvider(() => provider.listComments({ postId: opts.postId }))();
264
+
234
265
  case 'analyze-post':
235
266
  if (!opts.postId) {
236
267
  throw createError('MISSING_PARAM', 'analyze-post requires --post-id');