@jackwener/opencli 1.0.1 → 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 (91) 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 +15 -7
  10. package/README.zh-CN.md +15 -7
  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/index.d.ts +3 -0
  15. package/dist/browser/index.js +4 -0
  16. package/dist/browser/page.js +2 -23
  17. package/dist/browser/utils.d.ts +10 -0
  18. package/dist/browser/utils.js +27 -0
  19. package/dist/browser.test.js +42 -1
  20. package/dist/chaoxing.d.ts +58 -0
  21. package/dist/chaoxing.js +225 -0
  22. package/dist/chaoxing.test.d.ts +1 -0
  23. package/dist/chaoxing.test.js +38 -0
  24. package/dist/cli-manifest.json +203 -0
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +197 -0
  27. package/dist/clis/boss/chatlist.d.ts +1 -0
  28. package/dist/clis/boss/chatlist.js +50 -0
  29. package/dist/clis/boss/chatmsg.d.ts +1 -0
  30. package/dist/clis/boss/chatmsg.js +73 -0
  31. package/dist/clis/boss/send.d.ts +1 -0
  32. package/dist/clis/boss/send.js +176 -0
  33. package/dist/clis/chaoxing/assignments.d.ts +1 -0
  34. package/dist/clis/chaoxing/assignments.js +74 -0
  35. package/dist/clis/chaoxing/exams.d.ts +1 -0
  36. package/dist/clis/chaoxing/exams.js +74 -0
  37. package/dist/clis/chatgpt/ask.js +15 -14
  38. package/dist/clis/chatgpt/ax.d.ts +1 -0
  39. package/dist/clis/chatgpt/ax.js +78 -0
  40. package/dist/clis/chatgpt/read.js +5 -6
  41. package/dist/clis/twitter/post.js +9 -2
  42. package/dist/clis/twitter/search.js +14 -33
  43. package/dist/clis/xiaohongshu/download.d.ts +1 -1
  44. package/dist/clis/xiaohongshu/download.js +1 -1
  45. package/dist/engine.js +24 -13
  46. package/dist/explore.js +46 -101
  47. package/dist/main.js +4 -193
  48. package/dist/output.d.ts +1 -1
  49. package/dist/registry.d.ts +3 -3
  50. package/dist/scripts/framework.d.ts +4 -0
  51. package/dist/scripts/framework.js +21 -0
  52. package/dist/scripts/interact.d.ts +4 -0
  53. package/dist/scripts/interact.js +20 -0
  54. package/dist/scripts/store.d.ts +9 -0
  55. package/dist/scripts/store.js +44 -0
  56. package/dist/synthesize.js +1 -1
  57. package/extension/dist/background.js +338 -430
  58. package/extension/manifest.json +2 -2
  59. package/extension/src/background.ts +2 -2
  60. package/package.json +1 -1
  61. package/src/browser/cdp.ts +295 -0
  62. package/src/browser/index.ts +4 -0
  63. package/src/browser/page.ts +2 -24
  64. package/src/browser/utils.ts +27 -0
  65. package/src/browser.test.ts +46 -0
  66. package/src/chaoxing.test.ts +45 -0
  67. package/src/chaoxing.ts +268 -0
  68. package/src/cli.ts +185 -0
  69. package/src/clis/antigravity/SKILL.md +5 -0
  70. package/src/clis/boss/chatlist.ts +50 -0
  71. package/src/clis/boss/chatmsg.ts +70 -0
  72. package/src/clis/boss/send.ts +193 -0
  73. package/src/clis/chaoxing/README.md +36 -0
  74. package/src/clis/chaoxing/README.zh-CN.md +35 -0
  75. package/src/clis/chaoxing/assignments.ts +88 -0
  76. package/src/clis/chaoxing/exams.ts +88 -0
  77. package/src/clis/chatgpt/ask.ts +14 -15
  78. package/src/clis/chatgpt/ax.ts +81 -0
  79. package/src/clis/chatgpt/read.ts +5 -7
  80. package/src/clis/twitter/post.ts +9 -2
  81. package/src/clis/twitter/search.ts +15 -33
  82. package/src/clis/xiaohongshu/download.ts +1 -1
  83. package/src/engine.ts +20 -13
  84. package/src/explore.ts +51 -100
  85. package/src/main.ts +4 -180
  86. package/src/output.ts +12 -12
  87. package/src/registry.ts +3 -3
  88. package/src/scripts/framework.ts +20 -0
  89. package/src/scripts/interact.ts +22 -0
  90. package/src/scripts/store.ts +40 -0
  91. package/src/synthesize.ts +1 -1
@@ -0,0 +1,193 @@
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
+ import type { IPage } from '../../types.js';
9
+
10
+ cli({
11
+ site: 'boss',
12
+ name: 'send',
13
+ description: 'BOSS直聘发送聊天消息',
14
+ domain: 'www.zhipin.com',
15
+ strategy: Strategy.COOKIE,
16
+
17
+ browser: true,
18
+ args: [
19
+ { name: 'uid', required: true, help: 'Encrypted UID of the candidate (from chatlist)' },
20
+ { name: 'text', required: true, help: 'Message text to send' },
21
+ ],
22
+ columns: ['status', 'detail'],
23
+ func: async (page: IPage | null, kwargs) => {
24
+ if (!page) throw new Error('Browser page required');
25
+
26
+ const uid = kwargs.uid;
27
+ const text = kwargs.text;
28
+
29
+ // Step 1: Navigate to chat page
30
+ await page.goto('https://www.zhipin.com/web/chat/index');
31
+ await page.wait({ time: 3 });
32
+
33
+ // Step 2: Find friend in list to get their numeric uid, then click
34
+ const friendData: any = await page.evaluate(`
35
+ async () => {
36
+ return new Promise((resolve, reject) => {
37
+ const xhr = new XMLHttpRequest();
38
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
39
+ xhr.withCredentials = true;
40
+ xhr.timeout = 15000;
41
+ xhr.setRequestHeader('Accept', 'application/json');
42
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
43
+ xhr.onerror = () => reject(new Error('Network Error'));
44
+ xhr.send();
45
+ });
46
+ }
47
+ `);
48
+
49
+ if (friendData.code !== 0) {
50
+ if (friendData.code === 7 || friendData.code === 37) {
51
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
52
+ }
53
+ throw new Error('获取好友列表失败: ' + (friendData.message || friendData.code));
54
+ }
55
+
56
+ let target: any = null;
57
+ const allFriends = friendData.zpData?.friendList || [];
58
+ target = allFriends.find((f: any) => f.encryptUid === uid);
59
+
60
+ if (!target) {
61
+ for (let p = 2; p <= 5; p++) {
62
+ const moreUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${p}&status=0&jobId=0`;
63
+ const moreData: any = await page.evaluate(`
64
+ async () => {
65
+ return new Promise((resolve, reject) => {
66
+ const xhr = new XMLHttpRequest();
67
+ xhr.open('GET', '${moreUrl}', true);
68
+ xhr.withCredentials = true;
69
+ xhr.timeout = 15000;
70
+ xhr.setRequestHeader('Accept', 'application/json');
71
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
72
+ xhr.onerror = () => reject(new Error('Network Error'));
73
+ xhr.send();
74
+ });
75
+ }
76
+ `);
77
+ if (moreData.code === 0) {
78
+ const list = moreData.zpData?.friendList || [];
79
+ target = list.find((f: any) => f.encryptUid === uid);
80
+ if (target) break;
81
+ if (list.length === 0) break;
82
+ }
83
+ }
84
+ }
85
+
86
+ if (!target) throw new Error('未找到该候选人,请确认 uid 是否正确');
87
+
88
+ const numericUid = target.uid;
89
+ const friendName = target.name || '候选人';
90
+
91
+ // Step 3: Click on the user in the chat list to open conversation
92
+ const clicked: any = await page.evaluate(`
93
+ async () => {
94
+ // The geek-item has id like _748787762-0
95
+ const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
96
+ if (item) {
97
+ item.click();
98
+ return { clicked: true, id: item.id };
99
+ }
100
+ // Fallback: try clicking by iterating geek items
101
+ const items = document.querySelectorAll('.geek-item');
102
+ for (const el of items) {
103
+ if (el.id && el.id.startsWith('_${numericUid}')) {
104
+ el.click();
105
+ return { clicked: true, id: el.id };
106
+ }
107
+ }
108
+ return { clicked: false };
109
+ }
110
+ `);
111
+
112
+ if (!clicked.clicked) {
113
+ throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
114
+ }
115
+
116
+ // Step 4: Wait for the conversation to load and input area to appear
117
+ await page.wait({ time: 2 });
118
+
119
+ // Step 5: Find the message editor and type
120
+ const typed: any = await page.evaluate(`
121
+ async () => {
122
+ // Look for the chat editor - BOSS uses contenteditable div or textarea
123
+ const selectors = [
124
+ '.chat-editor [contenteditable="true"]',
125
+ '.chat-input [contenteditable="true"]',
126
+ '.message-editor [contenteditable="true"]',
127
+ '.chat-conversation [contenteditable="true"]',
128
+ '[contenteditable="true"]',
129
+ '.chat-editor textarea',
130
+ '.chat-input textarea',
131
+ 'textarea',
132
+ ];
133
+
134
+ for (const sel of selectors) {
135
+ const el = document.querySelector(sel);
136
+ if (el && el.offsetParent !== null) {
137
+ el.focus();
138
+
139
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
140
+ el.value = ${JSON.stringify(text)};
141
+ el.dispatchEvent(new Event('input', { bubbles: true }));
142
+ el.dispatchEvent(new Event('change', { bubbles: true }));
143
+ } else {
144
+ // contenteditable
145
+ el.textContent = '';
146
+ el.focus();
147
+ document.execCommand('insertText', false, ${JSON.stringify(text)});
148
+ el.dispatchEvent(new Event('input', { bubbles: true }));
149
+ }
150
+
151
+ return { found: true, selector: sel, tag: el.tagName };
152
+ }
153
+ }
154
+
155
+ // Debug: list all visible elements in chat-conversation
156
+ const conv = document.querySelector('.chat-conversation');
157
+ const allEls = conv ? Array.from(conv.querySelectorAll('*')).filter(e => e.offsetParent !== null).map(e => e.tagName + '.' + (e.className?.substring?.(0, 50) || '')).slice(0, 30) : [];
158
+
159
+ return { found: false, visibleElements: allEls };
160
+ }
161
+ `);
162
+
163
+ if (!typed.found) {
164
+ throw new Error('找不到消息输入框。可能的元素: ' + JSON.stringify(typed.visibleElements || []));
165
+ }
166
+
167
+ await page.wait({ time: 0.5 });
168
+
169
+ // Step 6: Click the send button (Enter key doesn't trigger send on BOSS)
170
+ const sent: any = await page.evaluate(`
171
+ async () => {
172
+ // The send button is .submit inside .submit-content
173
+ const btn = document.querySelector('.conversation-editor .submit')
174
+ || document.querySelector('.submit-content .submit')
175
+ || document.querySelector('.conversation-operate .submit');
176
+ if (btn) {
177
+ btn.click();
178
+ return { clicked: true };
179
+ }
180
+ return { clicked: false };
181
+ }
182
+ `);
183
+
184
+ if (!sent.clicked) {
185
+ // Fallback: try Enter key
186
+ await page.pressKey('Enter');
187
+ }
188
+
189
+ await page.wait({ time: 1 });
190
+
191
+ return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${text}` }];
192
+ },
193
+ });
@@ -0,0 +1,36 @@
1
+ # Chaoxing (学习通) Adapter
2
+
3
+ View your Chaoxing assignments and exams from the terminal by reusing your Chrome login session.
4
+
5
+ ## Prerequisites
6
+
7
+ 1. Chrome must be running and already logged into Chaoxing (`i.chaoxing.com`).
8
+ 2. The opencli Browser Bridge extension must be installed.
9
+
10
+ ## Commands
11
+
12
+ | Command | Description |
13
+ |---------|-------------|
14
+ | `opencli chaoxing assignments` | List assignments across all courses |
15
+ | `opencli chaoxing assignments --course "数学"` | Filter by course name (fuzzy match) |
16
+ | `opencli chaoxing assignments --status pending` | Filter: `all` / `pending` / `submitted` / `graded` |
17
+ | `opencli chaoxing exams` | List exams across all courses |
18
+ | `opencli chaoxing exams --course "数学"` | Filter by course name |
19
+ | `opencli chaoxing exams --status upcoming` | Filter: `all` / `upcoming` / `ongoing` / `finished` |
20
+
21
+ ## How It Works
22
+
23
+ Chaoxing has no flat API for listing assignments/exams. The adapter follows the same
24
+ flow a student would in the browser:
25
+
26
+ 1. Establish session via the interaction page
27
+ 2. Fetch enrolled course list (`backclazzdata` JSON API)
28
+ 3. Enter each course via `stucoursemiddle` redirect (obtains session `enc`)
29
+ 4. Click the 作业/考试 tab and capture the iframe URL
30
+ 5. Navigate to that URL and parse the DOM
31
+
32
+ ## Limitations
33
+
34
+ - Requires `--course` filter for practical use (scanning all 40+ courses is slow)
35
+ - Does not submit homework or exams
36
+ - If Chaoxing changes page structure, the DOM parser may need updates
@@ -0,0 +1,35 @@
1
+ # 学习通(Chaoxing)适配器
2
+
3
+ 通过复用 Chrome 登录态,在终端查看学习通的作业和考试列表。
4
+
5
+ ## 前置条件
6
+
7
+ 1. Chrome 必须正在运行,且已经登录学习通(`i.chaoxing.com`)。
8
+ 2. 需安装 opencli Browser Bridge 扩展。
9
+
10
+ ## 命令
11
+
12
+ | 命令 | 说明 |
13
+ |------|------|
14
+ | `opencli chaoxing assignments` | 列出所有课程的作业 |
15
+ | `opencli chaoxing assignments --course "数学"` | 按课程名模糊过滤 |
16
+ | `opencli chaoxing assignments --status pending` | 按状态过滤:`all` / `pending` / `submitted` / `graded` |
17
+ | `opencli chaoxing exams` | 列出所有课程的考试 |
18
+ | `opencli chaoxing exams --course "数学"` | 按课程名模糊过滤 |
19
+ | `opencli chaoxing exams --status upcoming` | 按状态过滤:`all` / `upcoming` / `ongoing` / `finished` |
20
+
21
+ ## 工作原理
22
+
23
+ 学习通没有扁平的"作业列表"API,适配器模拟学生在浏览器中的操作流程:
24
+
25
+ 1. 通过交互页建立会话
26
+ 2. 通过 `backclazzdata` JSON API 获取课程列表
27
+ 3. 通过 `stucoursemiddle` 重定向进入课程(获取会话 `enc`)
28
+ 4. 点击作业/考试标签,捕获 iframe URL
29
+ 5. 导航到该 URL 并解析 DOM
30
+
31
+ ## 限制
32
+
33
+ - 实际使用建议配合 `--course` 过滤(扫描全部 40+ 门课程较慢)
34
+ - 不提交作业、不参加考试
35
+ - 如学习通页面结构变动,DOM 解析器需同步更新
@@ -0,0 +1,88 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import {
3
+ getCourses, initSession, enterCourse, getTabIframeUrl,
4
+ parseAssignmentsFromDom, sleep,
5
+ type AssignmentRow,
6
+ } from '../../chaoxing.js';
7
+
8
+ cli({
9
+ site: 'chaoxing',
10
+ name: 'assignments',
11
+ description: '学习通作业列表',
12
+ domain: 'mooc2-ans.chaoxing.com',
13
+ strategy: Strategy.COOKIE,
14
+ timeoutSeconds: 90,
15
+ args: [
16
+ { name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
17
+ {
18
+ name: 'status',
19
+ type: 'string',
20
+ default: 'all',
21
+ choices: ['all', 'pending', 'submitted', 'graded'],
22
+ help: '按状态过滤',
23
+ },
24
+ { name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
25
+ ],
26
+ columns: ['rank', 'course', 'title', 'deadline', 'status', 'score'],
27
+
28
+ func: async (page, kwargs) => {
29
+ const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
30
+
31
+ // 1. Establish session
32
+ await initSession(page);
33
+
34
+ // 2. Get courses
35
+ const courses = await getCourses(page);
36
+ if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
37
+
38
+ const filtered = courseFilter
39
+ ? courses.filter(c => c.title.includes(courseFilter))
40
+ : courses;
41
+ if (courseFilter && !filtered.length) {
42
+ throw new Error(`未找到匹配「${courseFilter}」的课程`);
43
+ }
44
+
45
+ // 3. Per-course: enter → click 作业 tab → navigate to iframe → parse
46
+ const allRows: AssignmentRow[] = [];
47
+
48
+ for (const c of filtered) {
49
+ try {
50
+ await enterCourse(page, c);
51
+ const iframeUrl = await getTabIframeUrl(page, '作业');
52
+ if (!iframeUrl) continue;
53
+
54
+ await page.goto(iframeUrl);
55
+ await page.wait(2);
56
+
57
+ const rows = await parseAssignmentsFromDom(page, c.title);
58
+ allRows.push(...rows);
59
+ } catch {
60
+ // Single course failure: skip, continue
61
+ }
62
+ if (filtered.length > 1) await sleep(600);
63
+ }
64
+
65
+ // 4. Sort: pending first, then by deadline
66
+ allRows.sort((a, b) => {
67
+ const order = (s: string) =>
68
+ s === '未交' ? 0 : s === '待批阅' ? 1 : s === '已完成' ? 2 : s === '已批阅' ? 3 : 4;
69
+ return order(a.status) - order(b.status);
70
+ });
71
+
72
+ // 5. Filter by status
73
+ const statusMap: Record<string, string[]> = {
74
+ pending: ['未交'],
75
+ submitted: ['待批阅', '已完成'],
76
+ graded: ['已批阅'],
77
+ };
78
+ const finalRows =
79
+ statusFilter === 'all'
80
+ ? allRows
81
+ : allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
82
+
83
+ return finalRows.slice(0, Number(limit)).map((item, i) => ({
84
+ rank: i + 1,
85
+ ...item,
86
+ }));
87
+ },
88
+ });
@@ -0,0 +1,88 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import {
3
+ getCourses, initSession, enterCourse, getTabIframeUrl,
4
+ parseExamsFromDom, sleep,
5
+ type ExamRow,
6
+ } from '../../chaoxing.js';
7
+
8
+ cli({
9
+ site: 'chaoxing',
10
+ name: 'exams',
11
+ description: '学习通考试列表',
12
+ domain: 'mooc2-ans.chaoxing.com',
13
+ strategy: Strategy.COOKIE,
14
+ timeoutSeconds: 90,
15
+ args: [
16
+ { name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
17
+ {
18
+ name: 'status',
19
+ type: 'string',
20
+ default: 'all',
21
+ choices: ['all', 'upcoming', 'ongoing', 'finished'],
22
+ help: '按状态过滤',
23
+ },
24
+ { name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
25
+ ],
26
+ columns: ['rank', 'course', 'title', 'start', 'end', 'status', 'score'],
27
+
28
+ func: async (page, kwargs) => {
29
+ const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
30
+
31
+ // 1. Establish session
32
+ await initSession(page);
33
+
34
+ // 2. Get courses
35
+ const courses = await getCourses(page);
36
+ if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
37
+
38
+ const filtered = courseFilter
39
+ ? courses.filter(c => c.title.includes(courseFilter))
40
+ : courses;
41
+ if (courseFilter && !filtered.length) {
42
+ throw new Error(`未找到匹配「${courseFilter}」的课程`);
43
+ }
44
+
45
+ // 3. Per-course: enter → click 考试 tab → navigate to iframe → parse
46
+ const allRows: ExamRow[] = [];
47
+
48
+ for (const c of filtered) {
49
+ try {
50
+ await enterCourse(page, c);
51
+ const iframeUrl = await getTabIframeUrl(page, '考试');
52
+ if (!iframeUrl) continue;
53
+
54
+ await page.goto(iframeUrl);
55
+ await page.wait(2);
56
+
57
+ const rows = await parseExamsFromDom(page, c.title);
58
+ allRows.push(...rows);
59
+ } catch {
60
+ // Single course failure: skip, continue
61
+ }
62
+ if (filtered.length > 1) await sleep(600);
63
+ }
64
+
65
+ // 4. Sort: upcoming first
66
+ allRows.sort((a, b) => {
67
+ const order = (s: string) =>
68
+ s === '未开始' ? 0 : s === '进行中' ? 1 : s === '已结束' ? 2 : s === '已完成' ? 3 : 4;
69
+ return order(a.status) - order(b.status);
70
+ });
71
+
72
+ // 5. Filter by status
73
+ const statusMap: Record<string, string[]> = {
74
+ upcoming: ['未开始'],
75
+ ongoing: ['进行中'],
76
+ finished: ['已结束', '已完成'],
77
+ };
78
+ const finalRows =
79
+ statusFilter === 'all'
80
+ ? allRows
81
+ : allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
82
+
83
+ return finalRows.slice(0, Number(limit)).map((item, i) => ({
84
+ rank: i + 1,
85
+ ...item,
86
+ }));
87
+ },
88
+ });
@@ -1,6 +1,7 @@
1
1
  import { execSync, spawnSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '../../registry.js';
3
3
  import type { IPage } from '../../types.js';
4
+ import { getVisibleChatMessages } from './ax.js';
4
5
 
5
6
  export const askCommand = cli({
6
7
  site: 'chatgpt',
@@ -21,6 +22,7 @@ export const askCommand = cli({
21
22
  // Backup clipboard
22
23
  let clipBackup = '';
23
24
  try { clipBackup = execSync('pbpaste', { encoding: 'utf-8' }); } catch {}
25
+ const messagesBefore = getVisibleChatMessages();
24
26
 
25
27
  // Send the message
26
28
  spawnSync('pbcopy', { input: text });
@@ -35,33 +37,30 @@ export const askCommand = cli({
35
37
  "-e 'end tell'";
36
38
  execSync(cmd);
37
39
 
38
- // Clear clipboard marker
39
- spawnSync('pbcopy', { input: '__OPENCLI_WAITING__' });
40
+ // Restore clipboard after the prompt is sent.
41
+ if (clipBackup) spawnSync('pbcopy', { input: clipBackup });
40
42
 
41
- // Wait for response, then read it
42
- const pollInterval = 3;
43
+ // Wait for response, then read the latest visible assistant message from the AX tree.
44
+ const pollInterval = 1;
43
45
  const maxPolls = Math.ceil(timeout / pollInterval);
44
46
  let response = '';
45
47
 
46
48
  for (let i = 0; i < maxPolls; i++) {
47
- // Wait
48
49
  execSync(`sleep ${pollInterval}`);
49
-
50
- // Try Cmd+Shift+C to copy the latest response
51
50
  execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
52
- execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'");
53
- execSync("osascript -e 'delay 0.3'");
51
+ execSync("osascript -e 'delay 0.2'");
52
+
53
+ const messagesNow = getVisibleChatMessages();
54
+ if (messagesNow.length <= messagesBefore.length) continue;
54
55
 
55
- const copied = execSync('pbpaste', { encoding: 'utf-8' }).trim();
56
- if (copied && copied !== '__OPENCLI_WAITING__' && copied !== text) {
57
- response = copied;
56
+ const newMessages = messagesNow.slice(messagesBefore.length);
57
+ const candidate = [...newMessages].reverse().find((message) => message !== text);
58
+ if (candidate) {
59
+ response = candidate;
58
60
  break;
59
61
  }
60
62
  }
61
63
 
62
- // Restore clipboard
63
- if (clipBackup) spawnSync('pbcopy', { input: clipBackup });
64
-
65
64
  if (!response) {
66
65
  return [
67
66
  { Role: 'User', Text: text },
@@ -0,0 +1,81 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const AX_READ_SCRIPT = `
4
+ import Cocoa
5
+ import ApplicationServices
6
+
7
+ func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
8
+ var value: CFTypeRef?
9
+ guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
10
+ return value as AnyObject?
11
+ }
12
+
13
+ func s(_ el: AXUIElement, _ name: String) -> String? {
14
+ if let v = attr(el, name) as? String, !v.isEmpty { return v }
15
+ return nil
16
+ }
17
+
18
+ func children(_ el: AXUIElement) -> [AXUIElement] {
19
+ (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
20
+ }
21
+
22
+ func collectLists(_ el: AXUIElement, into out: inout [AXUIElement]) {
23
+ let role = s(el, kAXRoleAttribute as String) ?? ""
24
+ if role == kAXListRole as String { out.append(el) }
25
+ for c in children(el) { collectLists(c, into: &out) }
26
+ }
27
+
28
+ func collectTexts(_ el: AXUIElement, into out: inout [String]) {
29
+ let role = s(el, kAXRoleAttribute as String) ?? ""
30
+ if role == kAXStaticTextRole as String {
31
+ if let text = s(el, kAXDescriptionAttribute as String), !text.isEmpty {
32
+ out.append(text)
33
+ }
34
+ }
35
+ for c in children(el) { collectTexts(c, into: &out) }
36
+ }
37
+
38
+ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
39
+ fputs("ChatGPT not running\\n", stderr)
40
+ exit(1)
41
+ }
42
+
43
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
44
+ guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
45
+ fputs("No focused ChatGPT window\\n", stderr)
46
+ exit(1)
47
+ }
48
+
49
+ var lists: [AXUIElement] = []
50
+ collectLists(win, into: &lists)
51
+
52
+ var best: [String] = []
53
+ for list in lists {
54
+ var texts: [String] = []
55
+ collectTexts(list, into: &texts)
56
+ if texts.count > best.count {
57
+ best = texts
58
+ }
59
+ }
60
+
61
+ let data = try! JSONSerialization.data(withJSONObject: best, options: [])
62
+ print(String(data: data, encoding: .utf8)!)
63
+ `;
64
+
65
+ export function getVisibleChatMessages(): string[] {
66
+ const output = execFileSync('swift', ['-'], {
67
+ input: AX_READ_SCRIPT,
68
+ encoding: 'utf-8',
69
+ maxBuffer: 10 * 1024 * 1024,
70
+ }).trim();
71
+
72
+ if (!output) return [];
73
+
74
+ const parsed = JSON.parse(output);
75
+ if (!Array.isArray(parsed)) return [];
76
+
77
+ return parsed
78
+ .filter((item): item is string => typeof item === 'string')
79
+ .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
80
+ .filter((item) => item.length > 0);
81
+ }
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '../../registry.js';
3
3
  import type { IPage } from '../../types.js';
4
+ import { getVisibleChatMessages } from './ax.js';
4
5
 
5
6
  export const readCommand = cli({
6
7
  site: 'chatgpt',
@@ -14,17 +15,14 @@ export const readCommand = cli({
14
15
  func: async (page: IPage | null) => {
15
16
  try {
16
17
  execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
17
- execSync("osascript -e 'delay 0.5'");
18
- execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'");
19
18
  execSync("osascript -e 'delay 0.3'");
19
+ const messages = getVisibleChatMessages();
20
20
 
21
- const result = execSync('pbpaste', { encoding: 'utf-8' }).trim();
22
-
23
- if (!result) {
24
- return [{ Role: 'System', Text: 'No text was copied. Is there a response in the chat?' }];
21
+ if (!messages.length) {
22
+ return [{ Role: 'System', Text: 'No visible chat messages were found in the current ChatGPT window.' }];
25
23
  }
26
24
 
27
- return [{ Role: 'Assistant', Text: result }];
25
+ return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
28
26
  } catch (err: any) {
29
27
  throw new Error("Failed to read from ChatGPT: " + err.message);
30
28
  }
@@ -26,8 +26,15 @@ cli({
26
26
  const box = document.querySelector('[data-testid="tweetTextarea_0"]');
27
27
  if (box) {
28
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)});
29
+ // Simulate a paste event to properly handle newlines in Draft.js/React
30
+ const textToInsert = ${JSON.stringify(kwargs.text)};
31
+ const dataTransfer = new DataTransfer();
32
+ dataTransfer.setData('text/plain', textToInsert);
33
+ box.dispatchEvent(new ClipboardEvent('paste', {
34
+ clipboardData: dataTransfer,
35
+ bubbles: true,
36
+ cancelable: true
37
+ }));
31
38
  } else {
32
39
  return { ok: false, message: 'Could not find the tweet composer text area.' };
33
40
  }