diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ab1e5710d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +npm install # Install dependencies +npm run build # tsc + copy-yaml + build-manifest +npm run dev # Run via tsx (no build needed) +npm start # Run built CLI from dist/ +npm link # Global `opencli` command for testing +npx tsc --noEmit # Type-check without emitting +``` + +## Test Commands + +```bash +npm test # unit + extension + adapter projects +npm run test:adapter # Adapter project only +npm run test:e2e # E2E project (hits real APIs/browsers) +npm run test:all # All projects including e2e + smoke + +# Single test file +npm test -- --run clis/hackernews/hackernews.test.js +npx vitest run tests/e2e/management.test.ts + +# Watch mode for development +npx vitest src/ +``` + +Vitest projects: `unit` (src/**/*.test.ts), `extension` (extension/src/**/*.test.ts), `adapter` (clis/**/*.test.{ts,js}), `e2e` (tests/e2e/), `smoke` (tests/smoke/). Default `npm test` runs unit + extension + adapter only. Extended E2E browser tests require `OPENCLI_E2E=1`. + +## Architecture Overview + +OpenCLI turns websites, Electron apps, and local tools into CLI commands. The system has three main layers: + +### 1. Registry & Adapter System (`src/registry.ts`, `clis/`) + +Adapters define CLI commands via `cli()` from `@jackwener/opencli/registry`. Each adapter specifies a `site`, `name`, `strategy`, `args`, `columns`, and either a `pipeline` (declarative YAML-like) or `func` (imperative async function). + +**Two adapter patterns:** + +- **Pipeline adapters** (`browser: false`, strategy `PUBLIC`): Declarative chain of steps (`fetch`, `map`, `filter`, `limit`, etc.). No browser needed. See `clis/hackernews/top.js`. +- **func() adapters** (`browser: true`, strategies `COOKIE`/`HEADER`/`INTERCEPT`/`UI`): Imperative async function receiving an `IPage`. Uses logged-in browser session. See `clis/twitter/trending.js`. + +**Strategy enum** determines auth: `PUBLIC` (no auth), `LOCAL` (local tool), `COOKIE` (reuse browser cookies), `HEADER` (inject headers), `INTERCEPT` (intercept network), `UI` (full browser automation). + +Adapters live in `clis//.js` (JS-first, loaded at runtime). The registry also supports user adapters at `~/.opencli/clis/` and plugins. + +### 2. Pipeline Engine (`src/pipeline/`) + +Declarative execution engine. Steps are registered in `src/pipeline/registry.ts` and executed sequentially by `executor.ts`. Each step receives `(page, params, data, args)` and returns new data state. + +Core pipeline steps: `fetch`, `map`, `filter`, `sort`, `limit`, `select`, `navigate`, `click`, `type`, `fill`, `wait`, `press`, `snapshot`, `evaluate`, `intercept`, `tap`, `download`. + +Pipeline steps that need a browser session are listed in `src/capabilityRouting.ts` (`BROWSER_ONLY_STEPS`). The `shouldUseBrowserSession()` function decides whether to spin up a browser for a given command. + +### 3. Browser Layer (`src/browser/`) + +Manages connections to Chrome/Chromium via CDP (Chrome DevTools Protocol). Key components: + +- **Browser Bridge**: Chrome extension + local daemon (`src/browser/daemon-client.ts`) — connects to already-running Chrome with login sessions. +- **CDP** (`src/browser/cdp.ts`): Direct CDP connection for Electron apps or remote browsers. +- **Page abstraction** (`src/browser/page.ts`): `IPage` interface wrapping DOM operations (goto, evaluate, wait, click, etc.). +- **DOM snapshot** (`src/browser/dom-snapshot.ts`): Structured DOM extraction for AI agent consumption. + +The `src/runtime.ts` module manages browser session lifecycle (attach, detach, timeout). + +### Command Execution Flow + +`main.ts` → Commander.js parses CLI args → `execution.ts` resolves the command from registry → validates args → opens browser session if needed → runs pipeline or func → formats output → exits with standard exit code. + +### Extension (`extension/`) + +Chrome extension (Manifest V3) with `background.ts` (native messaging to daemon) and `cdp.ts` (CDP bridge). Installed separately from the npm package. + +## Key Exports (public API for plugins) + +```typescript +import { cli, Strategy, onStartup, onBeforeExecute, onAfterExecute } from '@jackwener/opencli/registry'; +import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; +``` + +## Adapter Conventions + +- **Arg design**: Use `positional: true` for the primary required argument (query, symbol, id). Use named `--flag` for secondary/optional config (limit, format, sort). +- **Access**: `read` or `write` — write commands modify state (post, follow, delete). +- **File naming**: `kebab-case` for files, one command per file in `clis//`. +- **Errors**: Throw `AuthRequiredError` or `EmptyResultError` from `@jackwener/opencli/errors` for structured error handling. +- **Validation**: Run `opencli validate` to check all adapter definitions. + +## Code Style + +- TypeScript strict mode, ES Modules with `.js` extensions in imports +- `kebab-case` files, `camelCase` variables, `PascalCase` types +- Named exports only (no default exports) +- Conventional Commits: `feat(twitter): add thread command` + +## GeoGebra Workflow + +- `clis/geogebra/` has two intended modes: + - Fresh automation page: `opencli geogebra ...` + - Existing user tab: `opencli browser bind --workspace bound:geogebra --domain www.geogebra.org`, then `opencli geogebra ... --workspace bound:geogebra` +- Each `opencli geogebra ...` command runs in a fresh browser session unless `--workspace` is passed. Multi-step drawings must happen in one `eval` call, inside a purpose-built helper like `triangle`, or in a reused bound workspace. +- On the Geometry page, prefer constructions built from `Circle`, `Intersect`, and `Polygon`. Do not assume `RegularPolygon(...)` is available. +- In bound tabs, use distinctive labels like `OCLIA`, `OCLIB`, `OCLIC` to avoid clobbering the user's existing objects. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ab1e5710d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +npm install # Install dependencies +npm run build # tsc + copy-yaml + build-manifest +npm run dev # Run via tsx (no build needed) +npm start # Run built CLI from dist/ +npm link # Global `opencli` command for testing +npx tsc --noEmit # Type-check without emitting +``` + +## Test Commands + +```bash +npm test # unit + extension + adapter projects +npm run test:adapter # Adapter project only +npm run test:e2e # E2E project (hits real APIs/browsers) +npm run test:all # All projects including e2e + smoke + +# Single test file +npm test -- --run clis/hackernews/hackernews.test.js +npx vitest run tests/e2e/management.test.ts + +# Watch mode for development +npx vitest src/ +``` + +Vitest projects: `unit` (src/**/*.test.ts), `extension` (extension/src/**/*.test.ts), `adapter` (clis/**/*.test.{ts,js}), `e2e` (tests/e2e/), `smoke` (tests/smoke/). Default `npm test` runs unit + extension + adapter only. Extended E2E browser tests require `OPENCLI_E2E=1`. + +## Architecture Overview + +OpenCLI turns websites, Electron apps, and local tools into CLI commands. The system has three main layers: + +### 1. Registry & Adapter System (`src/registry.ts`, `clis/`) + +Adapters define CLI commands via `cli()` from `@jackwener/opencli/registry`. Each adapter specifies a `site`, `name`, `strategy`, `args`, `columns`, and either a `pipeline` (declarative YAML-like) or `func` (imperative async function). + +**Two adapter patterns:** + +- **Pipeline adapters** (`browser: false`, strategy `PUBLIC`): Declarative chain of steps (`fetch`, `map`, `filter`, `limit`, etc.). No browser needed. See `clis/hackernews/top.js`. +- **func() adapters** (`browser: true`, strategies `COOKIE`/`HEADER`/`INTERCEPT`/`UI`): Imperative async function receiving an `IPage`. Uses logged-in browser session. See `clis/twitter/trending.js`. + +**Strategy enum** determines auth: `PUBLIC` (no auth), `LOCAL` (local tool), `COOKIE` (reuse browser cookies), `HEADER` (inject headers), `INTERCEPT` (intercept network), `UI` (full browser automation). + +Adapters live in `clis//.js` (JS-first, loaded at runtime). The registry also supports user adapters at `~/.opencli/clis/` and plugins. + +### 2. Pipeline Engine (`src/pipeline/`) + +Declarative execution engine. Steps are registered in `src/pipeline/registry.ts` and executed sequentially by `executor.ts`. Each step receives `(page, params, data, args)` and returns new data state. + +Core pipeline steps: `fetch`, `map`, `filter`, `sort`, `limit`, `select`, `navigate`, `click`, `type`, `fill`, `wait`, `press`, `snapshot`, `evaluate`, `intercept`, `tap`, `download`. + +Pipeline steps that need a browser session are listed in `src/capabilityRouting.ts` (`BROWSER_ONLY_STEPS`). The `shouldUseBrowserSession()` function decides whether to spin up a browser for a given command. + +### 3. Browser Layer (`src/browser/`) + +Manages connections to Chrome/Chromium via CDP (Chrome DevTools Protocol). Key components: + +- **Browser Bridge**: Chrome extension + local daemon (`src/browser/daemon-client.ts`) — connects to already-running Chrome with login sessions. +- **CDP** (`src/browser/cdp.ts`): Direct CDP connection for Electron apps or remote browsers. +- **Page abstraction** (`src/browser/page.ts`): `IPage` interface wrapping DOM operations (goto, evaluate, wait, click, etc.). +- **DOM snapshot** (`src/browser/dom-snapshot.ts`): Structured DOM extraction for AI agent consumption. + +The `src/runtime.ts` module manages browser session lifecycle (attach, detach, timeout). + +### Command Execution Flow + +`main.ts` → Commander.js parses CLI args → `execution.ts` resolves the command from registry → validates args → opens browser session if needed → runs pipeline or func → formats output → exits with standard exit code. + +### Extension (`extension/`) + +Chrome extension (Manifest V3) with `background.ts` (native messaging to daemon) and `cdp.ts` (CDP bridge). Installed separately from the npm package. + +## Key Exports (public API for plugins) + +```typescript +import { cli, Strategy, onStartup, onBeforeExecute, onAfterExecute } from '@jackwener/opencli/registry'; +import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; +``` + +## Adapter Conventions + +- **Arg design**: Use `positional: true` for the primary required argument (query, symbol, id). Use named `--flag` for secondary/optional config (limit, format, sort). +- **Access**: `read` or `write` — write commands modify state (post, follow, delete). +- **File naming**: `kebab-case` for files, one command per file in `clis//`. +- **Errors**: Throw `AuthRequiredError` or `EmptyResultError` from `@jackwener/opencli/errors` for structured error handling. +- **Validation**: Run `opencli validate` to check all adapter definitions. + +## Code Style + +- TypeScript strict mode, ES Modules with `.js` extensions in imports +- `kebab-case` files, `camelCase` variables, `PascalCase` types +- Named exports only (no default exports) +- Conventional Commits: `feat(twitter): add thread command` + +## GeoGebra Workflow + +- `clis/geogebra/` has two intended modes: + - Fresh automation page: `opencli geogebra ...` + - Existing user tab: `opencli browser bind --workspace bound:geogebra --domain www.geogebra.org`, then `opencli geogebra ... --workspace bound:geogebra` +- Each `opencli geogebra ...` command runs in a fresh browser session unless `--workspace` is passed. Multi-step drawings must happen in one `eval` call, inside a purpose-built helper like `triangle`, or in a reused bound workspace. +- On the Geometry page, prefer constructions built from `Circle`, `Intersect`, and `Polygon`. Do not assume `RegularPolygon(...)` is available. +- In bound tabs, use distinctive labels like `OCLIA`, `OCLIB`, `OCLIC` to avoid clobbering the user's existing objects. diff --git a/cli-manifest.json b/cli-manifest.json index 3ec013b26..b4cb94290 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -9366,6 +9366,269 @@ "sourceFile": "gemini/new.js", "navigateBefore": false }, + { + "site": "geogebra", + "name": "add-circle", + "description": "Create a circle by center+radius or center+point", + "access": "write", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "center", + "type": "str", + "required": true, + "help": "Center point label (e.g. A)" + }, + { + "name": "radius", + "type": "str", + "required": false, + "help": "Radius value (number) or a point label on the circle" + }, + { + "name": "point", + "type": "str", + "required": false, + "help": "Alternative: a point label on the circle (use instead of --radius for Circle(center,point))" + } + ], + "columns": [ + "label", + "center", + "radius" + ], + "type": "js", + "modulePath": "geogebra/add-circle.js", + "sourceFile": "geogebra/add-circle.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "add-line", + "description": "Create a line through two points or a segment between two points", + "access": "write", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "points", + "type": "str", + "required": true, + "help": "Two point labels separated by comma (e.g. \"A,B\")" + }, + { + "name": "type", + "type": "str", + "default": "line", + "required": false, + "help": "Type: line, segment, or ray (default: line)", + "choices": [ + "line", + "segment", + "ray" + ] + } + ], + "columns": [ + "label", + "type", + "points" + ], + "type": "js", + "modulePath": "geogebra/add-line.js", + "sourceFile": "geogebra/add-line.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "add-point", + "description": "Create a point with given label and coordinates", + "access": "write", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "help": "Point label (e.g. A, B, P1)" + }, + { + "name": "coords", + "type": "str", + "required": true, + "help": "Coordinates as x,y (e.g. \"1,2\")" + } + ], + "columns": [ + "name", + "x", + "y" + ], + "type": "js", + "modulePath": "geogebra/add-point.js", + "sourceFile": "geogebra/add-point.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "add-polygon", + "description": "Create a polygon from a list of point labels", + "access": "write", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "points", + "type": "str", + "required": true, + "help": "Comma-separated point labels (e.g. \"A,B,C\" or \"A,B,C,D\")" + } + ], + "columns": [ + "label", + "vertices" + ], + "type": "js", + "modulePath": "geogebra/add-polygon.js", + "sourceFile": "geogebra/add-polygon.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "eval", + "description": "Execute one or more GeoGebra command strings (semicolon-separated)", + "access": "write", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "command", + "type": "str", + "required": true, + "positional": true, + "help": "GeoGebra command string (use ; to chain multiple commands)" + } + ], + "columns": [ + "command", + "result" + ], + "type": "js", + "modulePath": "geogebra/eval.js", + "sourceFile": "geogebra/eval.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "hexagon", + "description": "Draw a regular hexagon centered at the origin", + "access": "write", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "size", + "type": "str", + "default": "2", + "required": false, + "help": "Radius of the hexagon (default: 2)" + } + ], + "columns": [ + "step", + "result" + ], + "type": "js", + "modulePath": "geogebra/hexagon.js", + "sourceFile": "geogebra/hexagon.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "info", + "description": "Get detailed properties of a GeoGebra object", + "access": "read", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "help": "Object label (e.g. A, c1, poly1)" + } + ], + "columns": [ + "property", + "value" + ], + "type": "js", + "modulePath": "geogebra/info.js", + "sourceFile": "geogebra/info.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "list", + "description": "List all geometric objects on the GeoGebra canvas", + "access": "read", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "type", + "type": "str", + "required": false, + "help": "Filter by object type (e.g. \"point\", \"line\", \"circle\")" + } + ], + "columns": [ + "name", + "type", + "value", + "visible" + ], + "type": "js", + "modulePath": "geogebra/list.js", + "sourceFile": "geogebra/list.js", + "navigateBefore": false + }, + { + "site": "geogebra", + "name": "triangle", + "description": "Draw an equilateral triangle from a horizontal base segment", + "access": "write", + "domain": "www.geogebra.org", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "size", + "type": "str", + "default": "2", + "required": false, + "help": "Side length of the triangle (default: 2)" + } + ], + "columns": [ + "step", + "result" + ], + "type": "js", + "modulePath": "geogebra/triangle.js", + "sourceFile": "geogebra/triangle.js", + "navigateBefore": false + }, { "site": "gitee", "name": "search", diff --git a/clis/geogebra/add-circle.js b/clis/geogebra/add-circle.js new file mode 100644 index 000000000..24e84a150 --- /dev/null +++ b/clis/geogebra/add-circle.js @@ -0,0 +1,45 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ensureApplet, ggbEval } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'add-circle', + access: 'write', + description: 'Create a circle by center+radius or center+point', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra add-circle --center A --radius 3', + args: [ + { name: 'center', required: true, help: 'Center point label (e.g. A)' }, + { name: 'radius', required: false, help: 'Radius value (number) or a point label on the circle' }, + { name: 'point', required: false, help: 'Alternative: a point label on the circle (use instead of --radius for Circle(center,point))' }, + ], + columns: ['label', 'center', 'radius'], + func: async (page, kwargs) => { + await ensureApplet(page); + const center = kwargs.center; + const pointOnCircle = kwargs.point; + const radiusValue = kwargs.radius; + + let cmd; + if (pointOnCircle) { + cmd = `Circle(${center},${pointOnCircle})`; + } else if (radiusValue !== undefined) { + const num = Number(radiusValue); + if (Number.isNaN(num)) { + // Might be a point name + cmd = `Circle(${center},${radiusValue})`; + } else { + cmd = `Circle(${center},${num})`; + } + } else { + throw new Error('Provide --radius (number or point label) or --point (point on circle)'); + } + + const result = await ggbEval(page, cmd); + if (!result.ok) throw new Error(`Failed to create circle: ${cmd}`); + return [{ label: result.label, center, radius: pointOnCircle || radiusValue }]; + }, +}); diff --git a/clis/geogebra/add-line.js b/clis/geogebra/add-line.js new file mode 100644 index 000000000..60e257150 --- /dev/null +++ b/clis/geogebra/add-line.js @@ -0,0 +1,37 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ensureApplet, ggbEval } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'add-line', + access: 'write', + description: 'Create a line through two points or a segment between two points', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra add-line --points A,B --type segment', + args: [ + { name: 'points', required: true, help: 'Two point labels separated by comma (e.g. "A,B")' }, + { name: 'type', required: false, choices: ['line', 'segment', 'ray'], default: 'line', help: 'Type: line, segment, or ray (default: line)' }, + ], + columns: ['label', 'type', 'points'], + func: async (page, kwargs) => { + await ensureApplet(page); + const parts = String(kwargs.points).split(',').map(s => s.trim()); + if (parts.length !== 2) throw new Error('points must be two labels separated by comma (e.g. "A,B")'); + const [a, b] = parts; + const type = kwargs.type || 'line'; + + const geogebraCmd = { + line: `Line(${a},${b})`, + segment: `Segment(${a},${b})`, + ray: `Ray(${a},${b})`, + }[type]; + if (!geogebraCmd) throw new Error(`Unknown line type: ${type}`); + + const result = await ggbEval(page, geogebraCmd); + if (!result.ok) throw new Error(`Failed to create ${type}: ${geogebraCmd}`); + return [{ label: result.label, type, points: `${a},${b}` }]; + }, +}); diff --git a/clis/geogebra/add-point.js b/clis/geogebra/add-point.js new file mode 100644 index 000000000..967103263 --- /dev/null +++ b/clis/geogebra/add-point.js @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ensureApplet, ggbEval } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'add-point', + access: 'write', + description: 'Create a point with given label and coordinates', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra add-point --name A --coords 1,2', + args: [ + { name: 'name', required: true, help: 'Point label (e.g. A, B, P1)' }, + { name: 'coords', required: true, help: 'Coordinates as x,y (e.g. "1,2")' }, + ], + columns: ['name', 'x', 'y'], + func: async (page, kwargs) => { + await ensureApplet(page); + const { name, coords } = kwargs; + const parts = String(coords).split(',').map(s => s.trim()); + if (parts.length !== 2) throw new Error('coords must be in "x,y" format (e.g. "1,2")'); + const [x, y] = parts; + const cmd = `${name}=(${x},${y})`; + const result = await ggbEval(page, cmd); + if (!result.ok) throw new Error(`Failed to create point: ${cmd}`); + return [{ name, x, y }]; + }, +}); diff --git a/clis/geogebra/add-polygon.js b/clis/geogebra/add-polygon.js new file mode 100644 index 000000000..b6ba76976 --- /dev/null +++ b/clis/geogebra/add-polygon.js @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ensureApplet, ggbEval } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'add-polygon', + access: 'write', + description: 'Create a polygon from a list of point labels', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra add-polygon --points A,B,C', + args: [ + { name: 'points', required: true, help: 'Comma-separated point labels (e.g. "A,B,C" or "A,B,C,D")' }, + ], + columns: ['label', 'vertices'], + func: async (page, kwargs) => { + await ensureApplet(page); + const points = String(kwargs.points).split(',').map(s => s.trim()).filter(Boolean); + if (points.length < 3) throw new Error('At least 3 points required for a polygon'); + const cmd = `Polygon(${points.join(',')})`; + const result = await ggbEval(page, cmd); + if (!result.ok) throw new Error(`Failed to create polygon: ${cmd}`); + return [{ label: result.label, vertices: points.join(',') }]; + }, +}); diff --git a/clis/geogebra/eval.js b/clis/geogebra/eval.js new file mode 100644 index 000000000..eb555cd78 --- /dev/null +++ b/clis/geogebra/eval.js @@ -0,0 +1,33 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ensureApplet, ggbEval } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'eval', + access: 'write', + description: 'Execute one or more GeoGebra command strings (semicolon-separated)', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra eval "A=(0,0);B=(4,0);c=Circle(A,B);d=Circle(B,A);C=Intersect(c,d,1);Polygon(A,B,C)"', + args: [ + { name: 'command', positional: true, required: true, help: 'GeoGebra command string (use ; to chain multiple commands)' }, + ], + columns: ['command', 'result'], + func: async (page, kwargs) => { + await ensureApplet(page); + const commands = String(kwargs.command).split(';').map(s => s.trim()).filter(Boolean); + const results = []; + for (const command of commands) { + const result = await ggbEval(page, command); + results.push({ + command, + result: result.ok + ? `ok (${result.label || 'no label'})` + : `failed${result.error ? ` (${result.error})` : ''}`, + }); + } + return results; + }, +}); diff --git a/clis/geogebra/geogebra.test.js b/clis/geogebra/geogebra.test.js new file mode 100644 index 000000000..8e266ebd1 --- /dev/null +++ b/clis/geogebra/geogebra.test.js @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ensureApplet, ggbEval, ggbGetProperty, ggbListObjects, ggbWaitForObjectCount } from './utils.js'; + +function createPageMock(url = 'https://www.geogebra.org/geometry') { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn(), + getCurrentUrl: vi.fn().mockResolvedValue(url), + wait: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('ensureApplet', () => { + it('skips navigation when already on the geometry page', async () => { + const page = createPageMock('https://www.geogebra.org/geometry'); + page.evaluate.mockResolvedValue(true); + await ensureApplet(page); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('navigates when not on the geometry page', async () => { + const page = createPageMock('https://example.com'); + page.evaluate.mockResolvedValue(true); + await ensureApplet(page); + expect(page.goto).toHaveBeenCalledWith('https://www.geogebra.org/geometry'); + }); + + it('throws when ggbApplet never becomes available', async () => { + const page = createPageMock(); + page.evaluate.mockResolvedValue(false); + await expect(ensureApplet(page)).rejects.toThrow('ggbApplet not available'); + }); +}); + +describe('ggbEval', () => { + it('calls evalCommandGetLabels and evalCommand', async () => { + const page = createPageMock(); + page.evaluate.mockResolvedValue({ ok: true, label: 'A', beforeCount: 0, afterCount: 1, error: null }); + const result = await ggbEval(page, 'A=(1,2)'); + expect(result).toEqual({ ok: true, label: 'A', beforeCount: 0, afterCount: 1, error: null }); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); +}); + +describe('ggbGetProperty', () => { + it('requests a property from the applet', async () => { + const page = createPageMock(); + page.evaluate.mockResolvedValue('point'); + const result = await ggbGetProperty(page, 'A', 'type'); + expect(result).toBe('point'); + }); +}); + +describe('ggbListObjects', () => { + it('normalizes object rows from the applet', async () => { + const page = createPageMock(); + page.evaluate.mockResolvedValue([ + { name: 'A', type: 'point', value: '(0, 0)', visible: true }, + { name: 't1', type: 'polygon', value: '', visible: true }, + ]); + const result = await ggbListObjects(page); + expect(result).toHaveLength(2); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); +}); + +describe('ggbWaitForObjectCount', () => { + it('returns the detected object count', async () => { + const page = createPageMock(); + page.evaluate.mockResolvedValue(4); + const result = await ggbWaitForObjectCount(page, 4); + expect(result).toBe(4); + }); +}); diff --git a/clis/geogebra/hexagon.js b/clis/geogebra/hexagon.js new file mode 100644 index 000000000..f66479b0c --- /dev/null +++ b/clis/geogebra/hexagon.js @@ -0,0 +1,59 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import os from 'node:os'; +import path from 'node:path'; +import { ensureApplet, ggbEval, ggbListObjects, ggbWaitForObjectCount } from './utils.js'; + +/** + * Draw a regular hexagon on the GeoGebra Geometry canvas. + * Creates center point, vertex, and the regular polygon in one session. + */ +cli({ + site: 'geogebra', + name: 'hexagon', + access: 'write', + description: 'Draw a regular hexagon centered at the origin', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra hexagon --size 3', + args: [ + { name: 'size', required: false, default: '2', help: 'Radius of the hexagon (default: 2)' }, + ], + columns: ['step', 'result'], + func: async (page, kwargs) => { + await ensureApplet(page); + const size = Number(kwargs.size) || 2; + const results = []; + + const vertices = [ + ['V1', `(${size},0)`], + ['V2', `(${size}*cos(pi/3),${size}*sin(pi/3))`], + ['V3', `(${size}*cos(2*pi/3),${size}*sin(2*pi/3))`], + ['V4', `(-${size},0)`], + ['V5', `(${size}*cos(4*pi/3),${size}*sin(4*pi/3))`], + ['V6', `(${size}*cos(5*pi/3),${size}*sin(5*pi/3))`], + ]; + for (const [name, coords] of vertices) { + const result = await ggbEval(page, `${name}=${coords}`); + if (!result.ok) throw new Error(result.error || `Failed to create point ${name}`); + results.push({ step: `${name}=${coords}`, result: `ok (${result.label || name})` }); + } + + const polygon = await ggbEval(page, 'Hexagon=Polygon(V1,V2,V3,V4,V5,V6)'); + if (!polygon.ok) throw new Error(polygon.error || 'Failed to create hexagon polygon'); + results.push({ step: 'Hexagon=Polygon(V1,V2,V3,V4,V5,V6)', result: `ok (${polygon.label || 'hexagon created'})` }); + + const objectCount = await ggbWaitForObjectCount(page, 7); + const objects = await ggbListObjects(page); + const screenshotPath = path.join(os.tmpdir(), 'opencli-geogebra-hexagon.png'); + await page.screenshot({ path: screenshotPath }); + + if (Array.isArray(objects) && objects.length > 0) { + results.push({ step: `canvas has ${objectCount} objects`, result: objects.map(o => `${o.name}(${o.type})`).join(', ') }); + } + results.push({ step: 'screenshot', result: screenshotPath }); + + return results; + }, +}); diff --git a/clis/geogebra/info.js b/clis/geogebra/info.js new file mode 100644 index 000000000..58cda5d47 --- /dev/null +++ b/clis/geogebra/info.js @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ensureApplet, ggbGetProperty } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'info', + access: 'read', + description: 'Get detailed properties of a GeoGebra object', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra info --name A', + args: [ + { name: 'name', required: true, help: 'Object label (e.g. A, c1, poly1)' }, + ], + columns: ['property', 'value'], + func: async (page, kwargs) => { + await ensureApplet(page); + const objName = kwargs.name; + + const exists = await page.evaluate(` + (name => typeof ggbApplet !== 'undefined' && ggbApplet.getObjectType(name) !== '') + (${JSON.stringify(objName)}) + `); + if (!exists) throw new Error(`Object "${objName}" not found on the canvas`); + + const properties = ['type', 'value', 'definition', 'command', 'caption', 'visible', 'color']; + const rows = []; + for (const prop of properties) { + const val = await ggbGetProperty(page, objName, prop); + rows.push({ property: prop, value: String(val ?? '') }); + } + + // For point-like objects, also include coordinates + const objType = await ggbGetProperty(page, objName, 'type'); + if (objType === 'point') { + const x = await ggbGetProperty(page, objName, 'xcoord'); + const y = await ggbGetProperty(page, objName, 'ycoord'); + rows.push({ property: 'x', value: String(x ?? '') }); + rows.push({ property: 'y', value: String(y ?? '') }); + } + + return rows; + }, +}); diff --git a/clis/geogebra/list.js b/clis/geogebra/list.js new file mode 100644 index 000000000..5a3e6a990 --- /dev/null +++ b/clis/geogebra/list.js @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { ensureApplet, ggbListObjects } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'list', + access: 'read', + description: 'List all geometric objects on the GeoGebra canvas', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + args: [ + { name: 'type', required: false, help: 'Filter by object type (e.g. "point", "line", "circle")' }, + ], + columns: ['name', 'type', 'value', 'visible'], + func: async (page, kwargs) => { + await ensureApplet(page); + const filterType = kwargs.type?.toLowerCase(); + const objects = await ggbListObjects(page, filterType); + if (!Array.isArray(objects) || objects.length === 0) { + throw new EmptyResultError( + 'geogebra list', + 'No objects found on the canvas. Fresh runs start a blank session; use one "eval" call, or pass --workspace bound: to inspect a bound tab.', + ); + } + return objects; + }, +}); diff --git a/clis/geogebra/triangle.js b/clis/geogebra/triangle.js new file mode 100644 index 000000000..86bf256bf --- /dev/null +++ b/clis/geogebra/triangle.js @@ -0,0 +1,61 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import os from 'node:os'; +import path from 'node:path'; +import { ensureApplet, ggbEval, ggbListObjects, ggbWaitForObjectCount } from './utils.js'; + +cli({ + site: 'geogebra', + name: 'triangle', + access: 'write', + description: 'Draw an equilateral triangle from a horizontal base segment', + domain: 'www.geogebra.org', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + example: 'opencli geogebra triangle --size 4', + args: [ + { name: 'size', required: false, default: '2', help: 'Side length of the triangle (default: 2)' }, + ], + columns: ['step', 'result'], + func: async (page, kwargs) => { + await ensureApplet(page); + const size = Number(kwargs.size) || 2; + const results = []; + + const r1 = await ggbEval(page, 'A=(0,0)'); + if (!r1.ok) throw new Error(r1.error || 'Failed to create point A'); + results.push({ step: 'base point A=(0,0)', result: `ok (${r1.label || 'A'})` }); + + const r2 = await ggbEval(page, `B=(${size},0)`); + if (!r2.ok) throw new Error(r2.error || 'Failed to create point B'); + results.push({ step: `base point B=(${size},0)`, result: `ok (${r2.label || 'B'})` }); + + const r3 = await ggbEval(page, 'c=Circle(A,B)'); + if (!r3.ok) throw new Error(r3.error || 'Failed to create circle c'); + results.push({ step: 'c=Circle(A,B)', result: `ok (${r3.label || 'c'})` }); + + const r4 = await ggbEval(page, 'd=Circle(B,A)'); + if (!r4.ok) throw new Error(r4.error || 'Failed to create circle d'); + results.push({ step: 'd=Circle(B,A)', result: `ok (${r4.label || 'd'})` }); + + const r5 = await ggbEval(page, 'C=Intersect(c,d,1)'); + if (!r5.ok) throw new Error(r5.error || 'Failed to create point C'); + results.push({ step: 'C=Intersect(c,d,1)', result: `ok (${r5.label || 'C'})` }); + + const r6 = await ggbEval(page, 'Polygon(A,B,C)'); + if (!r6.ok) throw new Error(r6.error || 'Failed to create triangle polygon'); + results.push({ step: 'Polygon(A,B,C)', result: `ok (${r6.label || 'triangle created'})` }); + + const objectCount = await ggbWaitForObjectCount(page, 5); + const objects = await ggbListObjects(page); + const screenshotPath = path.join(os.tmpdir(), 'opencli-geogebra-triangle.png'); + await page.screenshot({ path: screenshotPath }); + results.push({ + step: `canvas has ${objectCount} objects`, + result: objects.map((obj) => `${obj.name}(${obj.type})`).join(', '), + }); + results.push({ step: 'screenshot', result: screenshotPath }); + + return results; + }, +}); diff --git a/clis/geogebra/utils.js b/clis/geogebra/utils.js new file mode 100644 index 000000000..0165655fc --- /dev/null +++ b/clis/geogebra/utils.js @@ -0,0 +1,155 @@ +/** + * Shared utilities for GeoGebra adapters. + * + * GeoGebra Geometry exposes a `ggbApplet` JavaScript API on the page after + * the GWT-compiled app initializes. All adapters share the same pattern: + * navigate → wait for applet → call API via page.evaluate(). + */ + +const GEOGEBRA_URL = 'https://www.geogebra.org/geometry'; +const APPLET_WAIT_MS = 15_000; + +/** + * Navigate to GeoGebra Geometry (if not already there) and wait for + * the ggbApplet API to become available. + */ +export async function ensureApplet(page) { + const currentUrl = await page.getCurrentUrl(); + // If already on the geometry page, check if applet is ready without re-navigating + if (currentUrl?.includes('geogebra.org/geometry')) { + const ready = await page.evaluate(`typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function'`); + if (ready) return; + } + // Navigate to GeoGebra Geometry + await page.goto(GEOGEBRA_URL); + + const ready = await page.evaluate(` + (async () => { + const deadline = Date.now() + ${APPLET_WAIT_MS}; + while (Date.now() < deadline) { + if (typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function') { + return true; + } + await new Promise(r => setTimeout(r, 500)); + } + return false; + })() + `); + if (!ready) throw new Error('ggbApplet not available after waiting. Make sure the GeoGebra Geometry page is fully loaded.'); +} + +/** + * Execute a GeoGebra command string via ggbApplet.evalCommandGetLabels. + * evalCommandGetLabels both executes the command and returns the created + * object label(s). We use it instead of evalCommand to avoid double-execution. + * Returns { ok, label } where label is the resulting object label(s). + */ +export async function ggbEval(page, cmd) { + return page.evaluate(` + (cmd => { + const collectNames = () => { + let names = ggbApplet.getAllObjectNames(); + if (typeof names === 'string') { + names = names.split(',').map(s => s.trim()).filter(Boolean); + } + return Array.isArray(names) ? names : []; + }; + const beforeCount = collectNames().length; + const label = ggbApplet.evalCommandGetLabels(cmd); + const afterCount = collectNames().length; + const dialogText = [...document.querySelectorAll('[role="dialog"], .gwt-DialogBox')] + .map(node => node.textContent?.trim() || '') + .find(text => /error|unknown command|错误|未知的指令/i.test(text)) || ''; + return { + ok: label !== '' || afterCount > beforeCount, + label, + beforeCount, + afterCount, + error: dialogText || null, + }; + })(${JSON.stringify(cmd)}) + `); +} + +/** + * List all currently known GeoGebra objects, optionally filtered by type. + */ +export async function ggbListObjects(page, filterType) { + const normalizedFilter = filterType ? String(filterType).toLowerCase() : ''; + return page.evaluate(` + (filterType => { + const api = ggbApplet; + let names = api.getAllObjectNames(); + if (typeof names === 'string') { + names = names.split(',').map(s => s.trim()).filter(Boolean); + } + if (!Array.isArray(names)) return []; + const result = []; + for (const name of names) { + try { + const type = api.getObjectType(name); + if (!type) continue; + if (filterType && type.toLowerCase() !== filterType) continue; + result.push({ + name, + type, + value: api.getValueString(name) || '', + visible: api.getVisible(name), + }); + } catch {} + } + return result; + })(${JSON.stringify(normalizedFilter)}) + `); +} + +/** + * Poll until the object count reaches the requested minimum. + */ +export async function ggbWaitForObjectCount(page, minCount, timeoutMs = 4_000) { + const normalizedMinCount = Number(minCount); + const normalizedTimeoutMs = Number(timeoutMs); + return page.evaluate(` + (async () => { + const deadline = Date.now() + ${normalizedTimeoutMs}; + while (Date.now() < deadline) { + let names = ggbApplet.getAllObjectNames(); + if (typeof names === 'string') { + names = names.split(',').map(s => s.trim()).filter(Boolean); + } + if (Array.isArray(names) && names.length >= ${normalizedMinCount}) { + return names.length; + } + await new Promise(resolve => setTimeout(resolve, 200)); + } + let names = ggbApplet.getAllObjectNames(); + if (typeof names === 'string') { + names = names.split(',').map(s => s.trim()).filter(Boolean); + } + return Array.isArray(names) ? names.length : 0; + })() + `); +} + +/** + * Read a property from a GeoGebra object. + */ +export async function ggbGetProperty(page, objName, property) { + return page.evaluate(` + (objName, property) => { + const api = ggbApplet; + switch (property) { + case 'type': return api.getObjectType(objName); + case 'value': return api.getValueString(objName); + case 'color': return api.getColor(objName); + case 'visible': return api.getVisible(objName); + case 'caption': return api.getCaption(objName) || ''; + case 'xcoord': return api.getXcoord(objName); + case 'ycoord': return api.getYcoord(objName); + case 'definition': return api.getDefinitionString(objName); + case 'command': return api.getCommandString(objName); + default: return null; + } + } + `, objName, property); +} diff --git a/docs/adapters/browser/geogebra.md b/docs/adapters/browser/geogebra.md new file mode 100644 index 000000000..52ae82e46 --- /dev/null +++ b/docs/adapters/browser/geogebra.md @@ -0,0 +1,89 @@ +# GeoGebra + +**Mode**: Browser | **Domain**: `www.geogebra.org` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli geogebra eval ";;..."` | Execute one or more GeoGebra commands in a fresh automation page or an explicit workspace | +| `opencli geogebra add-point --name A --coords 1,2` | Create one point | +| `opencli geogebra add-line --points A,B --type segment` | Create a line, segment, or ray from existing points | +| `opencli geogebra add-circle --center A --radius 3` | Create a circle from an existing center | +| `opencli geogebra add-polygon --points A,B,C` | Create a polygon from existing points | +| `opencli geogebra triangle --size 4` | Draw an equilateral triangle | +| `opencli geogebra hexagon --size 3` | Draw a regular hexagon | +| `opencli geogebra list` | List current objects on the canvas | +| `opencli geogebra info --name A` | Inspect one object | + +## Two Workflows + +### 1. Fresh automation page + +Use the site command directly when OpenCLI is allowed to open its own GeoGebra page. + +```bash +opencli geogebra triangle --size 4 +opencli geogebra eval "A=(0,0);B=(4,0);c=Circle(A,B);d=Circle(B,A);C=Intersect(c,d,1);Polygon(A,B,C)" +``` + +Important: + +- Each `opencli geogebra ...` command runs in its own fresh browser session unless you pass `--workspace`. +- Passing `--workspace bound:` makes the command run against that already-bound user tab instead of opening a fresh page. +- `add-point`, `triangle`, and `hexagon` are self-contained and work on a blank Geometry canvas. +- `add-line`, `add-circle`, `add-polygon`, `list`, and `info` need an already-populated canvas or a bound tab workflow. +- For multi-step constructions, prefer one `eval` call with semicolon-separated commands, or use a shape-specific helper like `triangle`. + +### 2. Already-open user tab + +Use this when a human or another agent already has the right `geogebra.org` tab open and you want to draw in that exact tab. + +```bash +opencli browser bind --workspace bound:geogebra --domain www.geogebra.org +opencli geogebra triangle --workspace bound:geogebra --size 4 +opencli geogebra list --workspace bound:geogebra +opencli geogebra info --workspace bound:geogebra --name OCLIA +``` + +If you need raw browser JavaScript, you can still drop down to: + +```bash +opencli browser --workspace bound:geogebra get url +opencli browser --workspace bound:geogebra eval "(() => { + const cmds = [ + 'OCLIA=(0,0)', + 'OCLIB=(4,0)', + 'OCLIc=Circle(OCLIA,OCLIB)', + 'OCLId=Circle(OCLIB,OCLIA)', + 'OCLIC=Intersect(OCLIc,OCLId,1)', + 'OCLIt=Polygon(OCLIA,OCLIB,OCLIC)', + ]; + return cmds.map(cmd => ({ cmd, label: ggbApplet.evalCommandGetLabels(cmd) })); +})()" +``` + +This bound-tab workflow is the safest option when: + +- the user explicitly asks to use an existing Chrome tab +- the tab is already positioned the way the user wants +- you do not want OpenCLI to navigate away or replace the user's page state + +## Geometry Notes + +- On the GeoGebra Geometry page, `RegularPolygon(...)` is not reliable here and may show an "unknown command" error. +- Prefer explicit constructions built from `Circle`, `Intersect`, `Segment`, and `Polygon`. +- `ggbApplet.evalCommandGetLabels(...)` can return multiple labels for commands like `Polygon(...)`; that is expected. + +## Agent Notes + +- Start with `opencli doctor` if Browser Bridge behavior looks stale. +- If the user wants the current visible tab, bind first and then prefer `opencli geogebra ... --workspace bound:geogebra`. +- If a fresh page is acceptable, use `opencli geogebra eval ...` or `opencli geogebra triangle`. +- Use unique temporary labels like `OCLIA`, `OCLIB`, `OCLIC` in bound tabs to avoid colliding with the user's existing objects. + +## Prerequisites + +- Chrome running +- [Browser Bridge extension](/guide/browser-bridge) installed +- A `www.geogebra.org/geometry` page that has fully loaded diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 7388bed3c..445a9b6f5 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -37,6 +37,7 @@ Run `opencli list` for the live registry. | **[chaoxing](./browser/chaoxing.md)** | `assignments` `exams` | 🔐 Browser | | **[grok](./browser/grok.md)** | `ask` `image` | 🔐 Browser | | **[gemini](./browser/gemini.md)** | `new` `ask` `image` `deep-research` `deep-research-result` | 🔐 Browser | +| **[geogebra](./browser/geogebra.md)** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | 🔐 Browser | | **[claude](./browser/claude.md)** | `ask` `send` `new` `status` `read` `history` `detail` | 🔐 Browser | | **[maimai](./browser/maimai.md)** | `search-talents` | 🔐 Browser | | **[yuanbao](./browser/yuanbao.md)** | `new` `ask` | 🔐 Browser | diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index a0b9e674f..c6284e6d7 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + BrowserCommandError, fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, @@ -207,4 +208,17 @@ describe('daemon-client', () => { }); expect(ids[0]).not.toBe(ids[1]); }); + + it('sendCommand rewrites unknown actions as an extension version mismatch', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ ok: false, error: 'Unknown action: bind' }), + } as Response); + + await expect(sendCommand('bind', { workspace: 'bound:default' })).rejects.toMatchObject>({ + name: 'BrowserCommandError', + code: 'extension_outdated', + }); + }); }); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 1250f2f5d..7f5d8525e 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -194,6 +194,13 @@ async function sendCommandRaw( const result = (await res.json()) as DaemonResult; if (!result.ok) { + if (typeof result.error === 'string' && /^Unknown action:\s*/.test(result.error)) { + throw new BrowserCommandError( + `Browser extension is too old to handle "${action}".`, + 'extension_outdated', + 'Reload or update the OpenCLI Browser Bridge extension so it matches this CLI version, then retry.', + ); + } const isDuplicateCommandId = res.status === 409 || (result.error ?? '').includes('Duplicate command id'); if (isDuplicateCommandId && attempt < maxRetries) { diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index a263c2b75..dea1889e5 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -209,6 +209,51 @@ describe('commanderAdapter value-required optional options', () => { }); }); +describe('commanderAdapter browser workspace option', () => { + const cmd: CliCommand = { + site: 'geogebra', + name: 'triangle', access: 'write', + description: 'Draw triangle', + browser: true, + args: [ + { name: 'size', default: '2', help: 'Side length' }, + ], + func: vi.fn(), + }; + + beforeEach(() => { + mockExecuteCommand.mockReset(); + mockExecuteCommand.mockResolvedValue([]); + mockRenderOutput.mockReset(); + delete process.env.OPENCLI_VERBOSE; + process.exitCode = undefined; + }); + + it('passes explicit workspace through to executeCommand for browser adapters', async () => { + const program = new Command(); + const siteCmd = program.command('geogebra'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync([ + 'node', + 'opencli', + 'geogebra', + 'triangle', + '--workspace', + 'bound:geogebra', + '--size', + '4', + ]); + + expect(mockExecuteCommand).toHaveBeenCalledWith( + expect.objectContaining({ site: 'geogebra', name: 'triangle' }), + expect.objectContaining({ size: '4' }), + false, + { prepared: true, workspace: 'bound:geogebra' }, + ); + }); +}); + describe('commanderAdapter command aliases', () => { const cmd: CliCommand = { site: 'notebooklm', diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 7c4180aa9..83b5f49cc 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -57,6 +57,12 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi .option('-f, --format ', 'Output format: table, plain, json, yaml, md, csv', 'table') .option('--trace ', 'Trace capture: off, on, retain-on-failure', 'off') .option('-v, --verbose', 'Debug output', false); + if (cmd.browser) { + subCmd.option( + '--workspace ', + 'Browser workspace to use (use bound: to target a bound user tab)', + ); + } installStructuredHelp(subCmd, () => commandHelpData(cmd), () => formatRegistryHelpText(cmd)); @@ -105,6 +111,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const result = await executeCommand(cmd, kwargs, verbose, { prepared: true, ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}), + ...(typeof optionsRecord.workspace === 'string' && optionsRecord.workspace.trim() + ? { workspace: optionsRecord.workspace.trim() } + : {}), ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}), }); if (result === null || result === undefined) { diff --git a/src/execution.test.ts b/src/execution.test.ts index a217f86c6..6f792efac 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -241,6 +241,35 @@ describe('executeCommand — non-browser timeout', () => { vi.restoreAllMocks(); }); + it('uses an explicit workspace for browser commands and leaves that workspace open', async () => { + const closeWindow = vi.fn().mockResolvedValue(undefined); + const mockPage = { closeWindow } as any; + + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + const browserSessionSpy = vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => { + return fn(mockPage); + }); + + const cmd = cli({ + site: 'test-execution', + name: 'browser-explicit-workspace', access: 'read', + description: 'test explicit workspace reuse', + browser: true, + strategy: Strategy.PUBLIC, + func: async () => [{ ok: true }], + }); + + await expect(executeCommand(cmd, {}, false, { workspace: 'bound:geogebra' })).resolves.toEqual([{ ok: true }]); + expect(browserSessionSpy).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + expect.objectContaining({ workspace: 'bound:geogebra' }), + ); + expect(closeWindow).not.toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + it('skips closeWindow when OPENCLI_LIVE=1 (success path)', async () => { const closeWindow = vi.fn().mockResolvedValue(undefined); const mockPage = { closeWindow } as any; diff --git a/src/execution.ts b/src/execution.ts index fac37f1f5..7a0c2fdbe 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -169,6 +169,7 @@ export async function executeCommand( opts: { prepared?: boolean; profile?: string; + workspace?: string; trace?: string; onTraceExport?: (trace: ObservationExportResult) => void; } = {}, @@ -196,6 +197,11 @@ export async function executeCommand( if (shouldUseBrowserSession(cmd)) { const electron = isElectronApp(cmd.site); let cdpEndpoint: string | undefined; + const requestedWorkspace = typeof opts.workspace === 'string' && opts.workspace.trim() + ? opts.workspace.trim() + : undefined; + const sessionWorkspace = requestedWorkspace ?? `site:${cmd.site}:${crypto.randomUUID()}`; + const keepWorkspaceOpen = requestedWorkspace !== undefined; if (electron) { // Electron apps: respect manual endpoint override, then try auto-detect @@ -223,7 +229,7 @@ export async function executeCommand( : new ObservationSession({ scope: { contextId, - workspace: `site:${cmd.site}`, + workspace: sessionWorkspace, target: page.getActivePage?.(), site: cmd.site, command: fullName(cmd), @@ -305,10 +311,9 @@ export async function executeCommand( await collectObservationEvidence(observation, page).catch(() => {}); exportTraceArtifact(observation, 'success', undefined, opts.onTraceExport); } - // Adapter commands are one-shot — release the current tab lease immediately - // instead of waiting for the 30s idle timeout. The automation container - // window stays open for reuse. - if (!keepOpen) await page.closeWindow?.().catch(() => {}); + // Adapter commands are one-shot — close the automation window immediately + // instead of waiting for the 30s idle timeout. + if (!keepOpen && !keepWorkspaceOpen) await page.closeWindow?.().catch(() => {}); return result; } catch (err) { if (observation) { @@ -328,13 +333,13 @@ export async function executeCommand( exportTraceArtifact(observation, 'failure', err, opts.onTraceExport); } } - // Release the tab lease on failure too — without this, the lease lingers - // until the extension's idle timer fires (unreliable on Windows where - // MV3 service workers may be suspended before setTimeout triggers). - if (!keepOpen) await page.closeWindow?.().catch(() => {}); + // Close the automation window on failure too — without this, the window + // lingers until the extension's idle timer fires (unreliable on Windows + // where MV3 service workers may be suspended before setTimeout triggers). + if (!keepOpen && !keepWorkspaceOpen) await page.closeWindow?.().catch(() => {}); throw err; } - }, { workspace: `site:${cmd.site}:${crypto.randomUUID()}`, cdpEndpoint, contextId }); + }, { workspace: sessionWorkspace, cdpEndpoint, contextId }); } else { // Non-browser commands: enforce a timeout only when the command exposes // a `--timeout` arg (and the resolved value is positive). Without that