mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
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:
@@ -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
@@ -181,7 +181,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
|
||||
注册自定义本地 CLI:`opencli external register <name>`;查看所有:`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/)。
|
||||
|
||||
## 下载支持
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }];
|
||||
},
|
||||
});
|
||||
@@ -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 }));
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -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 }));
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user