From 6126413d60a774d68551743652e99b7375b387d4 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:07:49 +0800 Subject: [PATCH] feat(qoder): add Qoder IDE adapter Reviewed-by: codex-mini0 Reviewed-by: First-principles-0 No-check override: WAWQAQ 2026-06-01 --- README.md | 2 +- README.zh-CN.md | 2 +- cli-manifest.json | 396 +++++++++++++++++++++++++++++++++ clis/qoder/_utils.js | 191 ++++++++++++++++ clis/qoder/composer.js | 45 ++++ clis/qoder/history.js | 49 ++++ clis/qoder/qoder.test.js | 125 +++++++++++ clis/qoder/quest.js | 147 ++++++++++++ clis/qoder/read.js | 29 +++ clis/qoder/status.js | 21 ++ clis/qoder/ui.js | 332 +++++++++++++++++++++++++++ docs/adapters/desktop/qoder.md | 57 +++++ docs/adapters/index.md | 1 + src/electron-apps.test.ts | 11 + src/electron-apps.ts | 7 + 15 files changed, 1413 insertions(+), 2 deletions(-) create mode 100644 clis/qoder/_utils.js create mode 100644 clis/qoder/composer.js create mode 100644 clis/qoder/history.js create mode 100644 clis/qoder/qoder.test.js create mode 100644 clis/qoder/quest.js create mode 100644 clis/qoder/read.js create mode 100644 clis/qoder/status.js create mode 100644 clis/qoder/ui.js create mode 100644 docs/adapters/desktop/qoder.md diff --git a/README.md b/README.md index 206410d8..80da8043 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Unified passthrough for your existing command-line tools. Run `opencli .. Register your own with `opencli external register `; list everything with `opencli external list`. -**Desktop app adapters** (Electron, via CDP): Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Discord / Doubao — see [`docs/adapters/desktop/`](./docs/adapters/desktop/). +**Desktop app adapters** (Electron, via CDP): Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao — see [`docs/adapters/desktop/`](./docs/adapters/desktop/). ## Download Support diff --git a/README.zh-CN.md b/README.zh-CN.md index a5444f05..3795a554 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -181,7 +181,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 注册自定义本地 CLI:`opencli external register `;查看所有:`opencli external list`。 -**桌面应用适配器**(Electron,通过 CDP):Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Discord / Doubao — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。 +**桌面应用适配器**(Electron,通过 CDP):Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。 ## 下载支持 diff --git a/cli-manifest.json b/cli-manifest.json index ab084483..4d4c1d2c 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -21881,6 +21881,402 @@ "modulePath": "pypi/package.js", "sourceFile": "pypi/package.js" }, + { + "site": "qoder", + "name": "account", + "description": "Click the account button (username) in the Qoder sidebar and return the visible account dropdown items.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": false, + "help": "Username text shown in the sidebar (default: tries common short labels)" + } + ], + "columns": [ + "Field", + "Value" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "add-workspace", + "description": "Click \"Add Workspace\" — opens the folder picker. Note: this opens a system file-picker dialog that Qoder controls; the actual folder selection must be done in the UI by the user.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "ask", + "description": "Send a prompt to Qoder and wait up to --timeout seconds for the reply (best-effort: polls for the chat turn count to grow + stabilize).", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Prompt text" + }, + { + "name": "timeout", + "type": "int", + "default": 120, + "required": false, + "help": "Max seconds to wait" + } + ], + "columns": [ + "Role", + "Text", + "WaitedSeconds" + ], + "type": "js", + "modulePath": "qoder/quest.js", + "sourceFile": "qoder/quest.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "credits", + "description": "Click \"Credits Usage\" and return the credits-usage display text.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Field", + "Value" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "history", + "description": "List Quests visible in the Qoder sidebar. Returns title + visible metadata.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Title" + ], + "type": "js", + "modulePath": "qoder/history.js", + "sourceFile": "qoder/history.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "knowledge", + "description": "Open the Knowledge view (Qoder's personal/team knowledge base).", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "marketplace", + "description": "Open the Qoder Marketplace.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "more-actions", + "description": "Click the \"More Actions\" button and list its menu items.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Item" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "new", + "description": "Start a new Qoder Quest (conversation). Clicks the \"New Quest\" button in the sidebar (or its ⌘N variant).", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/quest.js", + "sourceFile": "qoder/quest.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "open-editor", + "description": "Click \"Open Editor\" — opens the current draft in a full editor pane.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/composer.js", + "sourceFile": "qoder/composer.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "open-panel", + "description": "Open / close the Qoder bottom panel (Output / Terminal / Debug Console). ⌥⌘B equivalent.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "prompt-enhance", + "description": "Click \"Prompt Enhance\" — Qoder rewrites the current composer draft for better LLM consumption.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/composer.js", + "sourceFile": "qoder/composer.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "read", + "description": "Read messages in the current Qoder Quest. Returns role + text for each visible turn.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 30, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Role", + "Text" + ], + "type": "js", + "modulePath": "qoder/read.js", + "sourceFile": "qoder/read.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "search", + "description": "Open Qoder Search palette (⌘P), type a query, return matched options.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search text" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Item" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "send", + "description": "Type text into the Qoder composer and click \"Send message\" (fire-and-forget).", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Text to send" + } + ], + "columns": [ + "Status", + "Length" + ], + "type": "js", + "modulePath": "qoder/quest.js", + "sourceFile": "qoder/quest.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "settings", + "description": "Click the Settings button in the Qoder sidebar.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "sidebar-toggle", + "description": "Collapse / Expand the Qoder Quest List sidebar (⌘B).", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "status", + "description": "Check Qoder CDP connection and report the current renderer URL + title.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status", + "Url", + "Title" + ], + "type": "js", + "modulePath": "qoder/status.js", + "sourceFile": "qoder/status.js", + "navigateBefore": true + }, + { + "site": "qoder", + "name": "view-all", + "description": "Click \"View all\" to show all Quests.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "qoder/ui.js", + "sourceFile": "qoder/ui.js", + "navigateBefore": true + }, { "site": "quark", "name": "ls", diff --git a/clis/qoder/_utils.js b/clis/qoder/_utils.js new file mode 100644 index 00000000..666ce28e --- /dev/null +++ b/clis/qoder/_utils.js @@ -0,0 +1,191 @@ +// Shared helpers for the Qoder adapter. +// +// Qoder is an Electron-based AI IDE (Alibaba; com.qoder.ide). It's +// VSCode-derived (same Electron + Monaco shell) and shares many DOM +// patterns with Trae SOLO. CDP port: 9237 (declared in +// src/electron-apps.ts and launched by ~/.claude/bin/qoder-launch-with-cdp.sh). +// +// Terminology: +// - "Quest" = conversation (Qoder's term for a chat thread) +// - "Workspace" = open folder (VSCode-style) +// - "Knowledge" = personal/team knowledge base +// - "Marketplace"= plugin/skill marketplace + +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; + +export const IS_VISIBLE_JS = ` + const isVisible = (el) => { + if (!el) return false; + const r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return false; + const cs = getComputedStyle(el); + if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') return false; + return true; + }; +`; + +export function unwrapEvaluateResult(payload) { + if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) { + return payload.data; + } + return payload; +} + +export async function evaluateQoder(page, script) { + return unwrapEvaluateResult(await page.evaluate(script)); +} + +export function requireArrayResult(value, label) { + if (!Array.isArray(value)) { + throw new CommandExecutionError(`${label}: unexpected evaluate result shape`); + } + return value; +} + +export function parsePositiveInt(raw, fallback, label) { + const value = raw == null || raw === '' ? fallback : Number(raw); + if (!Number.isInteger(value) || value < 1) { + throw new ArgumentError(`${label} must be a positive integer`); + } + return value; +} + +// Build a JS snippet that clicks the first visible element matching any +// of the given CSS selectors. Uses the full pointer-event chain to +// satisfy radix/headless menu libraries. +export function clickFirstScript(selectors) { + return `(() => { + ${IS_VISIBLE_JS} + const sels = ${JSON.stringify(selectors)}; + for (const sel of sels) { + const target = Array.from(document.querySelectorAll(sel)).filter(isVisible)[0]; + if (target) { + const r = target.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + target.dispatchEvent(new PointerEvent('pointerdown', opts)); + target.dispatchEvent(new MouseEvent('mousedown', opts)); + target.dispatchEvent(new PointerEvent('pointerup', opts)); + target.dispatchEvent(new MouseEvent('mouseup', opts)); + target.click(); + return { ok: true, sel }; + } + } + return { ok: false, reason: 'No matching visible element.' }; + })()`; +} + +// Variant: match a button by visible innerText (substring or full match). +// Useful when Qoder buttons lack aria-label. +export function clickByTextScript(textPatterns, opts = {}) { + const { exact = false, maxLen = 60 } = opts; + return `(() => { + ${IS_VISIBLE_JS} + const patterns = ${JSON.stringify(textPatterns)}; + const exact = ${exact ? 'true' : 'false'}; + const maxLen = ${maxLen}; + const isVis = isVisible; + const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="tab"]')).filter(isVis); + for (const pat of patterns) { + const target = candidates.find((b) => { + const tx = (b.innerText || b.textContent || '').trim(); + if (tx.length > maxLen) return false; + return exact ? tx === pat : tx.toLowerCase().includes(pat.toLowerCase()); + }); + if (target) { + const r = target.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + target.dispatchEvent(new PointerEvent('pointerdown', opts)); + target.dispatchEvent(new MouseEvent('mousedown', opts)); + target.dispatchEvent(new PointerEvent('pointerup', opts)); + target.dispatchEvent(new MouseEvent('mouseup', opts)); + target.click(); + return { ok: true, matched: pat }; + } + } + return { ok: false, reason: 'No button matching: ' + patterns.join(' / ') }; + })()`; +} + +export const QODER_TURNS_JS = `(() => { + ${IS_VISIBLE_JS} + const chatPanes = Array.from(document.querySelectorAll('[class*="chat"i], [class*="conversation"i], [class*="message"i]')).filter(isVisible); + if (!chatPanes.length) return []; + const pane = chatPanes.sort((a, b) => b.getBoundingClientRect().height - a.getBoundingClientRect().height)[0]; + const candidates = Array.from(pane.querySelectorAll('div, article, [class*="message"i], [class*="turn"i], [class*="bubble"i]')) + .filter(isVisible) + .filter((el) => { + const tx = (el.innerText || '').trim(); + return tx.length > 5 && tx.length < 4000 && el.children.length < 20; + }); + const seen = new Set(); + return candidates.map((el) => { + const tx = (el.innerText || '').trim().replace(/\\s+/g, ' '); + if (seen.has(tx)) return null; + seen.add(tx); + const cls = (el.className || '').toString().toLowerCase(); + const role = /user|me-|right/.test(cls) ? 'User' : (/assistant|ai|bot|response/.test(cls) ? 'Assistant' : 'Turn'); + return { role, text: tx }; + }).filter(Boolean); +})()`; + +export function buildQoderInjectTextScript(text) { + return `(() => { + ${IS_VISIBLE_JS} + const editors = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter(isVisible); + const candidates = editors.map((editor, index) => { + const rect = editor.getBoundingClientRect(); + const attrs = [ + editor.getAttribute('aria-label') || '', + editor.getAttribute('placeholder') || '', + editor.getAttribute('data-placeholder') || '', + editor.textContent || '', + editor.className || '', + editor.closest('[class*="composer"i], [class*="input"i], [class*="chat"i]')?.className || '', + ].join(' ').toLowerCase(); + let score = 0; + if (editor.getAttribute('role') === 'textbox') score += 25; + if (attrs.includes('message') || attrs.includes('prompt') || attrs.includes('ask')) score += 120; + if (attrs.includes('composer') || attrs.includes('input')) score += 60; + if (attrs.includes('optional description') || attrs.includes('user context document') || attrs.includes('knowledge')) score -= 240; + if (window.innerHeight > 0) score += Math.max(0, 80 - Math.abs(window.innerHeight - (rect.y + rect.height)) / 8); + return { editor, index, score }; + }).sort((a, b) => b.score - a.score || b.index - a.index); + const best = candidates[0]; + if (!best || best.score < 40) return { ok: false, reason: 'No high-confidence Qoder composer found.' }; + const editor = best.editor; + editor.focus(); + document.execCommand('selectAll', false); + const inserted = document.execCommand('insertText', false, ${JSON.stringify(String(text ?? ''))}); + if (!inserted) { + editor.textContent = ${JSON.stringify(String(text ?? ''))}; + } + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(String(text ?? ''))} })); + return { ok: true, score: best.score }; + })()`; +} + +export const QODER_MESSAGE_COUNT_JS = `(() => { + const turns = ${QODER_TURNS_JS}; + return Array.isArray(turns) ? turns.length : 0; +})()`; + +export function qoderResponseAfterScript(previousCount, userText) { + return `(() => { + const turns = ${QODER_TURNS_JS}; + if (!Array.isArray(turns) || turns.length <= ${Number(previousCount) || 0}) return null; + const fresh = turns.slice(${Number(previousCount) || 0}) + .filter((turn) => turn && turn.text && turn.text !== ${JSON.stringify(String(userText ?? ''))}); + return fresh.length ? fresh[fresh.length - 1] : null; + })()`; +} + +// Wait for an element to appear by polling page.evaluate. +export async function waitForSelector(page, selector, timeoutMs = 5000, intervalMs = 200) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const exists = await page.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`); + if (exists) return true; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; +} diff --git a/clis/qoder/composer.js b/clis/qoder/composer.js new file mode 100644 index 00000000..5d5482f8 --- /dev/null +++ b/clis/qoder/composer.js @@ -0,0 +1,45 @@ +// Composer-area commands for Qoder. +// +// prompt-enhance — click "Prompt Enhance" (rewrites the current draft) +// open-editor — click "Open Editor" (open the chat draft in a full editor pane) + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { clickByTextScript, evaluateQoder } from './_utils.js'; + +// -------- prompt-enhance -------- +cli({ + site: 'qoder', + name: 'prompt-enhance', + access: 'write', + description: 'Click "Prompt Enhance" — Qoder rewrites the current composer draft for better LLM consumption.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Prompt Enhance'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Prompt Enhance button not found', ''); + await page.wait(0.5); + return [{ Status: 'enhanced (check composer)' }]; + }, +}); + +// -------- open-editor -------- +cli({ + site: 'qoder', + name: 'open-editor', + access: 'write', + description: 'Click "Open Editor" — opens the current draft in a full editor pane.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Open Editor'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Open Editor button not found', ''); + return [{ Status: 'clicked' }]; + }, +}); diff --git a/clis/qoder/history.js b/clis/qoder/history.js new file mode 100644 index 00000000..0b026459 --- /dev/null +++ b/clis/qoder/history.js @@ -0,0 +1,49 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { evaluateQoder, IS_VISIBLE_JS, parsePositiveInt, requireArrayResult } from './_utils.js'; + +cli({ + site: 'qoder', + name: 'history', + access: 'read', + description: 'List Quests visible in the Qoder sidebar. Returns title + visible metadata.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'limit', type: 'int', required: false, default: 50 }, + ], + columns: ['Index', 'Title'], + func: async (page, kwargs) => { + const limit = parsePositiveInt(kwargs?.limit, 50, '--limit'); + const items = requireArrayResult(await evaluateQoder(page, `(() => { + ${IS_VISIBLE_JS} + // Qoder renders quests in the sidebar Quest List. They appear as + // clickable rows; structure is iterative-discovery — find rows + // that have either role=button or a click handler and contain a title. + // We use the heuristic: any element under the sidebar (left panel) + // whose textContent looks like a Quest title (< 100 chars, no menu indicator). + const sidebars = Array.from(document.querySelectorAll('[class*="sidebar"i], [class*="quest-list"i], [class*="quest"i]')).filter(isVisible); + const seen = new Set(); + const out = []; + sidebars.forEach((sb) => { + const rows = Array.from(sb.querySelectorAll('[role="button"], button, [class*="item"i]')).filter(isVisible); + rows.forEach((r) => { + const txt = (r.innerText || r.textContent || '').trim().replace(/\\s+/g, ' '); + if (!txt || txt.length < 2 || txt.length > 200) return; + // Skip menu items, headers, action buttons. + if (/^(New Quest|Search|Settings|View all|Knowledge|Marketplace|Credits Usage|Pin|Add Workspace|Open Editor|More Actions|Open Panel|Collapse|leo|button)$/i.test(txt.trim())) return; + if (txt.includes('⌘')) return; + if (seen.has(txt)) return; + seen.add(txt); + out.push(txt); + }); + }); + return out; + })()`), 'qoder history'); + if (!items.length) { + throw new EmptyResultError('qoder history', 'No quests visible. Try widening the sidebar or selecting a workspace.'); + } + return items.slice(0, limit).map((t, i) => ({ Index: i + 1, Title: t })); + }, +}); diff --git a/clis/qoder/qoder.test.js b/clis/qoder/qoder.test.js new file mode 100644 index 00000000..c43658fb --- /dev/null +++ b/clis/qoder/qoder.test.js @@ -0,0 +1,125 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './quest.js'; +import './history.js'; +import './read.js'; +import './status.js'; +import './ui.js'; +import './composer.js'; +import { + buildQoderInjectTextScript, + evaluateQoder, + parsePositiveInt, + unwrapEvaluateResult, +} from './_utils.js'; + +function makePage(evaluateResults = []) { + const evaluate = vi.fn(); + for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result); + evaluate.mockResolvedValue(null); + return { + evaluate, + wait: vi.fn().mockResolvedValue(undefined), + }; +} + +function runBrowserScript(html, script) { + const dom = new JSDOM(html, { url: 'vscode-file://qoder/agents-window.html', runScripts: 'outside-only' }); + dom.window.InputEvent = dom.window.Event; + dom.window.document.execCommand = vi.fn((command, _showUi, value) => { + const active = dom.window.document.activeElement; + if (command === 'insertText' && active) active.textContent = value; + return true; + }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value() { + const y = this.id === 'main' ? 860 : 320; + return { x: 0, y, width: 420, height: 32 }; + }, + }); + return { dom, result: dom.window.eval(script) }; +} + +describe('qoder registry', () => { + it('registers the expected command surface with access metadata', () => { + const registry = getRegistry(); + const expected = new Map([ + ['qoder/status', 'read'], + ['qoder/history', 'read'], + ['qoder/read', 'read'], + ['qoder/search', 'read'], + ['qoder/account', 'read'], + ['qoder/credits', 'read'], + ['qoder/more-actions', 'read'], + ['qoder/new', 'write'], + ['qoder/send', 'write'], + ['qoder/ask', 'write'], + ['qoder/sidebar-toggle', 'write'], + ['qoder/open-panel', 'write'], + ['qoder/settings', 'write'], + ['qoder/knowledge', 'write'], + ['qoder/marketplace', 'write'], + ['qoder/view-all', 'write'], + ['qoder/add-workspace', 'write'], + ['qoder/prompt-enhance', 'write'], + ['qoder/open-editor', 'write'], + ]); + for (const [name, access] of expected) { + expect(registry.get(name)).toMatchObject({ access }); + } + }); +}); + +describe('qoder helpers', () => { + it('unwraps Browser Bridge evaluate envelopes', async () => { + expect(unwrapEvaluateResult({ session: { id: 's1' }, data: ['row'] })).toEqual(['row']); + const page = makePage([{ session: { id: 's1' }, data: { ok: true } }]); + await expect(evaluateQoder(page, 'script')).resolves.toEqual({ ok: true }); + }); + + it('validates positive integer options explicitly', () => { + expect(parsePositiveInt(undefined, 20, '--limit')).toBe(20); + expect(parsePositiveInt('3', 20, '--limit')).toBe(3); + expect(() => parsePositiveInt(0, 20, '--limit')).toThrow(ArgumentError); + expect(() => parsePositiveInt('abc', 20, '--limit')).toThrow(ArgumentError); + }); + + it('injects into the high-confidence composer instead of arbitrary contenteditable panes', () => { + const html = ` +
+
Optional description
+
+
+
+
+ `; + const { dom, result } = runBrowserScript(html, buildQoderInjectTextScript('hello qoder')); + expect(result).toMatchObject({ ok: true }); + expect(dom.window.document.querySelector('#main')?.textContent).toBe('hello qoder'); + expect(dom.window.document.querySelector('#optional')?.textContent).toBe('Optional description'); + }); +}); + +describe('qoder send command', () => { + const send = getRegistry().get('qoder/send'); + + it('requires post-submit evidence before returning success', async () => { + const page = makePage([1, { ok: true }, { ok: true }, 2]); + await expect(send.func(page, { text: 'hello' })).resolves.toEqual([ + { Status: 'sent', Length: '5' }, + ]); + }); + + it('typed-fails when no composer can be selected', async () => { + const page = makePage([1, { ok: false, reason: 'No high-confidence Qoder composer found.' }]); + await expect(send.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('typed-fails when clicking send does not create a visible message row', async () => { + const page = makePage([1, { ok: true }, { ok: true }, 1]); + await expect(send.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError); + }); +}); diff --git a/clis/qoder/quest.js b/clis/qoder/quest.js new file mode 100644 index 00000000..84e1a071 --- /dev/null +++ b/clis/qoder/quest.js @@ -0,0 +1,147 @@ +// Quest (= conversation) lifecycle commands: new / send / ask. + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + CommandExecutionError, + TimeoutError, +} from '@jackwener/opencli/errors'; +import { + buildQoderInjectTextScript, + clickByTextScript, + clickFirstScript, + evaluateQoder, + parsePositiveInt, + QODER_MESSAGE_COUNT_JS, + qoderResponseAfterScript, +} from './_utils.js'; + +async function waitForMessageCountGrowth(page, beforeCount, timeoutMs = 5000) { + const attempts = Math.max(1, Math.ceil(timeoutMs / 500)); + for (let i = 0; i < attempts; i++) { + await page.wait(0.5); + const afterCount = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS); + if (Number(afterCount) > Number(beforeCount)) return afterCount; + } + return beforeCount; +} + +// -------- new -------- +cli({ + site: 'qoder', + name: 'new', + access: 'write', + description: 'Start a new Qoder Quest (conversation). Clicks the "New Quest" button in the sidebar (or its ⌘N variant).', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['New Quest'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'New Quest button not found', ''); + await page.wait(0.5); + return [{ Status: 'started' }]; + }, +}); + +// -------- send -------- +cli({ + site: 'qoder', + name: 'send', + access: 'write', + description: 'Type text into the Qoder composer and click "Send message" (fire-and-forget).', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', positional: true, required: true, help: 'Text to send' }, + ], + columns: ['Status', 'Length'], + func: async (page, kwargs) => { + const text = String(kwargs?.text || '').trim(); + if (!text) throw new ArgumentError('text is required'); + + const beforeCount = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS); + const typeRes = await evaluateQoder(page, buildQoderInjectTextScript(text)); + if (!typeRes?.ok) throw new CommandExecutionError(typeRes?.reason || 'composer type failed', ''); + await page.wait(0.3); + + // Click Send message + const sendRes = await evaluateQoder(page, clickFirstScript([ + 'button[aria-label="Send message"]', + 'button[title="Send message"]', + ])); + if (!sendRes?.ok) { + // Fallback: try clickByText. + const textRes = await evaluateQoder(page, clickByTextScript(['Send message', 'Send', '发送'])); + if (!textRes?.ok) throw new CommandExecutionError('Send button not found', ''); + } + const afterCount = await waitForMessageCountGrowth(page, beforeCount); + if (Number(afterCount) <= Number(beforeCount)) { + throw new CommandExecutionError('Qoder send did not create a new visible message row'); + } + return [{ Status: 'sent', Length: String(text.length) }]; + }, +}); + +// -------- ask -------- +cli({ + site: 'qoder', + name: 'ask', + access: 'write', + description: 'Send a prompt to Qoder and wait up to --timeout seconds for the reply (best-effort: polls for the chat turn count to grow + stabilize).', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', positional: true, required: true, help: 'Prompt text' }, + { name: 'timeout', type: 'int', required: false, default: 120, help: 'Max seconds to wait' }, + ], + columns: ['Role', 'Text', 'WaitedSeconds'], + func: async (page, kwargs) => { + const text = String(kwargs?.text || '').trim(); + if (!text) throw new ArgumentError('text is required'); + const timeoutSec = parsePositiveInt(kwargs?.timeout, 120, '--timeout'); + + // Type + send (mirror `send` logic) + const sendBefore = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS); + const typeRes = await evaluateQoder(page, buildQoderInjectTextScript(text)); + if (!typeRes?.ok) throw new CommandExecutionError(typeRes?.reason || 'composer type failed', ''); + await page.wait(0.3); + const sendRes = await evaluateQoder(page, clickFirstScript(['button[aria-label="Send message"]', 'button[title="Send message"]'])); + if (!sendRes?.ok) { + const textRes = await evaluateQoder(page, clickByTextScript(['Send message', 'Send', '发送'])); + if (!textRes?.ok) throw new CommandExecutionError('Send button not found', ''); + } + + const startedAt = Date.now(); + const deadline = startedAt + timeoutSec * 1000; + let lastCount = sendBefore; + let stableTicks = 0; + let response = null; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 1500)); + const cur = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS); + if (cur !== lastCount) { + lastCount = cur; + stableTicks = 0; + } else { + stableTicks++; + } + // Consider stable after 6 idle ticks (≈9s no change) IF count grew at all. + if (lastCount > sendBefore && stableTicks >= 6) { + response = await evaluateQoder(page, qoderResponseAfterScript(sendBefore, text)); + if (response?.text) break; + } + } + const elapsed = Math.round((Date.now() - startedAt) / 1000); + if (!response?.text) { + throw new TimeoutError('Qoder response', timeoutSec, 'Confirm Qoder sent the prompt and finished generating, then retry with a larger --timeout.'); + } + return [ + { Role: 'User', Text: text, WaitedSeconds: String(elapsed) }, + { Role: response.role || 'Assistant', Text: String(response.text).slice(0, 1200), WaitedSeconds: String(elapsed) }, + ]; + }, +}); diff --git a/clis/qoder/read.js b/clis/qoder/read.js new file mode 100644 index 00000000..4ec84095 --- /dev/null +++ b/clis/qoder/read.js @@ -0,0 +1,29 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { evaluateQoder, parsePositiveInt, QODER_TURNS_JS, requireArrayResult } from './_utils.js'; + +cli({ + site: 'qoder', + name: 'read', + access: 'read', + description: 'Read messages in the current Qoder Quest. Returns role + text for each visible turn.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'limit', type: 'int', required: false, default: 30 }, + ], + columns: ['Index', 'Role', 'Text'], + func: async (page, kwargs) => { + const limit = parsePositiveInt(kwargs?.limit, 30, '--limit'); + const turns = requireArrayResult(await evaluateQoder(page, QODER_TURNS_JS), 'qoder read'); + if (!turns.length) { + throw new EmptyResultError('qoder read', 'No chat turns detected. Open a quest first.'); + } + return turns.slice(0, limit).map((t, i) => ({ + Index: i + 1, + Role: t.role, + Text: (t.text || '').slice(0, 1200), + })); + }, +}); diff --git a/clis/qoder/status.js b/clis/qoder/status.js new file mode 100644 index 00000000..ba58c7fa --- /dev/null +++ b/clis/qoder/status.js @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { evaluateQoder } from './_utils.js'; + +cli({ + site: 'qoder', + name: 'status', + access: 'read', + description: 'Check Qoder CDP connection and report the current renderer URL + title.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status', 'Url', 'Title'], + func: async (page) => { + return [{ + Status: 'Connected', + Url: await evaluateQoder(page, 'window.location.href'), + Title: await evaluateQoder(page, 'document.title'), + }]; + }, +}); diff --git a/clis/qoder/ui.js b/clis/qoder/ui.js new file mode 100644 index 00000000..cd0e41c4 --- /dev/null +++ b/clis/qoder/ui.js @@ -0,0 +1,332 @@ +// Global UI commands for Qoder. +// +// sidebar-toggle — Collapse Quest List (⌘B) +// open-panel — Open Panel (⌥⌘B) — bottom panel +// search — Search (⌘P) palette +// settings — Settings +// knowledge — open Knowledge view +// marketplace — open Marketplace +// credits — Credits Usage +// view-all — View all quests +// add-workspace — Add Workspace +// account — read user info (the "leo"-style button at bottom) +// more-actions — open More Actions menu + list items + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { IS_VISIBLE_JS, clickByTextScript, clickFirstScript, evaluateQoder, parsePositiveInt, requireArrayResult } from './_utils.js'; + +// -------- sidebar-toggle -------- +cli({ + site: 'qoder', + name: 'sidebar-toggle', + access: 'write', + description: 'Collapse / Expand the Qoder Quest List sidebar (⌘B).', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Collapse Quest List', 'Expand Quest List'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'sidebar-toggle failed', ''); + return [{ Status: `clicked: ${res.matched}` }]; + }, +}); + +// -------- open-panel -------- +cli({ + site: 'qoder', + name: 'open-panel', + access: 'write', + description: 'Open / close the Qoder bottom panel (Output / Terminal / Debug Console). ⌥⌘B equivalent.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Open Panel', 'Close Panel'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'open-panel failed', ''); + return [{ Status: `clicked: ${res.matched}` }]; + }, +}); + +// -------- search -------- +cli({ + site: 'qoder', + name: 'search', + access: 'read', + description: 'Open Qoder Search palette (⌘P), type a query, return matched options.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'query', positional: true, required: true, help: 'Search text' }, + { name: 'limit', type: 'int', required: false, default: 20 }, + ], + columns: ['Index', 'Item'], + func: async (page, kwargs) => { + const query = String(kwargs?.query || '').trim(); + if (!query) throw new ArgumentError('query', 'is required'); + const limit = parsePositiveInt(kwargs?.limit, 20, '--limit'); + const openRes = await evaluateQoder(page, clickByTextScript(['Search'])); + if (!openRes?.ok) throw new CommandExecutionError(openRes?.reason || 'search open failed', ''); + await page.wait(0.5); + // Type into the visible input (usually the most-recently mounted one). + const fillRes = await evaluateQoder(page, `(() => { + ${IS_VISIBLE_JS} + const inputs = Array.from(document.querySelectorAll('input[type="text"], input[type="search"], input:not([type])')).filter(isVisible); + const input = inputs[inputs.length - 1]; + if (!input) return { ok: false, reason: 'No input visible.' }; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, ${JSON.stringify(query)}); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + return { ok: true }; + })()`); + if (!fillRes?.ok) throw new CommandExecutionError(fillRes?.reason || 'search type failed', ''); + await page.wait(0.8); + const items = requireArrayResult(await evaluateQoder(page, `(() => { + ${IS_VISIBLE_JS} + // Search palette results: prefer [role=option], else any clickable row in the modal. + const opts = Array.from(document.querySelectorAll('[role="option"], [role="menuitem"]')).filter(isVisible); + const titles = opts.map((o) => { + // Codex-style fix: text may be in per-char spans; use textContent. + const titleEl = o.querySelector('.truncate, [class*="title"i]') || o; + return (titleEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200); + }).filter(Boolean); + return [...new Set(titles)]; + })()`), 'qoder search'); + try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {} + if (!items.length) { + throw new EmptyResultError('qoder search', `No items matched "${query}".`); + } + return items.slice(0, limit).map((t, i) => ({ Index: i + 1, Item: t })); + }, +}); + +// -------- settings -------- +cli({ + site: 'qoder', + name: 'settings', + access: 'write', + description: 'Click the Settings button in the Qoder sidebar.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Settings'])); + if (!res?.ok) { + // Fallback: try aria-label. + const ariaRes = await evaluateQoder(page, clickFirstScript(['button[aria-label="Settings"]'])); + if (!ariaRes?.ok) throw new CommandExecutionError('Settings button not found', ''); + } + await page.wait(0.5); + return [{ Status: 'clicked' }]; + }, +}); + +// -------- knowledge -------- +cli({ + site: 'qoder', + name: 'knowledge', + access: 'write', + description: 'Open the Knowledge view (Qoder\'s personal/team knowledge base).', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Knowledge'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Knowledge button not found', ''); + return [{ Status: 'clicked' }]; + }, +}); + +// -------- marketplace -------- +cli({ + site: 'qoder', + name: 'marketplace', + access: 'write', + description: 'Open the Qoder Marketplace.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Marketplace'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Marketplace button not found', ''); + return [{ Status: 'clicked' }]; + }, +}); + +// -------- credits -------- +cli({ + site: 'qoder', + name: 'credits', + access: 'read', + description: 'Click "Credits Usage" and return the credits-usage display text.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Field', 'Value'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Credits Usage'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Credits Usage not visible', ''); + await page.wait(0.5); + // Try to read whatever appears. + const info = await evaluateQoder(page, `(() => { + ${IS_VISIBLE_JS} + // Find any dialog/popover that appeared. + const popovers = Array.from(document.querySelectorAll('[role="dialog"], [class*="popover"i], [class*="popup"i]')).filter(isVisible); + if (!popovers.length) return null; + const pop = popovers[popovers.length - 1]; + return (pop.innerText || pop.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 600); + })()`); + try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {} + return [ + { Field: 'Status', Value: 'clicked' }, + { Field: 'Info', Value: info || '(no popover detected after click)' }, + ]; + }, +}); + +// -------- view-all -------- +cli({ + site: 'qoder', + name: 'view-all', + access: 'write', + description: 'Click "View all" to show all Quests.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['View all'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'View all button not visible', ''); + return [{ Status: 'clicked' }]; + }, +}); + +// -------- add-workspace -------- +cli({ + site: 'qoder', + name: 'add-workspace', + access: 'write', + description: 'Click "Add Workspace" — opens the folder picker. Note: this opens a system file-picker dialog that Qoder controls; the actual folder selection must be done in the UI by the user.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['Add Workspace'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Add Workspace button not found', ''); + return [{ Status: 'clicked — folder picker opened (manual selection required)' }]; + }, +}); + +// -------- account -------- +cli({ + site: 'qoder', + name: 'account', + access: 'read', + description: 'Click the account button (username) in the Qoder sidebar and return the visible account dropdown items.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'username', required: false, help: 'Username text shown in the sidebar (default: tries common short labels)' }, + ], + columns: ['Field', 'Value'], + func: async (page, kwargs) => { + // The account button label is the user's display name. We try a few + // common patterns: if --username is passed we use it, else we look + // for any short button text that doesn't match other known controls. + let label = String(kwargs?.username || '').trim(); + if (!label) { + // Heuristic discovery. + label = await evaluateQoder(page, `(() => { + ${IS_VISIBLE_JS} + const btns = Array.from(document.querySelectorAll('button')).filter(isVisible); + const known = new Set(['Pin', 'Copy', 'New Quest', 'Search', 'Settings', 'View all', 'Knowledge', 'Marketplace', 'Credits Usage', 'Open Editor', 'Add Workspace', 'More Actions', 'Send message', 'Prompt Enhance', 'Voice input', 'button']); + const candidate = btns.find((b) => { + const tx = (b.innerText || '').trim(); + if (!tx || tx.length > 30 || tx.includes('⌘') || known.has(tx)) return false; + if (/^(New|Open|Add|Save|Edit|Cancel|OK|Close)/.test(tx)) return false; + return true; + }); + return candidate ? (candidate.innerText || '').trim() : ''; + })()`); + } + if (!label) { + throw new CommandExecutionError('No account button detected. Pass --username explicitly.', ''); + } + // Click the username button + const clickRes = await evaluateQoder(page, clickByTextScript([label], { exact: true, maxLen: 60 })); + if (!clickRes?.ok) throw new CommandExecutionError(`Click on account button "${label}" failed`, ''); + await page.wait(0.5); + const items = requireArrayResult(await evaluateQoder(page, `(() => { + ${IS_VISIBLE_JS} + const popovers = Array.from(document.querySelectorAll('[role="menu"], [role="dialog"], [class*="popover"i], [class*="dropdown"i]')).filter(isVisible) + .filter((el) => { const r = el.getBoundingClientRect(); return r.width < 500 && r.height < 600; }); + if (!popovers.length) return []; + const pop = popovers[popovers.length - 1]; + return Array.from(pop.querySelectorAll('button, [role="menuitem"], a')) + .filter(isVisible) + .map((b) => (b.innerText || b.textContent || '').trim().replace(/\\s+/g, ' ')) + .filter(Boolean); + })()`), 'qoder account'); + try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {} + const rows = [{ Field: 'Username', Value: label }]; + (items || []).slice(0, 20).forEach((item, i) => { + rows.push({ Field: `Item[${i + 1}]`, Value: item }); + }); + return rows; + }, +}); + +// -------- more-actions -------- +cli({ + site: 'qoder', + name: 'more-actions', + access: 'read', + description: 'Click the "More Actions" button and list its menu items.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Index', 'Item'], + func: async (page) => { + const res = await evaluateQoder(page, clickByTextScript(['More Actions'])); + if (!res?.ok) throw new CommandExecutionError(res?.reason || 'More Actions button not found', ''); + await page.wait(0.4); + const items = requireArrayResult(await evaluateQoder(page, `(() => { + ${IS_VISIBLE_JS} + const popovers = Array.from(document.querySelectorAll('[role="menu"], [role="dialog"], [class*="popover"i], [class*="menu"i]')).filter(isVisible) + .filter((el) => { const r = el.getBoundingClientRect(); return r.width < 500 && r.height < 600; }); + if (!popovers.length) return []; + const pop = popovers[popovers.length - 1]; + return Array.from(pop.querySelectorAll('[role="menuitem"], button')) + .filter(isVisible) + .map((b) => (b.innerText || b.textContent || '').trim().replace(/\\s+/g, ' ')) + .filter(Boolean); + })()`), 'qoder more-actions'); + try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {} + if (!items.length) { + throw new EmptyResultError('qoder more-actions', 'Menu opened but no items detected.'); + } + return items.map((it, i) => ({ Index: i + 1, Item: it })); + }, +}); diff --git a/docs/adapters/desktop/qoder.md b/docs/adapters/desktop/qoder.md new file mode 100644 index 00000000..d2b96ae8 --- /dev/null +++ b/docs/adapters/desktop/qoder.md @@ -0,0 +1,57 @@ +# Qoder + +Control the **Qoder IDE** desktop app from OpenCLI through Chrome DevTools Protocol (CDP). Qoder is an Electron / VS Code-derived AI IDE; these commands operate the currently connected Qoder renderer, so open Qoder with remote debugging enabled before use. + +## Prerequisites + +1. Install Qoder. +2. Launch Qoder with CDP enabled on its registered port: + +```bash +/Applications/Qoder.app/Contents/MacOS/Electron \ + --remote-debugging-port=9237 \ + --remote-allow-origins='*' +``` + +## Setup + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9237" +``` + +## Commands + +### Diagnostics + +- `opencli qoder status`: Check the active Qoder renderer URL and title. + +### Quest Lifecycle + +- `opencli qoder new`: Start a new Quest. +- `opencli qoder history --limit 20`: List visible Quests from the sidebar. +- `opencli qoder read --limit 30`: Read visible turns in the current Quest. +- `opencli qoder send "message"`: Send a message to the current Quest. +- `opencli qoder ask "prompt" --timeout 120`: Send a prompt and wait for a visible reply. + +### Sidebar And Views + +- `opencli qoder sidebar-toggle`: Collapse or expand the Quest sidebar. +- `opencli qoder open-panel`: Toggle the bottom panel. +- `opencli qoder search "query"`: Open the Qoder search palette and list results. +- `opencli qoder settings`: Open Settings. +- `opencli qoder knowledge`: Open Knowledge. +- `opencli qoder marketplace`: Open Marketplace. +- `opencli qoder credits`: Open Credits Usage and read the visible popover. +- `opencli qoder view-all`: Click View all in the Quest list. +- `opencli qoder add-workspace`: Open the Add Workspace folder picker. +- `opencli qoder account [--username name]`: Open the account menu and list items. +- `opencli qoder more-actions`: Open More Actions and list menu items. + +### Composer + +- `opencli qoder prompt-enhance`: Click Prompt Enhance for the current draft. +- `opencli qoder open-editor`: Open the current draft in Qoder's editor view. + +## Notes + +Most commands use Qoder's visible desktop UI as the source of truth. Commands that click a button will fail with a typed error if the target control is not visible in the current Qoder view. `send` and `ask` require post-submit evidence from the visible Quest before returning success. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index a0057329..9b1b1989 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -169,5 +169,6 @@ Run `opencli list` for the live registry. | **[Antigravity](./desktop/antigravity.md)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | | **[ChatGPT App](./desktop/chatgpt-app.md)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` `model` | | **[ChatWise](./desktop/chatwise.md)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | +| **[Qoder](./desktop/qoder.md)** | Control Qoder IDE | `status` `new` `history` `send` `ask` `read` `search` `settings` `knowledge` `marketplace` `credits` `view-all` `add-workspace` `account` `more-actions` `prompt-enhance` `open-editor` `sidebar-toggle` `open-panel` | | **[Discord](./desktop/discord.md)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` | | **[Doubao App](./desktop/doubao-app.md)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` | diff --git a/src/electron-apps.test.ts b/src/electron-apps.test.ts index 9d07138a..f8674293 100644 --- a/src/electron-apps.test.ts +++ b/src/electron-apps.test.ts @@ -31,6 +31,17 @@ describe('electron-apps registry', () => { expect(isElectronApp('cursor')).toBe(true); expect(isElectronApp('codex')).toBe(true); expect(isElectronApp('chatwise')).toBe(true); + expect(isElectronApp('qoder')).toBe(true); + }); + + it('registers Qoder on its own CDP port', () => { + const app = getElectronApp('qoder'); + expect(app).toMatchObject({ + port: 9237, + processName: 'Qoder', + bundleId: 'com.qoder.ide', + displayName: 'Qoder', + }); }); it('isElectronApp returns false for non-Electron sites', () => { diff --git a/src/electron-apps.ts b/src/electron-apps.ts index 8f508960..8b2f3b53 100644 --- a/src/electron-apps.ts +++ b/src/electron-apps.ts @@ -39,6 +39,13 @@ export const builtinApps: Record = { displayName: 'Antigravity', }, 'chatgpt-app': { port: 9236, processName: 'ChatGPT', bundleId: 'com.openai.chat', displayName: 'ChatGPT' }, + qoder: { + port: 9237, + processName: 'Qoder', + executableNames: ['Electron'], + bundleId: 'com.qoder.ide', + displayName: 'Qoder', + }, }; /** Merge builtin + user-defined apps. User entries are additive only. */