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
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}}
`opera-browser-cli` controls an Opera browser session.

- **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.
- **`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.
- **`mcp-servers`** — list MCP servers registered in the browser (requires Opera Neon).
Expand Down
81 changes: 71 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,15 @@ const VERSION = getPackageVersion();
const RAW_STDOUT_MARKER = "__OPERA_BROWSER_CLI_RAW__";


// NOTE: These error keys are part of the CDP contract between the CLI and the
// browser extension. Any change here MUST be mirrored in opera-chat:
// src/sagas/chat/handleCdpActionRequested.ts → CdpResultErrorKey
const CdpResultErrorKey = {
NOT_SIGNED_IN: "[OPERA_CDP_ERR:NOT_SIGNED_IN]",
SUBSCRIPTION_REQUIRED: "[OPERA_CDP_ERR:SUBSCRIPTION_REQUIRED]",
CONSENT_REQUIRED: "[OPERA_CDP_ERR:CONSENT_REQUIRED]",
NEON_ONLY: "[OPERA_CDP_ERR:NEON_ONLY]",
CONVERSATION_NOT_FOUND: "[OPERA_CDP_ERR:CONVERSATION_NOT_FOUND]",
} as const;

type CliStdout = Pick<NodeJS.WriteStream, "write">;
Expand All @@ -108,7 +112,7 @@ 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>] <prompt>, invoke-do <prompt>, make <prompt>,
chat [--model <id>] [--conversation-id <id>] <prompt>, invoke-do <prompt>, make <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>,
Expand Down Expand Up @@ -651,19 +655,20 @@ args:
examples:
opera-browser-cli heap ./snapshot.heapsnapshot`,

// Opera AI
chat: `usage: opera-browser-cli chat [--model <model-id>] <prompt>
chat: `usage: opera-browser-cli chat [--model <model-id>] [--conversation-id <id>] <prompt>
Send a chat message to the Opera AI.

args:
<prompt> Message to send (required)

options:
--model <model-id> AI model to use (run "opera-browser-cli models" to list)
--model <model-id> AI model to use (run "opera-browser-cli models" to list)
--conversation-id, -c <id> Continue an existing conversation (omit to start a new one)

examples:
opera-browser-cli chat "Hello, who are you?"
opera-browser-cli chat --model claude-sonnet-4 "Summarize this page"`,
opera-browser-cli chat --model claude-sonnet-4 "Summarize this page"
opera-browser-cli chat --conversation-id conversation-1725000000000 "What else can you tell me?"`,

"invoke-do": `usage: opera-browser-cli invoke-do <prompt>
Ask the Opera AI to perform a complex browsing task.
Expand Down Expand Up @@ -1133,6 +1138,7 @@ export const EXIT_CODES: Record<ErrorCode, number> = {
PAGE_CLOSED: 6,
EXTENSION_NOT_FOUND: 3,
NOT_FOUND: 2,
CONVERSATION_NOT_FOUND: 2,
SERVER_DISCONNECTED: 3,
UNKNOWN: 1,
};
Expand Down Expand Up @@ -3053,6 +3059,15 @@ const CDP_RESULT_ERRORS: readonly CdpResultErrorDescriptor[] = [
code: "UNSUPPORTED_OPERATION",
suggestions: () => NEON_ONLY_HELP,
},
{
match: (r) => r.includes(CdpResultErrorKey.CONVERSATION_NOT_FOUND),
message: "Opera: the specified conversation was not found or has expired",
code: "CONVERSATION_NOT_FOUND",
suggestions: (cmd) => [
`Run \`opera-browser-cli ${cmd}\` without --conversation-id to start a new conversation`,
"Use `opera-browser-cli models` to see available models",
],
},
];

function checkAiResultForCdpError(command: string, result: string): void {
Expand Down Expand Up @@ -3099,7 +3114,7 @@ async function callAiTool(
}

async function handleChat(args: string[]): Promise<string> {
const { prompt, model } = parseChatArgs(args);
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',
Expand All @@ -3110,9 +3125,41 @@ async function handleChat(args: string[]): Promise<string> {
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 formatMcpResult("result", result, [], true);
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.
return (
encode({ "conversation-id": null }) +
"\n" +
formatMcpResult("result", result, [], true)
);
}
if (
typeof parsed !== "object" ||
parsed === null ||
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"],
);
}
return (
encode({ "conversation-id": parsed.conversationId as string }) +
"\n" +
formatMcpResult("result", parsed.text as string, [], true)
);
}

async function handleInvokeDo(args: string[]): Promise<string> {
Expand Down Expand Up @@ -3147,19 +3194,33 @@ type ResearchType = (typeof VALID_RESEARCH_TYPES)[number];
export function parseChatArgs(args: string[]): {
prompt: string;
model?: string;
conversationId?: string;
} {
let model: string | undefined;
let conversationId: string | undefined;
const promptParts: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--model") {
const arg = args[i];
if (/^--conversation-id=/.test(arg) || /^-c=/.test(arg)) {
throw new CdpError(
`Invalid syntax: ${arg}. Use --conversation-id <id> (space-separated, not =).`,
"VALIDATION_ERROR",
['Run `opera-browser-cli chat --conversation-id <id> "prompt"`'],
);
}
if (arg === "--model") {
if (i + 1 < args.length) {
model = args[++i];
}
} else if (arg === "--conversation-id" || arg === "-c") {
if (i + 1 < args.length) {
conversationId = args[++i];
}
} else {
promptParts.push(args[i]);
promptParts.push(arg);
}
}
return { prompt: promptParts.join(" "), model };
return { prompt: promptParts.join(" "), model, conversationId };
}

export function parseResearchArgs(args: string[]): {
Expand Down
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export type ErrorCode =
| "UNSUPPORTED_OPERATION"
| "EXTENSION_NOT_FOUND"
| "NOT_FOUND"
| "CONVERSATION_NOT_FOUND"
| "SERVER_DISCONNECTED"
| "UNKNOWN";

Expand Down
66 changes: 61 additions & 5 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,26 +164,82 @@ describe("formatScreenshotOutput", () => {
describe("parseChatArgs", () => {
it("parses prompt only", () => {
const result = parseChatArgs(["Hello", "world"]);
expect(result).toEqual({ prompt: "Hello world", model: undefined });
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?"]);
expect(result).toEqual({ prompt: "What is this?", model: "gpt-4o" });
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"]);
expect(result).toEqual({ prompt: "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"]);
expect(result).toEqual({ prompt: "", model: "gpt-4o" });
expect(result).toEqual({ prompt: "", model: "gpt-4o", conversationId: undefined });
});

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

it("parses --conversation-id flag", () => {
const result = parseChatArgs(["--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"]);
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"]);
expect(result).toEqual({ prompt: "Hello", model: "gpt-4o", conversationId: "conv-1" });
});
});

describe("handleChat JSON response parsing", () => {
const isValidShape = (parsed: unknown): parsed is { conversationId: string; text: string } =>
typeof parsed === "object" &&
parsed !== null &&
typeof (parsed as Record<string, unknown>).conversationId === "string" &&
typeof (parsed as Record<string, unknown>).text === "string";

it("accepts valid JSON response", () => {
const parsed = JSON.parse('{"conversationId":"conv-1","text":"Hello"}');
expect(isValidShape(parsed)).toBe(true);
if (isValidShape(parsed)) {
expect(parsed.conversationId).toBe("conv-1");
expect(parsed.text).toBe("Hello");
}
});

it("rejects a number", () => {
expect(isValidShape(JSON.parse("42"))).toBe(false);
});

it("rejects null", () => {
expect(isValidShape(JSON.parse("null"))).toBe(false);
});

it("rejects a plain string", () => {
expect(isValidShape(JSON.parse('"hello"'))).toBe(false);
});

it("rejects an empty object", () => {
expect(isValidShape(JSON.parse("{}"))).toBe(false);
});

it("rejects an object with missing text field", () => {
expect(isValidShape(JSON.parse('{"conversationId":"x"}'))).toBe(false);
});

it("rejects an object with wrong types", () => {
expect(isValidShape(JSON.parse('{"conversationId":42,"text":true}'))).toBe(false);
});
});
1 change: 1 addition & 0 deletions test/exit-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("exit codes", () => {
PAGE_CLOSED: 6,
EXTENSION_NOT_FOUND: 3,
NOT_FOUND: 2,
CONVERSATION_NOT_FOUND: 2,
SERVER_DISCONNECTED: 3,
UNKNOWN: 1,
};
Expand Down
Loading