@jackwener/opencli 1.7.7 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli-manifest.json CHANGED
@@ -13039,6 +13039,43 @@
13039
13039
  "sourceFile": "pixiv/user.js",
13040
13040
  "navigateBefore": "https://www.pixiv.net"
13041
13041
  },
13042
+ {
13043
+ "site": "powerchina",
13044
+ "name": "search",
13045
+ "description": "搜索中国电建阳光采购公告",
13046
+ "domain": "bid.powerchina.cn",
13047
+ "strategy": "cookie",
13048
+ "browser": true,
13049
+ "args": [
13050
+ {
13051
+ "name": "query",
13052
+ "type": "str",
13053
+ "required": true,
13054
+ "positional": true,
13055
+ "help": "Search keyword, e.g. \"procurement\""
13056
+ },
13057
+ {
13058
+ "name": "limit",
13059
+ "type": "int",
13060
+ "default": 20,
13061
+ "required": false,
13062
+ "help": "Number of results (max 50)"
13063
+ }
13064
+ ],
13065
+ "columns": [
13066
+ "rank",
13067
+ "content_type",
13068
+ "title",
13069
+ "publish_time",
13070
+ "project_code",
13071
+ "budget_or_limit",
13072
+ "url"
13073
+ ],
13074
+ "type": "js",
13075
+ "modulePath": "powerchina/search.js",
13076
+ "sourceFile": "powerchina/search.js",
13077
+ "navigateBefore": "https://bid.powerchina.cn"
13078
+ },
13042
13079
  {
13043
13080
  "site": "producthunt",
13044
13081
  "name": "browse",
@@ -15593,6 +15630,36 @@
15593
15630
  "sourceFile": "tiktok/user.js",
15594
15631
  "navigateBefore": "https://www.tiktok.com"
15595
15632
  },
15633
+ {
15634
+ "site": "toutiao",
15635
+ "name": "articles",
15636
+ "description": "获取头条号创作者后台文章列表及数据",
15637
+ "domain": "mp.toutiao.com",
15638
+ "strategy": "cookie",
15639
+ "browser": true,
15640
+ "args": [
15641
+ {
15642
+ "name": "page",
15643
+ "type": "int",
15644
+ "default": 1,
15645
+ "required": false,
15646
+ "help": "页码 (1-4)"
15647
+ }
15648
+ ],
15649
+ "columns": [
15650
+ "title",
15651
+ "date",
15652
+ "status",
15653
+ "展现",
15654
+ "阅读",
15655
+ "点赞",
15656
+ "评论"
15657
+ ],
15658
+ "type": "js",
15659
+ "modulePath": "toutiao/articles.js",
15660
+ "sourceFile": "toutiao/articles.js",
15661
+ "navigateBefore": "https://mp.toutiao.com"
15662
+ },
15596
15663
  {
15597
15664
  "site": "twitter",
15598
15665
  "name": "accept",
@@ -17274,6 +17341,56 @@
17274
17341
  "sourceFile": "weibo/user.js",
17275
17342
  "navigateBefore": "https://weibo.com"
17276
17343
  },
17344
+ {
17345
+ "site": "weixin",
17346
+ "name": "create-draft",
17347
+ "description": "创建微信公众号图文草稿",
17348
+ "domain": "mp.weixin.qq.com",
17349
+ "strategy": "cookie",
17350
+ "browser": true,
17351
+ "args": [
17352
+ {
17353
+ "name": "title",
17354
+ "type": "str",
17355
+ "required": true,
17356
+ "help": "文章标题 (最长64字)"
17357
+ },
17358
+ {
17359
+ "name": "content",
17360
+ "type": "str",
17361
+ "required": true,
17362
+ "positional": true,
17363
+ "help": "文章正文"
17364
+ },
17365
+ {
17366
+ "name": "author",
17367
+ "type": "str",
17368
+ "required": false,
17369
+ "help": "作者名 (最长8字)"
17370
+ },
17371
+ {
17372
+ "name": "cover-image",
17373
+ "type": "str",
17374
+ "required": false,
17375
+ "help": "封面图片路径 (会先上传到正文再设为封面)"
17376
+ },
17377
+ {
17378
+ "name": "summary",
17379
+ "type": "str",
17380
+ "required": false,
17381
+ "help": "文章摘要"
17382
+ }
17383
+ ],
17384
+ "columns": [
17385
+ "status",
17386
+ "detail"
17387
+ ],
17388
+ "timeout": 180,
17389
+ "type": "js",
17390
+ "modulePath": "weixin/create-draft.js",
17391
+ "sourceFile": "weixin/create-draft.js",
17392
+ "navigateBefore": false
17393
+ },
17277
17394
  {
17278
17395
  "site": "weixin",
17279
17396
  "name": "download",
@@ -17316,6 +17433,33 @@
17316
17433
  "sourceFile": "weixin/download.js",
17317
17434
  "navigateBefore": "https://mp.weixin.qq.com"
17318
17435
  },
17436
+ {
17437
+ "site": "weixin",
17438
+ "name": "drafts",
17439
+ "description": "列出微信公众号草稿箱",
17440
+ "domain": "mp.weixin.qq.com",
17441
+ "strategy": "cookie",
17442
+ "browser": true,
17443
+ "args": [
17444
+ {
17445
+ "name": "limit",
17446
+ "type": "int",
17447
+ "default": 10,
17448
+ "required": false,
17449
+ "help": "最多显示条数"
17450
+ }
17451
+ ],
17452
+ "columns": [
17453
+ "Index",
17454
+ "Title",
17455
+ "Time"
17456
+ ],
17457
+ "timeout": 60,
17458
+ "type": "js",
17459
+ "modulePath": "weixin/drafts.js",
17460
+ "sourceFile": "weixin/drafts.js",
17461
+ "navigateBefore": false
17462
+ },
17319
17463
  {
17320
17464
  "site": "weread",
17321
17465
  "name": "ai-outline",
@@ -1,6 +1,6 @@
1
- import { CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
3
+ import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
4
4
  function normalizeDiscussionPayload(payload) {
5
5
  const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
6
6
  const asin = extractAsin(payload.href ?? '') ?? null;
@@ -28,10 +28,16 @@ function normalizeDiscussionPayload(payload) {
28
28
  })),
29
29
  };
30
30
  }
31
- async function readDiscussionPayload(page, input, limit) {
32
- const url = buildDiscussionUrl(input);
33
- const state = await gotoAndReadState(page, url, 2500, 'discussion');
34
- assertUsableState(state, 'discussion');
31
+ function hasDiscussionSummary(payload) {
32
+ return Boolean(cleanText(payload.average_rating_text) || cleanText(payload.total_review_count_text));
33
+ }
34
+ function isSignInState(state) {
35
+ const href = cleanText(state.href).toLowerCase();
36
+ const title = cleanText(state.title).toLowerCase();
37
+ return href.includes('/ap/signin')
38
+ || title.includes('amazon sign-in');
39
+ }
40
+ async function readCurrentDiscussionPayload(page, limit) {
35
41
  return await page.evaluate(`
36
42
  (() => ({
37
43
  href: window.location.href,
@@ -53,6 +59,29 @@ async function readDiscussionPayload(page, input, limit) {
53
59
  }))()
54
60
  `);
55
61
  }
62
+ async function readDiscussionPayload(page, input, limit) {
63
+ const reviewUrl = buildDiscussionUrl(input);
64
+ const reviewState = await gotoAndReadState(page, reviewUrl, 2500, 'discussion');
65
+ assertUsableState(reviewState, 'discussion');
66
+ const reviewPayload = await readCurrentDiscussionPayload(page, limit);
67
+ if (hasDiscussionSummary(reviewPayload)) {
68
+ return reviewPayload;
69
+ }
70
+ const productUrl = buildProductUrl(input);
71
+ const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion');
72
+ assertUsableState(productState, 'discussion');
73
+ if (isSignInState(reviewState) && isSignInState(productState)) {
74
+ throw new AuthRequiredError('amazon.com', 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
75
+ }
76
+ const productPayload = await readCurrentDiscussionPayload(page, limit);
77
+ if (hasDiscussionSummary(productPayload)) {
78
+ return productPayload;
79
+ }
80
+ if (isSignInState(reviewState)) {
81
+ throw new CommandExecutionError('amazon review page redirected to sign-in and product page fallback did not expose review summary', 'Open the product page in Chrome, verify reviews are visible, and retry.');
82
+ }
83
+ return reviewPayload;
84
+ }
56
85
  cli({
57
86
  site: 'amazon',
58
87
  name: 'discussion',
@@ -88,4 +117,6 @@ cli({
88
117
  });
89
118
  export const __test__ = {
90
119
  normalizeDiscussionPayload,
120
+ hasDiscussionSummary,
121
+ isSignInState,
91
122
  };
@@ -1,36 +1,151 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
2
4
  import { __test__ } from './discussion.js';
5
+ import './discussion.js';
6
+
7
+ function createPageMock(evaluateResults) {
8
+ const evaluate = vi.fn();
9
+ for (const result of evaluateResults) {
10
+ evaluate.mockResolvedValueOnce(result);
11
+ }
12
+ return {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate,
16
+ snapshot: vi.fn().mockResolvedValue(undefined),
17
+ click: vi.fn().mockResolvedValue(undefined),
18
+ typeText: vi.fn().mockResolvedValue(undefined),
19
+ pressKey: vi.fn().mockResolvedValue(undefined),
20
+ scrollTo: vi.fn().mockResolvedValue(undefined),
21
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
22
+ tabs: vi.fn().mockResolvedValue([]),
23
+ selectTab: vi.fn().mockResolvedValue(undefined),
24
+ networkRequests: vi.fn().mockResolvedValue([]),
25
+ consoleMessages: vi.fn().mockResolvedValue([]),
26
+ scroll: vi.fn().mockResolvedValue(undefined),
27
+ autoScroll: vi.fn().mockResolvedValue(undefined),
28
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
29
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
30
+ getCookies: vi.fn().mockResolvedValue([]),
31
+ screenshot: vi.fn().mockResolvedValue(''),
32
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
33
+ };
34
+ }
35
+
3
36
  describe('amazon discussion normalization', () => {
4
- it('normalizes review summary and sample reviews', () => {
5
- const result = __test__.normalizeDiscussionPayload({
6
- href: 'https://www.amazon.com/product-reviews/B0FJS72893',
7
- average_rating_text: '3.9 out of 5',
8
- total_review_count_text: '27 global ratings',
9
- qa_links: [],
10
- review_samples: [
11
- {
12
- title: '5.0 out of 5 stars Great value and quality',
13
- rating_text: '5.0 out of 5 stars',
14
- author: 'GTreader2',
15
- date_text: 'Reviewed in the United States on February 21, 2026',
16
- body: 'Small but mighty.',
17
- verified: true,
18
- },
19
- ],
20
- });
21
- expect(result.asin).toBe('B0FJS72893');
22
- expect(result.average_rating_value).toBe(3.9);
23
- expect(result.total_review_count).toBe(27);
24
- expect(result.review_samples).toEqual([
25
- {
26
- title: 'Great value and quality',
27
- rating_text: '5.0 out of 5 stars',
28
- rating_value: 5,
29
- author: 'GTreader2',
30
- date_text: 'Reviewed in the United States on February 21, 2026',
31
- body: 'Small but mighty.',
32
- verified_purchase: true,
33
- },
34
- ]);
37
+ it('normalizes review summary and sample reviews', () => {
38
+ const result = __test__.normalizeDiscussionPayload({
39
+ href: 'https://www.amazon.com/product-reviews/B0FJS72893',
40
+ average_rating_text: '3.9 out of 5',
41
+ total_review_count_text: '27 global ratings',
42
+ qa_links: [],
43
+ review_samples: [
44
+ {
45
+ title: '5.0 out of 5 stars Great value and quality',
46
+ rating_text: '5.0 out of 5 stars',
47
+ author: 'GTreader2',
48
+ date_text: 'Reviewed in the United States on February 21, 2026',
49
+ body: 'Small but mighty.',
50
+ verified: true,
51
+ },
52
+ ],
35
53
  });
54
+
55
+ expect(result.asin).toBe('B0FJS72893');
56
+ expect(result.average_rating_value).toBe(3.9);
57
+ expect(result.total_review_count).toBe(27);
58
+ expect(result.review_samples).toEqual([
59
+ {
60
+ title: 'Great value and quality',
61
+ rating_text: '5.0 out of 5 stars',
62
+ rating_value: 5,
63
+ author: 'GTreader2',
64
+ date_text: 'Reviewed in the United States on February 21, 2026',
65
+ body: 'Small but mighty.',
66
+ verified_purchase: true,
67
+ },
68
+ ]);
69
+ });
70
+
71
+ it('falls back to the product page when the review page redirects to sign-in', async () => {
72
+ const command = getRegistry().get('amazon/discussion');
73
+ const page = createPageMock([
74
+ {
75
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
76
+ title: 'Amazon Sign-In',
77
+ body_text: 'Sign in Create account',
78
+ },
79
+ {
80
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
81
+ average_rating_text: '',
82
+ total_review_count_text: '',
83
+ review_samples: [],
84
+ },
85
+ {
86
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
87
+ title: 'Amazon.com: Example product',
88
+ body_text: 'Hello, zejia-wu Reviews',
89
+ },
90
+ {
91
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
92
+ average_rating_text: '4.4 out of 5',
93
+ total_review_count_text: '349 global ratings',
94
+ review_samples: [
95
+ {
96
+ title: '5.0 out of 5 stars Perfect for the office',
97
+ rating_text: '5.0 out of 5 stars',
98
+ author: 'Ken',
99
+ date_text: 'Reviewed in the United States on March 19, 2026',
100
+ body: 'Good for the office, no complaints.',
101
+ verified: true,
102
+ },
103
+ ],
104
+ },
105
+ ]);
106
+
107
+ const result = await command.func(page, { input: 'B09HKN2ZRT', limit: 1 });
108
+
109
+ expect(page.goto.mock.calls.map((call) => call[0])).toEqual([
110
+ 'https://www.amazon.com/product-reviews/B09HKN2ZRT',
111
+ 'https://www.amazon.com/dp/B09HKN2ZRT',
112
+ ]);
113
+ expect(result).toEqual([
114
+ expect.objectContaining({
115
+ asin: 'B09HKN2ZRT',
116
+ discussion_url: 'https://www.amazon.com/dp/B09HKN2ZRT',
117
+ average_rating_value: 4.4,
118
+ total_review_count: 349,
119
+ }),
120
+ ]);
121
+ });
122
+
123
+ it('throws AuthRequiredError when both review and product pages are gated', async () => {
124
+ const command = getRegistry().get('amazon/discussion');
125
+ const authState = {
126
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
127
+ title: 'Amazon Sign-In',
128
+ body_text: 'Sign in Create account',
129
+ };
130
+ const page = createPageMock([
131
+ authState,
132
+ {
133
+ href: authState.href,
134
+ average_rating_text: '',
135
+ total_review_count_text: '',
136
+ review_samples: [],
137
+ },
138
+ authState,
139
+ ]);
140
+
141
+ await expect(command.func(page, { input: 'B09HKN2ZRT', limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError);
142
+ });
143
+
144
+ it('does not treat a public product page with sign-in copy as a gated page', () => {
145
+ expect(__test__.isSignInState({
146
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
147
+ title: 'Amazon.com: Example product',
148
+ body_text: 'Hello, sign in Account & Lists Create account',
149
+ })).toBe(false);
150
+ });
36
151
  });
@@ -1,7 +1,7 @@
1
- import { execSync, spawnSync } from 'node:child_process';
1
+ import { execSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { ConfigError } from '@jackwener/opencli/errors';
4
- import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating } from './ax.js';
4
+ import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating, sendPrompt } from './ax.js';
5
5
  export const askCommand = cli({
6
6
  site: 'chatgpt-app',
7
7
  name: 'ask',
@@ -27,26 +27,10 @@ export const askCommand = cli({
27
27
  activateChatGPT();
28
28
  selectModel(model);
29
29
  }
30
- // Backup clipboard
31
- let clipBackup = '';
32
- try {
33
- clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
34
- }
35
- catch { }
36
30
  const messagesBefore = getVisibleChatMessages();
37
31
  // Send the message
38
- spawnSync('pbcopy', { input: text });
39
32
  activateChatGPT();
40
- const cmd = "osascript " +
41
- "-e 'tell application \"System Events\"' " +
42
- "-e 'keystroke \"v\" using command down' " +
43
- "-e 'delay 0.2' " +
44
- "-e 'keystroke return' " +
45
- "-e 'end tell'";
46
- execSync(cmd);
47
- // Restore clipboard after the prompt is sent.
48
- if (clipBackup)
49
- spawnSync('pbcopy', { input: clipBackup });
33
+ sendPrompt(text);
50
34
  // Wait for response: poll until ChatGPT stops generating ("Stop generating" button disappears),
51
35
  // then read the final response text.
52
36
  const pollInterval = 2;
@@ -60,6 +60,125 @@ for list in lists {
60
60
  let data = try! JSONSerialization.data(withJSONObject: best, options: [])
61
61
  print(String(data: data, encoding: .utf8)!)
62
62
  `;
63
+ const AX_SEND_SCRIPT = `
64
+ import Cocoa
65
+ import ApplicationServices
66
+
67
+ func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
68
+ var value: CFTypeRef?
69
+ guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
70
+ return value as AnyObject?
71
+ }
72
+
73
+ func s(_ el: AXUIElement, _ name: String) -> String? {
74
+ if let v = attr(el, name) as? String { return v }
75
+ return nil
76
+ }
77
+
78
+ func isEnabled(_ el: AXUIElement) -> Bool {
79
+ (attr(el, kAXEnabledAttribute as String) as? Bool) ?? true
80
+ }
81
+
82
+ func children(_ el: AXUIElement) -> [AXUIElement] {
83
+ (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
84
+ }
85
+
86
+ func collectEditableInputs(_ el: AXUIElement, into out: inout [AXUIElement], depth: Int = 0) {
87
+ guard depth < 25 else { return }
88
+ let role = s(el, kAXRoleAttribute as String) ?? ""
89
+ if (role == kAXTextAreaRole as String || role == kAXTextFieldRole as String) && isEnabled(el) {
90
+ out.append(el)
91
+ }
92
+ for c in children(el) { collectEditableInputs(c, into: &out, depth: depth + 1) }
93
+ }
94
+
95
+ func isInput(_ el: AXUIElement) -> Bool {
96
+ let role = s(el, kAXRoleAttribute as String) ?? ""
97
+ return role == kAXTextAreaRole as String || role == kAXTextFieldRole as String
98
+ }
99
+
100
+ func focusedInput(_ axApp: AXUIElement) -> AXUIElement? {
101
+ guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) as! AXUIElement? else {
102
+ return nil
103
+ }
104
+ return isInput(focused) && isEnabled(focused) ? focused : nil
105
+ }
106
+
107
+ func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? {
108
+ guard depth < 25 else { return nil }
109
+ let role = s(el, kAXRoleAttribute as String) ?? ""
110
+ let desc = s(el, kAXDescriptionAttribute as String) ?? ""
111
+ if role == "AXButton" && targets.contains(desc) && isEnabled(el) { return el }
112
+ for c in children(el) {
113
+ if let found = findByDescriptions(c, targets, depth: depth + 1) { return found }
114
+ }
115
+ return nil
116
+ }
117
+
118
+ func press(_ el: AXUIElement) {
119
+ AXUIElementPerformAction(el, kAXPressAction as CFString)
120
+ }
121
+
122
+ let args = CommandLine.arguments
123
+ guard args.count > 1 else {
124
+ fputs("Missing prompt text\\n", stderr)
125
+ exit(1)
126
+ }
127
+ let text = args[1]
128
+
129
+ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
130
+ fputs("ChatGPT not running\\n", stderr)
131
+ exit(1)
132
+ }
133
+
134
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
135
+ guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
136
+ fputs("No focused ChatGPT window\\n", stderr)
137
+ exit(1)
138
+ }
139
+
140
+ var inputs: [AXUIElement] = []
141
+ collectEditableInputs(win, into: &inputs)
142
+ guard let input = focusedInput(axApp) ?? inputs.last else {
143
+ fputs("Could not find editable input area\\n", stderr)
144
+ exit(1)
145
+ }
146
+
147
+ guard AXUIElementSetAttributeValue(input, kAXValueAttribute as CFString, text as CFTypeRef) == .success else {
148
+ fputs("Failed to set input value\\n", stderr)
149
+ exit(1)
150
+ }
151
+
152
+ Thread.sleep(forTimeInterval: 0.2)
153
+
154
+ guard s(input, kAXValueAttribute as String) == text else {
155
+ fputs("Failed to verify input value after AX set\\n", stderr)
156
+ exit(1)
157
+ }
158
+
159
+ guard let sendButton = findByDescriptions(win, ["发送", "Send"]) else {
160
+ fputs("Could not find send button\\n", stderr)
161
+ exit(1)
162
+ }
163
+
164
+ press(sendButton)
165
+
166
+ var submitted = false
167
+ for _ in 0..<15 {
168
+ Thread.sleep(forTimeInterval: 0.1)
169
+ if s(input, kAXValueAttribute as String) != text {
170
+ submitted = true
171
+ break
172
+ }
173
+ }
174
+
175
+ guard submitted else {
176
+ fputs("Prompt did not leave input after pressing send\\n", stderr)
177
+ exit(1)
178
+ }
179
+
180
+ print("Sent")
181
+ `;
63
182
  const AX_MODEL_SCRIPT = `
64
183
  import Cocoa
65
184
  import ApplicationServices
@@ -192,7 +311,8 @@ let axApp = AXUIElementCreateApplication(app.processIdentifier)
192
311
  guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
193
312
  print("false"); exit(0)
194
313
  }
195
- print(hasButton(win, desc: "Stop generating") ? "true" : "false")
314
+ let targets = ["Stop generating", "停止生成"]
315
+ print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false")
196
316
  `;
197
317
  const MODEL_MAP = {
198
318
  'auto': { desc: 'Auto' },
@@ -221,6 +341,13 @@ export function selectModel(model) {
221
341
  }).trim();
222
342
  return output;
223
343
  }
344
+ export function sendPrompt(text) {
345
+ return execFileSync('swift', ['-', text], {
346
+ input: AX_SEND_SCRIPT,
347
+ encoding: 'utf-8',
348
+ maxBuffer: 10 * 1024 * 1024,
349
+ }).trim();
350
+ }
224
351
  export function isGenerating() {
225
352
  try {
226
353
  const output = execFileSync('swift', ['-'], {
@@ -250,3 +377,7 @@ export function getVisibleChatMessages() {
250
377
  .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
251
378
  .filter((item) => item.length > 0);
252
379
  }
380
+ export const __test__ = {
381
+ AX_SEND_SCRIPT,
382
+ AX_GENERATING_SCRIPT,
383
+ };
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './ax.js';
3
+
4
+ describe('chatgpt-app AX send script', () => {
5
+ it('prefers the focused composer before falling back to the last editable input', () => {
6
+ expect(__test__.AX_SEND_SCRIPT).toContain('kAXFocusedUIElementAttribute');
7
+ });
8
+
9
+ it('fails fast when the AX set does not round-trip into the composer value', () => {
10
+ expect(__test__.AX_SEND_SCRIPT).toContain('Failed to verify input value after AX set');
11
+ });
12
+
13
+ it('does not report success until the prompt leaves the composer after send', () => {
14
+ expect(__test__.AX_SEND_SCRIPT).toContain('Prompt did not leave input after pressing send');
15
+ });
16
+ });
17
+
18
+ describe('chatgpt-app generating detection', () => {
19
+ it('supports both english and zh-CN stop-generating labels', () => {
20
+ expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating');
21
+ expect(__test__.AX_GENERATING_SCRIPT).toContain('停止生成');
22
+ });
23
+ });
@@ -1,7 +1,6 @@
1
- import { execSync, spawnSync } from 'node:child_process';
2
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
2
  import { getErrorMessage } from '@jackwener/opencli/errors';
4
- import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
3
+ import { activateChatGPT, selectModel, MODEL_CHOICES, sendPrompt } from './ax.js';
5
4
  export const sendCommand = cli({
6
5
  site: 'chatgpt-app',
7
6
  name: 'send',
@@ -23,26 +22,8 @@ export const sendCommand = cli({
23
22
  activateChatGPT();
24
23
  selectModel(model);
25
24
  }
26
- // Backup current clipboard content
27
- let clipBackup = '';
28
- try {
29
- clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
30
- }
31
- catch { /* clipboard may be empty */ }
32
- // Copy text to clipboard
33
- spawnSync('pbcopy', { input: text });
34
25
  activateChatGPT();
35
- const cmd = "osascript " +
36
- "-e 'tell application \"System Events\"' " +
37
- "-e 'keystroke \"v\" using command down' " +
38
- "-e 'delay 0.2' " +
39
- "-e 'keystroke return' " +
40
- "-e 'end tell'";
41
- execSync(cmd);
42
- // Restore original clipboard content
43
- if (clipBackup) {
44
- spawnSync('pbcopy', { input: clipBackup });
45
- }
26
+ sendPrompt(text);
46
27
  return [{ Status: 'Success' }];
47
28
  }
48
29
  catch (err) {