feat: add ChatWise Desktop adapter (9 commands via CDP)

- 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
This commit is contained in:
jackwener committed 2026-03-18 20:48:05 +08:00
1 parent e1d4a6e5e6
commit 3d1ea9b15c
13 files changed
+527 -6

No files matched your search

+3 -2
View File
@@ -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 |
+5 -4
View File
@@ -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 | 🔐 浏览器 |
+38
View File
@@ -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.
+38
View File
@@ -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 文件。
+87
View File
@@ -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 },
];
},
});
+51
View File
@@ -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,
},
];
},
});
+47
View File
@@ -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;
},
});
+87
View File
@@ -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,
},
];
}
},
});
+21
View File
@@ -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' }];
},
});
+42
View File
@@ -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 }];
},
});
+33
View File
@@ -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 },
];
},
});
+50
View File
@@ -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,
},
];
},
});
+25
View File
@@ -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,
},
];
},
});