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: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ opera-browser-cli make "a todo app in vanilla JS" # generate and open a webpage
opera-browser-cli research "solid-state batteries" # in-depth research across multiple sources
```

Add `--open-fulltab-view` to any AI command to open the created tab in the foreground (default: background). Not available in headless mode.

Run `opera-browser-cli setup` to get started, or `opera-browser-cli doctor` to check your configuration.

## Install
Expand Down Expand Up @@ -231,6 +233,7 @@ opera-browser-cli eval "(() => { const rows = [...document.querySelectorAll('tr'
| `make <prompt>` | Ask the AI to build a webpage or app | Opera Neon |
| `research <prompt>` | Ask the AI to research a topic in depth | Opera Neon |

All four AI commands accept `--open-fulltab-view` to open the created tab in the foreground (default: background). Not available in headless mode.
`research` accepts `--type local` (default), `--type one-minute`, or `--type deep`.

### Configuration
Expand Down
1 change: 1 addition & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}}
- **`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. `make` accepts `--conversation-id <id>` to continue an existing conversation.
- **`--open-fulltab-view`** — applicable to `chat`, `invoke-do`, `make`, `research`. Opens the created tab in the foreground so the user sees the AI UI immediately (default: background). Not available in headless mode.
- **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).
Expand Down
85 changes: 74 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ 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 [--conversation-id <id>] <prompt>, research <prompt>, models,
chat [--model <id>] [--conversation-id <id>] [--open-fulltab-view] <prompt>,
invoke-do [--open-fulltab-view] <prompt>,
make [--conversation-id <id>] [--open-fulltab-view] <prompt>,
research [--open-fulltab-view] <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 @@ -152,6 +154,8 @@ opera ai:
mcp-remove, mcp-enable, and mcp-disable require Opera Neon with an active sign-in.
Run \`opera-browser-cli setup\` to configure the executable path, or set
OPERA_CLI_EXECUTABLE_PATH="/Applications/Opera Neon.app/Contents/MacOS/Opera".
chat, invoke-do, make, and research accept --open-fulltab-view to open the
created tab in the foreground (default: background). Not available in headless mode.

gpu:
Headless Chrome cannot access hardware GPU on most Linux systems.
Expand Down Expand Up @@ -664,6 +668,7 @@ args:
options:
--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)
--open-fulltab-view Open the chat tab in the foreground (default: background)

examples:
opera-browser-cli chat "Hello, who are you?"
Expand All @@ -677,6 +682,9 @@ Requires Opera Neon with an active sign-in. Run \`opera-browser-cli setup\` to c
args:
<prompt> Task to perform (required)

flags:
--open-fulltab-view Open the do tab in the foreground (default: background)

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"`,
Expand All @@ -690,6 +698,7 @@ args:

flags:
--conversation-id, -c <id> Continue an existing make conversation (omit to start a new one)
--open-fulltab-view Open the make tab in the foreground (default: background)

examples:
opera-browser-cli make "A landing page for a coffee shop with a menu and contact form"
Expand All @@ -705,6 +714,7 @@ args:

flags:
--type <mode> Research depth: local, one-minute, or deep (default: local)
--open-fulltab-view Open the research tab in the foreground (default: background)

examples:
opera-browser-cli research "the history of the Roman Empire"
Expand Down Expand Up @@ -2931,6 +2941,24 @@ function requireNeon(command: string): void {
}
}

/**
* Reject `--open-fulltab-view` in headless mode. Activating a foreground tab
* in a headless window is nonsensical; the extension cannot distinguish headed
* from headless, so the CLI owns this validation.
*/
function requireHeadedForFullTabView(command: string): void {
if (!shouldRunHeaded()) {
throw new CdpError(
"--open-fulltab-view is not available in headless mode.",
"VALIDATION_ERROR",
[
"Run with `OPERA_CLI_HEADED=1` to use a visible browser",
`Or omit the flag to run ${command} in the background`,
],
);
}
}

// --- Login ---

const OPERA_ACCOUNT_URL = "https://auth.opera.com/account/";
Expand Down Expand Up @@ -3163,51 +3191,64 @@ function formatConversationResult(result: string): string {
}

async function handleChat(args: string[]): Promise<string> {
const { prompt, model, conversationId } = parseChatOrMakeArgs(args);
const { prompt, model, conversationId, openFullTabView } = 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)",
]);
}
if (openFullTabView) requireHeadedForFullTabView("chat");
const toolArgs: Record<string, unknown> = { prompt };
if (model !== undefined) {
toolArgs["model"] = model;
}
if (conversationId !== undefined) {
toolArgs["conversationId"] = conversationId;
}
if (openFullTabView) {
toolArgs["openFullTabView"] = true;
}
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(" ");
const { prompt, openFullTabView } = parseChatOrMakeArgs(args);
if (!prompt) {
throw new CdpError("Missing prompt", "VALIDATION_ERROR", [
'Run `opera-browser-cli invoke-do "Click the login button"` to perform an action',
]);
}
if (openFullTabView) requireHeadedForFullTabView("invoke-do");
requireNeon("invoke-do");
const result = await callAiTool("invoke-do", "opera_do", { prompt });
const toolArgs: Record<string, unknown> = { prompt };
if (openFullTabView) {
toolArgs["openFullTabView"] = true;
}
const result = await callAiTool("invoke-do", "opera_do", toolArgs);
checkAiResultForCdpError("invoke-do", result);
return formatMcpResult("result", result, [], true);
}

async function handleMake(args: string[]): Promise<string> {
const { prompt, conversationId } = parseMakeArgs(args);
const { prompt, conversationId, openFullTabView } = parseMakeArgs(args);
if (!prompt) {
throw new CdpError("Missing prompt", "VALIDATION_ERROR", [
'Run `opera-browser-cli make "A summary of this page"` to create something',
]);
}
if (openFullTabView) requireHeadedForFullTabView("make");
requireNeon("make");
const toolArgs: Record<string, unknown> = { prompt };
if (conversationId !== undefined) {
toolArgs["conversationId"] = conversationId;
}
if (openFullTabView) {
toolArgs["openFullTabView"] = true;
}
const result = await callAiTool("make", "opera_make", toolArgs);
checkAiResultForCdpError("make", result);
return formatConversationResult(result);
Expand All @@ -3220,9 +3261,11 @@ export function parseChatOrMakeArgs(args: string[]): {
prompt: string;
model?: string;
conversationId?: string;
openFullTabView?: boolean;
} {
let model: string | undefined;
let conversationId: string | undefined;
let openFullTabView = false;
const promptParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
Expand All @@ -3241,16 +3284,24 @@ export function parseChatOrMakeArgs(args: string[]): {
if (i + 1 < args.length) {
conversationId = args[++i];
}
} else if (arg === "--open-fulltab-view") {
openFullTabView = true;
} else {
promptParts.push(arg);
}
}
return { prompt: promptParts.join(" "), model, conversationId };
return {
prompt: promptParts.join(" "),
model,
conversationId,
openFullTabView: openFullTabView || undefined,
};
}

export function parseMakeArgs(args: string[]): {
prompt: string;
conversationId?: string;
openFullTabView?: boolean;
} {
if (args.includes("--model")) {
throw new CdpError(
Expand All @@ -3259,28 +3310,36 @@ export function parseMakeArgs(args: string[]): {
["Run `opera-browser-cli models` to list the models available for chat"],
);
}
const { prompt, conversationId } = parseChatOrMakeArgs(args);
return { prompt, conversationId };
const { prompt, conversationId, openFullTabView } = parseChatOrMakeArgs(args);
return { prompt, conversationId, openFullTabView };
}

export function parseResearchArgs(args: string[]): {
prompt: string;
researchType?: ResearchType;
openFullTabView?: boolean;
} {
let researchType: ResearchType | undefined;
let openFullTabView = false;
const promptParts: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--type" && i + 1 < args.length) {
researchType = args[++i] as ResearchType;
} else if (args[i] === "--open-fulltab-view") {
openFullTabView = true;
} else {
promptParts.push(args[i]);
}
}
return { prompt: promptParts.join(" "), researchType };
return {
prompt: promptParts.join(" "),
researchType,
openFullTabView: openFullTabView || undefined,
};
}

async function handleResearch(args: string[]): Promise<string> {
const { prompt, researchType } = parseResearchArgs(args);
const { prompt, researchType, openFullTabView } = parseResearchArgs(args);
if (!prompt) {
throw new CdpError("Missing prompt", "VALIDATION_ERROR", [
'Run `opera-browser-cli research "quantum computing"` to research a topic',
Expand All @@ -3297,9 +3356,13 @@ async function handleResearch(args: string[]): Promise<string> {
["Valid types: local, one-minute, deep"],
);
}
if (openFullTabView) requireHeadedForFullTabView("research");
requireNeon("research");
const toolArgs: Record<string, unknown> = { prompt };
if (researchType !== undefined) toolArgs.researchType = researchType;
if (openFullTabView) {
toolArgs["openFullTabView"] = true;
}
const result = await callAiTool("research", "opera_research", toolArgs);
checkAiResultForCdpError("research", result);
return formatMcpResult("result", result, [], true);
Expand Down
51 changes: 51 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getCommandHelp,
parseChatOrMakeArgs,
parseMakeArgs,
parseResearchArgs,
parseScreenshotArgs,
parseSetupArgs,
} from "../src/cli.js";
Expand Down Expand Up @@ -202,6 +203,21 @@ describe("parseChatOrMakeArgs", () => {
const result = parseChatOrMakeArgs(["--model", "gpt-4o", "--conversation-id", "conv-1", "Hello"]);
expect(result).toEqual({ prompt: "Hello", model: "gpt-4o", conversationId: "conv-1" });
});

it("parses --open-fulltab-view flag", () => {
const result = parseChatOrMakeArgs(["Hello", "--open-fulltab-view"]);
expect(result).toEqual({ prompt: "Hello", model: undefined, conversationId: undefined, openFullTabView: true });
});

it("omits openFullTabView when flag absent", () => {
const result = parseChatOrMakeArgs(["Hello"]);
expect(result).toEqual({ prompt: "Hello", model: undefined, conversationId: undefined });
});

it("parses --open-fulltab-view with --model and --conversation-id", () => {
const result = parseChatOrMakeArgs(["--model", "claude-sonnet-4", "-c", "conv-1", "Hello", "--open-fulltab-view"]);
expect(result).toEqual({ prompt: "Hello", model: "claude-sonnet-4", conversationId: "conv-1", openFullTabView: true });
});
});

describe("handleChat JSON response parsing", () => {
Expand Down Expand Up @@ -265,4 +281,39 @@ describe("parseMakeArgs", () => {
"make does not accept --model",
);
});

it("parses --open-fulltab-view flag", () => {
expect(parseMakeArgs(["Build a todo app", "--open-fulltab-view"])).toEqual({
prompt: "Build a todo app",
conversationId: undefined,
openFullTabView: true,
});
});
});

describe("parseResearchArgs", () => {
it("parses prompt only", () => {
const result = parseResearchArgs(["quantum computing"]);
expect(result).toEqual({ prompt: "quantum computing", researchType: undefined });
});

it("parses --type flag", () => {
const result = parseResearchArgs(["quantum computing", "--type", "deep"]);
expect(result).toEqual({ prompt: "quantum computing", researchType: "deep" });
});

it("parses --open-fulltab-view flag", () => {
const result = parseResearchArgs(["quantum computing", "--open-fulltab-view"]);
expect(result).toEqual({ prompt: "quantum computing", researchType: undefined, openFullTabView: true });
});

it("omits openFullTabView when flag absent", () => {
const result = parseResearchArgs(["quantum computing"]);
expect(result).toEqual({ prompt: "quantum computing", researchType: undefined });
});

it("parses --type and --open-fulltab-view together", () => {
const result = parseResearchArgs(["--type", "one-minute", "quantum", "--open-fulltab-view"]);
expect(result).toEqual({ prompt: "quantum", researchType: "one-minute", openFullTabView: true });
});
});
Loading