diff --git a/README.md b/README.md index ad4fb46f..543e15e6 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index eda93647..2a389087 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` | 桌面端 | @@ -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) | ## 下载支持 diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e73ca4d2..435ec367 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -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' }, ], }, diff --git a/docs/adapters/desktop/dory.md b/docs/adapters/desktop/dory.md new file mode 100644 index 00000000..a424199a --- /dev/null +++ b/docs/adapters/desktop/dory.md @@ -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 ` — Navigate to the SQL console for a specific connection. +- `opencli dory databases ` — List all databases available for a connection. + +### Schema Exploration +- `opencli dory tables ` — List tables in a database. + - Optional: `--schema ` to filter by schema. +- `opencli dory columns ` — List columns for a specific table. +- `opencli dory table-preview
` — Preview rows from a table. + - Optional: `--limit 100` (default: 50). + +### SQL Queries +- `opencli dory query "SQL" --connection ` — Execute SQL and print results. + - Optional: `--database ` to set the active database. +- `opencli dory query-export "SQL" --connection ` — Execute SQL and save results as CSV. + - Optional: `--database `, `--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 ` flag. If provided, the app automatically navigates to the chatbot page for that connection before executing. + +- `opencli dory send "message" [--connection ]` — Inject text into the chat composer and submit. +- `opencli dory ask "message" [--connection ]` — 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 ]` — Extract the full conversation thread (user + assistant messages). +- `opencli dory export [--connection ]` — 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 ]` — Create a new chat session. +- `opencli dory sessions [--connection ]` — 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 `. 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. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 191fb778..add749cb 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -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` | diff --git a/src/clis/dory/_shared.ts b/src/clis/dory/_shared.ts new file mode 100644 index 00000000..663e39e5 --- /dev/null +++ b/src/clis/dory/_shared.ts @@ -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 { + 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 { + 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 { + 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)}) + `); +} diff --git a/src/clis/dory/ask.ts b/src/clis/dory/ask.ts new file mode 100644 index 00000000..f543ed79 --- /dev/null +++ b/src/clis/dory/ask.ts @@ -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 }, + ]; + }, +}); diff --git a/src/clis/dory/chart-download.ts b/src/clis/dory/chart-download.ts new file mode 100644 index 00000000..0aa8c0e2 --- /dev/null +++ b/src/clis/dory/chart-download.ts @@ -0,0 +1,89 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const chartDownloadCommand = cli({ + site: 'dory', + name: 'chart-download', + description: 'Download the currently visible chart as SVG or PNG', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, help: 'Output file path (default: /tmp/dory-chart.svg). Use .png for PNG.' }, + { name: 'image-format', required: false, help: 'Image format: svg or png (default: svg)', choices: ['svg', 'png'], default: 'svg' }, + ], + columns: ['Status', 'File', 'Format', 'Width', 'Height'], + func: async (page: IPage, kwargs: any) => { + const format = (kwargs['image-format'] as string) || 'svg'; + const ext = format === 'png' ? 'png' : 'svg'; + const outputPath = (kwargs.output as string) || `/tmp/dory-chart.${ext}`; + + const chartData = await page.evaluate(` + (async function(format) { + // Find the Recharts SVG — it lives inside .recharts-wrapper + const svgEl = document.querySelector('.recharts-wrapper svg') + ?? document.querySelector('[data-testid="result-table"] svg') + ?? document.querySelector('svg.recharts-surface'); + + if (!svgEl) return { error: 'No chart SVG found on page. Switch to Charts view first.' }; + + const width = svgEl.clientWidth || svgEl.getAttribute('width') || 800; + const height = svgEl.clientHeight || svgEl.getAttribute('height') || 400; + + // Serialize SVG with proper namespaces + const cloned = svgEl.cloneNode(true); + cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + cloned.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(cloned); + + if (format === 'svg') { + return { svgString: svgString, width: width, height: height }; + } + + // PNG: render SVG onto a canvas and export as base64 + return new Promise(function(resolve) { + const canvas = document.createElement('canvas'); + canvas.width = Number(width); + canvas.height = Number(height); + const ctx = canvas.getContext('2d'); + const img = new Image(); + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + img.onload = function() { + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + const dataUrl = canvas.toDataURL('image/png'); + resolve({ pngBase64: dataUrl.split(',')[1], width: Number(width), height: Number(height) }); + }; + img.onerror = function() { + URL.revokeObjectURL(url); + resolve({ error: 'Failed to render chart to canvas', svgString: svgString, width: Number(width), height: Number(height) }); + }; + img.src = url; + }); + })(${JSON.stringify(format)}) + `); + + if (chartData.error && !chartData.svgString) { + return [{ Status: 'Error: ' + chartData.error, File: '', Format: '', Width: '', Height: '' }]; + } + + if (format === 'png' && chartData.pngBase64) { + fs.writeFileSync(outputPath, Buffer.from(chartData.pngBase64, 'base64')); + } else { + // SVG (or PNG fallback to SVG when canvas fails) + const content = chartData.svgString; + fs.writeFileSync(outputPath, content, 'utf-8'); + } + + return [{ + Status: 'Success', + File: outputPath, + Format: format === 'png' && chartData.pngBase64 ? 'png' : 'svg', + Width: chartData.width, + Height: chartData.height, + }]; + }, +}); diff --git a/src/clis/dory/columns.ts b/src/clis/dory/columns.ts new file mode 100644 index 00000000..045ab62a --- /dev/null +++ b/src/clis/dory/columns.ts @@ -0,0 +1,52 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { resolveConnectionId } from './_shared.js'; + +export const columnsCommand = cli({ + site: 'dory', + name: 'columns', + description: 'List columns for a specific table', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'connection', required: true, positional: true, help: 'Connection name or ID' }, + { name: 'database', required: true, positional: true, help: 'Database name' }, + { name: 'table', required: true, positional: true, help: 'Table name' }, + ], + columns: ['Name', 'Type', 'PrimaryKey', 'Default'], + func: async (page: IPage, kwargs: any) => { + const connectionId = await resolveConnectionId(page, kwargs.connection as string); + const database = kwargs.database as string; + const table = kwargs.table as string; + + const result = await page.evaluate(` + (async function(connectionId, database, table) { + const url = '/api/connection/' + encodeURIComponent(connectionId) + + '/databases/' + encodeURIComponent(database) + + '/tables/' + encodeURIComponent(table) + '/columns'; + const res = await fetch(url, { + credentials: 'include', + headers: { 'X-Connection-ID': connectionId }, + }); + if (!res.ok) throw new Error('API error ' + res.status + ': ' + await res.text()); + const json = await res.json(); + const list = json.data ?? json ?? []; + // TableColumnInfo: { columnName, columnType, defaultExpression, isPrimaryKey, ... } + return list.map(function(col) { + return { + Name: col.columnName ?? col.name ?? String(col), + Type: col.columnType ?? col.type ?? '', + PrimaryKey: col.isPrimaryKey ? 'YES' : '', + Default: col.defaultExpression ?? col.default ?? '', + }; + }); + })(${JSON.stringify(connectionId)}, ${JSON.stringify(database)}, ${JSON.stringify(table)}) + `); + + if (!result || result.length === 0) { + return [{ Name: 'No columns found', Type: '', PrimaryKey: '', Default: '' }]; + } + return result; + }, +}); diff --git a/src/clis/dory/connect.ts b/src/clis/dory/connect.ts new file mode 100644 index 00000000..63be3ddb --- /dev/null +++ b/src/clis/dory/connect.ts @@ -0,0 +1,51 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { resolveConnectionId } from './_shared.js'; + +export const connectCommand = cli({ + site: 'dory', + name: 'connect', + description: 'Navigate to the SQL console for a specific connection', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'connection', required: true, positional: true, help: 'Connection name or ID' }, + ], + columns: ['Status', 'URL'], + func: async (page: IPage, kwargs: any) => { + const connectionId = await resolveConnectionId(page, kwargs.connection as string); + + // Resolve the organization slug from the current URL + // URL pattern: /[organization]/... or just / + const org = await page.evaluate(` + (function() { + const parts = window.location.pathname.split('/').filter(Boolean); + return parts.length > 0 ? parts[0] : null; + })() + `); + + if (!org) { + // Fallback: use the connections API to find the org from current session + // Navigate using just the connection ID via the connect API + const connectResult = await page.evaluate(` + (async function(connectionId) { + const res = await fetch('/api/connection/connect', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: connectionId }), + }); + return res.ok; + })(${JSON.stringify(connectionId)}) + `); + return [{ Status: connectResult ? 'Connected' : 'Error', URL: '' }]; + } + + const targetUrl = `/${org}/${connectionId}/sql-console`; + await page.goto(`http://localhost:3000${targetUrl}`); + await page.wait(1.5); + + return [{ Status: 'Connected', URL: `http://localhost:3000${targetUrl}` }]; + }, +}); diff --git a/src/clis/dory/connections.ts b/src/clis/dory/connections.ts new file mode 100644 index 00000000..12f7afbb --- /dev/null +++ b/src/clis/dory/connections.ts @@ -0,0 +1,38 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const connectionsCommand = cli({ + site: 'dory', + name: 'connections', + description: 'List all Dory database connections', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['ID', 'Name', 'Engine', 'Host'], + func: async (page: IPage) => { + const result = await page.evaluate(` + (async function() { + const res = await fetch(window.location.origin + '/api/connection', { credentials: 'include' }); + if (!res.ok) throw new Error('API error ' + res.status); + const json = await res.json(); + const list = json.data ?? json ?? []; + return list.map(function(item) { + // Each item is { connection: {...}, identities: [...], ssh: ... } + const c = item.connection ?? item; + return { + ID: c.id, + Name: c.name ?? '', + Engine: c.engine ?? '', + Host: (c.host ?? '') + (c.port ? ':' + c.port : ''), + }; + }); + })() + `); + + if (!result || result.length === 0) { + return [{ ID: '', Name: 'No connections found', Driver: '', Host: '' }]; + } + return result; + }, +}); diff --git a/src/clis/dory/databases.ts b/src/clis/dory/databases.ts new file mode 100644 index 00000000..61954dcd --- /dev/null +++ b/src/clis/dory/databases.ts @@ -0,0 +1,44 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { resolveConnectionId } from './_shared.js'; + +export const databasesCommand = cli({ + site: 'dory', + name: 'databases', + description: 'List all databases available for a connection', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'connection', required: true, positional: true, help: 'Connection name or ID' }, + ], + columns: ['Name', 'Value'], + func: async (page: IPage, kwargs: any) => { + const connectionId = await resolveConnectionId(page, kwargs.connection as string); + + const result = await page.evaluate(` + (async function(connectionId) { + const url = '/api/connection/' + encodeURIComponent(connectionId) + '/databases'; + const res = await fetch(url, { + credentials: 'include', + headers: { 'X-Connection-ID': connectionId }, + }); + if (!res.ok) throw new Error('API error ' + res.status + ': ' + await res.text()); + const json = await res.json(); + const list = json.data ?? json ?? []; + // DatabaseMeta: { label: string, value: string } + return list.map(function(db) { + return { + Name: db.label ?? db.name ?? String(db), + Value: db.value ?? '', + }; + }); + })(${JSON.stringify(connectionId)}) + `); + + if (!result || result.length === 0) { + return [{ Name: 'No databases found', Value: '' }]; + } + return result; + }, +}); diff --git a/src/clis/dory/dump.ts b/src/clis/dory/dump.ts new file mode 100644 index 00000000..a7b6fada --- /dev/null +++ b/src/clis/dory/dump.ts @@ -0,0 +1,3 @@ +import { makeDumpCommand } from '../_shared/desktop-commands.js'; + +export const dumpCommand = makeDumpCommand('dory'); diff --git a/src/clis/dory/export.ts b/src/clis/dory/export.ts new file mode 100644 index 00000000..242eeb76 --- /dev/null +++ b/src/clis/dory/export.ts @@ -0,0 +1,71 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ensureChatbotPage, resolveConnectionId } from './_shared.js'; + +export const exportCommand = cli({ + site: 'dory', + name: 'export', + description: 'Export the current Dory conversation to a Markdown file', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'connection', required: false, help: 'Connection name or ID to navigate to before exporting' }, + { name: 'output', required: false, help: 'Output file path (default: /tmp/dory-export.md)' }, + ], + columns: ['Status', 'File', 'Messages'], + func: async (page: IPage, kwargs: any) => { + const rawConn = kwargs.connection as string | undefined; + const connectionId = rawConn ? await resolveConnectionId(page, rawConn) : undefined; + await ensureChatbotPage(page, connectionId); + const outputPath = (kwargs.output as string) || '/tmp/dory-export.md'; + + const messages = await page.evaluate(` + (function() { + const log = document.querySelector('[role="log"]'); + if (!log) return []; + const results = []; + const wrappers = log.querySelectorAll('.is-user, .is-assistant'); + wrappers.forEach(function(el) { + const isUser = el.classList.contains('is-user'); + const text = (el.innerText || el.textContent || '').trim(); + if (text) results.push({ role: isUser ? 'User' : 'Assistant', text: text }); + }); + return results; + })() + `); + + const url = await page.evaluate('window.location.href'); + const title = await page.evaluate('document.title'); + + let md = `# Dory Conversation Export\n\n`; + md += `**Source:** ${url}\n`; + md += `**Page:** ${title}\n\n---\n\n`; + + if (messages && messages.length > 0) { + for (const msg of messages) { + md += `## ${msg.role}\n\n${msg.text}\n\n---\n\n`; + } + } else { + // Fallback: dump entire log + const fallback = await page.evaluate(` + (function() { + const log = document.querySelector('[role="log"]'); + return log ? (log.innerText || log.textContent || '') : document.body.innerText; + })() + `); + md += fallback; + } + + fs.writeFileSync(outputPath, md); + + return [ + { + Status: 'Success', + File: outputPath, + Messages: messages ? messages.length : 0, + }, + ]; + }, +}); diff --git a/src/clis/dory/new.ts b/src/clis/dory/new.ts new file mode 100644 index 00000000..98ee3f6d --- /dev/null +++ b/src/clis/dory/new.ts @@ -0,0 +1,49 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ensureChatbotPage, resolveConnectionId } from './_shared.js'; + +export const newCommand = cli({ + site: 'dory', + name: 'new', + description: 'Create a new Dory chat session', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'connection', required: false, help: 'Connection name or ID to navigate to before creating a new session' }, + ], + columns: ['Status'], + func: async (page: IPage, kwargs: any) => { + const rawConn = kwargs.connection as string | undefined; + const connectionId = rawConn ? await resolveConnectionId(page, rawConn) : undefined; + await ensureChatbotPage(page, connectionId); + + // Try to find and click the "New" session button in the sidebar + const clicked = await page.evaluate(` + (function() { + // Look for a button/link that creates a new session + // The ChatSessionSidebar renders an onCreate button — find it by common aria labels or text + const candidates = Array.from(document.querySelectorAll('button, a[role="button"]')); + const newBtn = candidates.find(function(el) { + const text = (el.textContent || el.innerText || '').trim().toLowerCase(); + const label = (el.getAttribute('aria-label') || '').toLowerCase(); + return text === 'new' || text === 'new chat' || label.includes('new') || label.includes('create'); + }); + if (newBtn) { + newBtn.click(); + return true; + } + return false; + })() + `); + + if (!clicked) { + // Fallback: Cmd/Ctrl+K is a common new-chat shortcut in web chat apps + const isMac = process.platform === 'darwin'; + await page.pressKey(isMac ? 'Meta+K' : 'Control+K'); + } + + await page.wait(1); + return [{ Status: 'Success' }]; + }, +}); diff --git a/src/clis/dory/query-export.ts b/src/clis/dory/query-export.ts new file mode 100644 index 00000000..d1c83b53 --- /dev/null +++ b/src/clis/dory/query-export.ts @@ -0,0 +1,97 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { resolveConnectionId } from './_shared.js'; + +function toCsv(columns: string[], rows: any[]): string { + const escape = (v: unknown) => { + const s = v == null ? '' : String(v); + return s.includes(',') || s.includes('"') || s.includes('\n') + ? '"' + s.replace(/"/g, '""') + '"' + : s; + }; + const header = columns.map(escape).join(','); + const body = rows.map((row: any) => { + const values = Array.isArray(row) + ? row + : columns.map((c) => row[c]); + return values.map(escape).join(','); + }); + return [header, ...body].join('\n'); +} + +export const queryExportCommand = cli({ + site: 'dory', + name: 'query-export', + description: 'Execute a SQL query and export results to a CSV file', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'sql', required: true, positional: true, help: 'SQL statement to execute' }, + { name: 'connection', required: true, help: 'Connection name or ID' }, + { name: 'database', required: false, help: 'Database name (optional)' }, + { name: 'output', required: false, help: 'Output CSV file path (default: /tmp/dory-query.csv)' }, + ], + columns: ['Status', 'File', 'Rows', 'DurationMs'], + func: async (page: IPage, kwargs: any) => { + const sql = kwargs.sql as string; + const connectionId = await resolveConnectionId(page, kwargs.connection as string); + const database = (kwargs.database as string) || undefined; + const outputPath = (kwargs.output as string) || '/tmp/dory-query.csv'; + + const result = await page.evaluate(` + (async function(sql, connectionId, database) { + const body = { sql: sql, stopOnError: false }; + if (database) body.database = database; + + const res = await fetch('/api/query', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-Connection-ID': connectionId, + }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Query API error ' + res.status + ': ' + await res.text()); + const json = await res.json(); + const data = json.data ?? json; + + const sets = data.queryResultSets ?? []; + const allResults = data.results ?? []; + if (sets.length === 0) return { error: data.session?.errorMessage ?? 'No result sets', columns: [], rows: [] }; + + const firstSet = sets[0]; + if (firstSet.status === 'error') return { error: firstSet.errorMessage, columns: [], rows: [] }; + + const cols = (firstSet.columns ?? []).map(function(c) { return c.name ?? String(c); }); + const rawRows = allResults[0] ?? []; + const rows = rawRows.map(function(row) { + if (Array.isArray(row)) { + var obj = {}; + cols.forEach(function(col, i) { obj[col] = row[i]; }); + return obj; + } + return row; + }); + const columns = rows.length > 0 ? Object.keys(rows[0]) : cols; + return { columns: columns, rows: rows, rowCount: firstSet.rowCount, durationMs: firstSet.durationMs }; + })(${JSON.stringify(sql)}, ${JSON.stringify(connectionId)}, ${JSON.stringify(database ?? null)}) + `); + + if (result.error) { + return [{ Status: 'Error', File: '', Rows: 0, DurationMs: 0 }]; + } + + const csv = toCsv(result.columns, result.rows); + fs.writeFileSync(outputPath, csv, 'utf-8'); + + return [{ + Status: 'Success', + File: outputPath, + Rows: result.rows.length, + DurationMs: result.durationMs ?? 0, + }]; + }, +}); diff --git a/src/clis/dory/query.ts b/src/clis/dory/query.ts new file mode 100644 index 00000000..9af90282 --- /dev/null +++ b/src/clis/dory/query.ts @@ -0,0 +1,90 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { resolveConnectionId } from './_shared.js'; + +export const queryCommand = cli({ + site: 'dory', + name: 'query', + description: 'Execute a SQL query and print the results', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'sql', required: true, positional: true, help: 'SQL statement to execute' }, + { name: 'connection', required: true, help: 'Connection name or ID' }, + { name: 'database', required: false, help: 'Database name (optional)' }, + ], + // No fixed columns — inferred dynamically from query result fields + func: async (page: IPage, kwargs: any) => { + const sql = kwargs.sql as string; + const connectionId = await resolveConnectionId(page, kwargs.connection as string); + const database = (kwargs.database as string) || undefined; + + const result = await page.evaluate(` + (async function(sql, connectionId, database) { + const body = { sql: sql, stopOnError: false }; + if (database) body.database = database; + + const res = await fetch('/api/query', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-Connection-ID': connectionId, + }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Query API error ' + res.status + ': ' + await res.text()); + const json = await res.json(); + const data = json.data ?? json; + + const sets = data.queryResultSets ?? []; + const allResults = data.results ?? []; + + if (sets.length === 0) { + return { error: data.session?.errorMessage ?? 'No result sets returned', rows: [] }; + } + + const firstSet = sets[0]; + if (firstSet.status === 'error') { + return { error: firstSet.errorMessage, rows: [] }; + } + + // Rows may be plain objects already (keyed by column name) + const rows = allResults[0] ?? []; + // Normalise: if rows are arrays, zip with column names + const cols = (firstSet.columns ?? []).map(function(c) { return c.name ?? String(c); }); + const normalised = rows.map(function(row) { + if (Array.isArray(row)) { + var obj = {}; + cols.forEach(function(col, i) { obj[col] = row[i]; }); + return obj; + } + return row; + }); + + return { + rows: normalised, + rowCount: firstSet.rowCount, + durationMs: firstSet.durationMs, + sqlOp: firstSet.sqlOp ?? '', + }; + })(${JSON.stringify(sql)}, ${JSON.stringify(connectionId)}, ${JSON.stringify(database ?? null)}) + `); + + if (result.error) { + return [{ Error: String(result.error) }]; + } + + if (!result.rows || result.rows.length === 0) { + return [{ + Status: 'OK', + Operation: result.sqlOp ?? '', + RowCount: result.rowCount ?? 0, + DurationMs: result.durationMs ?? 0, + }]; + } + + return result.rows; + }, +}); diff --git a/src/clis/dory/read.ts b/src/clis/dory/read.ts new file mode 100644 index 00000000..1f838555 --- /dev/null +++ b/src/clis/dory/read.ts @@ -0,0 +1,50 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ensureChatbotPage, resolveConnectionId } from './_shared.js'; + +export const readCommand = cli({ + site: 'dory', + name: 'read', + description: 'Read the full conversation thread from the active Dory chat', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'connection', required: false, help: 'Connection name or ID to navigate to before reading' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: any) => { + const rawConn = kwargs.connection as string | undefined; + const connectionId = rawConn ? await resolveConnectionId(page, rawConn) : undefined; + + await ensureChatbotPage(page, connectionId); + + const messages = await page.evaluate(` + (function() { + const log = document.querySelector('[role="log"]'); + if (!log) return []; + + const results = []; + const wrappers = log.querySelectorAll('.is-user, .is-assistant'); + wrappers.forEach(function(el) { + const isUser = el.classList.contains('is-user'); + const text = (el.innerText || el.textContent || '').trim(); + if (text) results.push({ Role: isUser ? 'User' : 'Assistant', Text: text }); + }); + + if (results.length === 0) { + const text = (log.innerText || log.textContent || '').trim(); + if (text) results.push({ Role: 'Thread', Text: text }); + } + + return results; + })() + `); + + if (!messages || messages.length === 0) { + return [{ Role: 'System', Text: 'No messages found.' }]; + } + + return messages; + }, +}); diff --git a/src/clis/dory/screenshot.ts b/src/clis/dory/screenshot.ts new file mode 100644 index 00000000..d73145c1 --- /dev/null +++ b/src/clis/dory/screenshot.ts @@ -0,0 +1,3 @@ +import { makeScreenshotCommand } from '../_shared/desktop-commands.js'; + +export const screenshotCommand = makeScreenshotCommand('dory', 'Dory App'); diff --git a/src/clis/dory/send.ts b/src/clis/dory/send.ts new file mode 100644 index 00000000..09f5151c --- /dev/null +++ b/src/clis/dory/send.ts @@ -0,0 +1,33 @@ +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 sendCommand = cli({ + site: 'dory', + name: 'send', + description: 'Send a message to the active Dory chat composer', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Message text to send' }, + { name: 'connection', required: false, help: 'Connection name or ID to navigate to before sending' }, + ], + columns: ['Status', 'InjectedText'], + 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; + + await ensureChatbotPage(page, connectionId); + + const injected = await injectChatText(page, text); + if (!injected) throw new SelectorError('Dory chat textarea'); + + await page.wait(0.3); + await page.pressKey('Enter'); + + return [{ Status: 'Success', InjectedText: text }]; + }, +}); diff --git a/src/clis/dory/sessions.ts b/src/clis/dory/sessions.ts new file mode 100644 index 00000000..3517dfee --- /dev/null +++ b/src/clis/dory/sessions.ts @@ -0,0 +1,60 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ensureChatbotPage, resolveConnectionId } from './_shared.js'; + +export const sessionsCommand = cli({ + site: 'dory', + name: 'sessions', + description: 'List recent Dory chat sessions from the sidebar', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'connection', required: false, help: 'Connection name or ID to navigate to before listing sessions' }, + ], + columns: ['Index', 'Title'], + func: async (page: IPage, kwargs: any) => { + const rawConn = kwargs.connection as string | undefined; + const connectionId = rawConn ? await resolveConnectionId(page, rawConn) : undefined; + await ensureChatbotPage(page, connectionId); + const items = await page.evaluate(` + (function() { + const results = []; + + // ChatSessionSidebar renders session buttons inside an aside or sidebar panel. + // Each session is a