Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/build-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ describe('manifest helper rules', () => {
expect(scanTs(file, 'demo')).toBeNull();
});

it('detects shared desktop factory commands in TS adapters', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-shared-'));
tempDirs.push(dir);
const file = path.join(dir, 'status.ts');
fs.writeFileSync(
file,
`import { makeStatusCommand } from '../_shared/desktop-commands.js';
export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop');`,
);

expect(scanTs(file, 'chatwise')).toMatchObject({
site: 'chatwise',
name: 'status',
description: 'Check active CDP connection to ChatWise Desktop',
strategy: 'ui',
browser: true,
columns: ['Status', 'Url', 'Title'],
type: 'ts',
modulePath: 'chatwise/status.js',
});
});

it('keeps literal domain and navigateBefore for TS adapters', () => {
const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
const entry = scanTs(file, 'xueqiu');
Expand Down
70 changes: 70 additions & 0 deletions src/build-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import type { YamlCliDefinition } from './yaml-schema.js';

import { isRecord } from './utils.js';

const SHARED_DESKTOP_FACTORY_PATTERN = /\bmake(Status|New|Screenshot|Dump)Command\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*['"`]([^'"`]+)['"`])?/;


function extractBalancedBlock(
source: string,
Expand Down Expand Up @@ -217,6 +219,74 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
try {
const src = fs.readFileSync(filePath, 'utf-8');

const sharedFactoryMatch = src.match(SHARED_DESKTOP_FACTORY_PATTERN);
if (sharedFactoryMatch) {
const [, factoryName, siteName, displayName] = sharedFactoryMatch;
const label = displayName || siteName;

switch (factoryName) {
case 'Status':
return {
site: siteName,
name: baseName,
description: `Check active CDP connection to ${label}`,
domain: 'localhost',
strategy: 'ui',
browser: true,
args: [],
columns: ['Status', 'Url', 'Title'],
type: 'ts',
modulePath: relativePath,
};
case 'New':
return {
site: siteName,
name: baseName,
description: `Start a new ${label} session`,
domain: 'localhost',
strategy: 'ui',
browser: true,
args: [],
columns: ['Status'],
type: 'ts',
modulePath: relativePath,
};
case 'Screenshot':
return {
site: siteName,
name: baseName,
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
domain: 'localhost',
strategy: 'ui',
browser: true,
args: [
{
name: 'output',
type: 'str',
required: false,
help: `Output file path (default: /tmp/${siteName}-snapshot.txt)`,
},
],
columns: ['Status', 'File'],
type: 'ts',
modulePath: relativePath,
};
case 'Dump':
return {
site: siteName,
name: baseName,
description: `Dump the DOM and Accessibility tree of ${siteName} for reverse-engineering`,
domain: 'localhost',
strategy: 'ui',
browser: true,
args: [],
columns: ['action', 'files'],
type: 'ts',
modulePath: relativePath,
};
}
}

// Helper/test modules should not appear as CLI commands in the manifest.
if (!/\bcli\s*\(/.test(src)) return null;

Expand Down
75 changes: 75 additions & 0 deletions src/chatwise-composer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
scoreChatwiseComposerCandidate,
selectBestChatwiseComposer,
type ChatwiseComposerCandidate,
} from './clis/chatwise/utils.js';

function candidate(overrides: Partial<ChatwiseComposerCandidate>): ChatwiseComposerCandidate {
return {
index: 0,
hidden: false,
role: 'textbox',
classes: 'cm-content cm-lineWrapping',
editorClasses: 'cm-editor',
placeholder: '',
text: '',
rect: { y: 0, h: 30 },
...overrides,
};
}

describe('scoreChatwiseComposerCandidate', () => {
it('strongly prefers the main chat composer over auxiliary editors', () => {
const mainComposer = candidate({
index: 0,
placeholder: 'placeholder Enter a message here, press ⏎ to send',
rect: { y: 860, h: 32 },
});
const optionalDescription = candidate({
index: 1,
placeholder: 'placeholder Optional description',
editorClasses: 'cm-editor simple-editor',
rect: { y: 400, h: 32 },
});
const userContext = candidate({
index: 2,
text: '# User Context Document',
editorClasses: 'cm-editor simple-editor',
rect: { y: 460, h: 1200 },
});

expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
scoreChatwiseComposerCandidate(optionalDescription, 900),
);
expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
scoreChatwiseComposerCandidate(userContext, 900),
);
});
});

describe('selectBestChatwiseComposer', () => {
it('returns the candidate that matches the main message composer', () => {
const picked = selectBestChatwiseComposer([
candidate({
index: 0,
placeholder: 'placeholder Enter a message here, press ⏎ to send',
rect: { y: 858, h: 33 },
}),
candidate({
index: 1,
placeholder: 'placeholder Optional description',
editorClasses: 'cm-editor simple-editor',
rect: { y: 395, h: 30 },
}),
candidate({
index: 2,
text: '# User Context Document',
editorClasses: 'cm-editor simple-editor',
rect: { y: 464, h: 1200 },
}),
], 900);

expect(picked?.index).toBe(0);
});
});
42 changes: 16 additions & 26 deletions src/clis/chatwise/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { chatwiseRequiredEnv } from './shared.js';
import { buildChatwiseInjectTextJs } from './utils.js';

const MESSAGE_WRAPPER_SELECTOR = '[class*="group/message"]';

export const askCommand = cli({
site: 'chatwise',
Expand All @@ -23,31 +26,13 @@ export const askCommand = cli({
// Snapshot content length
const beforeLen = await page.evaluate(`
(function() {
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
const msgs = document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)});
return msgs.length;
})()
`);

// Send message
const injected = await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) return false;
composer.focus();
if (composer.tagName === 'TEXTAREA') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(composer, text);
composer.dispatchEvent(new Event('input', { bubbles: true }));
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
if (!injected) throw new SelectorError('ChatWise input element');

await page.wait(0.5);
Expand All @@ -63,17 +48,22 @@ export const askCommand = cli({

const result = await page.evaluate(`
(function(prevLen) {
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
const msgs = Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)}))
.map(node => (node.innerText || node.textContent || '').trim())
.filter(Boolean);
if (msgs.length <= prevLen) return null;
const last = msgs[msgs.length - 1];
const text = last.innerText || last.textContent;
return text ? text.trim() : null;
const fresh = msgs.slice(prevLen).filter(text => text !== ${JSON.stringify(text)});
if (fresh.length === 0) return null;
return fresh[fresh.length - 1];
})(${beforeLen})
`);

if (result) {
response = result;
break;
const next = String(result).trim();
if (next === response) {
break;
}
response = next;
}
}

Expand Down
26 changes: 2 additions & 24 deletions src/clis/chatwise/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cli, Strategy } from '../../registry.js';
import { SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { chatwiseRequiredEnv } from './shared.js';
import { buildChatwiseInjectTextJs } from './utils.js';

export const sendCommand = cli({
site: 'chatwise',
Expand All @@ -16,30 +17,7 @@ export const sendCommand = cli({
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;

const injected = await page.evaluate(`
(function(text) {
// ChatWise input can be textarea or contenteditable
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}

if (!composer) return false;

composer.focus();

if (composer.tagName === 'TEXTAREA') {
// For textarea, set value and dispatch input event
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
nativeInputValueSetter.call(composer, text);
composer.dispatchEvent(new Event('input', { bubbles: true }));
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
if (!injected) throw new SelectorError('ChatWise input element');

await page.wait(0.5);
Expand Down
Loading