@ericmhalvorsen/aperture 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @ericmhalvorsen/aperture
2
+
3
+ > Let Agent see browser. Agent build better stuff. Perchance
4
+
5
+ No extensions. No CORS hacks. Auto-connect your local dev browser to Claude Code, Cursor, OpenCode, or any other MCP-capable agent.
6
+
7
+ ## 5-Minute Quickstart
8
+
9
+ ### 1. Install
10
+
11
+ ```bash
12
+ npm install -D @ericmhalvorsen/aperture
13
+ ```
14
+
15
+ ### 2. Add to your app
16
+
17
+ **Next.js:**
18
+ ```ts
19
+ // next.config.ts
20
+ import { withAperture } from "@ericmhalvorsen/aperture/next";
21
+
22
+ export default withAperture({
23
+ // your existing config
24
+ });
25
+ ```
26
+
27
+ **Vite:**
28
+ ```ts
29
+ // vite.config.ts
30
+ import { aperture } from "@ericmhalvorsen/aperture/vite";
31
+
32
+ export default {
33
+ plugins: [aperture()],
34
+ };
35
+ ```
36
+
37
+ **Other frameworks:**
38
+ ```bash
39
+ # Run in a separate terminal
40
+ npx @ericmhalvorsen/aperture
41
+ ```
42
+
43
+ ### 3. Add the client
44
+
45
+ **React/Next.js:**
46
+ ```tsx
47
+ import { Aperture } from "@ericmhalvorsen/aperture/react";
48
+
49
+ export default function RootLayout({ children }) {
50
+ return (
51
+ <html>
52
+ <body>
53
+ {children}
54
+ {process.env.NODE_ENV === "development" && <Aperture />}
55
+ </body>
56
+ </html>
57
+ );
58
+ }
59
+ ```
60
+
61
+ **Vite/Vanilla:**
62
+ ```typescript
63
+ // In your main entry file
64
+ import "@ericmhalvorsen/aperture/register";
65
+ ```
66
+
67
+ **No bundler:**
68
+ ```html
69
+ <script src="http://localhost:3456/aperture.js"></script>
70
+ ```
71
+
72
+ ### 4. Start your dev server
73
+
74
+ ```bash
75
+ npm run dev
76
+ ```
77
+
78
+ You should see:
79
+ - A green dot badge in the bottom-right of your page
80
+ - Console output: `[Aperture] Server running on port 3456`
81
+
82
+ ### 5. Connect your agent
83
+
84
+ Add to your agent's MCP config:
85
+
86
+ ```json
87
+ {
88
+ "mcpServers": {
89
+ "aperture": {
90
+ "url": "http://localhost:3456/mcp"
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ When the agent makes its first request, you'll see an approval dialog in your browser. Click "Allow" to grant access.
97
+
98
+ ### More
99
+
100
+ - [How It Works](./docs/how-it-works.md)
101
+ - [MCP Capabilities](./docs/capabilities.md)
102
+ - [Security Model](./docs/security.md)
103
+ - [Architecture](./docs/architecture.md)
104
+ - [Debugging](./docs/debugging.md)
105
+ - [Chrome Extension Plan](./docs/plans/EXTENSION.md)
106
+
107
+ ---
108
+
109
+ ## AI Development Policy
110
+
111
+ This project is developed in part using AI. When submitting pull requests, you may use AI but please review your submission carefully. The pull request will be treated as if it was written by you.
112
+
113
+ ---
114
+
115
+ ## Local Development
116
+
117
+ ```bash
118
+ # Install
119
+ pnpm install
120
+
121
+ # Watch compilation
122
+ pnpm dev
123
+
124
+ # Build
125
+ pnpm build
126
+
127
+ # Lint & format
128
+ pnpm run check
129
+ pnpm run format
130
+ ```
131
+
132
+ ### Link for local testing
133
+
134
+ ```bash
135
+ # In the aperture directory:
136
+ pnpm link --global
137
+
138
+ # In your test project:
139
+ pnpm link --global @ericmhalvorsen/aperture
140
+ ```
141
+
142
+ After editing Aperture source, run `pnpm build` in the aperture directory. The consuming project (linked) will pick up the changes on restart.
143
+
144
+ ---
145
+
146
+ ## License
147
+
148
+ MIT
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { parseArgs } from "node:util";
4
+ import pc from "picocolors";
5
+ import { ApertureServer } from "./server.js";
6
+ const { values, positionals } = parseArgs({
7
+ options: {
8
+ port: { type: "string", short: "p" },
9
+ verbose: { type: "boolean", short: "v" },
10
+ help: { type: "boolean", short: "h" },
11
+ },
12
+ allowPositionals: true,
13
+ strict: false,
14
+ });
15
+ if (values.help) {
16
+ console.log(`
17
+ ${pc.cyan(pc.bold("Aperture MCP Server"))}
18
+
19
+ ${pc.bold("Usage:")}
20
+ $ aperture [options] [command...]
21
+
22
+ ${pc.bold("Options:")}
23
+ -p, --port <port> Port to run the MCP server on (default: 3456)
24
+ -v, --verbose Enable verbose logging of MCP traffic
25
+ -h, --help Show this help message
26
+
27
+ ${pc.bold("Examples:")}
28
+ $ aperture # Run standalone MCP server
29
+ $ aperture stdin # Run standalone MCP server via stdin/stdout
30
+ $ aperture -p 4000 # Run on port 4000
31
+ $ aperture next dev # Run MCP server and spawn "next dev"
32
+ `);
33
+ process.exit(0);
34
+ }
35
+ const port = Number(values.port || process.env.APERTURE_PORT) || 3456;
36
+ const verbose = Boolean(values.verbose);
37
+ const isStdioMode = positionals[0] === "stdin";
38
+ if (isStdioMode) {
39
+ new ApertureServer(port, { verbose, silentStartup: true, stdio: true });
40
+ console.error(pc.cyan(`\n● Aperture MCP Server initialized (stdio mode)`));
41
+ console.error(`${pc.dim("├")} MCP Endpoint: ${pc.green(`ws://localhost:${port}/mcp`)}`);
42
+ console.error(`${pc.dim("└")} Browser Script: ${pc.blue(`http://localhost:${port}/aperture.js`)}\n`);
43
+ }
44
+ else {
45
+ new ApertureServer(port, { verbose, silentStartup: true });
46
+ console.log(pc.cyan(`\n● Aperture MCP Server initialized`));
47
+ console.log(`${pc.dim("├")} MCP Endpoint: ${pc.green(`ws://localhost:${port}/mcp`)}`);
48
+ console.log(`${pc.dim("└")} Browser Script: ${pc.blue(`http://localhost:${port}/aperture.js`)}\n`);
49
+ if (positionals.length > 0) {
50
+ const [command, ...args] = positionals;
51
+ console.log(pc.dim(`> Spawning wrapped command: ${command} ${args.join(" ")}\n`));
52
+ const child = spawn(command, args, {
53
+ stdio: "inherit",
54
+ shell: true,
55
+ env: { ...process.env, APERTURE_PORT: port.toString() },
56
+ });
57
+ child.on("exit", (code) => {
58
+ process.exit(code ?? 0);
59
+ });
60
+ }
61
+ else {
62
+ console.log(pc.dim("Waiting for connections... (Press Ctrl+C to stop)"));
63
+ }
64
+ }
@@ -0,0 +1,2 @@
1
+ import type { ApertureClient } from "../client.js";
2
+ export declare const TOOL_HANDLERS: Record<string, (client: ApertureClient, args: Record<string, any>) => any | Promise<any>>;
@@ -0,0 +1,171 @@
1
+ import { getConsoleBuffer, getNetworkBuffer } from "./patches.js";
2
+ export const TOOL_HANDLERS = {
3
+ browser_dom_query: (_client, { selector, includeHtml = false }) => {
4
+ const elements = Array.from(document.querySelectorAll(String(selector)));
5
+ return elements.map((el) => ({
6
+ tag: el.tagName.toLowerCase(),
7
+ text: el.textContent?.slice(0, 200) || "",
8
+ visible: !!el.offsetParent,
9
+ attributes: Object.fromEntries(Array.from(el.attributes).map((a) => [a.name, a.value])),
10
+ html: includeHtml ? el.outerHTML.slice(0, 500) : undefined,
11
+ }));
12
+ },
13
+ browser_network_requests: (_client, { limit = 20 }) => {
14
+ return getNetworkBuffer().slice(-Number(limit));
15
+ },
16
+ browser_page_info: (_client, { logLimit = 20, logLevel = "all" }) => {
17
+ let logs = getConsoleBuffer();
18
+ if (logLevel !== "all") {
19
+ logs = logs.filter((e) => e.level === logLevel);
20
+ }
21
+ return {
22
+ url: window.location.href,
23
+ title: document.title,
24
+ width: window.innerWidth,
25
+ height: window.innerHeight,
26
+ scrollX: window.scrollX,
27
+ scrollY: window.scrollY,
28
+ userAgent: navigator.userAgent,
29
+ logs: logs.slice(-Number(logLimit)),
30
+ };
31
+ },
32
+ browser_storage_get: (_client, { type, key, prefix, name }) => {
33
+ if (type === "localStorage") {
34
+ const result = {};
35
+ if (key) {
36
+ result[String(key)] = localStorage.getItem(String(key));
37
+ }
38
+ if (prefix) {
39
+ for (let i = 0; i < localStorage.length; i++) {
40
+ const k = localStorage.key(i);
41
+ if (k?.startsWith(String(prefix))) {
42
+ result[k] = localStorage.getItem(k);
43
+ }
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+ if (type === "cookie") {
49
+ const cookies = {};
50
+ const docCookies = document.cookie;
51
+ if (docCookies) {
52
+ for (const cookie of docCookies.split(";")) {
53
+ const [k, v] = cookie.split("=").map((s) => s.trim());
54
+ if (k)
55
+ cookies[k] = decodeURIComponent(v || "");
56
+ }
57
+ }
58
+ if (name) {
59
+ const targetName = String(name);
60
+ return { [targetName]: cookies[targetName] || null };
61
+ }
62
+ return cookies;
63
+ }
64
+ return { error: `Unknown type: ${type}. Use 'localStorage' or 'cookie'.` };
65
+ },
66
+ browser_screenshot: async (client) => {
67
+ try {
68
+ const dataUrl = await client.captureScreenshotFromStream();
69
+ return { base64: dataUrl.split(",")[1], format: "png" };
70
+ }
71
+ catch (e) {
72
+ return { error: e instanceof Error ? e.message : String(e) };
73
+ }
74
+ },
75
+ browser_evaluate: (_client, { expression }) => {
76
+ // biome-ignore lint/security/noGlobalEval: developer-only bridge, gated by approval
77
+ const result = window.eval(String(expression));
78
+ return {
79
+ result: typeof result === "object" ? JSON.stringify(result) : String(result),
80
+ };
81
+ },
82
+ browser_click: (_client, { selector }) => {
83
+ const element = document.querySelector(String(selector));
84
+ if (!element) {
85
+ return { error: `Element not found matching selector: ${selector}` };
86
+ }
87
+ element.focus();
88
+ const events = [
89
+ "pointerdown",
90
+ "mousedown",
91
+ "pointerup",
92
+ "mouseup",
93
+ "click",
94
+ ];
95
+ for (const name of events) {
96
+ const ev = new MouseEvent(name, {
97
+ bubbles: true,
98
+ cancelable: true,
99
+ });
100
+ element.dispatchEvent(ev);
101
+ }
102
+ return { success: true };
103
+ },
104
+ browser_type: (_client, { selector, text }) => {
105
+ const element = document.querySelector(String(selector));
106
+ if (!element) {
107
+ return { error: `Element not found matching selector: ${selector}` };
108
+ }
109
+ const isInput = element instanceof HTMLInputElement;
110
+ const isTextArea = element instanceof HTMLTextAreaElement;
111
+ if (isInput || isTextArea) {
112
+ const proto = isInput
113
+ ? HTMLInputElement.prototype
114
+ : HTMLTextAreaElement.prototype;
115
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
116
+ if (setter) {
117
+ setter.call(element, String(text));
118
+ }
119
+ else {
120
+ element.value =
121
+ String(text);
122
+ }
123
+ }
124
+ else if (element.isContentEditable) {
125
+ element.textContent = String(text);
126
+ }
127
+ else {
128
+ return {
129
+ error: `Element is not an input, textarea, or contenteditable.`,
130
+ };
131
+ }
132
+ element.dispatchEvent(new Event("input", { bubbles: true }));
133
+ element.dispatchEvent(new Event("change", { bubbles: true }));
134
+ return { success: true };
135
+ },
136
+ browser_scroll: (_client, { selector, x, y, scrollIntoView }) => {
137
+ if (selector) {
138
+ const element = document.querySelector(String(selector));
139
+ if (!element) {
140
+ return { error: `Element not found matching selector: ${selector}` };
141
+ }
142
+ if (scrollIntoView) {
143
+ element.scrollIntoView({
144
+ behavior: "smooth",
145
+ block: "center",
146
+ inline: "nearest",
147
+ });
148
+ return { success: true };
149
+ }
150
+ if (x !== undefined)
151
+ element.scrollLeft = Number(x);
152
+ if (y !== undefined)
153
+ element.scrollTop = Number(y);
154
+ return {
155
+ success: true,
156
+ scrollLeft: element.scrollLeft,
157
+ scrollTop: element.scrollTop,
158
+ };
159
+ }
160
+ else {
161
+ const posX = x !== undefined ? Number(x) : window.scrollX;
162
+ const posY = y !== undefined ? Number(y) : window.scrollY;
163
+ window.scrollTo({ left: posX, top: posY, behavior: "smooth" });
164
+ return {
165
+ success: true,
166
+ scrollX: window.scrollX,
167
+ scrollY: window.scrollY,
168
+ };
169
+ }
170
+ },
171
+ };
@@ -0,0 +1,18 @@
1
+ export interface ConsoleEntry {
2
+ level: string;
3
+ message: string;
4
+ timestamp: number;
5
+ }
6
+ export interface NetworkEntry {
7
+ url: string;
8
+ method: string;
9
+ start: number;
10
+ end: number;
11
+ status: number;
12
+ responseText: string;
13
+ error?: string;
14
+ }
15
+ export declare function getConsoleBuffer(): ConsoleEntry[];
16
+ export declare function getNetworkBuffer(): NetworkEntry[];
17
+ export declare function patchConsole(): void;
18
+ export declare function patchFetch(): void;
@@ -0,0 +1,56 @@
1
+ const consoleBuffer = [];
2
+ const networkBuffer = [];
3
+ export function getConsoleBuffer() {
4
+ return consoleBuffer;
5
+ }
6
+ export function getNetworkBuffer() {
7
+ return networkBuffer;
8
+ }
9
+ export function patchConsole() {
10
+ const levels = ["log", "warn", "error", "info", "debug"];
11
+ for (const level of levels) {
12
+ const orig = console[level];
13
+ console[level] = (...args) => {
14
+ const message = args
15
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
16
+ .join(" ");
17
+ consoleBuffer.push({ level, message, timestamp: Date.now() });
18
+ if (consoleBuffer.length > 500)
19
+ consoleBuffer.shift();
20
+ orig(...args);
21
+ };
22
+ }
23
+ }
24
+ export function patchFetch() {
25
+ const origFetch = window.fetch;
26
+ window.fetch = async (...args) => {
27
+ const entry = {
28
+ url: String(args[0]),
29
+ method: "GET",
30
+ start: Date.now(),
31
+ end: 0,
32
+ status: 0,
33
+ responseText: "",
34
+ };
35
+ if (args[1]) {
36
+ entry.method = args[1].method || "GET";
37
+ }
38
+ try {
39
+ const res = await origFetch(...args);
40
+ entry.status = res.status;
41
+ entry.end = Date.now();
42
+ networkBuffer.push(entry);
43
+ if (networkBuffer.length > 100)
44
+ networkBuffer.shift();
45
+ return res;
46
+ }
47
+ catch (err) {
48
+ entry.end = Date.now();
49
+ entry.error = String(err);
50
+ networkBuffer.push(entry);
51
+ if (networkBuffer.length > 100)
52
+ networkBuffer.shift();
53
+ throw err;
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,5 @@
1
+ export declare const storage: {
2
+ get(key: string): string | null;
3
+ set(key: string, value: string): void;
4
+ remove(key: string): void;
5
+ };
@@ -0,0 +1,31 @@
1
+ export const storage = {
2
+ get(key) {
3
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
4
+ return null;
5
+ }
6
+ try {
7
+ return localStorage.getItem(key);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ },
13
+ set(key, value) {
14
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
15
+ return;
16
+ }
17
+ try {
18
+ localStorage.setItem(key, value);
19
+ }
20
+ catch { }
21
+ },
22
+ remove(key) {
23
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
24
+ return;
25
+ }
26
+ try {
27
+ localStorage.removeItem(key);
28
+ }
29
+ catch { }
30
+ },
31
+ };
@@ -0,0 +1,26 @@
1
+ export declare function injectStyles(): void;
2
+ export declare function requestDisplayMedia(): Promise<MediaStream | null>;
3
+ export declare function showApprovalDialog(agentName: string, onApprovalStateChange: (state: {
4
+ stream?: MediaStream | null;
5
+ capabilities?: string[];
6
+ }) => void): Promise<{
7
+ approved: boolean;
8
+ capabilities: string[];
9
+ ttlMs?: number;
10
+ dismissed?: boolean;
11
+ }>;
12
+ export declare function showStatusDialog(options: {
13
+ wsReadyState: number;
14
+ approved: boolean;
15
+ denied: boolean;
16
+ capabilities: string[];
17
+ isBadgeHidden: () => boolean;
18
+ showBadge: () => void;
19
+ hideBadgeFor24h: () => void;
20
+ revokeApproval: () => void;
21
+ onApprovalStateChange: (state: {
22
+ approved: boolean;
23
+ capabilities: string[];
24
+ stream?: MediaStream | null;
25
+ }) => void;
26
+ }): void;