feat(qoder): add Qoder IDE adapter

Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
No-check override: WAWQAQ 2026-06-01
This commit is contained in:
Zhongyue Lin
2026-06-01 00:07:49 +08:00
committed by GitHub
parent 5b11251692
commit 6126413d60
15 changed files with 1413 additions and 2 deletions
+1 -1
View File
@@ -193,7 +193,7 @@ Unified passthrough for your existing command-line tools. Run `opencli <tool> ..
Register your own with `opencli external register <name>`; 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
+1 -1
View File
@@ -181,7 +181,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
注册自定义本地 CLI`opencli external register <name>`;查看所有:`opencli external list`
**桌面应用适配器**Electron通过 CDPCursor / Codex / Antigravity / ChatGPT App / ChatWise / Discord / Doubao — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。
**桌面应用适配器**Electron通过 CDPCursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。
## 下载支持
+396
View File
@@ -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",
+191
View File
@@ -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;
}
+45
View File
@@ -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' }];
},
});
+49
View File
@@ -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 }));
},
});
+125
View File
@@ -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 = `
<div class="cm-editor simple-editor">
<div id="optional" contenteditable="true" role="textbox">Optional description</div>
</div>
<div class="qoder-composer input">
<div id="main" contenteditable="true" role="textbox" aria-label="Message Qoder"></div>
</div>
`;
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);
});
});
+147
View File
@@ -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) },
];
},
});
+29
View File
@@ -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),
}));
},
});
+21
View File
@@ -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'),
}];
},
});
+332
View File
@@ -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 <name> 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 }));
},
});
+57
View File
@@ -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.
+1
View File
@@ -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` |
+11
View File
@@ -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', () => {
+7
View File
@@ -39,6 +39,13 @@ export const builtinApps: Record<string, ElectronAppEntry> = {
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. */