From 3d1ea9b15c64f9de85a19c61e7eb491f1d8572ef Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 18 Mar 2026 20:44:27 +0800 Subject: [PATCH] feat: add ChatWise Desktop adapter (9 commands via CDP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status, new, send, read, ask, model, screenshot, history, export - Electron-based multi-LLM client (GPT-4/Claude/Gemini) - Includes adapter READMEs (EN+ZH) - Fix truncated README table rows - Total: 27 sites · 137 commands --- README.md | 5 +- README.zh-CN.md | 9 ++-- src/clis/chatwise/README.md | 38 ++++++++++++++ src/clis/chatwise/README.zh-CN.md | 38 ++++++++++++++ src/clis/chatwise/ask.ts | 87 +++++++++++++++++++++++++++++++ src/clis/chatwise/export.ts | 51 ++++++++++++++++++ src/clis/chatwise/history.ts | 47 +++++++++++++++++ src/clis/chatwise/model.ts | 87 +++++++++++++++++++++++++++++++ src/clis/chatwise/new.ts | 21 ++++++++ src/clis/chatwise/read.ts | 42 +++++++++++++++ src/clis/chatwise/screenshot.ts | 33 ++++++++++++ src/clis/chatwise/send.ts | 50 ++++++++++++++++++ src/clis/chatwise/status.ts | 25 +++++++++ 13 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 src/clis/chatwise/README.md create mode 100644 src/clis/chatwise/README.zh-CN.md create mode 100644 src/clis/chatwise/ask.ts create mode 100644 src/clis/chatwise/export.ts create mode 100644 src/clis/chatwise/history.ts create mode 100644 src/clis/chatwise/model.ts create mode 100644 src/clis/chatwise/new.ts create mode 100644 src/clis/chatwise/read.ts create mode 100644 src/clis/chatwise/screenshot.ts create mode 100644 src/clis/chatwise/send.ts create mode 100644 src/clis/chatwise/status.ts diff --git a/README.md b/README.md index f1ee252..75a5696 100644 --- a/README.md +++ b/README.md @@ -143,15 +143,16 @@ npm install -g @jackwener/opencli@latest ## Built-in Commands -**26 sites · 128 commands** — run `opencli list` for the live registry. +**27 sites · 137 commands** — run `opencli list` for the live registry. | Site | Commands | Count | Mode | |------|----------|:-----:|------| | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 Browser | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 Browser | -| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser | | **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 12 | 🖥️ Desktop | +| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser | | **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 10 | 🖥️ Desktop | +| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 9 | 🖥️ Desktop | | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser | | **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ Desktop | diff --git a/README.zh-CN.md b/README.zh-CN.md index c63d7de..1cf3664 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,19 +144,20 @@ npm install -g @jackwener/opencli@latest ## 内置命令 -**26 个站点 · 128 命令** — 运行 `opencli list` 查看完整注册表。 +**27 个站点 · 137 命令** — 运行 `opencli list` 查看完整注册表。 | 站点 | 命令 | 数量 | 模式 | |------|------|:----:|------| | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 浏览器 | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 浏览器 | -| **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ 桌面端 | -| **chatgpt** | `status` `new` `send` `read` `ask` | 5 | 🖥️ 桌面端 | -| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 10 | 🖥️ 桌面端 | | **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 12 | 🖥️ 桌面端 | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 浏览器 | +| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 10 | 🖥️ 桌面端 | +| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 9 | 🖥️ 桌面端 | | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 浏览器 | +| **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ 桌面端 | +| **chatgpt** | `status` `new` `send` `read` `ask` | 5 | 🖥️ 桌面端 | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 浏览器 | | **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 3 | 🌐 公开 | | **youtube** | `search` `video` `transcript` | 3 | 🔐 浏览器 | diff --git a/src/clis/chatwise/README.md b/src/clis/chatwise/README.md new file mode 100644 index 0000000..e343c0d --- /dev/null +++ b/src/clis/chatwise/README.md @@ -0,0 +1,38 @@ +# ChatWise Adapter for OpenCLI + +Control the **ChatWise Desktop App** from the terminal via Chrome DevTools Protocol (CDP). ChatWise is an Electron-based multi-LLM client supporting GPT-4, Claude, Gemini, and more. + +## Prerequisites + +1. Install [ChatWise](https://chatwise.app/). +2. Launch with remote debugging port: + ```bash + /Applications/ChatWise.app/Contents/MacOS/ChatWise \ + --remote-debugging-port=9228 + ``` + +## Setup + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9228" +``` + +## Commands + +### Diagnostics +- `opencli chatwise status`: Check CDP connection status. +- `opencli chatwise screenshot`: Export DOM + accessibility snapshot. + +### Chat +- `opencli chatwise new`: Start a new conversation (`Cmd+N`). +- `opencli chatwise send "message"`: Send a message to the active chat. +- `opencli chatwise read`: Read the current conversation. +- `opencli chatwise ask "prompt"`: Send + wait for response + return it (one-shot). + +### AI Features +- `opencli chatwise model`: Get the current AI model. +- `opencli chatwise model gpt-4`: Switch to a different model. + +### Organization +- `opencli chatwise history`: List conversations from the sidebar. +- `opencli chatwise export`: Export conversation as Markdown. diff --git a/src/clis/chatwise/README.zh-CN.md b/src/clis/chatwise/README.zh-CN.md new file mode 100644 index 0000000..2da3915 --- /dev/null +++ b/src/clis/chatwise/README.zh-CN.md @@ -0,0 +1,38 @@ +# ChatWise 适配器 + +通过 Chrome DevTools Protocol (CDP) 在终端中控制 **ChatWise 桌面应用**。ChatWise 是基于 Electron 的多 LLM 客户端,支持 GPT-4、Claude、Gemini 等。 + +## 前置条件 + +1. 安装 [ChatWise](https://chatwise.app/)。 +2. 通过远程调试端口启动: + ```bash + /Applications/ChatWise.app/Contents/MacOS/ChatWise \ + --remote-debugging-port=9228 + ``` + +## 配置 + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9228" +``` + +## 命令 + +### 诊断 +- `opencli chatwise status`:检查 CDP 连接状态。 +- `opencli chatwise screenshot`:导出 DOM + accessibility 快照。 + +### 对话 +- `opencli chatwise new`:开始新对话(`Cmd+N`)。 +- `opencli chatwise send "消息"`:发送消息到当前对话。 +- `opencli chatwise read`:读取当前对话内容。 +- `opencli chatwise ask "提示词"`:发送 + 等待回复 + 返回结果(一站式)。 + +### AI 功能 +- `opencli chatwise model`:获取当前 AI 模型。 +- `opencli chatwise model gpt-4`:切换模型。 + +### 组织管理 +- `opencli chatwise history`:列出 sidebar 会话列表。 +- `opencli chatwise export`:导出对话为 Markdown 文件。 diff --git a/src/clis/chatwise/ask.ts b/src/clis/chatwise/ask.ts new file mode 100644 index 0000000..e4e8e8d --- /dev/null +++ b/src/clis/chatwise/ask.ts @@ -0,0 +1,87 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const askCommand = cli({ + site: 'chatwise', + name: 'ask', + description: 'Send a prompt and wait for the AI response (send + wait + read)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Prompt to send' }, + { name: 'timeout', required: false, help: 'Max seconds to wait (default: 30)', default: '30' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: any) => { + const text = kwargs.text as string; + const timeout = parseInt(kwargs.timeout as string, 10) || 30; + + // Snapshot content length + const beforeLen = await page.evaluate(` + (function() { + const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]'); + return msgs.length; + })() + `); + + // Send message + await page.evaluate(` + (function(text) { + let composer = document.querySelector('textarea'); + if (!composer) { + const editables = Array.from(document.querySelectorAll('[contenteditable="true"]')); + composer = editables.length > 0 ? editables[editables.length - 1] : null; + } + if (!composer) throw new Error('Could not find input'); + composer.focus(); + if (composer.tagName === 'TEXTAREA') { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + setter.call(composer, text); + composer.dispatchEvent(new Event('input', { bubbles: true })); + } else { + document.execCommand('insertText', false, text); + } + })(${JSON.stringify(text)}) + `); + + await page.wait(0.5); + await page.pressKey('Enter'); + + // Poll for response + const pollInterval = 2; + const maxPolls = Math.ceil(timeout / pollInterval); + let response = ''; + + for (let i = 0; i < maxPolls; i++) { + await page.wait(pollInterval); + + const result = await page.evaluate(` + (function(prevLen) { + const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]'); + if (msgs.length <= prevLen) return null; + const last = msgs[msgs.length - 1]; + const text = last.innerText || last.textContent; + return text ? text.trim() : null; + })(${beforeLen}) + `); + + if (result) { + response = result; + break; + } + } + + if (!response) { + return [ + { Role: 'User', Text: text }, + { Role: 'System', Text: `No response within ${timeout}s.` }, + ]; + } + + return [ + { Role: 'User', Text: text }, + { Role: 'Assistant', Text: response }, + ]; + }, +}); diff --git a/src/clis/chatwise/export.ts b/src/clis/chatwise/export.ts new file mode 100644 index 0000000..47a3f8c --- /dev/null +++ b/src/clis/chatwise/export.ts @@ -0,0 +1,51 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const exportCommand = cli({ + site: 'chatwise', + name: 'export', + description: 'Export the current ChatWise conversation to a Markdown file', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, positional: true, help: 'Output file (default: /tmp/chatwise-export.md)' }, + ], + columns: ['Status', 'File', 'Messages'], + func: async (page: IPage, kwargs: any) => { + const outputPath = (kwargs.output as string) || '/tmp/chatwise-export.md'; + + const md = await page.evaluate(` + (function() { + const selectors = [ + '[data-message-id]', + '[class*="message"]', + '[class*="chat-item"]', + '[class*="bubble"]', + ]; + + for (const sel of selectors) { + const nodes = document.querySelectorAll(sel); + if (nodes.length > 0) { + return Array.from(nodes).map((n, i) => '## Message ' + (i + 1) + '\\n\\n' + (n.innerText || n.textContent).trim()).join('\\n\\n---\\n\\n'); + } + } + + const main = document.querySelector('main, [role="main"], [class*="chat-container"]'); + if (main) return main.innerText || main.textContent; + return document.body.innerText; + })() + `); + + fs.writeFileSync(outputPath, '# ChatWise Conversation Export\\n\\n' + md); + + return [ + { + Status: 'Success', + File: outputPath, + Messages: md.split('## Message').length - 1, + }, + ]; + }, +}); diff --git a/src/clis/chatwise/history.ts b/src/clis/chatwise/history.ts new file mode 100644 index 0000000..4cda47c --- /dev/null +++ b/src/clis/chatwise/history.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const historyCommand = cli({ + site: 'chatwise', + name: 'history', + description: 'List conversation history in ChatWise sidebar', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Index', 'Title'], + func: async (page: IPage) => { + const items = await page.evaluate(` + (function() { + const results = []; + const selectors = [ + '[class*="sidebar"] [class*="item"]', + '[class*="conversation-list"] a', + '[class*="chat-list"] > *', + 'nav a', + 'aside a', + '[role="listbox"] [role="option"]', + ]; + + for (const sel of selectors) { + const nodes = document.querySelectorAll(sel); + if (nodes.length > 0) { + nodes.forEach((n, i) => { + const text = (n.textContent || '').trim().substring(0, 100); + if (text) results.push({ Index: i + 1, Title: text }); + }); + break; + } + } + + return results; + })() + `); + + if (items.length === 0) { + return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }]; + } + + return items; + }, +}); diff --git a/src/clis/chatwise/model.ts b/src/clis/chatwise/model.ts new file mode 100644 index 0000000..617820d --- /dev/null +++ b/src/clis/chatwise/model.ts @@ -0,0 +1,87 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const modelCommand = cli({ + site: 'chatwise', + name: 'model', + description: 'Get or switch the active AI model in ChatWise', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'model_name', required: false, positional: true, help: 'Model to switch to (e.g. gpt-4, claude-3)' }, + ], + columns: ['Status', 'Model'], + func: async (page: IPage, kwargs: any) => { + const desiredModel = kwargs.model_name as string | undefined; + + if (!desiredModel) { + // Read current model + const currentModel = await page.evaluate(` + (function() { + // ChatWise is a multi-LLM client, it typically shows the model name in a dropdown or header + const selectors = [ + '[class*="model"] span', + '[class*="Model"] span', + '[data-testid*="model"]', + 'button[class*="model"]', + '[aria-label*="Model"]', + '[aria-label*="model"]', + ]; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { + const text = (el.textContent || el.getAttribute('title') || '').trim(); + if (text) return text; + } + } + + return 'Unknown or Not Found'; + })() + `); + + return [{ Status: 'Active', Model: currentModel }]; + } else { + // Try to switch model + await page.evaluate(` + (function(target) { + const selectors = [ + '[class*="model"]', + '[class*="Model"]', + 'button[class*="model"]', + ]; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { el.click(); return; } + } + throw new Error('Could not find model selector'); + })(${JSON.stringify(desiredModel)}) + `); + + await page.wait(0.5); + + // Find and click the target model in the dropdown + const found = await page.evaluate(` + (function(target) { + const options = document.querySelectorAll('[role="option"], [role="menuitem"], [class*="dropdown-item"], li'); + for (const opt of options) { + if ((opt.textContent || '').toLowerCase().includes(target.toLowerCase())) { + opt.click(); + return true; + } + } + return false; + })(${JSON.stringify(desiredModel)}) + `); + + return [ + { + Status: found ? 'Switched' : 'Dropdown opened but model not found', + Model: desiredModel, + }, + ]; + } + }, +}); diff --git a/src/clis/chatwise/new.ts b/src/clis/chatwise/new.ts new file mode 100644 index 0000000..ed23ce5 --- /dev/null +++ b/src/clis/chatwise/new.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const newCommand = cli({ + site: 'chatwise', + name: 'new', + description: 'Start a new conversation in ChatWise', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page: IPage) => { + // ChatWise uses standard Electron shortcuts + const isMac = process.platform === 'darwin'; + await page.pressKey(isMac ? 'Meta+N' : 'Control+N'); + await page.wait(1); + + return [{ Status: 'Success' }]; + }, +}); diff --git a/src/clis/chatwise/read.ts b/src/clis/chatwise/read.ts new file mode 100644 index 0000000..4b52c50 --- /dev/null +++ b/src/clis/chatwise/read.ts @@ -0,0 +1,42 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const readCommand = cli({ + site: 'chatwise', + name: 'read', + description: 'Read the current ChatWise conversation history', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Content'], + func: async (page: IPage) => { + const content = await page.evaluate(` + (function() { + // Try common chat message selectors + const selectors = [ + '[data-message-id]', + '[class*="message"]', + '[class*="chat-item"]', + '[class*="bubble"]', + '[role="log"] > *', + ]; + + for (const sel of selectors) { + const nodes = document.querySelectorAll(sel); + if (nodes.length > 0) { + return Array.from(nodes).map(n => (n.innerText || n.textContent).trim()).filter(Boolean).join('\\n\\n---\\n\\n'); + } + } + + // Fallback: main content area + const main = document.querySelector('main, [role="main"], [class*="chat-container"], [class*="conversation"]'); + if (main) return main.innerText || main.textContent; + + return document.body.innerText; + })() + `); + + return [{ Content: content }]; + }, +}); diff --git a/src/clis/chatwise/screenshot.ts b/src/clis/chatwise/screenshot.ts new file mode 100644 index 0000000..f88d326 --- /dev/null +++ b/src/clis/chatwise/screenshot.ts @@ -0,0 +1,33 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const screenshotCommand = cli({ + site: 'chatwise', + name: 'screenshot', + description: 'Capture a snapshot of the current ChatWise window (DOM + Accessibility tree)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, positional: true, help: 'Output file path (default: /tmp/chatwise-snapshot)' }, + ], + columns: ['Status', 'File'], + func: async (page: IPage, kwargs: any) => { + const basePath = (kwargs.output as string) || '/tmp/chatwise-snapshot'; + + const snap = await page.snapshot({ compact: true }); + const html = await page.evaluate('document.documentElement.outerHTML'); + + const htmlPath = basePath + '-dom.html'; + const snapPath = basePath + '-a11y.txt'; + + fs.writeFileSync(htmlPath, html); + fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2)); + + return [ + { Status: 'Success', File: htmlPath }, + { Status: 'Success', File: snapPath }, + ]; + }, +}); diff --git a/src/clis/chatwise/send.ts b/src/clis/chatwise/send.ts new file mode 100644 index 0000000..a6b40a2 --- /dev/null +++ b/src/clis/chatwise/send.ts @@ -0,0 +1,50 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const sendCommand = cli({ + site: 'chatwise', + name: 'send', + description: 'Send a message to the active ChatWise conversation', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }], + columns: ['Status', 'InjectedText'], + func: async (page: IPage, kwargs: any) => { + const text = kwargs.text as string; + + await page.evaluate(` + (function(text) { + // ChatWise input can be textarea or contenteditable + let composer = document.querySelector('textarea'); + if (!composer) { + const editables = Array.from(document.querySelectorAll('[contenteditable="true"]')); + composer = editables.length > 0 ? editables[editables.length - 1] : null; + } + + if (!composer) throw new Error('Could not find ChatWise input element'); + + composer.focus(); + + if (composer.tagName === 'TEXTAREA') { + // For textarea, set value and dispatch input event + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + nativeInputValueSetter.call(composer, text); + composer.dispatchEvent(new Event('input', { bubbles: true })); + } else { + document.execCommand('insertText', false, text); + } + })(${JSON.stringify(text)}) + `); + + await page.wait(0.5); + await page.pressKey('Enter'); + + return [ + { + Status: 'Success', + InjectedText: text, + }, + ]; + }, +}); diff --git a/src/clis/chatwise/status.ts b/src/clis/chatwise/status.ts new file mode 100644 index 0000000..4984ffd --- /dev/null +++ b/src/clis/chatwise/status.ts @@ -0,0 +1,25 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const statusCommand = cli({ + site: 'chatwise', + name: 'status', + description: 'Check active CDP connection to ChatWise Desktop', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status', 'Url', 'Title'], + func: async (page: IPage) => { + const url = await page.evaluate('window.location.href'); + const title = await page.evaluate('document.title'); + + return [ + { + Status: 'Connected', + Url: url, + Title: title, + }, + ]; + }, +});