@jackwener/opencli 0.4.2 → 0.4.4

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 (65) hide show
  1. package/{CLI-CREATOR.md → CLI-EXPLORER.md} +15 -11
  2. package/CLI-ONESHOT.md +216 -0
  3. package/LICENSE +28 -0
  4. package/README.md +114 -63
  5. package/README.zh-CN.md +115 -63
  6. package/SKILL.md +25 -6
  7. package/dist/browser.d.ts +53 -10
  8. package/dist/browser.js +491 -111
  9. package/dist/browser.test.d.ts +1 -0
  10. package/dist/browser.test.js +56 -0
  11. package/dist/build-manifest.js +4 -0
  12. package/dist/cli-manifest.json +279 -3
  13. package/dist/clis/boss/search.js +186 -30
  14. package/dist/clis/twitter/delete.d.ts +1 -0
  15. package/dist/clis/twitter/delete.js +73 -0
  16. package/dist/clis/twitter/followers.d.ts +1 -0
  17. package/dist/clis/twitter/followers.js +104 -0
  18. package/dist/clis/twitter/following.d.ts +1 -0
  19. package/dist/clis/twitter/following.js +90 -0
  20. package/dist/clis/twitter/like.d.ts +1 -0
  21. package/dist/clis/twitter/like.js +69 -0
  22. package/dist/clis/twitter/notifications.d.ts +1 -0
  23. package/dist/clis/twitter/notifications.js +109 -0
  24. package/dist/clis/twitter/post.d.ts +1 -0
  25. package/dist/clis/twitter/post.js +63 -0
  26. package/dist/clis/twitter/reply.d.ts +1 -0
  27. package/dist/clis/twitter/reply.js +57 -0
  28. package/dist/clis/v2ex/daily.d.ts +1 -0
  29. package/dist/clis/v2ex/daily.js +98 -0
  30. package/dist/clis/v2ex/me.d.ts +1 -0
  31. package/dist/clis/v2ex/me.js +99 -0
  32. package/dist/clis/v2ex/notifications.d.ts +1 -0
  33. package/dist/clis/v2ex/notifications.js +72 -0
  34. package/dist/doctor.d.ts +50 -0
  35. package/dist/doctor.js +372 -0
  36. package/dist/doctor.test.d.ts +1 -0
  37. package/dist/doctor.test.js +114 -0
  38. package/dist/main.js +47 -5
  39. package/dist/output.test.d.ts +1 -0
  40. package/dist/output.test.js +20 -0
  41. package/dist/registry.d.ts +4 -0
  42. package/dist/registry.js +1 -0
  43. package/dist/runtime.d.ts +3 -1
  44. package/dist/runtime.js +2 -2
  45. package/package.json +2 -2
  46. package/src/browser.test.ts +77 -0
  47. package/src/browser.ts +541 -99
  48. package/src/build-manifest.ts +4 -0
  49. package/src/clis/boss/search.ts +196 -29
  50. package/src/clis/twitter/delete.ts +78 -0
  51. package/src/clis/twitter/followers.ts +119 -0
  52. package/src/clis/twitter/following.ts +105 -0
  53. package/src/clis/twitter/like.ts +74 -0
  54. package/src/clis/twitter/notifications.ts +119 -0
  55. package/src/clis/twitter/post.ts +68 -0
  56. package/src/clis/twitter/reply.ts +62 -0
  57. package/src/clis/v2ex/daily.ts +105 -0
  58. package/src/clis/v2ex/me.ts +103 -0
  59. package/src/clis/v2ex/notifications.ts +77 -0
  60. package/src/doctor.test.ts +133 -0
  61. package/src/doctor.ts +424 -0
  62. package/src/main.ts +47 -4
  63. package/src/output.test.ts +27 -0
  64. package/src/registry.ts +5 -0
  65. package/src/runtime.ts +2 -1
@@ -0,0 +1,119 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import * as fs from 'fs';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'notifications',
7
+ description: 'Get Twitter/X notifications',
8
+ domain: 'x.com',
9
+ strategy: Strategy.INTERCEPT,
10
+ browser: true,
11
+ args: [
12
+ { name: 'limit', type: 'int', default: 20 },
13
+ ],
14
+ columns: ['id', 'action', 'author', 'text', 'url'],
15
+ func: async (page, kwargs) => {
16
+ // 1. Navigate to notifications
17
+ await page.goto('https://x.com/notifications');
18
+ await page.wait(5);
19
+
20
+ // 2. Inject interceptor
21
+ await page.installInterceptor('NotificationsTimeline');
22
+
23
+ // 3. Trigger API by scrolling (if we need to load more)
24
+ await page.autoScroll({ times: 2, delayMs: 2000 });
25
+
26
+ // 4. Retrieve data
27
+ const requests = await page.getInterceptedRequests();
28
+ if (!requests || requests.length === 0) return [];
29
+
30
+ let results: any[] = [];
31
+ for (const req of requests) {
32
+ try {
33
+ let instructions = [];
34
+ if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
35
+ instructions = req.data.data.viewer.timeline_response.timeline.instructions;
36
+ } else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
37
+ instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
38
+ } else if (req.data?.data?.timeline?.instructions) {
39
+ instructions = req.data.data.timeline.instructions;
40
+ }
41
+
42
+ let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
43
+
44
+ // Sometimes it's the first object without a 'type' field but has 'entries'
45
+ if (!addEntries) {
46
+ addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
47
+ }
48
+
49
+ if (!addEntries) continue;
50
+
51
+ for (const entry of addEntries.entries) {
52
+ if (!entry.entryId.startsWith('notification-')) {
53
+ if (entry.content?.items) {
54
+ for (const subItem of entry.content.items) {
55
+ processNotificationItem(subItem.item?.itemContent, subItem.entryId);
56
+ }
57
+ }
58
+ continue;
59
+ }
60
+
61
+ processNotificationItem(entry.content?.itemContent, entry.entryId);
62
+ }
63
+
64
+ function processNotificationItem(itemContent: any, entryId: string) {
65
+ if (!itemContent) return;
66
+
67
+ // Twitter wraps standard notifications
68
+ let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
69
+
70
+ let actionText = 'Notification';
71
+ let author = 'unknown';
72
+ let text = '';
73
+ let urlStr = '';
74
+
75
+ if (item.__typename === 'TimelineNotification') {
76
+ // Greet likes, retweet, mentions
77
+ text = item.rich_message?.text || item.message?.text || '';
78
+ author = item.template?.from_users?.[0]?.user_results?.result?.core?.screen_name || 'unknown';
79
+ urlStr = item.notification_url?.url || '';
80
+ actionText = item.notification_icon || 'Activity';
81
+
82
+ // If there's an attached tweet
83
+ const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
84
+ if (targetTweet) {
85
+ text += ' | ' + (targetTweet.legacy?.full_text || '');
86
+ if (!urlStr) {
87
+ urlStr = `https://x.com/i/status/${targetTweet.rest_id}`;
88
+ }
89
+ }
90
+ } else if (item.__typename === 'TweetNotification') {
91
+ // Direct mention/reply
92
+ const tweet = item.tweet_result?.result;
93
+ author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
94
+ text = tweet?.legacy?.full_text || item.message?.text || '';
95
+ actionText = 'Mention/Reply';
96
+ urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
97
+ } else if (item.__typename === 'Tweet') {
98
+ author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
99
+ text = item.legacy?.full_text || '';
100
+ actionText = 'Mention';
101
+ urlStr = `https://x.com/i/status/${item.rest_id}`;
102
+ }
103
+
104
+ results.push({
105
+ id: item.id || item.rest_id || entryId,
106
+ action: actionText,
107
+ author: author,
108
+ text: text,
109
+ url: urlStr || `https://x.com/notifications`
110
+ });
111
+ }
112
+ } catch (e) {
113
+ // ignore parsing errors for individual payloads
114
+ }
115
+ }
116
+
117
+ return results.slice(0, kwargs.limit);
118
+ }
119
+ });
@@ -0,0 +1,68 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'post',
7
+ description: 'Post a new tweet/thread',
8
+ domain: 'x.com',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'text', type: 'string', required: true, help: 'The text content of the tweet' },
13
+ ],
14
+ columns: ['status', 'message', 'text'],
15
+ func: async (page: IPage | null, kwargs: any) => {
16
+ if (!page) throw new Error('Requires browser');
17
+
18
+ // 1. Navigate directly to the compose tweet modal
19
+ await page.goto('https://x.com/compose/tweet');
20
+ await page.wait(3); // Wait for the modal and React app to hydrate
21
+
22
+ // 2. Automate typing and clicking
23
+ const result = await page.evaluate(`(async () => {
24
+ try {
25
+ // Find the active text area
26
+ const box = document.querySelector('[data-testid="tweetTextarea_0"]');
27
+ if (box) {
28
+ box.focus();
29
+ // insertText is the most reliable way to trigger React's onChange events
30
+ document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
31
+ } else {
32
+ return { ok: false, message: 'Could not find the tweet composer text area.' };
33
+ }
34
+
35
+ // Wait a brief moment for the button state to update
36
+ await new Promise(r => setTimeout(r, 1000));
37
+
38
+ // Click the post button
39
+ const btn = document.querySelector('[data-testid="tweetButton"]');
40
+ if (btn && !btn.disabled) {
41
+ btn.click();
42
+ return { ok: true, message: 'Tweet posted successfully.' };
43
+ } else {
44
+ // Sometimes it's rendered inline depending on the viewport
45
+ const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]');
46
+ if (inlineBtn && !inlineBtn.disabled) {
47
+ inlineBtn.click();
48
+ return { ok: true, message: 'Tweet posted successfully.' };
49
+ }
50
+ return { ok: false, message: 'Tweet button is disabled or not found.' };
51
+ }
52
+ } catch (e) {
53
+ return { ok: false, message: e.toString() };
54
+ }
55
+ })()`);
56
+
57
+ // 3. Wait a few seconds for the network request to finish sending
58
+ if (result.ok) {
59
+ await page.wait(3);
60
+ }
61
+
62
+ return [{
63
+ status: result.ok ? 'success' : 'failed',
64
+ message: result.message,
65
+ text: kwargs.text
66
+ }];
67
+ }
68
+ });
@@ -0,0 +1,62 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'reply',
7
+ description: 'Reply to a specific tweet',
8
+ domain: 'x.com',
9
+ strategy: Strategy.UI, // Uses the UI directly to input and click post
10
+ browser: true,
11
+ args: [
12
+ { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to reply to' },
13
+ { name: 'text', type: 'string', required: true, help: 'The text content of your reply' },
14
+ ],
15
+ columns: ['status', 'message', 'text'],
16
+ func: async (page: IPage | null, kwargs: any) => {
17
+ if (!page) throw new Error('Requires browser');
18
+
19
+ // 1. Navigate to the tweet page
20
+ await page.goto(kwargs.url);
21
+ await page.wait(5); // Wait for the react application to hydrate
22
+
23
+ // 2. Automate typing the reply and clicking reply
24
+ const result = await page.evaluate(`(async () => {
25
+ try {
26
+ // Find the reply text area on the tweet page.
27
+ // The placeholder is usually "Post your reply"
28
+ const box = document.querySelector('[data-testid="tweetTextarea_0"]');
29
+ if (box) {
30
+ box.focus();
31
+ document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
32
+ } else {
33
+ return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
34
+ }
35
+
36
+ // Wait for React state to register the input and enable the button
37
+ await new Promise(r => setTimeout(r, 1000));
38
+
39
+ // Find the Reply button. It usually shares the same test id tweetButtonInline in this context
40
+ const btn = document.querySelector('[data-testid="tweetButtonInline"]');
41
+ if (btn && !btn.disabled) {
42
+ btn.click();
43
+ return { ok: true, message: 'Reply posted successfully.' };
44
+ } else {
45
+ return { ok: false, message: 'Reply button is disabled or not found.' };
46
+ }
47
+ } catch (e) {
48
+ return { ok: false, message: e.toString() };
49
+ }
50
+ })()`);
51
+
52
+ if (result.ok) {
53
+ await page.wait(3); // Wait for network submission to complete
54
+ }
55
+
56
+ return [{
57
+ status: result.ok ? 'success' : 'failed',
58
+ message: result.message,
59
+ text: kwargs.text
60
+ }];
61
+ }
62
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * V2EX Daily Check-in adapter.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ import type { IPage } from '../../types.js';
6
+
7
+ cli({
8
+ site: 'v2ex',
9
+ name: 'daily',
10
+ description: 'V2EX 每日签到并领取铜币',
11
+ domain: 'www.v2ex.com',
12
+ strategy: Strategy.COOKIE,
13
+ browser: true,
14
+ forceExtension: true,
15
+ args: [],
16
+ columns: ['status', 'message'],
17
+ func: async (page: IPage | null) => {
18
+ if (!page) throw new Error('Browser page required');
19
+
20
+ if (process.env.OPENCLI_VERBOSE) {
21
+ console.error('[opencli:v2ex] Navigating to /mission/daily');
22
+ }
23
+ await page.goto('https://www.v2ex.com/mission/daily');
24
+
25
+ // Cloudflare challenge bypass wait
26
+ for (let i = 0; i < 5; i++) {
27
+ await new Promise(r => setTimeout(r, 1500));
28
+ const title = await page.evaluate(`() => document.title`);
29
+ if (!title?.includes('Just a moment')) break;
30
+ if (process.env.OPENCLI_VERBOSE) console.error('[opencli:v2ex] Waiting for Cloudflare...');
31
+ }
32
+
33
+ // Evaluate DOM to find if we need to check in
34
+ const checkResult = await page.evaluate(`
35
+ async () => {
36
+ const btn = document.querySelector('input.super.normal.button');
37
+ if (!btn || !btn.value.includes('领取')) {
38
+ return { claimed: true, message: '今日奖励已发/无需领取' };
39
+ }
40
+
41
+ const onclick = btn.getAttribute('onclick');
42
+ if (onclick) {
43
+ const match = onclick.match(/once=(\\d+)/);
44
+ if (match) {
45
+ return { claimed: false, once: match[1], message: btn.value };
46
+ }
47
+ }
48
+
49
+ return {
50
+ claimed: false,
51
+ error: '找到了按钮,但未能提取 once token',
52
+ debug_title: document.title,
53
+ debug_body: document.body.innerText.substring(0, 200).replace(/\\n/g, ' ')
54
+ };
55
+ }
56
+ `);
57
+
58
+ if (checkResult.error) {
59
+ if (process.env.OPENCLI_VERBOSE) {
60
+ console.error(`[opencli:v2ex:debug] Page Title: ${checkResult.debug_title}`);
61
+ console.error(`[opencli:v2ex:debug] Page Body: ${checkResult.debug_body}`);
62
+ }
63
+ throw new Error(checkResult.error);
64
+ }
65
+
66
+ if (checkResult.claimed) {
67
+ return [{ status: '✅ 已签到', message: checkResult.message }];
68
+ }
69
+
70
+ // Perform check in
71
+ if (process.env.OPENCLI_VERBOSE) {
72
+ console.error(`[opencli:v2ex] Found check-in token: once=${checkResult.once}. Checking in...`);
73
+ }
74
+
75
+ await page.goto(`https://www.v2ex.com/mission/daily/redeem?once=${checkResult.once}`);
76
+ await new Promise(resolve => setTimeout(resolve, 3000)); // wait longer for redirect
77
+
78
+ // Verify result
79
+ const verifyResult = await page.evaluate(`
80
+ async () => {
81
+ const btn = document.querySelector('input.super.normal.button');
82
+ if (!btn || !btn.value.includes('领取')) {
83
+ // fetch balance to show user
84
+ let balance = '';
85
+ const balanceLink = document.querySelector('a.balance_area');
86
+ if (balanceLink) {
87
+ balance = Array.from(balanceLink.childNodes)
88
+ .filter(n => n.nodeType === 3)
89
+ .map(n => n.textContent?.trim())
90
+ .join(' ')
91
+ .trim();
92
+ }
93
+ return { success: true, balance };
94
+ }
95
+ return { success: false };
96
+ }
97
+ `);
98
+
99
+ if (verifyResult.success) {
100
+ return [{ status: '🎉 签到成功', message: `当前余额: ${verifyResult.balance || '未知'}` }];
101
+ } else {
102
+ return [{ status: '❌ 签到失败', message: '未能确认签到结果,请手动检查' }];
103
+ }
104
+ },
105
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * V2EX Me (Profile/Balance) adapter.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ import type { IPage } from '../../types.js';
6
+
7
+ cli({
8
+ site: 'v2ex',
9
+ name: 'me',
10
+ description: 'V2EX 获取个人资料 (余额/未读提醒)',
11
+ domain: 'www.v2ex.com',
12
+ strategy: Strategy.COOKIE,
13
+ browser: true,
14
+ forceExtension: true,
15
+ args: [],
16
+ columns: ['username', 'balance', 'unread_notifications', 'daily_reward_ready'],
17
+ func: async (page: IPage | null) => {
18
+ if (!page) throw new Error('Browser page required');
19
+
20
+ if (process.env.OPENCLI_VERBOSE) {
21
+ console.error('[opencli:v2ex] Navigating to /');
22
+ }
23
+ await page.goto('https://www.v2ex.com/');
24
+
25
+ // Cloudflare challenge bypass wait
26
+ for (let i = 0; i < 5; i++) {
27
+ await new Promise(r => setTimeout(r, 1500));
28
+ const title = await page.evaluate(`() => document.title`);
29
+ if (!title?.includes('Just a moment')) break;
30
+ if (process.env.OPENCLI_VERBOSE) console.error('[opencli:v2ex] Waiting for Cloudflare...');
31
+ }
32
+
33
+ // Evaluate DOM to extract user profile
34
+ const data = await page.evaluate(`
35
+ async () => {
36
+ let username = 'Unknown';
37
+ const navLinks = Array.from(document.querySelectorAll('a.top')).map(a => a.textContent?.trim());
38
+ if (navLinks.length > 1 && navLinks[0] === '首页') {
39
+ username = navLinks[1] || 'Unknown';
40
+ }
41
+
42
+ if (username === 'Unknown') {
43
+ // Fallback check just in case
44
+ const profileEl = document.querySelector('a[href^="/member/"]');
45
+ if (profileEl && profileEl.textContent && profileEl.textContent.trim().length > 0) {
46
+ username = profileEl.textContent.trim();
47
+ }
48
+ }
49
+
50
+ let balance = '0';
51
+ const balanceLink = document.querySelector('a.balance_area');
52
+ if (balanceLink) {
53
+ balance = Array.from(balanceLink.childNodes)
54
+ .filter(n => n.nodeType === 3)
55
+ .map(n => n.textContent?.trim())
56
+ .join(' ')
57
+ .trim();
58
+ }
59
+
60
+ let unread_notifications = '0';
61
+ const notesEl = document.querySelector('a[href="/notifications"]');
62
+ if (notesEl) {
63
+ const text = notesEl.textContent?.trim() || '';
64
+ const match = text.match(/(\\d+)\\s*未读提醒/);
65
+ if (match) {
66
+ unread_notifications = match[1];
67
+ }
68
+ }
69
+
70
+ let daily_reward_ready = false;
71
+ const dailyEl = document.querySelector('a[href^="/mission/daily"]');
72
+ if (dailyEl && dailyEl.textContent?.includes('领取今日的登录奖励')) {
73
+ daily_reward_ready = true;
74
+ }
75
+
76
+ if (username === 'Unknown') {
77
+ return {
78
+ error: '请先登录 V2EX(可能是 Cookie 未配置或已失效)',
79
+ debug_title: document.title,
80
+ debug_body: document.body.innerText.substring(0, 200).replace(/\\n/g, ' ')
81
+ };
82
+ }
83
+
84
+ return {
85
+ username,
86
+ balance,
87
+ unread_notifications,
88
+ daily_reward_ready: daily_reward_ready ? '是' : '否'
89
+ };
90
+ }
91
+ `);
92
+
93
+ if (data.error) {
94
+ if (process.env.OPENCLI_VERBOSE) {
95
+ console.error(`[opencli:v2ex:debug] Page Title: ${data.debug_title}`);
96
+ console.error(`[opencli:v2ex:debug] Page Body: ${data.debug_body}`);
97
+ }
98
+ throw new Error(data.error);
99
+ }
100
+
101
+ return [data];
102
+ },
103
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * V2EX Notifications adapter.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ import type { IPage } from '../../types.js';
6
+
7
+ cli({
8
+ site: 'v2ex',
9
+ name: 'notifications',
10
+ description: 'V2EX 获取提醒 (回复/由于)',
11
+ domain: 'www.v2ex.com',
12
+ strategy: Strategy.COOKIE,
13
+ browser: true,
14
+ forceExtension: true,
15
+ args: [
16
+ { name: 'limit', type: 'int', default: 20, help: 'Number of notifications' }
17
+ ],
18
+ columns: ['type', 'content', 'time'],
19
+ func: async (page: IPage | null, kwargs) => {
20
+ if (!page) throw new Error('Browser page required');
21
+
22
+ if (process.env.OPENCLI_VERBOSE) {
23
+ console.error('[opencli:v2ex] Navigating to /notifications');
24
+ }
25
+ await page.goto('https://www.v2ex.com/notifications');
26
+ await new Promise(r => setTimeout(r, 1500)); // waitForLoadState doesn't always work robustly
27
+
28
+ // Evaluate DOM to extract notifications
29
+ const data = await page.evaluate(`
30
+ async () => {
31
+ const items = Array.from(document.querySelectorAll('#Main .box .cell[id^="n_"]'));
32
+ return items.map(item => {
33
+ let type = '通知';
34
+ let time = '';
35
+
36
+ // determine type based on text content
37
+ const text = item.textContent || '';
38
+ if (text.includes('回复了你')) type = '回复';
39
+ else if (text.includes('感谢了你')) type = '感谢';
40
+ else if (text.includes('收藏了你')) type = '收藏';
41
+ else if (text.includes('提及你')) type = '提及';
42
+
43
+ const timeEl = item.querySelector('.snow');
44
+ if (timeEl) {
45
+ time = timeEl.textContent?.trim() || '';
46
+ }
47
+
48
+ // payload contains the actual reply text if any
49
+ let payload = '';
50
+ const payloadEl = item.querySelector('.payload');
51
+ if (payloadEl) {
52
+ payload = payloadEl.textContent?.trim() || '';
53
+ }
54
+
55
+ // fallback to full text cleaning if no payload (e.g. for favorites/thanks)
56
+ let content = payload;
57
+ if (!content) {
58
+ content = text.replace(/\\s+/g, ' ').trim();
59
+ // strip out time from content if present
60
+ if (time && content.includes(time)) {
61
+ content = content.replace(time, '').trim();
62
+ }
63
+ }
64
+
65
+ return { type, content, time };
66
+ });
67
+ }
68
+ `);
69
+
70
+ if (!Array.isArray(data)) {
71
+ throw new Error('Failed to parse notifications data');
72
+ }
73
+
74
+ const limit = kwargs.limit || 20;
75
+ return data.slice(0, limit);
76
+ },
77
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ readTokenFromShellContent,
4
+ renderBrowserDoctorReport,
5
+ upsertShellToken,
6
+ readTomlConfigToken,
7
+ upsertTomlConfigToken,
8
+ upsertJsonConfigToken,
9
+ } from './doctor.js';
10
+
11
+ describe('shell token helpers', () => {
12
+ it('reads token from shell export', () => {
13
+ expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
14
+ });
15
+
16
+ it('appends token export when missing', () => {
17
+ const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
18
+ expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
19
+ });
20
+
21
+ it('replaces token export when present', () => {
22
+ const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
23
+ expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
24
+ expect(next).not.toContain('"old"');
25
+ });
26
+ });
27
+
28
+ describe('toml token helpers', () => {
29
+ it('reads token from playwright env section', () => {
30
+ const content = `
31
+ [mcp_servers.playwright.env]
32
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
33
+ `;
34
+ expect(readTomlConfigToken(content)).toBe('abc123');
35
+ });
36
+
37
+ it('updates token inside existing env section', () => {
38
+ const content = `
39
+ [mcp_servers.playwright.env]
40
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
41
+ `;
42
+ const next = upsertTomlConfigToken(content, 'new');
43
+ expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
44
+ expect(next).not.toContain('"old"');
45
+ });
46
+
47
+ it('creates env section when missing', () => {
48
+ const content = `
49
+ [mcp_servers.playwright]
50
+ type = "stdio"
51
+ `;
52
+ const next = upsertTomlConfigToken(content, 'abc123');
53
+ expect(next).toContain('[mcp_servers.playwright.env]');
54
+ expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
55
+ });
56
+ });
57
+
58
+ describe('json token helpers', () => {
59
+ it('writes token into standard mcpServers config', () => {
60
+ const next = upsertJsonConfigToken(JSON.stringify({
61
+ mcpServers: {
62
+ playwright: {
63
+ command: 'npx',
64
+ args: ['-y', '@playwright/mcp@latest', '--extension'],
65
+ },
66
+ },
67
+ }), 'abc123');
68
+ const parsed = JSON.parse(next);
69
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
70
+ });
71
+
72
+ it('writes token into opencode mcp config', () => {
73
+ const next = upsertJsonConfigToken(JSON.stringify({
74
+ $schema: 'https://opencode.ai/config.json',
75
+ mcp: {
76
+ playwright: {
77
+ command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
78
+ enabled: true,
79
+ type: 'local',
80
+ },
81
+ },
82
+ }), 'abc123');
83
+ const parsed = JSON.parse(next);
84
+ expect(parsed.mcp.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
85
+ });
86
+ });
87
+
88
+ describe('doctor report rendering', () => {
89
+ it('renders OK-style report when tokens match', () => {
90
+ const text = renderBrowserDoctorReport({
91
+ envToken: 'abc123',
92
+ envFingerprint: 'fp1',
93
+ shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
94
+ configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
95
+ remoteDebuggingEnabled: true,
96
+ remoteDebuggingEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test',
97
+ cdpEnabled: false,
98
+ cdpToken: null,
99
+ cdpFingerprint: null,
100
+ recommendedToken: 'abc123',
101
+ recommendedFingerprint: 'fp1',
102
+ warnings: [],
103
+ issues: [],
104
+ });
105
+
106
+ expect(text).toContain('[OK] Chrome remote debugging: enabled');
107
+ expect(text).toContain('[OK] Environment token: configured (fp1)');
108
+ expect(text).toContain('[OK] MCP config /tmp/mcp.json: configured (fp1)');
109
+ });
110
+
111
+ it('renders MISMATCH-style report when fingerprints differ', () => {
112
+ const text = renderBrowserDoctorReport({
113
+ envToken: 'abc123',
114
+ envFingerprint: 'fp1',
115
+ shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
116
+ configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
117
+ remoteDebuggingEnabled: false,
118
+ remoteDebuggingEndpoint: null,
119
+ cdpEnabled: false,
120
+ cdpToken: null,
121
+ cdpFingerprint: null,
122
+ recommendedToken: 'abc123',
123
+ recommendedFingerprint: 'fp1',
124
+ warnings: ['Chrome remote debugging appears to be disabled or Chrome is not currently exposing a DevTools endpoint.'],
125
+ issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
126
+ });
127
+
128
+ expect(text).toContain('[WARN] Chrome remote debugging: disabled');
129
+ expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
130
+ expect(text).toContain('[MISMATCH] Shell file /tmp/.zshrc: configured (fp2)');
131
+ expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
132
+ });
133
+ });