Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Run `opencli list` for the live registry.
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | Desktop |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
| **dory** | `status` `dump` `screenshot` `connections` `connect` `databases` `tables` `columns` `table-preview` `query` `query-export` `chart-download` `send` `ask` `read` `export` `new` `sessions` | Desktop |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | Browser |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
Expand Down Expand Up @@ -218,6 +219,7 @@ If you want to add support for a new Electron desktop app, start with [docs/guid
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
| **[Dory](https://github.com/dorylab/dory)** | SQL client & AI assistant desktop app | [Doc](./docs/adapters/desktop/dory.md) |

## Download Support

Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ npm install -g @jackwener/opencli@latest
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **dory** | `status` `dump` `screenshot` `connections` `connect` `databases` `tables` `columns` `table-preview` `query` `query-export` `chart-download` `send` `ask` `read` `export` `new` `sessions` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
Expand Down Expand Up @@ -218,6 +219,7 @@ opencli register mycli
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) |
| **[Dory](https://github.com/dorylab/dory)** | SQL 客户端 & AI 助手桌面应用 | [Doc](./docs/adapters/desktop/dory.md) |

## 下载支持

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export default defineConfig({
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
{ text: 'Notion', link: '/adapters/desktop/notion' },
{ text: 'Discord', link: '/adapters/desktop/discord' },
{ text: 'Dory', link: '/adapters/desktop/dory' },
{ text: 'Doubao App', link: '/adapters/desktop/doubao-app' },
],
},
Expand Down
123 changes: 123 additions & 0 deletions docs/adapters/desktop/dory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Dory

Control the **Dory Desktop App** headless or headfully via Chrome DevTools Protocol (CDP). Because Dory is built on Electron, OpenCLI can directly drive its internal UI, send messages to the AI chat, read responses, and manage sessions.

## Prerequisites

1. You must have the official Dory app installed.
2. Launch it via the terminal and expose the remote debugging port:
```bash
# macOS
/Applications/Dory.app/Contents/MacOS/Dory --remote-debugging-port=9300
```

## Setup

```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9300"
```

## Commands

### Diagnostics
- `opencli dory status` — Check CDP connection, current URL and page title.
- `opencli dory dump` — Dump the full DOM and accessibility tree to `/tmp/dory-dom.html` and `/tmp/dory-snapshot.json`.
- `opencli dory screenshot` — Capture DOM + accessibility snapshot to `/tmp/dory-snapshot-dom.html` and `/tmp/dory-snapshot-a11y.txt`.

### Connection Management
- `opencli dory connections` — List all database connections.
- `opencli dory connect <connectionId>` — Navigate to the SQL console for a specific connection.
- `opencli dory databases <connectionId>` — List all databases available for a connection.

### Schema Exploration
- `opencli dory tables <connectionId> <database>` — List tables in a database.
- Optional: `--schema <name>` to filter by schema.
- `opencli dory columns <connectionId> <database> <table>` — List columns for a specific table.
- `opencli dory table-preview <connectionId> <database> <table>` — Preview rows from a table.
- Optional: `--limit 100` (default: 50).

### SQL Queries
- `opencli dory query "SQL" --connection <id>` — Execute SQL and print results.
- Optional: `--database <name>` to set the active database.
- `opencli dory query-export "SQL" --connection <id>` — Execute SQL and save results as CSV.
- Optional: `--database <name>`, `--output /path/to/file.csv` (default: `/tmp/dory-query.csv`).

### Charts
- `opencli dory chart-download` — Download the currently visible chart.
- Optional: `--image-format png` or `--image-format svg` (default: `svg`).
- Optional: `--output /path/to/file.svg` (default: `/tmp/dory-chart.svg`).
- *Note: switch the result table to "Charts" view first before running this command.*

### Chat (AI Assistant)
All chat commands accept an optional `--connection <id>` flag. If provided, the app automatically navigates to the chatbot page for that connection before executing.

- `opencli dory send "message" [--connection <id>]` — Inject text into the chat composer and submit.
- `opencli dory ask "message" [--connection <id>]` — Send a message, wait for the AI response, and print it.
- Optional: `--timeout 120` to wait up to 120 seconds (default: 60).
- `opencli dory read [--connection <id>]` — Extract the full conversation thread (user + assistant messages).
- `opencli dory export [--connection <id>]` — Export the current conversation to a Markdown file.
- Optional: `--output /path/to/file.md` (default: `/tmp/dory-export.md`).

### Session Management
- `opencli dory new [--connection <id>]` — Create a new chat session.
- `opencli dory sessions [--connection <id>]` — List recent chat sessions shown in the sidebar.

## Example Workflows

### Explore a database
```bash
# List all connections (shows names)
opencli dory connections

# List databases — use the connection name directly
opencli dory databases "My Postgres"

# List tables
opencli dory tables "My Postgres" my_db

# Inspect columns of a table
opencli dory columns "My Postgres" my_db users

# Preview rows
opencli dory table-preview "My Postgres" my_db users --limit 20
```

### Run queries and export
```bash
# Navigate to the SQL console
opencli dory connect "My Postgres"

# Run a query and print results
opencli dory query "SELECT * FROM orders LIMIT 10" --connection "My Postgres" --database my_db

# Export query results to CSV
opencli dory query-export "SELECT id, name, created_at FROM users" \
--connection "My Postgres" --database my_db --output ~/users.csv
```

### Render and download a chart
```bash
# Ask the AI to build a chart (auto-navigates to chatbot for this connection)
opencli dory ask "Show me a bar chart of orders by month" --connection "My Postgres"

# In the SQL console, switch to Charts view, then:
opencli dory chart-download --image-format png --output ~/chart.png
```

### AI chat session
```bash
opencli dory ask "What tables are available in the active database?" --connection "My Postgres"
opencli dory read --connection "My Postgres"
opencli dory export --connection "My Postgres" --output ~/dory-session.md
opencli dory new --connection "My Postgres"
```

## Notes

- **Connection names**: all commands that accept a connection argument resolve names to IDs automatically (case-insensitive). You can always pass a raw UUID instead if needed.
- **API commands** (`connections`, `databases`, `tables`, `columns`, `table-preview`, `query`, `query-export`) call Dory's REST API using browser session cookies — no extra authentication needed.
- **`query` / `query-export`**: the `--connection` flag is required; use `opencli dory connections` to find your connection ID.
- **`chart-download`**: finds the first Recharts SVG on the page. If `--format png` fails due to canvas restrictions, it automatically falls back to SVG.
- **Chat commands** (`send`, `ask`, `read`, `export`, `new`, `sessions`) all support `--connection <id>`. When provided, the app automatically navigates to `/[org]/[connectionId]/chatbot` before executing. If omitted and already on a chatbot page, the current page is used.
- **Chat commands** use the native `HTMLTextAreaElement` value setter to properly trigger React's synthetic event system.
- The `ask` command polls every 2 seconds and considers the response complete once the text stabilizes across two consecutive polls.
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@ Run `opencli list` for the live registry.
| **[Notion](/adapters/desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
| **[Discord](/adapters/desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
| **[Doubao App](/adapters/desktop/doubao-app)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` |
| **[Dory](/adapters/desktop/dory)** | SQL client & AI assistant desktop app (https://github.com/dorylab/dory) | `status` `dump` `screenshot` `connections` `connect` `databases` `tables` `columns` `table-preview` `query` `query-export` `chart-download` `send` `ask` `read` `export` `new` `sessions` |
108 changes: 108 additions & 0 deletions src/clis/dory/_shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Shared utilities for the Dory adapter.
*/
import type { IPage } from '../../types.js';

/**
* Resolve a connection name or ID to a connection ID.
* Fetches all connections and matches by name (case-insensitive).
* Falls back to returning the input as-is if no name match is found
* (treats it as a raw ID).
*/
export async function resolveConnectionId(page: IPage, nameOrId: string): Promise<string> {
const resolved = await page.evaluate(`
(async function(nameOrId) {
try {
const res = await fetch('/api/connection', { credentials: 'include' });
if (!res.ok) return nameOrId;
const json = await res.json();
const list = json.data ?? json ?? [];
const lower = nameOrId.toLowerCase();
const match = list.find(function(item) {
const c = item.connection ?? item;
return (c.name ?? '').toLowerCase() === lower;
});
if (match) {
const c = match.connection ?? match;
return c.id;
}
} catch (_) {}
return nameOrId;
})(${JSON.stringify(nameOrId)})
`);
return resolved as string;
}

/**
* Ensure the browser is on a Dory chatbot page.
*
* Priority:
* 1. If `connectionId` is given, navigate to /[org]/[connectionId]/chatbot
* (org is extracted from the current URL, or falls back to a click on any chatbot link).
* 2. If already on a /chatbot route, do nothing.
* 3. If on another Dory route, extract org + connectionId from the URL and navigate.
*
* Waits up to `waitSec` seconds for the chat textarea to appear.
*/
export async function ensureChatbotPage(page: IPage, connectionId?: string, waitSec = 5): Promise<void> {
const navResult = await page.evaluate(`
(function() {
const path = window.location.pathname;
if (path.includes('/chatbot')) return { already: true, path: path };
const parts = path.split('/').filter(Boolean);
return {
already: false,
org: parts.length >= 1 ? parts[0] : null,
connectionId: parts.length >= 2 ? parts[1] : null,
};
})()
`);

if (!connectionId && navResult.already) return;

const org: string | null = navResult.org;
const resolvedConn = connectionId ?? navResult.connectionId;

if (org && resolvedConn) {
const target = `http://localhost:3000/${org}/${resolvedConn}/chatbot`;
const currentUrl = await page.evaluate(`window.location.href`);
// Only navigate if not already on the exact chatbot page for this connection
if (!String(currentUrl).includes(`/${resolvedConn}/chatbot`)) {
await page.goto(target);
}
} else {
// Fallback: click the first chatbot nav link on the page
await page.evaluate(`
(function() {
const link = Array.from(document.querySelectorAll('a[href*="chatbot"], a[href*="chat"]'))[0];
if (link) link.click();
})()
`);
}

// Wait for textarea to become available
for (let i = 0; i < waitSec * 2; i++) {
await page.wait(0.5);
const found = await page.evaluate(`!!document.querySelector('textarea[name="message"]')`);
if (found) return;
}
}

/**
* Inject text into the Dory chat textarea using the React native setter.
* Returns true on success.
*/
export async function injectChatText(page: IPage, text: string): Promise<boolean> {
return page.evaluate(`
(function(text) {
const textarea = document.querySelector('textarea[name="message"]') || document.querySelector('textarea');
if (!textarea) return false;
textarea.focus();
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
nativeSetter.call(textarea, text);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})(${JSON.stringify(text)})
`);
}
79 changes: 79 additions & 0 deletions src/clis/dory/ask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { ensureChatbotPage, injectChatText, resolveConnectionId } from './_shared.js';

export const askCommand = cli({
site: 'dory',
name: 'ask',
description: 'Send a message and wait for the AI response (send + wait + read)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'text', required: true, positional: true, help: 'Message to send' },
{ name: 'connection', required: false, help: 'Connection name or ID to navigate to before sending' },
{ name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 60)', default: '60' },
],
columns: ['Role', 'Text'],
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;
const rawConn = kwargs.connection as string | undefined;
const connectionId = rawConn ? await resolveConnectionId(page, rawConn) : undefined;
const timeout = parseInt(kwargs.timeout as string, 10) || 60;

await ensureChatbotPage(page, connectionId);

const beforeCount = await page.evaluate(`
(function() {
return document.querySelectorAll('[role="log"] .is-assistant').length;
})()
`);

const injected = await injectChatText(page, text);
if (!injected) throw new SelectorError('Dory chat textarea');

await page.wait(0.3);
await page.pressKey('Enter');

const pollInterval = 2;
const maxPolls = Math.ceil(timeout / pollInterval);
let response = '';
let lastText = '';

for (let i = 0; i < maxPolls; i++) {
await page.wait(pollInterval);

const result = await page.evaluate(`
(function(prevCount) {
const msgs = document.querySelectorAll('[role="log"] .is-assistant');
if (msgs.length <= prevCount) return null;
const last = msgs[msgs.length - 1];
return (last.innerText || last.textContent || '').trim();
})(${beforeCount})
`);

if (result) {
if (result === lastText) {
response = result;
break;
}
lastText = result;
}
}

if (!response && lastText) response = lastText;

if (!response) {
return [
{ Role: 'User', Text: text },
{ Role: 'System', Text: `No response within ${timeout}s. The AI may still be generating.` },
];
}

return [
{ Role: 'User', Text: text },
{ Role: 'Assistant', Text: response },
];
},
});
Loading