chore: import upstream snapshot with attribution
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:48 +08:00
commit 9b395f5cc3
2400 changed files with 376116 additions and 0 deletions
+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 }));
},
});