Skip to content
Merged
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
3 changes: 2 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}}
- **Standard commands** (`open`, `click`, `fill`, `screenshot`, etc.) — work with any Opera browser session.
- **`chat`** — available on any Opera browser. Use `--model <id>` to select an AI model, `--conversation-id <id>` to continue a conversation.
- **`models`** — list available AI models for chat (shows IDs and which is the default).
- **`invoke-do`, `make`, `research`** — require **Opera Neon** with an active sign-in.
- **`invoke-do`, `make`, `research`** — require **Opera Neon** with an active sign-in. `make` accepts `--conversation-id <id>` to continue an existing conversation.
- **Research conversation IDs**: Each research session creates a fresh, unique conversation. The conversation ID is **not resumable** for research (no `--conversation-id` flag). However, the conversation ID from a research can be used with `chat` or `make` for follow-up questions/actions in the same research context.
- **`mcp-servers`** — list MCP servers registered in the browser (requires Opera Neon).
- **`mcp-tools --server <name>`** — list tools exposed by a specific MCP server (requires Opera Neon).
- **`mcp-call --server <name> --tool <name> [--params '{...}']`** — execute a tool on an MCP server (requires Opera Neon).
Expand Down
106 changes: 73 additions & 33 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ commands[55]:
network-get [id], lighthouse, perf-start, perf-stop,
perf-insight <set> <name>, heap <path>, start, stop, restart, status,
attach, launch-args, login,
chat [--model <id>] [--conversation-id <id>] <prompt>, invoke-do <prompt>, make <prompt>,
research <prompt>, models,
chat [--model <id>] [--conversation-id <id>] <prompt>, invoke-do <prompt>,
make [--conversation-id <id>] <prompt>, research <prompt>, models,
mcp-servers, mcp-tools --server <name>, mcp-call --server <name> --tool <name>,
mcp-add <name> <url>, mcp-auth <name>, mcp-remove <name>,
mcp-enable <name>, mcp-disable <name>,
Expand Down Expand Up @@ -681,16 +681,20 @@ examples:
opera-browser-cli invoke-do "Find the cheapest flight from London to Tokyo next month"
opera-browser-cli invoke-do "Log in to my account and check my order history"`,

make: `usage: opera-browser-cli make <prompt>
make: `usage: opera-browser-cli make [--conversation-id <id>] <prompt>
Ask the Opera AI to build something, e.g. a webpage or web app.
Requires Opera Neon with an active sign-in. Run \`opera-browser-cli setup\` to configure.

args:
<prompt> What to build (required)

flags:
--conversation-id, -c <id> Continue an existing make conversation (omit to start a new one)

examples:
opera-browser-cli make "A landing page for a coffee shop with a menu and contact form"
opera-browser-cli make "A todo app with local storage and drag-and-drop reordering"`,
opera-browser-cli make "A todo app with local storage and drag-and-drop reordering"
opera-browser-cli make --conversation-id conversation-123 "Change the hero section to full-width"`,

research: `usage: opera-browser-cli research <prompt> [--type <mode>]
Ask the Opera AI to research a topic in depth.
Expand Down Expand Up @@ -3113,30 +3117,24 @@ async function callAiTool(
}
}

async function handleChat(args: string[]): Promise<string> {
const { prompt, model, conversationId } = parseChatArgs(args);
if (!prompt) {
throw new CdpError("Missing prompt", "VALIDATION_ERROR", [
'Run `opera-browser-cli chat "What is on this page?"` to chat with Opera AI',
"Use --model <id> to select a model (run `opera-browser-cli models` to list)",
]);
}
const toolArgs: Record<string, unknown> = { prompt };
if (model !== undefined) {
toolArgs["model"] = model;
}
if (conversationId !== undefined) {
toolArgs["conversationId"] = conversationId;
}
const result = await callAiTool("chat", "opera_chat", toolArgs);
// CDP errors are raw strings checked first; only success responses are JSON.
checkAiResultForCdpError("chat", result);
/**
* Render an Opera AI response that carries a conversation id.
*
* A current extension returns JSON `{conversationId, text}`; an older extension
* returns plain text, and `make` replies without a conversation are possible.
* CDP errors are raw strings and must be checked with
* {@link checkAiResultForCdpError} before this is called.
*
* Anything that is not the `{conversationId, text}` envelope is rendered raw
* (with a null conversation id) rather than failing, so an unknown response
* shape still prints.
*/
function formatConversationResult(result: string): string {
let parsed: { conversationId: string; text: string };
try {
parsed = JSON.parse(result);
} catch {
// Not JSON — the extension may not support the structured response format yet.
// Return the raw text as the chat response, without a conversation ID.
// Not JSON — render the raw text without a conversation ID.
return (
encode({ "conversation-id": null }) +
"\n" +
Expand All @@ -3149,10 +3147,12 @@ async function handleChat(args: string[]): Promise<string> {
typeof (parsed as Record<string, unknown>).conversationId !== "string" ||
typeof (parsed as Record<string, unknown>).text !== "string"
) {
throw new CdpError(
"Unexpected response format from Opera AI — the browser extension may be out of date",
"BROWSER_ERROR",
["Run `opera-browser-cli setup` to ensure the latest extension is installed"],
// JSON, but not the `{conversationId, text}` envelope. Backward-compatible:
// print the raw response instead of throwing on an unknown shape.
return (
encode({ "conversation-id": null }) +
"\n" +
formatMcpResult("result", result, [], true)
);
}
return (
Expand All @@ -3162,6 +3162,27 @@ async function handleChat(args: string[]): Promise<string> {
);
}

async function handleChat(args: string[]): Promise<string> {
const { prompt, model, conversationId } = parseChatOrMakeArgs(args);
if (!prompt) {
throw new CdpError("Missing prompt", "VALIDATION_ERROR", [
'Run `opera-browser-cli chat "What is on this page?"` to chat with Opera AI',
"Use --model <id> to select a model (run `opera-browser-cli models` to list)",
]);
}
const toolArgs: Record<string, unknown> = { prompt };
if (model !== undefined) {
toolArgs["model"] = model;
}
if (conversationId !== undefined) {
toolArgs["conversationId"] = conversationId;
}
const result = await callAiTool("chat", "opera_chat", toolArgs);
// CDP errors are raw strings checked first; only success responses are JSON.
checkAiResultForCdpError("chat", result);
return formatConversationResult(result);
}

async function handleInvokeDo(args: string[]): Promise<string> {
const prompt = args.join(" ");
if (!prompt) {
Expand All @@ -3176,22 +3197,26 @@ async function handleInvokeDo(args: string[]): Promise<string> {
}

async function handleMake(args: string[]): Promise<string> {
const prompt = args.join(" ");
const { prompt, conversationId } = parseMakeArgs(args);
if (!prompt) {
throw new CdpError("Missing prompt", "VALIDATION_ERROR", [
'Run `opera-browser-cli make "A summary of this page"` to create something',
]);
}
requireNeon("make");
const result = await callAiTool("make", "opera_make", { prompt });
const toolArgs: Record<string, unknown> = { prompt };
if (conversationId !== undefined) {
Comment thread
mateuszk-opera marked this conversation as resolved.
toolArgs["conversationId"] = conversationId;
}
const result = await callAiTool("make", "opera_make", toolArgs);
checkAiResultForCdpError("make", result);
return formatMcpResult("result", result, [], true);
return formatConversationResult(result);
}

const VALID_RESEARCH_TYPES = ["local", "one-minute", "deep"] as const;
type ResearchType = (typeof VALID_RESEARCH_TYPES)[number];

export function parseChatArgs(args: string[]): {
export function parseChatOrMakeArgs(args: string[]): {
prompt: string;
model?: string;
conversationId?: string;
Expand All @@ -3205,7 +3230,7 @@ export function parseChatArgs(args: string[]): {
throw new CdpError(
`Invalid syntax: ${arg}. Use --conversation-id <id> (space-separated, not =).`,
"VALIDATION_ERROR",
['Run `opera-browser-cli chat --conversation-id <id> "prompt"`'],
['Use --conversation-id <id> (space-separated, not =)'],
);
}
if (arg === "--model") {
Expand All @@ -3223,6 +3248,21 @@ export function parseChatArgs(args: string[]): {
return { prompt: promptParts.join(" "), model, conversationId };
}

export function parseMakeArgs(args: string[]): {
prompt: string;
conversationId?: string;
} {
if (args.includes("--model")) {
throw new CdpError(
"make does not accept --model; select a model with `chat --model <id>` instead",
"VALIDATION_ERROR",
["Run `opera-browser-cli models` to list the models available for chat"],
);
}
const { prompt, conversationId } = parseChatOrMakeArgs(args);
return { prompt, conversationId };
}
Comment thread
mateuszk-opera marked this conversation as resolved.

export function parseResearchArgs(args: string[]): {
prompt: string;
researchType?: ResearchType;
Expand Down
43 changes: 33 additions & 10 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
formatStopOutput,
formatScreenshotOutput,
getCommandHelp,
parseChatArgs,
parseChatOrMakeArgs,
parseMakeArgs,
parseScreenshotArgs,
parseSetupArgs,
} from "../src/cli.js";
Expand Down Expand Up @@ -161,44 +162,44 @@ describe("formatScreenshotOutput", () => {
});
});

describe("parseChatArgs", () => {
describe("parseChatOrMakeArgs", () => {
it("parses prompt only", () => {
const result = parseChatArgs(["Hello", "world"]);
const result = parseChatOrMakeArgs(["Hello", "world"]);
expect(result).toEqual({ prompt: "Hello world", model: undefined, conversationId: undefined });
});

it("parses --model flag with prompt", () => {
const result = parseChatArgs(["--model", "gpt-4o", "What", "is", "this?"]);
const result = parseChatOrMakeArgs(["--model", "gpt-4o", "What", "is", "this?"]);
expect(result).toEqual({ prompt: "What is this?", model: "gpt-4o", conversationId: undefined });
});

it("parses --model at end of args", () => {
const result = parseChatArgs(["Hello", "--model", "claude-sonnet-4"]);
const result = parseChatOrMakeArgs(["Hello", "--model", "claude-sonnet-4"]);
expect(result).toEqual({ prompt: "Hello", model: "claude-sonnet-4", conversationId: undefined });
});

it("returns empty prompt when only --model is given", () => {
const result = parseChatArgs(["--model", "gpt-4o"]);
const result = parseChatOrMakeArgs(["--model", "gpt-4o"]);
expect(result).toEqual({ prompt: "", model: "gpt-4o", conversationId: undefined });
});

it("ignores --model without a value", () => {
const result = parseChatArgs(["Hello", "--model"]);
const result = parseChatOrMakeArgs(["Hello", "--model"]);
expect(result).toEqual({ prompt: "Hello", model: undefined, conversationId: undefined });
});

it("parses --conversation-id flag", () => {
const result = parseChatArgs(["--conversation-id", "conversation-123", "Hello"]);
const result = parseChatOrMakeArgs(["--conversation-id", "conversation-123", "Hello"]);
expect(result).toEqual({ prompt: "Hello", model: undefined, conversationId: "conversation-123" });
});

it("parses -c shorthand for conversation-id", () => {
const result = parseChatArgs(["-c", "conversation-456", "Hi"]);
const result = parseChatOrMakeArgs(["-c", "conversation-456", "Hi"]);
expect(result).toEqual({ prompt: "Hi", model: undefined, conversationId: "conversation-456" });
});

it("parses both --model and --conversation-id", () => {
const result = parseChatArgs(["--model", "gpt-4o", "--conversation-id", "conv-1", "Hello"]);
const result = parseChatOrMakeArgs(["--model", "gpt-4o", "--conversation-id", "conv-1", "Hello"]);
expect(result).toEqual({ prompt: "Hello", model: "gpt-4o", conversationId: "conv-1" });
});
});
Expand Down Expand Up @@ -243,3 +244,25 @@ describe("handleChat JSON response parsing", () => {
expect(isValidShape(JSON.parse('{"conversationId":42,"text":true}'))).toBe(false);
});
});

describe("parseMakeArgs", () => {
it("parses a bare prompt", () => {
expect(parseMakeArgs(["Build a todo app"])).toEqual({
prompt: "Build a todo app",
conversationId: undefined,
});
});

it("parses --conversation-id", () => {
expect(parseMakeArgs(["--conversation-id", "conv-1", "Change the hero"])).toEqual({
prompt: "Change the hero",
conversationId: "conv-1",
});
});

it("rejects --model instead of silently dropping it", () => {
expect(() => parseMakeArgs(["--model", "gpt-4o", "Build a todo app"])).toThrow(
"make does not accept --model",
);
});
});
Loading