@jackwener/opencli 0.9.5 → 0.9.6

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 (53) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/dist/cli-manifest.json +222 -4
  4. package/dist/clis/antigravity/model.js +2 -2
  5. package/dist/clis/antigravity/send.js +2 -2
  6. package/dist/clis/chatgpt/ask.d.ts +1 -0
  7. package/dist/clis/chatgpt/ask.js +68 -0
  8. package/dist/clis/chatgpt/send.js +11 -0
  9. package/dist/clis/codex/ask.d.ts +1 -0
  10. package/dist/clis/codex/ask.js +67 -0
  11. package/dist/clis/codex/export.d.ts +1 -0
  12. package/dist/clis/codex/export.js +37 -0
  13. package/dist/clis/codex/history.d.ts +1 -0
  14. package/dist/clis/codex/history.js +43 -0
  15. package/dist/clis/codex/read.js +3 -5
  16. package/dist/clis/codex/screenshot.d.ts +1 -0
  17. package/dist/clis/codex/screenshot.js +27 -0
  18. package/dist/clis/codex/send.js +3 -6
  19. package/dist/clis/codex/status.js +2 -1
  20. package/dist/clis/cursor/ask.d.ts +1 -0
  21. package/dist/clis/cursor/ask.js +69 -0
  22. package/dist/clis/cursor/composer.js +9 -28
  23. package/dist/clis/cursor/export.d.ts +1 -0
  24. package/dist/clis/cursor/export.js +51 -0
  25. package/dist/clis/cursor/history.d.ts +1 -0
  26. package/dist/clis/cursor/history.js +43 -0
  27. package/dist/clis/cursor/new.js +4 -13
  28. package/dist/clis/cursor/screenshot.d.ts +1 -0
  29. package/dist/clis/cursor/screenshot.js +31 -0
  30. package/package.json +1 -1
  31. package/src/clis/antigravity/README.md +2 -3
  32. package/src/clis/antigravity/README.zh-CN.md +2 -3
  33. package/src/clis/antigravity/SKILL.md +1 -1
  34. package/src/clis/antigravity/model.ts +2 -2
  35. package/src/clis/antigravity/send.ts +2 -2
  36. package/src/clis/chatgpt/README.md +25 -16
  37. package/src/clis/chatgpt/README.zh-CN.md +27 -18
  38. package/src/clis/chatgpt/ask.ts +77 -0
  39. package/src/clis/chatgpt/send.ts +12 -0
  40. package/src/clis/codex/ask.ts +77 -0
  41. package/src/clis/codex/export.ts +42 -0
  42. package/src/clis/codex/extract-diff.ts +1 -0
  43. package/src/clis/codex/history.ts +47 -0
  44. package/src/clis/codex/read.ts +5 -6
  45. package/src/clis/codex/screenshot.ts +33 -0
  46. package/src/clis/codex/send.ts +6 -7
  47. package/src/clis/codex/status.ts +4 -2
  48. package/src/clis/cursor/ask.ts +81 -0
  49. package/src/clis/cursor/composer.ts +9 -30
  50. package/src/clis/cursor/export.ts +57 -0
  51. package/src/clis/cursor/history.ts +47 -0
  52. package/src/clis/cursor/new.ts +4 -15
  53. package/src/clis/cursor/screenshot.ts +38 -0
@@ -0,0 +1,27 @@
1
+ import * as fs from 'node:fs';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ export const screenshotCommand = cli({
4
+ site: 'codex',
5
+ name: 'screenshot',
6
+ description: 'Capture a snapshot of the current Codex window (DOM + Accessibility tree)',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [
11
+ { name: 'output', required: false, positional: true, help: 'Output file path (default: /tmp/codex-snapshot.txt)' },
12
+ ],
13
+ columns: ['Status', 'File'],
14
+ func: async (page, kwargs) => {
15
+ const outputPath = kwargs.output || '/tmp/codex-snapshot.txt';
16
+ const snap = await page.snapshot({ compact: true });
17
+ const html = await page.evaluate('document.documentElement.outerHTML');
18
+ const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
19
+ const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
20
+ fs.writeFileSync(htmlPath, html);
21
+ fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
22
+ return [
23
+ { Status: 'Success', File: htmlPath },
24
+ { Status: 'Success', File: snapPath },
25
+ ];
26
+ },
27
+ });
@@ -10,16 +10,13 @@ export const sendCommand = cli({
10
10
  columns: ['Status', 'InjectedText'],
11
11
  func: async (page, kwargs) => {
12
12
  const textToInsert = kwargs.text;
13
- // We use evaluate to inject text bypassing complex nested shadow roots or contenteditables
14
13
  await page.evaluate(`
15
14
  (function(text) {
16
- // Attempt 1: Look for standard textarea/composer input
17
15
  let composer = document.querySelector('textarea, [contenteditable="true"]');
18
16
 
19
- // Basic heuristic: prioritize elements that are deeply nested, visible, and have 'composer' or 'input' classes
20
17
  const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
21
18
  if (editables.length > 0) {
22
- composer = editables[editables.length - 1]; // Often the active input is appended near the end
19
+ composer = editables[editables.length - 1];
23
20
  }
24
21
 
25
22
  if (!composer) {
@@ -27,11 +24,11 @@ export const sendCommand = cli({
27
24
  }
28
25
 
29
26
  composer.focus();
30
-
31
- // This handles Lexical/ProseMirror/Monaco rich-text editors effectively by mimicking human paste/type deeply.
32
27
  document.execCommand('insertText', false, text);
33
28
  })(${JSON.stringify(textToInsert)})
34
29
  `);
30
+ // Wait for the UI to register the input
31
+ await page.wait(0.5);
35
32
  // Simulate Enter key to submit
36
33
  await page.pressKey('Enter');
37
34
  return [
@@ -4,8 +4,9 @@ export const statusCommand = cli({
4
4
  name: 'status',
5
5
  description: 'Check active CDP connection to OpenAI Codex App',
6
6
  domain: 'localhost',
7
- strategy: Strategy.UI, // Interactive UI manipulation
7
+ strategy: Strategy.UI,
8
8
  browser: true,
9
+ args: [],
9
10
  columns: ['Status', 'Url', 'Title'],
10
11
  func: async (page) => {
11
12
  const url = await page.evaluate('window.location.href');
@@ -0,0 +1 @@
1
+ export declare const askCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,69 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const askCommand = cli({
3
+ site: 'cursor',
4
+ name: 'ask',
5
+ description: 'Send a prompt and wait for the AI response (send + wait + read)',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'text', required: true, positional: true, help: 'Prompt to send' },
11
+ { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' },
12
+ ],
13
+ columns: ['Role', 'Text'],
14
+ func: async (page, kwargs) => {
15
+ const text = kwargs.text;
16
+ const timeout = parseInt(kwargs.timeout, 10) || 30;
17
+ // Count existing messages before sending
18
+ const beforeCount = await page.evaluate(`
19
+ document.querySelectorAll('[data-message-role]').length
20
+ `);
21
+ // Inject text into the active editor and submit
22
+ const injected = await page.evaluate(`(function(text) {
23
+ let editor = document.querySelector('.aislash-editor-input, [data-lexical-editor="true"], [contenteditable="true"]');
24
+ if (!editor) return false;
25
+ editor.focus();
26
+ document.execCommand('insertText', false, text);
27
+ return true;
28
+ })(${JSON.stringify(text)})`);
29
+ if (!injected)
30
+ throw new Error('Could not find input element.');
31
+ await page.wait(0.5);
32
+ await page.pressKey('Enter');
33
+ // Poll until a new assistant message appears or timeout
34
+ const pollInterval = 2; // seconds
35
+ const maxPolls = Math.ceil(timeout / pollInterval);
36
+ let response = '';
37
+ for (let i = 0; i < maxPolls; i++) {
38
+ await page.wait(pollInterval);
39
+ const result = await page.evaluate(`
40
+ (function(prevCount) {
41
+ const msgs = document.querySelectorAll('[data-message-role]');
42
+ if (msgs.length <= prevCount) return null;
43
+
44
+ const lastMsg = msgs[msgs.length - 1];
45
+ const role = lastMsg.getAttribute('data-message-role');
46
+ if (role === 'human') return null; // Still waiting for assistant
47
+
48
+ const root = lastMsg.querySelector('.markdown-root');
49
+ const text = root ? root.innerText : lastMsg.innerText;
50
+ return text ? text.trim() : null;
51
+ })(${beforeCount})
52
+ `);
53
+ if (result) {
54
+ response = result;
55
+ break;
56
+ }
57
+ }
58
+ if (!response) {
59
+ return [
60
+ { Role: 'User', Text: text },
61
+ { Role: 'System', Text: `No response received within ${timeout}s. The AI may still be generating.` },
62
+ ];
63
+ }
64
+ return [
65
+ { Role: 'User', Text: text },
66
+ { Role: 'Assistant', Text: response },
67
+ ];
68
+ },
69
+ });
@@ -10,33 +10,16 @@ export const composerCommand = cli({
10
10
  columns: ['Status', 'InjectedText'],
11
11
  func: async (page, kwargs) => {
12
12
  const textToInsert = kwargs.text;
13
- const injected = await page.evaluate(`(async function() {
14
- let isComposerVisible = document.querySelector('.composer-bar') !== null || document.querySelector('#composer-toolbar-section') !== null;
15
- return isComposerVisible;
16
- })()`);
17
- if (!injected) {
18
- await page.pressKey('Meta+I');
19
- await page.wait(1.0);
20
- }
21
- else {
22
- // Just focus it if it's open but unfocused (we can't easily know if it's focused without triggering something)
23
- await page.pressKey('Meta+I');
24
- await page.wait(0.2);
25
- const isStillVisible = await page.evaluate('document.querySelector(".composer-bar") !== null');
26
- if (!isStillVisible) {
27
- await page.pressKey('Meta+I'); // Re-open
28
- await page.wait(0.5);
29
- }
30
- }
13
+ // Open/Focus Composer via shortcut always works regardless of current state
14
+ await page.pressKey('Meta+I');
15
+ await page.wait(1);
31
16
  const typed = await page.evaluate(`(function(text) {
32
- let composer = document.querySelector('.composer-bar [data-lexical-editor="true"], [id*="composer"] [contenteditable="true"], .aislash-editor-input');
33
-
34
- if (!composer) {
35
- composer = document.activeElement;
36
- if (!composer || !composer.isContentEditable) {
37
- return false;
38
- }
17
+ let composer = document.activeElement;
18
+ if (!composer || !composer.isContentEditable) {
19
+ composer = document.querySelector('.composer-bar [data-lexical-editor="true"], [id*="composer"] [contenteditable="true"], .aislash-editor-input');
39
20
  }
21
+
22
+ if (!composer) return false;
40
23
 
41
24
  composer.focus();
42
25
  document.execCommand('insertText', false, text);
@@ -45,14 +28,12 @@ export const composerCommand = cli({
45
28
  if (!typed) {
46
29
  throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
47
30
  }
48
- // Submit the command. In Cursor Composer, Enter usually submits if it's not a multi-line edit.
49
- // Sometimes Cmd+Enter is needed? We'll just submit standard Enter.
50
31
  await page.wait(0.5);
51
32
  await page.pressKey('Enter');
52
33
  await page.wait(1);
53
34
  return [
54
35
  {
55
- Status: 'Success (Composer)',
36
+ Status: 'Success',
56
37
  InjectedText: textToInsert,
57
38
  },
58
39
  ];
@@ -0,0 +1 @@
1
+ export declare const cursorExport: import("../../registry.js").CliCommand;
@@ -0,0 +1,51 @@
1
+ import * as fs from 'node:fs';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ function makeExportCommand(site, readSelector) {
4
+ return cli({
5
+ site,
6
+ name: 'export',
7
+ description: `Export the current ${site} conversation to a Markdown file`,
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'output', required: false, positional: true, help: `Output file (default: /tmp/${site}-export.md)` },
13
+ ],
14
+ columns: ['Status', 'File', 'Messages'],
15
+ func: async (page, kwargs) => {
16
+ const outputPath = kwargs.output || `/tmp/${site}-export.md`;
17
+ const md = await page.evaluate(`
18
+ (function() {
19
+ const selectors = ${JSON.stringify(readSelector)}.split(',');
20
+ let messages = [];
21
+
22
+ for (const sel of selectors) {
23
+ const nodes = document.querySelectorAll(sel.trim());
24
+ if (nodes.length > 0) {
25
+ messages = Array.from(nodes).map(n => n.innerText || n.textContent);
26
+ break;
27
+ }
28
+ }
29
+
30
+ if (messages.length === 0) {
31
+ const main = document.querySelector('main, [role="main"], .messages-list, [role="log"]');
32
+ if (main) messages = [main.innerText || main.textContent];
33
+ }
34
+
35
+ if (messages.length === 0) messages = [document.body.innerText];
36
+
37
+ return messages.map((m, i) => '## Message ' + (i + 1) + '\\n\\n' + m.trim()).join('\\n\\n---\\n\\n');
38
+ })()
39
+ `);
40
+ fs.writeFileSync(outputPath, `# ${site} Conversation Export\\n\\n` + md);
41
+ return [
42
+ {
43
+ Status: 'Success',
44
+ File: outputPath,
45
+ Messages: md.split('## Message').length - 1,
46
+ },
47
+ ];
48
+ },
49
+ });
50
+ }
51
+ export const cursorExport = makeExportCommand('cursor', '[data-message-role]');
@@ -0,0 +1 @@
1
+ export declare const historyCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,43 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const historyCommand = cli({
3
+ site: 'cursor',
4
+ name: 'history',
5
+ description: 'List recent chat sessions from the Cursor sidebar',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [],
10
+ columns: ['Index', 'Title'],
11
+ func: async (page) => {
12
+ const items = await page.evaluate(`
13
+ (function() {
14
+ const results = [];
15
+ // Cursor chat history lives in sidebar items
16
+ const entries = document.querySelectorAll('.agent-sidebar-list-item, [data-testid="chat-history-item"], .chat-history-item, .tree-item');
17
+
18
+ entries.forEach((item, i) => {
19
+ const title = (item.textContent || item.innerText || '').trim().substring(0, 100);
20
+ if (title) results.push({ Index: i + 1, Title: title });
21
+ });
22
+
23
+ // Fallback: try to find sidebar text items
24
+ if (results.length === 0) {
25
+ const sidebar = document.querySelector('.sidebar, [class*="sidebar"], .agent-sidebar, .side-bar-container');
26
+ if (sidebar) {
27
+ const links = sidebar.querySelectorAll('a, [role="treeitem"], [role="option"]');
28
+ links.forEach((link, i) => {
29
+ const text = (link.textContent || '').trim().substring(0, 100);
30
+ if (text) results.push({ Index: i + 1, Title: text });
31
+ });
32
+ }
33
+ }
34
+
35
+ return results;
36
+ })()
37
+ `);
38
+ if (items.length === 0) {
39
+ return [{ Index: 0, Title: 'No chat history found. Open the AI sidebar first.' }];
40
+ }
41
+ return items;
42
+ },
43
+ });
@@ -6,21 +6,12 @@ export const newCommand = cli({
6
6
  domain: 'localhost',
7
7
  strategy: Strategy.UI,
8
8
  browser: true,
9
+ args: [],
9
10
  columns: ['Status'],
10
11
  func: async (page) => {
11
- const success = await page.evaluate(`
12
- (function() {
13
- const newChatButton = document.querySelector('[aria-label="New Chat"], [aria-label="New Chat (⌘N)"], .agent-sidebar-new-agent-button');
14
- if (newChatButton) {
15
- newChatButton.click();
16
- return true;
17
- }
18
- return false;
19
- })()
20
- `);
21
- if (!success) {
22
- throw new Error('Could not find New Chat button in Cursor DOM.');
23
- }
12
+ // Use keyboard shortcut — most robust approach, avoids brittle DOM selectors
13
+ const isMac = process.platform === 'darwin';
14
+ await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
24
15
  await page.wait(1);
25
16
  return [{ Status: 'Success' }];
26
17
  },
@@ -0,0 +1 @@
1
+ export declare const screenshotCursor: import("../../registry.js").CliCommand;
@@ -0,0 +1,31 @@
1
+ import * as fs from 'node:fs';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ function makeScreenshotCommand(site) {
4
+ return cli({
5
+ site,
6
+ name: 'screenshot',
7
+ description: `Capture a snapshot of the current ${site} window (DOM + Accessibility tree)`,
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'output', required: false, positional: true, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
13
+ ],
14
+ columns: ['Status', 'File'],
15
+ func: async (page, kwargs) => {
16
+ const outputPath = kwargs.output || `/tmp/${site}-snapshot.txt`;
17
+ // Get both the accessibility snapshot and the raw DOM HTML
18
+ const snap = await page.snapshot({ compact: true });
19
+ const html = await page.evaluate('document.documentElement.outerHTML');
20
+ const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
21
+ const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
22
+ fs.writeFileSync(htmlPath, html);
23
+ fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
24
+ return [
25
+ { Status: 'Success', File: htmlPath },
26
+ { Status: 'Success', File: snapPath },
27
+ ];
28
+ },
29
+ });
30
+ }
31
+ export const screenshotCursor = makeScreenshotCommand('cursor');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -12,9 +12,8 @@ Start the Antigravity desktop app with the Chrome DevTools `remote-debugging-por
12
12
 
13
13
  \`\`\`bash
14
14
  # Start Antigravity in the background
15
- /Applications/Antigravity.app/Contents/MacOS/Electron \\
16
- --remote-debugging-port=9224 \\
17
- --remote-allow-origins="*"
15
+ /Applications/Antigravity.app/Contents/MacOS/Electron \
16
+ --remote-debugging-port=9224
18
17
  \`\`\`
19
18
 
20
19
  *(Note: Depending on your installation, the executable might be named differently, e.g., \`Antigravity\` instead of \`Electron\`.)*
@@ -15,9 +15,8 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
15
15
 
16
16
  \`\`\`bash
17
17
  # 在后台启动并驻留
18
- /Applications/Antigravity.app/Contents/MacOS/Electron \\
19
- --remote-debugging-port=9224 \\
20
- --remote-allow-origins="*"
18
+ /Applications/Antigravity.app/Contents/MacOS/Electron \
19
+ --remote-debugging-port=9224
21
20
  \`\`\`
22
21
 
23
22
  *(注意:如果你打包的应用重命名过主构建,可能需要把 `Electron` 换成实际的可执行文件名,如 `Antigravity`)*
@@ -9,7 +9,7 @@ This skill allows AI agents to control the [Antigravity](https://github.com/chen
9
9
  ## Requirements
10
10
  The target Electron application MUST be launched with the remote-debugging-port flag:
11
11
  \`\`\`bash
12
- /Applications/Antigravity.app/Contents/MacOS/Electron --remote-debugging-port=9224 --remote-allow-origins="*"
12
+ /Applications/Antigravity.app/Contents/MacOS/Electron --remote-debugging-port=9224
13
13
  \`\`\`
14
14
 
15
15
  The agent must configure the endpoint environment variable locally before invoking standard commands:
@@ -10,7 +10,7 @@ export const modelCommand = cli({
10
10
  args: [
11
11
  { name: 'name', help: 'Target model name (e.g. claude, gemini, o1)', required: true, positional: true }
12
12
  ],
13
- columns: ['status'],
13
+ columns: ['Status'],
14
14
  func: async (page, kwargs) => {
15
15
  const targetName = kwargs.name.toLowerCase();
16
16
 
@@ -42,6 +42,6 @@ export const modelCommand = cli({
42
42
  `);
43
43
 
44
44
  await page.wait(0.5);
45
- return [{ status: `Model switched to: ${kwargs.name}` }];
45
+ return [{ Status: `Model switched to: ${kwargs.name}` }];
46
46
  },
47
47
  });
@@ -10,7 +10,7 @@ export const sendCommand = cli({
10
10
  args: [
11
11
  { name: 'message', help: 'The message text to send', required: true, positional: true }
12
12
  ],
13
- columns: ['status', 'message'],
13
+ columns: ['Status', 'Message'],
14
14
  func: async (page, kwargs) => {
15
15
  const text = kwargs.message;
16
16
 
@@ -35,6 +35,6 @@ export const sendCommand = cli({
35
35
  // Press Enter to submit the message
36
36
  await page.pressKey('Enter');
37
37
 
38
- return [{ status: 'Sent successfully', message: text }];
38
+ return [{ Status: 'Sent successfully', Message: text }];
39
39
  },
40
40
  });
@@ -1,35 +1,44 @@
1
1
  # ChatGPT Desktop Adapter for OpenCLI
2
2
 
3
- Control the **ChatGPT macOS Desktop App** directly from the terminal via native AppleScript automation. Unlike Electron-based apps (Antigravity, Codex, Cursor), ChatGPT Desktop is a native macOS application — OpenCLI drives it using `osascript` and System Events.
3
+ Control the **ChatGPT macOS Desktop App** directly from the terminal. OpenCLI supports two automation approaches for ChatGPT.
4
4
 
5
- ## Prerequisites
5
+ ## Approach 1: AppleScript (Default, No Setup)
6
6
 
7
+ The current built-in commands use native AppleScript automation — no extra launch flags needed.
8
+
9
+ ### Prerequisites
7
10
  1. Install the official [ChatGPT Desktop App](https://openai.com/chatgpt/mac/) from OpenAI.
8
11
  2. Grant **Accessibility permissions** to your terminal app (Terminal / iTerm / Warp) in **System Settings → Privacy & Security → Accessibility**. This is required for System Events keystroke simulation.
9
12
 
10
- ## Setup
11
-
12
- No extra environment variables needed — the adapter uses `osascript` directly.
13
-
14
- ## Commands
15
-
16
- ### Diagnostics
13
+ ### Commands
17
14
  - `opencli chatgpt status`: Check if the ChatGPT app is currently running.
18
-
19
- ### Chat Manipulation
20
15
  - `opencli chatgpt new`: Activate ChatGPT and press `Cmd+N` to start a new conversation.
21
16
  - `opencli chatgpt send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit.
22
17
  - `opencli chatgpt read`: Copy the last AI response via `Cmd+Shift+C` and return it as text.
23
18
 
19
+ ## Approach 2: CDP (Advanced, Electron Debug Mode)
20
+
21
+ ChatGPT Desktop is also an Electron app and can be launched with a remote debugging port for deeper automation via CDP:
22
+
23
+ ```bash
24
+ /Applications/ChatGPT.app/Contents/MacOS/ChatGPT \
25
+ --remote-debugging-port=9224
26
+ ```
27
+
28
+ Then set the endpoint:
29
+ ```bash
30
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
31
+ ```
32
+
33
+ > **Note**: The CDP approach enables future advanced commands like DOM inspection, model switching, and code extraction — similar to the Cursor and Codex adapters.
34
+
24
35
  ## How It Works
25
36
 
26
- Unlike CDP-based adapters, this adapter:
27
- - Uses `osascript` to send AppleScript commands to System Events
28
- - Leverages `pbcopy`/`pbpaste` for clipboard-based text transfer
29
- - Requires no remote debugging port — works with the stock app
37
+ - **AppleScript mode**: Uses `osascript` and `pbcopy`/`pbpaste` for clipboard-based text transfer. No remote debugging port needed.
38
+ - **CDP mode**: Connects via Playwright to the Electron renderer process for direct DOM manipulation.
30
39
 
31
40
  ## Limitations
32
41
 
33
42
  - macOS only (AppleScript dependency)
34
- - Requires Accessibility permissions for keystroke simulation
43
+ - AppleScript mode requires Accessibility permissions
35
44
  - `read` command copies the last response — earlier messages need manual scroll
@@ -1,35 +1,44 @@
1
1
  # ChatGPT 桌面端适配器
2
2
 
3
- 通过原生 AppleScript 自动化,在终端中直接控制 **ChatGPT macOS 桌面应用**。与基于 Electron 的应用(Antigravity、Codex、Cursor)不同,ChatGPT Desktop 是原生 macOS 应用 — OpenCLI 使用 `osascript` 和 System Events 来驱动它。
3
+ 在终端中直接控制 **ChatGPT macOS 桌面应用**。OpenCLI 支持两种自动化方式。
4
4
 
5
- ## 前置条件
5
+ ## 方式一:AppleScript(默认,无需配置)
6
6
 
7
- 1. 安装官方 [ChatGPT Desktop App](https://openai.com/chatgpt/mac/)。
8
- 2. 在 **系统设置 → 隐私与安全性 → 辅助功能** 中为终端应用(Terminal / iTerm / Warp)授予 **辅助功能权限**。这是 System Events 按键模拟所必需的。
9
-
10
- ## 配置
11
-
12
- 无需额外环境变量 — 适配器直接使用 `osascript`。
7
+ 内置命令使用原生 AppleScript 自动化,无需额外启动参数。
13
8
 
14
- ## 命令
9
+ ### 前置条件
10
+ 1. 安装官方 [ChatGPT Desktop App](https://openai.com/chatgpt/mac/)。
11
+ 2. 在 **系统设置 → 隐私与安全性 → 辅助功能** 中为终端应用授予权限。
15
12
 
16
- ### 诊断
13
+ ### 命令
17
14
  - `opencli chatgpt status`:检查 ChatGPT 应用是否在运行。
18
-
19
- ### 对话操作
20
15
  - `opencli chatgpt new`:激活 ChatGPT 并按 `Cmd+N` 开始新对话。
21
16
  - `opencli chatgpt send "消息"`:将消息复制到剪贴板,激活 ChatGPT,粘贴并提交。
22
17
  - `opencli chatgpt read`:通过 `Cmd+Shift+C` 复制最后一条 AI 回复并返回文本。
23
18
 
19
+ ## 方式二:CDP(高级,Electron 调试模式)
20
+
21
+ ChatGPT Desktop 同样是 Electron 应用,可以通过远程调试端口启动以实现更深度的自动化:
22
+
23
+ ```bash
24
+ /Applications/ChatGPT.app/Contents/MacOS/ChatGPT \
25
+ --remote-debugging-port=9224
26
+ ```
27
+
28
+ 然后设置环境变量:
29
+ ```bash
30
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
31
+ ```
32
+
33
+ > **注意**:CDP 模式支持未来的高级命令(如 DOM 检查、模型切换、代码提取等),与 Cursor 和 Codex 适配器类似。
34
+
24
35
  ## 工作原理
25
36
 
26
- 与基于 CDP 的适配器不同,此适配器:
27
- - 使用 `osascript` System Events 发送 AppleScript 命令
28
- - 利用 `pbcopy`/`pbpaste` 进行基于剪贴板的文本传输
29
- - 无需远程调试端口 — 直接与原生应用交互
37
+ - **AppleScript 模式**:使用 `osascript` 和 `pbcopy`/`pbpaste` 进行剪贴板文本传输,无需远程调试端口。
38
+ - **CDP 模式**:通过 Playwright 连接到 Electron 渲染进程,直接操作 DOM。
30
39
 
31
40
  ## 限制
32
41
 
33
42
  - 仅支持 macOS(AppleScript 依赖)
34
- - 需要辅助功能权限以模拟按键
35
- - `read` 命令复制最后一条回复 — 更早的消息需要手动滚动
43
+ - AppleScript 模式需要辅助功能权限
44
+ - `read` 命令复制最后一条回复,更早的消息需手动滚动
@@ -0,0 +1,77 @@
1
+ import { execSync, spawnSync } from 'node:child_process';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import type { IPage } from '../../types.js';
4
+
5
+ export const askCommand = cli({
6
+ site: 'chatgpt',
7
+ name: 'ask',
8
+ description: 'Send a prompt and wait for the AI response (send + wait + read)',
9
+ domain: 'localhost',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [
13
+ { name: 'text', required: true, positional: true, help: 'Prompt to send' },
14
+ { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' },
15
+ ],
16
+ columns: ['Role', 'Text'],
17
+ func: async (page: IPage | null, kwargs: any) => {
18
+ const text = kwargs.text as string;
19
+ const timeout = parseInt(kwargs.timeout as string, 10) || 30;
20
+
21
+ // Backup clipboard
22
+ let clipBackup = '';
23
+ try { clipBackup = execSync('pbpaste', { encoding: 'utf-8' }); } catch {}
24
+
25
+ // Send the message
26
+ spawnSync('pbcopy', { input: text });
27
+ execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
28
+ execSync("osascript -e 'delay 0.5'");
29
+
30
+ const cmd = "osascript " +
31
+ "-e 'tell application \"System Events\"' " +
32
+ "-e 'keystroke \"v\" using command down' " +
33
+ "-e 'delay 0.2' " +
34
+ "-e 'keystroke return' " +
35
+ "-e 'end tell'";
36
+ execSync(cmd);
37
+
38
+ // Clear clipboard marker
39
+ spawnSync('pbcopy', { input: '__OPENCLI_WAITING__' });
40
+
41
+ // Wait for response, then read it
42
+ const pollInterval = 3;
43
+ const maxPolls = Math.ceil(timeout / pollInterval);
44
+ let response = '';
45
+
46
+ for (let i = 0; i < maxPolls; i++) {
47
+ // Wait
48
+ execSync(`sleep ${pollInterval}`);
49
+
50
+ // Try Cmd+Shift+C to copy the latest response
51
+ 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'");
54
+
55
+ const copied = execSync('pbpaste', { encoding: 'utf-8' }).trim();
56
+ if (copied && copied !== '__OPENCLI_WAITING__' && copied !== text) {
57
+ response = copied;
58
+ break;
59
+ }
60
+ }
61
+
62
+ // Restore clipboard
63
+ if (clipBackup) spawnSync('pbcopy', { input: clipBackup });
64
+
65
+ if (!response) {
66
+ return [
67
+ { Role: 'User', Text: text },
68
+ { Role: 'System', Text: `No response within ${timeout}s. ChatGPT may still be generating.` },
69
+ ];
70
+ }
71
+
72
+ return [
73
+ { Role: 'User', Text: text },
74
+ { Role: 'Assistant', Text: response },
75
+ ];
76
+ },
77
+ });