Skip to content
95 changes: 94 additions & 1 deletion cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4692,9 +4692,40 @@
"default": false,
"required": false,
"help": "Start a new chat before sending"
},
{
"name": "conversation",
"type": "str",
"required": false,
"valueRequired": true,
"help": "Continue an existing ChatGPT conversation ID or /c/<id> URL"
},
{
"name": "wait",
"type": "boolean",
"default": true,
"required": false,
"help": "Wait for the assistant response after sending"
},
{
"name": "deep-research",
"type": "boolean",
"default": false,
"required": false,
"help": "Enable ChatGPT 深度研究 (Deep Research)"
},
{
"name": "web-search",
"type": "boolean",
"default": false,
"required": false,
"help": "Enable ChatGPT 网页搜索 (Web Search)"
}
],
"columns": [
"conversationId",
"conversationUrl",
"tool",
"response"
],
"type": "js",
Expand Down Expand Up @@ -4725,12 +4756,35 @@
"default": false,
"required": false,
"help": "Emit assistant replies as markdown"
},
{
"name": "wait",
"type": "boolean",
"default": false,
"required": false,
"help": "Wait until the conversation stops generating and stabilizes"
},
{
"name": "timeout",
"type": "int",
"default": 120,
"required": false,
"help": "Max seconds to wait when --wait is true"
},
{
"name": "stable",
"type": "int",
"default": 6,
"required": false,
"help": "Seconds the final messages must remain unchanged when --wait is true"
}
],
"columns": [
"Index",
"Role",
"Text"
"Text",
"Generating",
"StableSeconds"
],
"type": "js",
"modulePath": "chatgpt/detail.js",
Expand Down Expand Up @@ -4822,6 +4876,38 @@
"navigateBefore": false,
"siteSession": "persistent"
},
{
"site": "chatgpt",
"name": "model",
"description": "Switch ChatGPT web model/mode (instant, thinking, pro)",
"access": "write",
"domain": "chatgpt.com",
"strategy": "cookie",
"browser": true,
"args": [
{
"name": "model",
"type": "str",
"required": true,
"positional": true,
"help": "Model/mode to switch to",
"choices": [
"instant",
"thinking",
"pro"
]
}
],
"columns": [
"Status",
"Model"
],
"type": "js",
"modulePath": "chatgpt/model.js",
"sourceFile": "chatgpt/model.js",
"navigateBefore": false,
"siteSession": "persistent"
},
{
"site": "chatgpt",
"name": "new",
Expand Down Expand Up @@ -4890,6 +4976,13 @@
"default": false,
"required": false,
"help": "Start a new chat before sending"
},
{
"name": "conversation",
"type": "str",
"required": false,
"valueRequired": true,
"help": "Continue an existing ChatGPT conversation ID or /c/<id> URL"
}
],
"columns": [
Expand Down
63 changes: 59 additions & 4 deletions clis/chatgpt/ask.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import {
CHATGPT_DOMAIN,
CHATGPT_URL,
currentChatGPTUrl,
ensureChatGPTComposer,
ensureOnChatGPT,
getBubbleCount,
normalizeBooleanFlag,
openChatGPTConversation,
requireNonEmptyPrompt,
requirePositiveInt,
parseChatGPTConversationId,
sendChatGPTMessage,
selectChatGPTTool,
isGenerating,
startNewChat,
waitForChatGPTResponse,
} from './utils.js';

async function waitForConversationUrl(page, timeoutSeconds = 30) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutSeconds * 1000) {
const conversationUrl = await currentChatGPTUrl(page);
try {
const conversationId = parseChatGPTConversationId(conversationUrl);
return { conversationId, conversationUrl };
} catch {
await page.wait(1);
}
}
throw new CommandExecutionError('ChatGPT did not create a conversation URL after sending the message.');
}

export const askCommand = cli({
site: 'chatgpt',
name: 'ask',
Expand All @@ -28,31 +47,67 @@ export const askCommand = cli({
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
{ name: 'conversation', valueRequired: true, help: 'Continue an existing ChatGPT conversation ID or /c/<id> URL' },
{ name: 'wait', type: 'boolean', default: true, help: 'Wait for the assistant response after sending' },
{ name: 'deep-research', type: 'boolean', default: false, help: 'Enable ChatGPT 深度研究 (Deep Research)' },
{ name: 'web-search', type: 'boolean', default: false, help: 'Enable ChatGPT 网页搜索 (Web Search)' },
],
columns: ['response'],
columns: ['conversationId', 'conversationUrl', 'tool', 'response'],
func: async (page, kwargs) => {
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt ask');
const timeout = requirePositiveInt(
Number(kwargs.timeout ?? 120),
'chatgpt ask --timeout',
'Example: opencli chatgpt ask "hello" --timeout 120',
);
const useDeepResearch = normalizeBooleanFlag(kwargs['deep-research'], false);
const useWebSearch = normalizeBooleanFlag(kwargs['web-search'], false);
const shouldWait = normalizeBooleanFlag(kwargs.wait, true);
if (useDeepResearch && useWebSearch) {
throw new ArgumentError(
'chatgpt ask cannot enable both --deep-research and --web-search',
'Choose one ChatGPT composer tool for this message.',
);
}
if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) {
throw new ArgumentError(
'chatgpt ask cannot use --new and --conversation together',
'Choose either a new chat or an existing conversation.',
);
}
const tool = useDeepResearch ? 'deep-research' : (useWebSearch ? 'web-search' : null);

if (normalizeBooleanFlag(kwargs.new)) {
if (kwargs.conversation) {
await openChatGPTConversation(page, kwargs.conversation);
} else if (normalizeBooleanFlag(kwargs.new)) {
await startNewChat(page);
} else {
await ensureOnChatGPT(page);
}
// startNewChat / ensureOnChatGPT now wait for the composer selector
// after navigating, so the previous standalone 2 s settle is redundant.
await ensureChatGPTComposer(page, 'ChatGPT ask requires a logged-in ChatGPT session with a visible composer.');
const selectedTool = tool ? await selectChatGPTTool(page, tool) : null;

const settleStart = Date.now();
while (await isGenerating(page)) {
if (Date.now() - settleStart > timeout * 1000) {
throw new CommandExecutionError('ChatGPT conversation is still generating; wait for it to finish before sending another message.');
}
await page.wait(3);
}

const baseline = await getBubbleCount(page);
const sent = await sendChatGPTMessage(page, prompt);
if (!sent) {
throw new CommandExecutionError('Failed to send message to ChatGPT', `Open ${CHATGPT_URL} and verify the composer is ready.`);
}

return [{ response: await waitForChatGPTResponse(page, baseline, prompt, timeout) }];
const { conversationId, conversationUrl } = await waitForConversationUrl(page);
if (!shouldWait) {
return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response: '' }];
}
const response = await waitForChatGPTResponse(page, baseline, prompt, timeout);
return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response }];
},
});
53 changes: 53 additions & 0 deletions clis/chatgpt/commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import './detail.js';
import './new.js';
import './status.js';
import './image.js';
import './model.js';

describe('chatgpt browser command registration', () => {
it('registers the baseline web chat commands with persistent site sessions', () => {
Expand All @@ -20,6 +21,7 @@ describe('chatgpt browser command registration', () => {
new: 'read',
status: 'read',
image: 'write',
model: 'write',
};

for (const [name, access] of Object.entries(expectedAccess)) {
Expand All @@ -40,6 +42,57 @@ describe('chatgpt browser command registration', () => {
expect(ask.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
expect.objectContaining({ name: 'new', type: 'boolean', default: false }),
expect.objectContaining({ name: 'conversation', valueRequired: true }),
expect.objectContaining({ name: 'wait', type: 'boolean', default: true }),
expect.objectContaining({ name: 'deep-research', type: 'boolean', default: false }),
expect.objectContaining({ name: 'web-search', type: 'boolean', default: false }),
]));
expect(ask.columns).toEqual(['conversationId', 'conversationUrl', 'tool', 'response']);
});

it('registers send conversation routing option', () => {
const send = getRegistry().get('chatgpt/send');
expect(send.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'new', type: 'boolean', default: false }),
expect.objectContaining({ name: 'conversation', valueRequired: true }),
]));
});

it('registers detail wait options and generation state columns', () => {
const detail = getRegistry().get('chatgpt/detail');
expect(detail.args).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'wait', type: 'boolean', default: false }),
expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }),
expect.objectContaining({ name: 'stable', type: 'int', default: 6 }),
]));
expect(detail.columns).toEqual(['Index', 'Role', 'Text', 'Generating', 'StableSeconds']);
});

it('registers chatgpt model with web model choices', () => {
const model = getRegistry().get('chatgpt/model');
expect(model.args).toEqual([
expect.objectContaining({
name: 'model',
positional: true,
required: true,
choices: ['instant', 'thinking', 'pro'],
}),
]);
expect(model.columns).toEqual(['Status', 'Model']);
});

it('rejects off-domain conversation URLs before ask/send can navigate', async () => {
const ask = getRegistry().get('chatgpt/ask');
const send = getRegistry().get('chatgpt/send');
const page = {
goto: () => {
throw new Error('should not navigate');
},
};

await expect(ask.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' }))
.rejects.toMatchObject({ code: 'ARGUMENT' });
await expect(send.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' }))
.rejects.toMatchObject({ code: 'ARGUMENT' });
});
});
34 changes: 23 additions & 11 deletions clis/chatgpt/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
CHATGPT_URL,
CONVERSATION_MESSAGE_SELECTOR,
ensureChatGPTLogin,
getVisibleMessages,
messageHtmlToMarkdown,
getChatGPTDetailRows,
normalizeBooleanFlag,
parseChatGPTConversationId,
requireNonNegativeInt,
requirePositiveInt,
waitForChatGPTDetailRows,
} from './utils.js';

export const detailCommand = cli({
Expand All @@ -24,28 +26,38 @@ export const detailCommand = cli({
args: [
{ name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/<id> URL' },
{ name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
{ name: 'wait', type: 'boolean', default: false, help: 'Wait until the conversation stops generating and stabilizes' },
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' },
{ name: 'stable', type: 'int', default: 6, help: 'Seconds the final messages must remain unchanged when --wait is true' },
],
columns: ['Index', 'Role', 'Text'],
columns: ['Index', 'Role', 'Text', 'Generating', 'StableSeconds'],
func: async (page, kwargs) => {
const id = parseChatGPTConversationId(kwargs.id);
const wantMarkdown = normalizeBooleanFlag(kwargs.markdown, false);
const shouldWait = normalizeBooleanFlag(kwargs.wait, false);
const timeout = requirePositiveInt(
Number(kwargs.timeout ?? 120),
'chatgpt detail --timeout',
'Example: opencli chatgpt detail <id> --wait true --timeout 600',
);
const stableSeconds = requireNonNegativeInt(
Number(kwargs.stable ?? 6),
'chatgpt detail --stable',
'Example: opencli chatgpt detail <id> --wait true --stable 6',
);
await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 });
try {
await page.wait({ selector: CONVERSATION_MESSAGE_SELECTOR, timeout: 10 });
} catch {
// Empty conversation, missing access, or login redirect — handled by ensureChatGPTLogin / EmptyResultError below.
}
await ensureChatGPTLogin(page, 'ChatGPT detail requires a logged-in ChatGPT session.');
const messages = await getVisibleMessages(page);
const { messages, rows } = shouldWait
? await waitForChatGPTDetailRows(page, { wantMarkdown, timeoutSeconds: timeout, stableSeconds })
: await getChatGPTDetailRows(page, { wantMarkdown });
if (!messages.length) {
throw new EmptyResultError('chatgpt detail', `No visible ChatGPT messages were found for conversation ${id}.`);
}
return messages.map((message) => ({
Index: message.Index,
Role: message.Role,
Text: wantMarkdown && message.Role === 'Assistant' && message.Html
? (messageHtmlToMarkdown(message.Html) || message.Text)
: message.Text,
}));
return rows;
},
});
Loading