diff --git a/CLAUDE.md b/CLAUDE.md index a6885f0..b5b5c10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ Always check there before starting implementation work. | Spec | Status | |---|---| | [`specs/fix-parallel-streaming-routing.md`](specs/fix-parallel-streaming-routing.md) | Planned — parallel chunk routing for concurrent Opera AI calls | +| [`specs/chat-model-selector.md`](specs/chat-model-selector.md) | Planned — model selector for chat command | ## Common issues diff --git a/SKILL.md b/SKILL.md index 4240ffa..1bf6c52 100644 --- a/SKILL.md +++ b/SKILL.md @@ -8,7 +8,8 @@ description: Browser automation and web interaction using the 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. +- **`chat`** — available on any Opera browser. Use `--model ` to select an AI model. +- **`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. Run `opera-browser-cli --help` for the full command list, or `opera-browser-cli --help` for per-command flags and examples. diff --git a/specs/chat-model-selector.md b/specs/chat-model-selector.md new file mode 100644 index 0000000..ba8d45a --- /dev/null +++ b/specs/chat-model-selector.md @@ -0,0 +1,223 @@ +# Chat Model Selector + +**Status:** Planned +**Date:** 2026-05-25 +**Scope:** `opera-browser-cli` CLI + `opera-devtools-mcp` MCP layer + CDP contract for browser team + +## Overview + +Add a model selector to the Opera CLI Chat feature, allowing users to choose which AI model is used for chat sessions. The browser does not yet support model selection via CDP — this spec defines the full client-side implementation and the CDP contract the browser team must fulfill. + +## CLI UX + +### `--model` flag on `chat` + +``` +opera-browser-cli chat --model "What is on this page?" +opera-browser-cli chat "Hello" # no flag → browser default +``` + +When `--model` is omitted, no model field is sent in the CDP payload — the browser uses its own default. + +### `models` command + +New top-level command to discover available models: + +``` +opera-browser-cli models +``` + +Output: + +``` +Available models: + * gpt-4o (default) + claude-sonnet-4 + gemini-2.5-pro +``` + +The `*` marks the browser's reported default model. + +### Error handling + +| Scenario | Behavior | +|----------|----------| +| `--model` with invalid ID | Surface browser error: `Model "foo" is not available. Run "opera-browser-cli models" to see available models.` | +| `models` command when browser doesn't support listing | `Model listing not supported by connected browser. Upgrade Opera or check connection.` | +| `--model` when browser doesn't support model param | Forward the field; if browser errors, surface it with upgrade hint | + +## CDP Contract (Requirements for Browser Team) + +Everything goes through `Opera.dispatchAction` — no new CDP methods required. + +### List models action + +```json +{ "action": "listModels" } +``` + +**Response** (returned as `result` string, JSON-encoded): + +```json +{ + "models": [ + { "id": "gpt-4o", "name": "GPT-4o", "isDefault": true }, + { "id": "claude-sonnet-4", "name": "Claude Sonnet 4", "isDefault": false }, + { "id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro", "isDefault": false } + ] +} +``` + +**Model object schema:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Stable identifier used in `--model` flag and chat payload | +| `name` | `string` | Human-readable display name for CLI output | +| `isDefault` | `boolean` | Exactly one model has `isDefault: true` — the browser's current default | + +### Chat action with model selection + +Current (unchanged when no model specified): + +```json +{ "action": "chat", "prompt": "Hello" } +``` + +With model selection: + +```json +{ "action": "chat", "prompt": "Hello", "model": "claude-sonnet-4" } +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | `string` | Yes | `"chat"` | +| `prompt` | `string` | Yes | User's message | +| `model` | `string` | No | Model ID from `listModels`. Omit to use browser default. | + +### Error response for invalid model + +When `model` is provided but not recognized: + +```json +{ + "error": "MODEL_NOT_AVAILABLE", + "message": "Model \"foo\" is not available", + "availableModels": ["gpt-4o", "claude-sonnet-4", "gemini-2.5-pro"] +} +``` + +## MCP Layer Changes (opera-devtools-mcp) + +### Updated `opera_chat` tool + +Add optional `model` parameter to the schema: + +```typescript +schema: { + prompt: zod.string().describe('The prompt to send to Opera AI.'), + model: zod.string().optional().describe('Model ID to use. Omit for browser default.'), +} +``` + +Handler passes `model` through to CDP: + +```typescript +handler: async (request, response) => { + const session = getCDPSession(request.page.pptrPage); + const result = await dispatchAction(session, { + action: 'chat', + prompt: request.params.prompt, + ...(request.params.model && { model: request.params.model }), + }); + response.appendResponseLine(result); +} +``` + +### New `opera_list_models` tool + +```typescript +definePageTool({ + name: 'opera_list_models', + description: 'List available AI models for Opera chat.', + blockedByDialog: false, + annotations: { + category: ToolCategory.OPERA, + readOnlyHint: true, + }, + schema: {}, + handler: async (request, response) => { + const session = getCDPSession(request.page.pptrPage); + const result = await dispatchAction(session, { action: 'listModels' }); + response.appendResponseLine(result); + }, +}); +``` + +## CLI Layer Changes (opera-browser-cli) + +### Updated `handleChat` + +1. Parse `--model ` from args before joining remaining tokens as prompt +2. Pass `{ prompt, model }` (model omitted if not specified) to `callTool("opera_chat", ...)` +3. On `MODEL_NOT_AVAILABLE` error, format a helpful message with hint to run `models` + +### New `handleModels` command + +1. Call `callTool("opera_list_models", {})` +2. Parse JSON response +3. Format as bulleted list: `* (default)` for default model, ` ` for others +4. Register as top-level command in the command dispatch map + +### Command registration + +```typescript +models: handleModels, +``` + +### CLI help text + +The `chat` command help is updated to include the `--model` flag: + +``` +usage: opera-browser-cli chat [--model ] + +Send a chat message to the Opera AI. + +args: + Message to send (required) + +options: + --model AI model to use (run "opera-browser-cli models" to list) + +examples: + opera-browser-cli chat "Hello, who are you?" + opera-browser-cli chat --model claude-sonnet-4 "Summarize this page" +``` + +The `models` command help: + +``` +usage: opera-browser-cli models + +List available AI models for chat. + +examples: + opera-browser-cli models +``` + +Both commands are listed in the top-level help alongside existing AI commands. + +## Scope boundaries + +- `--model` applies to `chat` only (not `invoke-do`, `make`, `research`) — extensible later +- No client-side model persistence — each invocation is stateless +- No interactive model picker — discovery via `models` command, selection via `--model` flag +- Graceful degradation when connected browser doesn't support the new CDP methods + +## Future extensions + +- Extend `--model` to other AI commands (`invoke-do`, `make`, `research`) +- Add `--model` to the `research` command's `researchType`-style options +- Persist a default model preference client-side (e.g. `opera-browser-cli config set model `) diff --git a/src/cli.ts b/src/cli.ts index 63db721..18691bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { encode } from "@toon-format/toon"; import { runAxiCli } from "axi-sdk-js"; import { CdpError, + type ErrorCode, callTool, ensureBridge, getBridgeStatus, @@ -37,6 +38,14 @@ const HOME_DESCRIPTION = const VERSION = readPackageVersion(); const RAW_STDOUT_MARKER = "__OPERA_BROWSER_CLI_RAW__"; + +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]", +} as const; + type CliStdout = Pick; export type MainOptions = { @@ -54,7 +63,8 @@ commands[41]: resize , emulate, console, console-get , network, network-get [id], lighthouse, perf-start, perf-stop, perf-insight , heap , start, stop, - chat , invoke-do , make , research , + chat [--model ] , invoke-do , make , + research , models, setup, logs, doctor flags[2]: @@ -77,7 +87,8 @@ environment: Run \`opera-browser-cli setup\` to configure interactively. opera ai: - chat is available on any Opera browser. + chat is available on any Opera browser. Use --model to select a model. + Run "models" to list available models. invoke-do, make, and research 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". @@ -543,15 +554,18 @@ examples: opera-browser-cli heap ./snapshot.heapsnapshot`, // Opera AI - chat: `usage: opera-browser-cli chat + chat: `usage: opera-browser-cli chat [--model ] Send a chat message to the Opera AI. args: Message to send (required) +options: + --model AI model to use (run "opera-browser-cli models" to list) + examples: opera-browser-cli chat "Hello, who are you?" - opera-browser-cli chat "What can you help me with?"`, + opera-browser-cli chat --model claude-sonnet-4 "Summarize this page"`, "invoke-do": `usage: opera-browser-cli invoke-do Ask the Opera AI to perform a complex browsing task. @@ -590,6 +604,12 @@ examples: opera-browser-cli research "advances in CRISPR gene editing" --type deep opera-browser-cli research "best practices for React performance" --type one-minute`, + models: `usage: opera-browser-cli models +List available AI models for chat. + +examples: + opera-browser-cli models`, + setup: `usage: opera-browser-cli setup Interactive configuration wizard. Detects Opera Neon and writes settings to ~/.opera-browser-cli/config, which opera-browser-cli auto-loads on every run. @@ -2172,26 +2192,67 @@ function requireNeon(command: string): void { ); } +interface CdpResultErrorDescriptor { + match: (result: string) => boolean; + message: string | ((command: string) => string); + code: ErrorCode; + suggestions: (command: string) => string[]; +} + /** - * Opera Neon returns the "not signed in" message as text content on a - * successful tool call (no MCP isError flag), so callTool resolves rather - * than throws. Detect it here and convert to a CdpError so the UX matches - * the thrown-error path. + * Error conditions that Opera returns as plain text content on a successful + * tool call (no MCP isError flag). Each descriptor is checked in order; + * the first match is converted to a CdpError. */ -function checkAiResultForSignInError(command: string, result: string): void { - if ( - result.includes("User is not signed in") || - (result.includes("Opera.dispatchAction") && - result.includes("not signed in")) - ) { - throw new CdpError( - "Opera: user is not signed in", - "BROWSER_ERROR", - [ - `Re-run \`opera-browser-cli ${command}\` after signing in`, - "Run `opera-browser-cli doctor` to inspect the current configuration", - ], - ); +const CDP_RESULT_ERRORS: readonly CdpResultErrorDescriptor[] = [ + { + match: (r) => r.includes(CdpResultErrorKey.NOT_SIGNED_IN), + message: "Opera: user is not signed in", + code: "BROWSER_ERROR", + suggestions: (cmd) => [ + `Re-run \`opera-browser-cli ${cmd}\` after signing in`, + "Run `opera-browser-cli doctor` to inspect the current configuration", + ], + }, + { + match: (r) => r.includes(CdpResultErrorKey.SUBSCRIPTION_REQUIRED), + message: "Opera: an active subscription is required", + code: "BROWSER_ERROR", + suggestions: (cmd) => [ + "Check your Opera subscription at https://auth.opera.com/account/", + `Re-run \`opera-browser-cli ${cmd}\` after activating a subscription`, + ], + }, + { + match: (r) => r.includes(CdpResultErrorKey.CONSENT_REQUIRED), + message: "Opera: user consent has not been accepted", + code: "BROWSER_ERROR", + suggestions: (cmd) => [ + "Open Opera and accept the consent prompt before using AI features", + `Re-run \`opera-browser-cli ${cmd}\` after accepting consent`, + ], + }, + { + match: (r) => r.includes(CdpResultErrorKey.NEON_ONLY), + message: (cmd) => `Opera: ${cmd} is only available on Opera Neon`, + code: "BROWSER_ERROR", + suggestions: () => [ + "Install Opera Neon from https://www.operaneon.com", + "Run `opera-browser-cli setup` to configure the Opera Neon executable path", + "Run `opera-browser-cli doctor` to inspect the current configuration", + ], + }, +]; + +function checkAiResultForCdpError(command: string, result: string): void { + for (const descriptor of CDP_RESULT_ERRORS) { + if (descriptor.match(result)) { + const message = + typeof descriptor.message === "function" + ? descriptor.message(command) + : descriptor.message; + throw new CdpError(message, descriptor.code, descriptor.suggestions(command)); + } } } @@ -2227,14 +2288,19 @@ async function callAiTool( } async function handleChat(args: string[]): Promise { - const prompt = args.join(" "); + const { prompt, model } = 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 to select a model (run `opera-browser-cli models` to list)", ]); } - const result = await callAiTool("chat", "opera_chat", { prompt }); - checkAiResultForSignInError("chat", result); + const toolArgs: Record = { prompt }; + if (model !== undefined) { + toolArgs["model"] = model; + } + const result = await callAiTool("chat", "opera_chat", toolArgs); + checkAiResultForCdpError("chat", result); return formatMcpResult("result", result, []); } @@ -2247,7 +2313,7 @@ async function handleInvokeDo(args: string[]): Promise { } requireNeon("invoke-do"); const result = await callAiTool("invoke-do", "opera_do", { prompt }); - checkAiResultForSignInError("invoke-do", result); + checkAiResultForCdpError("invoke-do", result); return formatMcpResult("result", result, []); } @@ -2260,13 +2326,31 @@ async function handleMake(args: string[]): Promise { } requireNeon("make"); const result = await callAiTool("make", "opera_make", { prompt }); - checkAiResultForSignInError("make", result); + checkAiResultForCdpError("make", result); return formatMcpResult("result", result, []); } const VALID_RESEARCH_TYPES = ["local", "one-minute", "deep"] as const; type ResearchType = (typeof VALID_RESEARCH_TYPES)[number]; +export function parseChatArgs(args: string[]): { + prompt: string; + model?: string; +} { + let model: string | undefined; + const promptParts: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--model") { + if (i + 1 < args.length) { + model = args[++i]; + } + } else { + promptParts.push(args[i]); + } + } + return { prompt: promptParts.join(" "), model }; +} + export function parseResearchArgs(args: string[]): { prompt: string; researchType?: ResearchType; @@ -2305,10 +2389,36 @@ async function handleResearch(args: string[]): Promise { const toolArgs: Record = { prompt }; if (researchType !== undefined) toolArgs.researchType = researchType; const result = await callAiTool("research", "opera_research", toolArgs); - checkAiResultForSignInError("research", result); + checkAiResultForCdpError("research", result); return formatMcpResult("result", result, []); } +async function handleModels(): Promise { + requireNeon("models"); + const raw = await callTool("opera_list_models", {}); + checkAiResultForCdpError("models", raw); + + + let data: { models: Array<{ id: string; name: string; isDefault: boolean }> }; + try { + data = JSON.parse(raw); + } catch { + throw new CdpError( + raw || "Model listing returned an invalid response", + /Tool.*not found/i.test(raw) ? "UNSUPPORTED_OPERATION" : "UNKNOWN", + ['Run `opera-browser-cli doctor` to check the connection'], + ); + } + + const lines = ["Available models:"]; + for (const m of data.models) { + const marker = m.isDefault ? "* " : " "; + const suffix = m.isDefault ? " (default)" : ""; + lines.push(` ${marker}${m.id}${suffix}`); + } + return lines.join("\n"); +} + async function handleRun(): Promise { if (process.stdin.isTTY) { throw new CdpError("No script provided on stdin", "VALIDATION_ERROR", [ @@ -2455,6 +2565,7 @@ const COMMANDS: Record = { "invoke-do": withoutFullFlag(handleInvokeDo), make: withoutFullFlag(handleMake), research: withoutFullFlag(handleResearch), + models: withoutFullFlag(handleModels), setup: withoutFullFlag(handleSetup), logs: withoutFullFlag(handleLogs), doctor: withoutFullFlag(handleDoctor), diff --git a/src/client.ts b/src/client.ts index 99085f8..f7610a3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -69,6 +69,7 @@ export type ErrorCode = | "PAGE_CLOSED" | "BROWSER_ERROR" | "VALIDATION_ERROR" + | "UNSUPPORTED_OPERATION" | "UNKNOWN"; export class CdpError extends AxiError { diff --git a/test/cli.test.ts b/test/cli.test.ts index 25f5468..a1705c1 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { formatStopOutput, formatScreenshotOutput, getCommandHelp, parseScreenshotArgs } from "../src/cli.js"; +import { formatStopOutput, formatScreenshotOutput, getCommandHelp, parseChatArgs, parseScreenshotArgs } from "../src/cli.js"; describe("formatStopOutput", () => { it("returns stopped status when bridge was running", () => { @@ -81,3 +81,30 @@ describe("formatScreenshotOutput", () => { expect(output).toContain("./shot.png"); }); }); + +describe("parseChatArgs", () => { + it("parses prompt only", () => { + const result = parseChatArgs(["Hello", "world"]); + expect(result).toEqual({ prompt: "Hello world", model: 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" }); + }); + + it("parses --model at end of args", () => { + const result = parseChatArgs(["Hello", "--model", "claude-sonnet-4"]); + expect(result).toEqual({ prompt: "Hello", model: "claude-sonnet-4" }); + }); + + it("returns empty prompt when only --model is given", () => { + const result = parseChatArgs(["--model", "gpt-4o"]); + expect(result).toEqual({ prompt: "", model: "gpt-4o" }); + }); + + it("ignores --model without a value", () => { + const result = parseChatArgs(["Hello", "--model"]); + expect(result).toEqual({ prompt: "Hello", model: undefined }); + }); +});