@jackwener/opencli 1.0.0 → 1.0.3

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 (171) hide show
  1. package/.github/workflows/build-extension.yml +62 -0
  2. package/.github/workflows/ci.yml +6 -6
  3. package/.github/workflows/e2e-headed.yml +2 -2
  4. package/.github/workflows/pkg-pr-new.yml +2 -2
  5. package/.github/workflows/release.yml +2 -5
  6. package/.github/workflows/security.yml +2 -2
  7. package/CDP.md +1 -1
  8. package/CDP.zh-CN.md +1 -1
  9. package/README.md +35 -8
  10. package/README.zh-CN.md +35 -8
  11. package/SKILL.md +3 -5
  12. package/dist/browser/cdp.d.ts +27 -0
  13. package/dist/browser/cdp.js +295 -0
  14. package/dist/browser/daemon-client.d.ts +1 -1
  15. package/dist/browser/index.d.ts +4 -2
  16. package/dist/browser/index.js +5 -5
  17. package/dist/browser/mcp.d.ts +5 -8
  18. package/dist/browser/mcp.js +9 -10
  19. package/dist/browser/page.d.ts +8 -1
  20. package/dist/browser/page.js +25 -40
  21. package/dist/browser/utils.d.ts +10 -0
  22. package/dist/browser/utils.js +27 -0
  23. package/dist/browser.test.js +48 -7
  24. package/dist/chaoxing.d.ts +58 -0
  25. package/dist/chaoxing.js +225 -0
  26. package/dist/chaoxing.test.d.ts +1 -0
  27. package/dist/chaoxing.test.js +38 -0
  28. package/dist/cli-manifest.json +597 -14
  29. package/dist/cli.d.ts +1 -0
  30. package/dist/cli.js +197 -0
  31. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/episodes.js +28 -0
  33. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  34. package/dist/clis/apple-podcasts/search.js +29 -0
  35. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  36. package/dist/clis/apple-podcasts/top.js +34 -0
  37. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  38. package/dist/clis/apple-podcasts/utils.js +30 -0
  39. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  40. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  41. package/dist/clis/boss/chatlist.d.ts +1 -0
  42. package/dist/clis/boss/chatlist.js +50 -0
  43. package/dist/clis/boss/chatmsg.d.ts +1 -0
  44. package/dist/clis/boss/chatmsg.js +73 -0
  45. package/dist/clis/boss/send.d.ts +1 -0
  46. package/dist/clis/boss/send.js +176 -0
  47. package/dist/clis/chaoxing/assignments.d.ts +1 -0
  48. package/dist/clis/chaoxing/assignments.js +74 -0
  49. package/dist/clis/chaoxing/exams.d.ts +1 -0
  50. package/dist/clis/chaoxing/exams.js +74 -0
  51. package/dist/clis/chatgpt/ask.js +15 -14
  52. package/dist/clis/chatgpt/ax.d.ts +1 -0
  53. package/dist/clis/chatgpt/ax.js +78 -0
  54. package/dist/clis/chatgpt/read.js +5 -6
  55. package/dist/clis/chatwise/history.js +18 -1
  56. package/dist/clis/discord-app/channels.js +33 -21
  57. package/dist/clis/twitter/accept.d.ts +1 -0
  58. package/dist/clis/twitter/accept.js +202 -0
  59. package/dist/clis/twitter/followers.js +30 -22
  60. package/dist/clis/twitter/following.js +19 -14
  61. package/dist/clis/twitter/notifications.js +29 -22
  62. package/dist/clis/twitter/post.js +9 -2
  63. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  64. package/dist/clis/twitter/reply-dm.js +181 -0
  65. package/dist/clis/twitter/search.js +30 -11
  66. package/dist/clis/weread/book.d.ts +1 -0
  67. package/dist/clis/weread/book.js +26 -0
  68. package/dist/clis/weread/highlights.d.ts +1 -0
  69. package/dist/clis/weread/highlights.js +23 -0
  70. package/dist/clis/weread/notebooks.d.ts +1 -0
  71. package/dist/clis/weread/notebooks.js +21 -0
  72. package/dist/clis/weread/notes.d.ts +1 -0
  73. package/dist/clis/weread/notes.js +29 -0
  74. package/dist/clis/weread/ranking.d.ts +1 -0
  75. package/dist/clis/weread/ranking.js +28 -0
  76. package/dist/clis/weread/search.d.ts +1 -0
  77. package/dist/clis/weread/search.js +25 -0
  78. package/dist/clis/weread/shelf.d.ts +1 -0
  79. package/dist/clis/weread/shelf.js +24 -0
  80. package/dist/clis/weread/utils.d.ts +20 -0
  81. package/dist/clis/weread/utils.js +72 -0
  82. package/dist/clis/weread/utils.test.d.ts +1 -0
  83. package/dist/clis/weread/utils.test.js +85 -0
  84. package/dist/clis/xiaohongshu/download.d.ts +1 -1
  85. package/dist/clis/xiaohongshu/download.js +1 -1
  86. package/dist/daemon.js +2 -2
  87. package/dist/doctor.d.ts +0 -21
  88. package/dist/doctor.js +2 -24
  89. package/dist/engine.js +24 -13
  90. package/dist/explore.js +46 -101
  91. package/dist/main.js +4 -203
  92. package/dist/output.d.ts +1 -1
  93. package/dist/registry.d.ts +3 -3
  94. package/dist/runtime.d.ts +1 -4
  95. package/dist/runtime.js +1 -4
  96. package/dist/scripts/framework.d.ts +4 -0
  97. package/dist/scripts/framework.js +21 -0
  98. package/dist/scripts/interact.d.ts +4 -0
  99. package/dist/scripts/interact.js +20 -0
  100. package/dist/scripts/store.d.ts +9 -0
  101. package/dist/scripts/store.js +44 -0
  102. package/dist/setup.js +2 -2
  103. package/dist/synthesize.js +1 -1
  104. package/extension/dist/background.js +392 -0
  105. package/extension/manifest.json +3 -3
  106. package/extension/package.json +1 -1
  107. package/extension/src/background.ts +101 -24
  108. package/extension/src/protocol.ts +1 -1
  109. package/package.json +1 -1
  110. package/src/browser/cdp.ts +295 -0
  111. package/src/browser/daemon-client.ts +1 -1
  112. package/src/browser/index.ts +5 -6
  113. package/src/browser/mcp.ts +14 -15
  114. package/src/browser/page.ts +25 -41
  115. package/src/browser/utils.ts +27 -0
  116. package/src/browser.test.ts +52 -6
  117. package/src/chaoxing.test.ts +45 -0
  118. package/src/chaoxing.ts +268 -0
  119. package/src/cli.ts +185 -0
  120. package/src/clis/antigravity/SKILL.md +5 -0
  121. package/src/clis/apple-podcasts/episodes.ts +28 -0
  122. package/src/clis/apple-podcasts/search.ts +29 -0
  123. package/src/clis/apple-podcasts/top.ts +34 -0
  124. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  125. package/src/clis/apple-podcasts/utils.ts +37 -0
  126. package/src/clis/boss/chatlist.ts +50 -0
  127. package/src/clis/boss/chatmsg.ts +70 -0
  128. package/src/clis/boss/send.ts +193 -0
  129. package/src/clis/chaoxing/README.md +36 -0
  130. package/src/clis/chaoxing/README.zh-CN.md +35 -0
  131. package/src/clis/chaoxing/assignments.ts +88 -0
  132. package/src/clis/chaoxing/exams.ts +88 -0
  133. package/src/clis/chatgpt/ask.ts +14 -15
  134. package/src/clis/chatgpt/ax.ts +81 -0
  135. package/src/clis/chatgpt/read.ts +5 -7
  136. package/src/clis/chatwise/history.ts +15 -1
  137. package/src/clis/discord-app/channels.ts +33 -21
  138. package/src/clis/twitter/accept.ts +213 -0
  139. package/src/clis/twitter/followers.ts +36 -29
  140. package/src/clis/twitter/following.ts +25 -20
  141. package/src/clis/twitter/notifications.ts +34 -27
  142. package/src/clis/twitter/post.ts +9 -2
  143. package/src/clis/twitter/reply-dm.ts +193 -0
  144. package/src/clis/twitter/search.ts +34 -12
  145. package/src/clis/weread/book.ts +28 -0
  146. package/src/clis/weread/highlights.ts +25 -0
  147. package/src/clis/weread/notebooks.ts +23 -0
  148. package/src/clis/weread/notes.ts +31 -0
  149. package/src/clis/weread/ranking.ts +29 -0
  150. package/src/clis/weread/search.ts +26 -0
  151. package/src/clis/weread/shelf.ts +26 -0
  152. package/src/clis/weread/utils.test.ts +104 -0
  153. package/src/clis/weread/utils.ts +74 -0
  154. package/src/clis/xiaohongshu/download.ts +1 -1
  155. package/src/daemon.ts +2 -2
  156. package/src/doctor.ts +2 -19
  157. package/src/engine.ts +20 -13
  158. package/src/explore.ts +51 -100
  159. package/src/main.ts +4 -186
  160. package/src/output.ts +12 -12
  161. package/src/registry.ts +3 -3
  162. package/src/runtime.ts +2 -6
  163. package/src/scripts/framework.ts +20 -0
  164. package/src/scripts/interact.ts +22 -0
  165. package/src/scripts/store.ts +40 -0
  166. package/src/setup.ts +2 -2
  167. package/src/synthesize.ts +1 -1
  168. package/tests/e2e/public-commands.test.ts +68 -1
  169. package/dist/clis/grok/debug.d.ts +0 -1
  170. package/dist/clis/grok/debug.js +0 -45
  171. package/src/clis/grok/debug.ts +0 -49
@@ -0,0 +1,176 @@
1
+ /**
2
+ * BOSS直聘 send message — via UI automation on chat page.
3
+ *
4
+ * Flow: navigate to chat → click on user in list → type in editor → send.
5
+ * BOSS chat uses MQTT (not HTTP) for messaging, so we must go through the UI.
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ cli({
9
+ site: 'boss',
10
+ name: 'send',
11
+ description: 'BOSS直聘发送聊天消息',
12
+ domain: 'www.zhipin.com',
13
+ strategy: Strategy.COOKIE,
14
+ browser: true,
15
+ args: [
16
+ { name: 'uid', required: true, help: 'Encrypted UID of the candidate (from chatlist)' },
17
+ { name: 'text', required: true, help: 'Message text to send' },
18
+ ],
19
+ columns: ['status', 'detail'],
20
+ func: async (page, kwargs) => {
21
+ if (!page)
22
+ throw new Error('Browser page required');
23
+ const uid = kwargs.uid;
24
+ const text = kwargs.text;
25
+ // Step 1: Navigate to chat page
26
+ await page.goto('https://www.zhipin.com/web/chat/index');
27
+ await page.wait({ time: 3 });
28
+ // Step 2: Find friend in list to get their numeric uid, then click
29
+ const friendData = await page.evaluate(`
30
+ async () => {
31
+ return new Promise((resolve, reject) => {
32
+ const xhr = new XMLHttpRequest();
33
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
34
+ xhr.withCredentials = true;
35
+ xhr.timeout = 15000;
36
+ xhr.setRequestHeader('Accept', 'application/json');
37
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
38
+ xhr.onerror = () => reject(new Error('Network Error'));
39
+ xhr.send();
40
+ });
41
+ }
42
+ `);
43
+ if (friendData.code !== 0) {
44
+ if (friendData.code === 7 || friendData.code === 37) {
45
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
46
+ }
47
+ throw new Error('获取好友列表失败: ' + (friendData.message || friendData.code));
48
+ }
49
+ let target = null;
50
+ const allFriends = friendData.zpData?.friendList || [];
51
+ target = allFriends.find((f) => f.encryptUid === uid);
52
+ if (!target) {
53
+ for (let p = 2; p <= 5; p++) {
54
+ const moreUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${p}&status=0&jobId=0`;
55
+ const moreData = await page.evaluate(`
56
+ async () => {
57
+ return new Promise((resolve, reject) => {
58
+ const xhr = new XMLHttpRequest();
59
+ xhr.open('GET', '${moreUrl}', true);
60
+ xhr.withCredentials = true;
61
+ xhr.timeout = 15000;
62
+ xhr.setRequestHeader('Accept', 'application/json');
63
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
64
+ xhr.onerror = () => reject(new Error('Network Error'));
65
+ xhr.send();
66
+ });
67
+ }
68
+ `);
69
+ if (moreData.code === 0) {
70
+ const list = moreData.zpData?.friendList || [];
71
+ target = list.find((f) => f.encryptUid === uid);
72
+ if (target)
73
+ break;
74
+ if (list.length === 0)
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ if (!target)
80
+ throw new Error('未找到该候选人,请确认 uid 是否正确');
81
+ const numericUid = target.uid;
82
+ const friendName = target.name || '候选人';
83
+ // Step 3: Click on the user in the chat list to open conversation
84
+ const clicked = await page.evaluate(`
85
+ async () => {
86
+ // The geek-item has id like _748787762-0
87
+ const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
88
+ if (item) {
89
+ item.click();
90
+ return { clicked: true, id: item.id };
91
+ }
92
+ // Fallback: try clicking by iterating geek items
93
+ const items = document.querySelectorAll('.geek-item');
94
+ for (const el of items) {
95
+ if (el.id && el.id.startsWith('_${numericUid}')) {
96
+ el.click();
97
+ return { clicked: true, id: el.id };
98
+ }
99
+ }
100
+ return { clicked: false };
101
+ }
102
+ `);
103
+ if (!clicked.clicked) {
104
+ throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
105
+ }
106
+ // Step 4: Wait for the conversation to load and input area to appear
107
+ await page.wait({ time: 2 });
108
+ // Step 5: Find the message editor and type
109
+ const typed = await page.evaluate(`
110
+ async () => {
111
+ // Look for the chat editor - BOSS uses contenteditable div or textarea
112
+ const selectors = [
113
+ '.chat-editor [contenteditable="true"]',
114
+ '.chat-input [contenteditable="true"]',
115
+ '.message-editor [contenteditable="true"]',
116
+ '.chat-conversation [contenteditable="true"]',
117
+ '[contenteditable="true"]',
118
+ '.chat-editor textarea',
119
+ '.chat-input textarea',
120
+ 'textarea',
121
+ ];
122
+
123
+ for (const sel of selectors) {
124
+ const el = document.querySelector(sel);
125
+ if (el && el.offsetParent !== null) {
126
+ el.focus();
127
+
128
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
129
+ el.value = ${JSON.stringify(text)};
130
+ el.dispatchEvent(new Event('input', { bubbles: true }));
131
+ el.dispatchEvent(new Event('change', { bubbles: true }));
132
+ } else {
133
+ // contenteditable
134
+ el.textContent = '';
135
+ el.focus();
136
+ document.execCommand('insertText', false, ${JSON.stringify(text)});
137
+ el.dispatchEvent(new Event('input', { bubbles: true }));
138
+ }
139
+
140
+ return { found: true, selector: sel, tag: el.tagName };
141
+ }
142
+ }
143
+
144
+ // Debug: list all visible elements in chat-conversation
145
+ const conv = document.querySelector('.chat-conversation');
146
+ const allEls = conv ? Array.from(conv.querySelectorAll('*')).filter(e => e.offsetParent !== null).map(e => e.tagName + '.' + (e.className?.substring?.(0, 50) || '')).slice(0, 30) : [];
147
+
148
+ return { found: false, visibleElements: allEls };
149
+ }
150
+ `);
151
+ if (!typed.found) {
152
+ throw new Error('找不到消息输入框。可能的元素: ' + JSON.stringify(typed.visibleElements || []));
153
+ }
154
+ await page.wait({ time: 0.5 });
155
+ // Step 6: Click the send button (Enter key doesn't trigger send on BOSS)
156
+ const sent = await page.evaluate(`
157
+ async () => {
158
+ // The send button is .submit inside .submit-content
159
+ const btn = document.querySelector('.conversation-editor .submit')
160
+ || document.querySelector('.submit-content .submit')
161
+ || document.querySelector('.conversation-operate .submit');
162
+ if (btn) {
163
+ btn.click();
164
+ return { clicked: true };
165
+ }
166
+ return { clicked: false };
167
+ }
168
+ `);
169
+ if (!sent.clicked) {
170
+ // Fallback: try Enter key
171
+ await page.pressKey('Enter');
172
+ }
173
+ await page.wait({ time: 1 });
174
+ return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${text}` }];
175
+ },
176
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { getCourses, initSession, enterCourse, getTabIframeUrl, parseAssignmentsFromDom, sleep, } from '../../chaoxing.js';
3
+ cli({
4
+ site: 'chaoxing',
5
+ name: 'assignments',
6
+ description: '学习通作业列表',
7
+ domain: 'mooc2-ans.chaoxing.com',
8
+ strategy: Strategy.COOKIE,
9
+ timeoutSeconds: 90,
10
+ args: [
11
+ { name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
12
+ {
13
+ name: 'status',
14
+ type: 'string',
15
+ default: 'all',
16
+ choices: ['all', 'pending', 'submitted', 'graded'],
17
+ help: '按状态过滤',
18
+ },
19
+ { name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
20
+ ],
21
+ columns: ['rank', 'course', 'title', 'deadline', 'status', 'score'],
22
+ func: async (page, kwargs) => {
23
+ const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
24
+ // 1. Establish session
25
+ await initSession(page);
26
+ // 2. Get courses
27
+ const courses = await getCourses(page);
28
+ if (!courses.length)
29
+ throw new Error('未获取到课程列表,请确认已登录学习通');
30
+ const filtered = courseFilter
31
+ ? courses.filter(c => c.title.includes(courseFilter))
32
+ : courses;
33
+ if (courseFilter && !filtered.length) {
34
+ throw new Error(`未找到匹配「${courseFilter}」的课程`);
35
+ }
36
+ // 3. Per-course: enter → click 作业 tab → navigate to iframe → parse
37
+ const allRows = [];
38
+ for (const c of filtered) {
39
+ try {
40
+ await enterCourse(page, c);
41
+ const iframeUrl = await getTabIframeUrl(page, '作业');
42
+ if (!iframeUrl)
43
+ continue;
44
+ await page.goto(iframeUrl);
45
+ await page.wait(2);
46
+ const rows = await parseAssignmentsFromDom(page, c.title);
47
+ allRows.push(...rows);
48
+ }
49
+ catch {
50
+ // Single course failure: skip, continue
51
+ }
52
+ if (filtered.length > 1)
53
+ await sleep(600);
54
+ }
55
+ // 4. Sort: pending first, then by deadline
56
+ allRows.sort((a, b) => {
57
+ const order = (s) => s === '未交' ? 0 : s === '待批阅' ? 1 : s === '已完成' ? 2 : s === '已批阅' ? 3 : 4;
58
+ return order(a.status) - order(b.status);
59
+ });
60
+ // 5. Filter by status
61
+ const statusMap = {
62
+ pending: ['未交'],
63
+ submitted: ['待批阅', '已完成'],
64
+ graded: ['已批阅'],
65
+ };
66
+ const finalRows = statusFilter === 'all'
67
+ ? allRows
68
+ : allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
69
+ return finalRows.slice(0, Number(limit)).map((item, i) => ({
70
+ rank: i + 1,
71
+ ...item,
72
+ }));
73
+ },
74
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { getCourses, initSession, enterCourse, getTabIframeUrl, parseExamsFromDom, sleep, } from '../../chaoxing.js';
3
+ cli({
4
+ site: 'chaoxing',
5
+ name: 'exams',
6
+ description: '学习通考试列表',
7
+ domain: 'mooc2-ans.chaoxing.com',
8
+ strategy: Strategy.COOKIE,
9
+ timeoutSeconds: 90,
10
+ args: [
11
+ { name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
12
+ {
13
+ name: 'status',
14
+ type: 'string',
15
+ default: 'all',
16
+ choices: ['all', 'upcoming', 'ongoing', 'finished'],
17
+ help: '按状态过滤',
18
+ },
19
+ { name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
20
+ ],
21
+ columns: ['rank', 'course', 'title', 'start', 'end', 'status', 'score'],
22
+ func: async (page, kwargs) => {
23
+ const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
24
+ // 1. Establish session
25
+ await initSession(page);
26
+ // 2. Get courses
27
+ const courses = await getCourses(page);
28
+ if (!courses.length)
29
+ throw new Error('未获取到课程列表,请确认已登录学习通');
30
+ const filtered = courseFilter
31
+ ? courses.filter(c => c.title.includes(courseFilter))
32
+ : courses;
33
+ if (courseFilter && !filtered.length) {
34
+ throw new Error(`未找到匹配「${courseFilter}」的课程`);
35
+ }
36
+ // 3. Per-course: enter → click 考试 tab → navigate to iframe → parse
37
+ const allRows = [];
38
+ for (const c of filtered) {
39
+ try {
40
+ await enterCourse(page, c);
41
+ const iframeUrl = await getTabIframeUrl(page, '考试');
42
+ if (!iframeUrl)
43
+ continue;
44
+ await page.goto(iframeUrl);
45
+ await page.wait(2);
46
+ const rows = await parseExamsFromDom(page, c.title);
47
+ allRows.push(...rows);
48
+ }
49
+ catch {
50
+ // Single course failure: skip, continue
51
+ }
52
+ if (filtered.length > 1)
53
+ await sleep(600);
54
+ }
55
+ // 4. Sort: upcoming first
56
+ allRows.sort((a, b) => {
57
+ const order = (s) => s === '未开始' ? 0 : s === '进行中' ? 1 : s === '已结束' ? 2 : s === '已完成' ? 3 : 4;
58
+ return order(a.status) - order(b.status);
59
+ });
60
+ // 5. Filter by status
61
+ const statusMap = {
62
+ upcoming: ['未开始'],
63
+ ongoing: ['进行中'],
64
+ finished: ['已结束', '已完成'],
65
+ };
66
+ const finalRows = statusFilter === 'all'
67
+ ? allRows
68
+ : allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
69
+ return finalRows.slice(0, Number(limit)).map((item, i) => ({
70
+ rank: i + 1,
71
+ ...item,
72
+ }));
73
+ },
74
+ });
@@ -1,5 +1,6 @@
1
1
  import { execSync, spawnSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '../../registry.js';
3
+ import { getVisibleChatMessages } from './ax.js';
3
4
  export const askCommand = cli({
4
5
  site: 'chatgpt',
5
6
  name: 'ask',
@@ -21,6 +22,7 @@ export const askCommand = cli({
21
22
  clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
22
23
  }
23
24
  catch { }
25
+ const messagesBefore = getVisibleChatMessages();
24
26
  // Send the message
25
27
  spawnSync('pbcopy', { input: text });
26
28
  execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
@@ -32,28 +34,27 @@ export const askCommand = cli({
32
34
  "-e 'keystroke return' " +
33
35
  "-e 'end tell'";
34
36
  execSync(cmd);
35
- // Clear clipboard marker
36
- spawnSync('pbcopy', { input: '__OPENCLI_WAITING__' });
37
- // Wait for response, then read it
38
- const pollInterval = 3;
37
+ // Restore clipboard after the prompt is sent.
38
+ if (clipBackup)
39
+ spawnSync('pbcopy', { input: clipBackup });
40
+ // Wait for response, then read the latest visible assistant message from the AX tree.
41
+ const pollInterval = 1;
39
42
  const maxPolls = Math.ceil(timeout / pollInterval);
40
43
  let response = '';
41
44
  for (let i = 0; i < maxPolls; i++) {
42
- // Wait
43
45
  execSync(`sleep ${pollInterval}`);
44
- // Try Cmd+Shift+C to copy the latest response
45
46
  execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
46
- execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'");
47
- execSync("osascript -e 'delay 0.3'");
48
- const copied = execSync('pbpaste', { encoding: 'utf-8' }).trim();
49
- if (copied && copied !== '__OPENCLI_WAITING__' && copied !== text) {
50
- response = copied;
47
+ execSync("osascript -e 'delay 0.2'");
48
+ const messagesNow = getVisibleChatMessages();
49
+ if (messagesNow.length <= messagesBefore.length)
50
+ continue;
51
+ const newMessages = messagesNow.slice(messagesBefore.length);
52
+ const candidate = [...newMessages].reverse().find((message) => message !== text);
53
+ if (candidate) {
54
+ response = candidate;
51
55
  break;
52
56
  }
53
57
  }
54
- // Restore clipboard
55
- if (clipBackup)
56
- spawnSync('pbcopy', { input: clipBackup });
57
58
  if (!response) {
58
59
  return [
59
60
  { Role: 'User', Text: text },
@@ -0,0 +1 @@
1
+ export declare function getVisibleChatMessages(): string[];
@@ -0,0 +1,78 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ const AX_READ_SCRIPT = `
3
+ import Cocoa
4
+ import ApplicationServices
5
+
6
+ func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
7
+ var value: CFTypeRef?
8
+ guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
9
+ return value as AnyObject?
10
+ }
11
+
12
+ func s(_ el: AXUIElement, _ name: String) -> String? {
13
+ if let v = attr(el, name) as? String, !v.isEmpty { return v }
14
+ return nil
15
+ }
16
+
17
+ func children(_ el: AXUIElement) -> [AXUIElement] {
18
+ (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
19
+ }
20
+
21
+ func collectLists(_ el: AXUIElement, into out: inout [AXUIElement]) {
22
+ let role = s(el, kAXRoleAttribute as String) ?? ""
23
+ if role == kAXListRole as String { out.append(el) }
24
+ for c in children(el) { collectLists(c, into: &out) }
25
+ }
26
+
27
+ func collectTexts(_ el: AXUIElement, into out: inout [String]) {
28
+ let role = s(el, kAXRoleAttribute as String) ?? ""
29
+ if role == kAXStaticTextRole as String {
30
+ if let text = s(el, kAXDescriptionAttribute as String), !text.isEmpty {
31
+ out.append(text)
32
+ }
33
+ }
34
+ for c in children(el) { collectTexts(c, into: &out) }
35
+ }
36
+
37
+ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
38
+ fputs("ChatGPT not running\\n", stderr)
39
+ exit(1)
40
+ }
41
+
42
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
43
+ guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
44
+ fputs("No focused ChatGPT window\\n", stderr)
45
+ exit(1)
46
+ }
47
+
48
+ var lists: [AXUIElement] = []
49
+ collectLists(win, into: &lists)
50
+
51
+ var best: [String] = []
52
+ for list in lists {
53
+ var texts: [String] = []
54
+ collectTexts(list, into: &texts)
55
+ if texts.count > best.count {
56
+ best = texts
57
+ }
58
+ }
59
+
60
+ let data = try! JSONSerialization.data(withJSONObject: best, options: [])
61
+ print(String(data: data, encoding: .utf8)!)
62
+ `;
63
+ export function getVisibleChatMessages() {
64
+ const output = execFileSync('swift', ['-'], {
65
+ input: AX_READ_SCRIPT,
66
+ encoding: 'utf-8',
67
+ maxBuffer: 10 * 1024 * 1024,
68
+ }).trim();
69
+ if (!output)
70
+ return [];
71
+ const parsed = JSON.parse(output);
72
+ if (!Array.isArray(parsed))
73
+ return [];
74
+ return parsed
75
+ .filter((item) => typeof item === 'string')
76
+ .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
77
+ .filter((item) => item.length > 0);
78
+ }
@@ -1,5 +1,6 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '../../registry.js';
3
+ import { getVisibleChatMessages } from './ax.js';
3
4
  export const readCommand = cli({
4
5
  site: 'chatgpt',
5
6
  name: 'read',
@@ -12,14 +13,12 @@ export const readCommand = cli({
12
13
  func: async (page) => {
13
14
  try {
14
15
  execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
15
- execSync("osascript -e 'delay 0.5'");
16
- execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'");
17
16
  execSync("osascript -e 'delay 0.3'");
18
- const result = execSync('pbpaste', { encoding: 'utf-8' }).trim();
19
- if (!result) {
20
- return [{ Role: 'System', Text: 'No text was copied. Is there a response in the chat?' }];
17
+ const messages = getVisibleChatMessages();
18
+ if (!messages.length) {
19
+ return [{ Role: 'System', Text: 'No visible chat messages were found in the current ChatGPT window.' }];
21
20
  }
22
- return [{ Role: 'Assistant', Text: result }];
21
+ return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
23
22
  }
24
23
  catch (err) {
25
24
  throw new Error("Failed to read from ChatGPT: " + err.message);
@@ -38,6 +38,23 @@ export const historyCommand = cli({
38
38
  if (items.length === 0) {
39
39
  return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }];
40
40
  }
41
- return items;
41
+ const dateHeaders = /^(today|yesterday|last week|last month|last year|this week|this month|older|previous \d+ days|\d+ days ago)$/i;
42
+ const numericOnly = /^[\d\s]+$/;
43
+ const modelPath = /^[\w.-]+\/[\w.-]/;
44
+ const seen = new Set();
45
+ const deduped = items.filter((item) => {
46
+ const t = item.Title.trim();
47
+ if (dateHeaders.test(t))
48
+ return false;
49
+ if (numericOnly.test(t))
50
+ return false;
51
+ if (modelPath.test(t))
52
+ return false;
53
+ if (seen.has(t))
54
+ return false;
55
+ seen.add(t);
56
+ return true;
57
+ }).map((item, i) => ({ Index: i + 1, Title: item.Title }));
58
+ return deduped;
42
59
  },
43
60
  });
@@ -12,28 +12,40 @@ export const channelsCommand = cli({
12
12
  const channels = await page.evaluate(`
13
13
  (function() {
14
14
  const results = [];
15
- // Discord channel list items
16
- const items = document.querySelectorAll('[data-list-item-id*="channels___"], [class*="containerDefault_"]');
17
-
18
- items.forEach((item, i) => {
19
- const nameEl = item.querySelector('[class*="name_"], [class*="channelName"]');
20
- const name = nameEl ? nameEl.textContent.trim() : (item.textContent || '').trim().substring(0, 50);
21
-
22
- if (!name || name.length < 1) return;
23
-
24
- // Detect channel type from icon or aria-label
25
- const iconEl = item.querySelector('[class*="icon"]');
26
- let type = 'Text';
27
- if (iconEl) {
28
- const cls = iconEl.className || '';
29
- if (cls.includes('voice') || cls.includes('speaker')) type = 'Voice';
30
- else if (cls.includes('forum')) type = 'Forum';
31
- else if (cls.includes('announcement')) type = 'Announcement';
32
- }
33
-
34
- results.push({ Index: i + 1, Channel: name, Type: type });
15
+
16
+ // Discord channel links: <a> tags with href like /channels/GUILD/CHANNEL
17
+ const links = document.querySelectorAll('a[href*="/channels/"][data-list-item-id^="channels___"]');
18
+
19
+ links.forEach(function(el) {
20
+ var label = el.getAttribute('aria-label') || '';
21
+ if (!label) return;
22
+
23
+ // Skip categories
24
+ if (/[((]category[))]/i.test(label)) return;
25
+
26
+ // Strip any leading status prefix before the first comma (e.g. "unread, ", locale-agnostic)
27
+ var commaIdx = label.search(/[,,]/);
28
+ var cleaned = commaIdx !== -1 ? label.slice(commaIdx + 1).trimStart() : label;
29
+
30
+ // Extract name and type from "name (type)" or "name(type)"
31
+ var m = cleaned.match(/^(.+?)\s*[((](.+?)[))]\s*$/);
32
+ // If no type annotation found, skip — real channels always have "(Type channel)" in aria-label
33
+ if (!m) return;
34
+ var name = m[1].trim();
35
+ var rawType = m[2].toLowerCase();
36
+
37
+ // Discord channel names are ASCII-only; skip placeholder entries (e.g. locked channels)
38
+ if (!name || !/^[\x20-\x7E]+$/.test(name)) return;
39
+
40
+ var type = 'Text';
41
+ if (rawType.includes('voice')) type = 'Voice';
42
+ else if (rawType.includes('forum')) type = 'Forum';
43
+ else if (rawType.includes('announcement')) type = 'Announcement';
44
+ else if (rawType.includes('stage')) type = 'Stage';
45
+
46
+ results.push({ Index: results.length + 1, Channel: name, Type: type });
35
47
  });
36
-
48
+
37
49
  return results;
38
50
  })()
39
51
  `);
@@ -0,0 +1 @@
1
+ export {};