opendevbrowser 0.0.10

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.
@@ -0,0 +1,301 @@
1
+ import { DEFAULT_PAIRING_ENABLED, DEFAULT_PAIRING_TOKEN, DEFAULT_RELAY_PORT } from "../relay-settings.js";
2
+ import { RelayClient } from "./RelayClient.js";
3
+ import { CDPRouter } from "./CDPRouter.js";
4
+ import { TabManager } from "./TabManager.js";
5
+ export class ConnectionManager {
6
+ status = "disconnected";
7
+ listeners = new Set();
8
+ relay = null;
9
+ cdp = new CDPRouter();
10
+ tabs = new TabManager();
11
+ trackedTab = null;
12
+ disconnecting = false;
13
+ shouldReconnect = false;
14
+ reconnectTimer = null;
15
+ reconnectAttempts = 0;
16
+ reconnectDelayMs = 500;
17
+ pairingToken = DEFAULT_PAIRING_TOKEN;
18
+ pairingEnabled = DEFAULT_PAIRING_ENABLED;
19
+ relayPort = DEFAULT_RELAY_PORT;
20
+ maxReconnectAttempts = 5;
21
+ maxReconnectDelayMs = 5000;
22
+ constructor() {
23
+ this.loadSettings().catch(() => { });
24
+ chrome.storage.onChanged.addListener(this.handleStorageChange);
25
+ chrome.tabs.onRemoved.addListener(this.handleTabRemoved);
26
+ chrome.tabs.onUpdated.addListener(this.handleTabUpdated);
27
+ }
28
+ getStatus() {
29
+ return this.status;
30
+ }
31
+ async connect() {
32
+ if (this.status === "connected") {
33
+ return;
34
+ }
35
+ try {
36
+ this.shouldReconnect = true;
37
+ this.reconnectAttempts = 0;
38
+ await this.loadSettings();
39
+ await this.attachToActiveTab();
40
+ await this.connectRelay();
41
+ }
42
+ catch {
43
+ await this.disconnect();
44
+ }
45
+ }
46
+ async disconnect() {
47
+ if (this.disconnecting)
48
+ return;
49
+ this.disconnecting = true;
50
+ this.shouldReconnect = false;
51
+ this.clearReconnectTimer();
52
+ try {
53
+ if (this.relay) {
54
+ this.relay.disconnect();
55
+ this.relay = null;
56
+ }
57
+ if (this.trackedTab !== null) {
58
+ await this.cdp.detach();
59
+ this.trackedTab = null;
60
+ }
61
+ }
62
+ finally {
63
+ this.disconnecting = false;
64
+ this.setStatus("disconnected");
65
+ }
66
+ }
67
+ onStatus(listener) {
68
+ this.listeners.add(listener);
69
+ return () => this.listeners.delete(listener);
70
+ }
71
+ setStatus(status) {
72
+ this.status = status;
73
+ for (const listener of this.listeners) {
74
+ listener(status);
75
+ }
76
+ }
77
+ async attachToActiveTab() {
78
+ const tab = await this.tabs.getActiveTab();
79
+ if (!tab || typeof tab.id !== "number") {
80
+ this.trackedTab = null;
81
+ this.setStatus("disconnected");
82
+ throw new Error("No active tab available");
83
+ }
84
+ await this.cdp.attach(tab.id);
85
+ this.trackedTab = {
86
+ id: tab.id,
87
+ url: tab.url ?? undefined,
88
+ title: tab.title ?? undefined,
89
+ groupId: typeof tab.groupId === "number" ? tab.groupId : undefined
90
+ };
91
+ }
92
+ async connectRelay() {
93
+ if (!this.trackedTab) {
94
+ throw new Error("No tracked tab for relay connection");
95
+ }
96
+ const relay = new RelayClient(this.buildRelayUrl(), {
97
+ onCommand: (command) => {
98
+ this.cdp.handleCommand(command).catch(() => {
99
+ this.disconnect().catch(() => { });
100
+ });
101
+ },
102
+ onClose: () => {
103
+ this.handleRelayClose();
104
+ }
105
+ });
106
+ this.relay = relay;
107
+ this.cdp.setCallbacks({
108
+ onEvent: (event) => this.relay?.sendEvent(event),
109
+ onResponse: (response) => this.relay?.sendResponse(response),
110
+ onDetach: () => {
111
+ this.disconnect().catch(() => { });
112
+ }
113
+ });
114
+ try {
115
+ await relay.connect(this.buildHandshake());
116
+ this.setStatus("connected");
117
+ this.reconnectAttempts = 0;
118
+ this.reconnectDelayMs = 500;
119
+ }
120
+ catch (error) {
121
+ if (this.relay === relay) {
122
+ this.relay = null;
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+ handleRelayClose() {
128
+ this.relay = null;
129
+ if (!this.shouldReconnect || !this.trackedTab) {
130
+ return;
131
+ }
132
+ this.setStatus("disconnected");
133
+ this.scheduleReconnect();
134
+ }
135
+ scheduleReconnect() {
136
+ if (this.reconnectTimer !== null) {
137
+ return;
138
+ }
139
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
140
+ this.disconnect().catch(() => { });
141
+ return;
142
+ }
143
+ this.reconnectTimer = setTimeout(() => {
144
+ this.reconnectTimer = null;
145
+ this.reconnectAttempts += 1;
146
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.maxReconnectDelayMs);
147
+ this.reconnectRelay().catch(() => {
148
+ this.scheduleReconnect();
149
+ });
150
+ }, this.reconnectDelayMs);
151
+ }
152
+ async reconnectRelay() {
153
+ if (!this.trackedTab || !this.shouldReconnect) {
154
+ return;
155
+ }
156
+ const attachedId = this.cdp.getAttachedTabId();
157
+ if (attachedId !== this.trackedTab.id) {
158
+ this.disconnect().catch(() => { });
159
+ return;
160
+ }
161
+ const tab = await this.tabs.getTab(this.trackedTab.id);
162
+ if (!tab) {
163
+ this.disconnect().catch(() => { });
164
+ return;
165
+ }
166
+ this.trackedTab = {
167
+ id: tab.id ?? this.trackedTab.id,
168
+ url: tab.url ?? this.trackedTab.url,
169
+ title: tab.title ?? this.trackedTab.title,
170
+ groupId: typeof tab.groupId === "number" ? tab.groupId : this.trackedTab.groupId
171
+ };
172
+ await this.connectRelay();
173
+ }
174
+ buildHandshake() {
175
+ if (!this.trackedTab) {
176
+ throw new Error("No tracked tab for handshake");
177
+ }
178
+ const payload = {
179
+ tabId: this.trackedTab.id,
180
+ url: this.trackedTab.url,
181
+ title: this.trackedTab.title,
182
+ groupId: this.trackedTab.groupId
183
+ };
184
+ if (this.pairingEnabled && this.pairingToken) {
185
+ payload.pairingToken = this.pairingToken;
186
+ }
187
+ return {
188
+ type: "handshake",
189
+ payload
190
+ };
191
+ }
192
+ handleStorageChange = (changes, area) => {
193
+ if (area !== "local") {
194
+ return;
195
+ }
196
+ if (changes.pairingToken) {
197
+ this.updatePairingToken(changes.pairingToken.newValue);
198
+ this.refreshHandshake();
199
+ }
200
+ if (changes.pairingEnabled) {
201
+ this.updatePairingEnabled(changes.pairingEnabled.newValue);
202
+ this.ensurePairingTokenDefault();
203
+ this.refreshHandshake();
204
+ }
205
+ if (changes.relayPort) {
206
+ this.updateRelayPort(changes.relayPort.newValue);
207
+ this.refreshRelay().catch(() => { });
208
+ }
209
+ };
210
+ handleTabRemoved = (tabId) => {
211
+ if (this.trackedTab && this.trackedTab.id === tabId) {
212
+ this.disconnect().catch(() => { });
213
+ }
214
+ };
215
+ handleTabUpdated = (_tabId, _changeInfo, tab) => {
216
+ if (!this.trackedTab || tab.id !== this.trackedTab.id) {
217
+ return;
218
+ }
219
+ this.trackedTab = {
220
+ id: tab.id,
221
+ url: tab.url ?? this.trackedTab.url,
222
+ title: tab.title ?? this.trackedTab.title,
223
+ groupId: typeof tab.groupId === "number" ? tab.groupId : this.trackedTab.groupId
224
+ };
225
+ if (this.relay?.isConnected()) {
226
+ this.relay.sendHandshake(this.buildHandshake());
227
+ }
228
+ };
229
+ async loadSettings() {
230
+ const data = await new Promise((resolve) => {
231
+ chrome.storage.local.get(["pairingToken", "pairingEnabled", "relayPort"], (items) => {
232
+ resolve(items);
233
+ });
234
+ });
235
+ this.updatePairingEnabled(data.pairingEnabled);
236
+ this.updatePairingToken(data.pairingToken);
237
+ this.updateRelayPort(data.relayPort);
238
+ this.ensurePairingTokenDefault();
239
+ }
240
+ updatePairingToken(value) {
241
+ if (typeof value === "string" && value.trim().length > 0) {
242
+ this.pairingToken = value.trim();
243
+ return;
244
+ }
245
+ this.pairingToken = null;
246
+ }
247
+ updatePairingEnabled(value) {
248
+ if (typeof value === "boolean") {
249
+ this.pairingEnabled = value;
250
+ return;
251
+ }
252
+ this.pairingEnabled = DEFAULT_PAIRING_ENABLED;
253
+ }
254
+ ensurePairingTokenDefault() {
255
+ if (!this.pairingEnabled || this.pairingToken) {
256
+ return;
257
+ }
258
+ this.pairingToken = DEFAULT_PAIRING_TOKEN;
259
+ chrome.storage.local.set({ pairingToken: DEFAULT_PAIRING_TOKEN });
260
+ }
261
+ updateRelayPort(value) {
262
+ if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
263
+ this.relayPort = value;
264
+ return;
265
+ }
266
+ if (typeof value === "string" && value.trim()) {
267
+ const parsed = Number(value);
268
+ if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
269
+ this.relayPort = parsed;
270
+ return;
271
+ }
272
+ }
273
+ this.relayPort = DEFAULT_RELAY_PORT;
274
+ }
275
+ /**
276
+ * Chrome automatically sends Origin: chrome-extension://EXTENSION_ID
277
+ * for WebSocket connections from extensions. The relay server validates
278
+ * this to prevent CSWSH attacks from web pages.
279
+ */
280
+ buildRelayUrl() {
281
+ return `ws://127.0.0.1:${this.relayPort}/extension`;
282
+ }
283
+ async refreshRelay() {
284
+ if (this.status !== "connected")
285
+ return;
286
+ await this.disconnect();
287
+ await this.connect();
288
+ }
289
+ clearReconnectTimer() {
290
+ if (this.reconnectTimer !== null) {
291
+ clearTimeout(this.reconnectTimer);
292
+ this.reconnectTimer = null;
293
+ }
294
+ }
295
+ refreshHandshake() {
296
+ if (!this.trackedTab || !this.relay?.isConnected()) {
297
+ return;
298
+ }
299
+ this.relay.sendHandshake(this.buildHandshake());
300
+ }
301
+ }
@@ -0,0 +1,73 @@
1
+ export class RelayClient {
2
+ url;
3
+ handlers;
4
+ socket = null;
5
+ constructor(url, handlers) {
6
+ this.url = url;
7
+ this.handlers = handlers;
8
+ }
9
+ async connect(handshake) {
10
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
11
+ return;
12
+ }
13
+ this.socket = new WebSocket(this.url);
14
+ await new Promise((resolve, reject) => {
15
+ if (!this.socket) {
16
+ reject(new Error("Relay socket not created"));
17
+ return;
18
+ }
19
+ this.socket.addEventListener("open", () => resolve(), { once: true });
20
+ this.socket.addEventListener("error", () => reject(new Error("Relay socket error")), {
21
+ once: true
22
+ });
23
+ });
24
+ this.socket.addEventListener("message", (event) => {
25
+ const message = parseJson(event.data);
26
+ if (!message || typeof message !== "object")
27
+ return;
28
+ const record = message;
29
+ if (record.method === "forwardCDPCommand") {
30
+ this.handlers.onCommand(record);
31
+ }
32
+ });
33
+ this.socket.addEventListener("close", () => {
34
+ this.handlers.onClose();
35
+ });
36
+ this.send(handshake);
37
+ }
38
+ disconnect() {
39
+ if (!this.socket)
40
+ return;
41
+ if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) {
42
+ this.socket.close(1000, "Relay disconnect");
43
+ }
44
+ this.socket = null;
45
+ }
46
+ sendResponse(response) {
47
+ this.send(response);
48
+ }
49
+ sendEvent(event) {
50
+ this.send(event);
51
+ }
52
+ sendHandshake(handshake) {
53
+ this.send(handshake);
54
+ }
55
+ isConnected() {
56
+ return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
57
+ }
58
+ send(payload) {
59
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
60
+ return;
61
+ this.socket.send(JSON.stringify(payload));
62
+ }
63
+ }
64
+ const parseJson = (data) => {
65
+ if (typeof data !== "string")
66
+ return null;
67
+ try {
68
+ return JSON.parse(data);
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ };
@@ -0,0 +1,18 @@
1
+ export class TabManager {
2
+ async getTab(tabId) {
3
+ try {
4
+ return await chrome.tabs.get(tabId);
5
+ }
6
+ catch {
7
+ return null;
8
+ }
9
+ }
10
+ async getActiveTab() {
11
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
12
+ return tabs[0] ?? null;
13
+ }
14
+ async getActiveTabId() {
15
+ const tab = await this.getActiveTab();
16
+ return tab?.id ?? null;
17
+ }
18
+ }
@@ -0,0 +1 @@
1
+ export {};
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,34 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "OpenDevBrowser Relay",
4
+ "version": "0.0.10",
5
+ "description": "Optional bridge to reuse existing Chrome tabs with OpenDevBrowser.",
6
+ "permissions": [
7
+ "debugger",
8
+ "tabs",
9
+ "storage"
10
+ ],
11
+ "host_permissions": [
12
+ "http://127.0.0.1/*",
13
+ "http://localhost/*"
14
+ ],
15
+ "icons": {
16
+ "16": "icons/icon16.png",
17
+ "32": "icons/icon32.png",
18
+ "48": "icons/icon48.png",
19
+ "128": "icons/icon128.png"
20
+ },
21
+ "action": {
22
+ "default_popup": "popup.html",
23
+ "default_icon": {
24
+ "16": "icons/icon16.png",
25
+ "32": "icons/icon32.png",
26
+ "48": "icons/icon48.png",
27
+ "128": "icons/icon128.png"
28
+ }
29
+ },
30
+ "background": {
31
+ "service_worker": "dist/background.js",
32
+ "type": "module"
33
+ }
34
+ }
@@ -0,0 +1,108 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>OpenDevBrowser</title>
7
+ <style>
8
+ body {
9
+ font-family: Arial, sans-serif;
10
+ margin: 0;
11
+ padding: 12px;
12
+ min-width: 280px;
13
+ }
14
+ h1 {
15
+ font-size: 14px;
16
+ margin: 0 0 8px;
17
+ }
18
+ .status-container {
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 8px;
22
+ margin-bottom: 12px;
23
+ }
24
+ .status-indicator {
25
+ width: 12px;
26
+ height: 12px;
27
+ border-radius: 50%;
28
+ background-color: #dc3545;
29
+ transition: background-color 0.3s ease;
30
+ }
31
+ .status-indicator.connected {
32
+ background-color: #28a745;
33
+ }
34
+ #status {
35
+ font-size: 12px;
36
+ }
37
+ label {
38
+ font-size: 11px;
39
+ display: block;
40
+ margin-bottom: 4px;
41
+ }
42
+ label.toggle {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 6px;
46
+ margin-bottom: 10px;
47
+ }
48
+ input {
49
+ width: 100%;
50
+ padding: 6px;
51
+ margin-bottom: 10px;
52
+ border: 1px solid #ccc;
53
+ border-radius: 4px;
54
+ box-sizing: border-box;
55
+ }
56
+ input[type="checkbox"] {
57
+ width: auto;
58
+ margin: 0;
59
+ padding: 0;
60
+ }
61
+ input:disabled {
62
+ background-color: #f5f5f5;
63
+ color: #999;
64
+ }
65
+ button {
66
+ width: 100%;
67
+ padding: 8px;
68
+ border: 1px solid #222;
69
+ background: #111;
70
+ color: #fff;
71
+ cursor: pointer;
72
+ border-radius: 4px;
73
+ transition: background-color 0.2s ease;
74
+ }
75
+ button:hover {
76
+ background: #333;
77
+ }
78
+ .auto-pair-note {
79
+ font-size: 10px;
80
+ color: #666;
81
+ margin-top: -6px;
82
+ margin-bottom: 10px;
83
+ }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <h1>OpenDevBrowser</h1>
88
+ <div class="status-container">
89
+ <div id="statusIndicator" class="status-indicator"></div>
90
+ <div id="status">Disconnected</div>
91
+ </div>
92
+ <label for="relayPort">Relay port</label>
93
+ <input id="relayPort" type="number" min="1" max="65535" />
94
+ <label class="toggle" for="autoPair">
95
+ <input id="autoPair" type="checkbox" />
96
+ Auto-Pair (fetch token from plugin)
97
+ </label>
98
+ <div class="auto-pair-note">When enabled, token is fetched automatically from running plugin</div>
99
+ <label class="toggle" for="pairingEnabled">
100
+ <input id="pairingEnabled" type="checkbox" />
101
+ Require pairing token
102
+ </label>
103
+ <label for="pairingToken">Pairing token</label>
104
+ <input id="pairingToken" type="text" placeholder="Enter token or enable Auto-Pair" />
105
+ <button id="toggle">Connect</button>
106
+ <script type="module" src="dist/popup.js"></script>
107
+ </body>
108
+ </html>
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "opendevbrowser",
3
+ "version": "0.0.10",
4
+ "description": "OpenCode plugin for browser automation via CDP with snapshot-refs-actions workflow",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "opendevbrowser": "dist/cli/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "skills",
14
+ "extension/manifest.json",
15
+ "extension/popup.html",
16
+ "extension/dist",
17
+ "extension/icons"
18
+ ],
19
+ "keywords": [
20
+ "opencode",
21
+ "plugin",
22
+ "browser",
23
+ "automation",
24
+ "cdp",
25
+ "playwright",
26
+ "testing",
27
+ "web-scraping",
28
+ "chrome"
29
+ ],
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/freshtechbro/opendevbrowser.git"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup src/index.ts src/cli/index.ts --format esm --dts --clean --sourcemap && node --input-type=module -e \"import { copyFileSync, existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\nconst dist = resolve('dist');\nconst pairs = [\n ['index.js', 'opendevbrowser.js'],\n ['index.js.map', 'opendevbrowser.js.map'],\n ['index.d.ts', 'opendevbrowser.d.ts'],\n ['index.d.ts.map', 'opendevbrowser.d.ts.map'],\n];\nfor (const [src, dst] of pairs) {\n const from = resolve(dist, src);\n const to = resolve(dist, dst);\n if (existsSync(from)) copyFileSync(from, to);\n}\"",
40
+ "dev": "tsup src/index.ts src/cli/index.ts --format esm --dts --watch",
41
+ "lint": "eslint \"{src,tests}/**/*.ts\"",
42
+ "test": "vitest run --coverage",
43
+ "extension:sync": "node scripts/sync-extension-version.mjs",
44
+ "extension:build": "npm run extension:sync && tsc -p extension/tsconfig.json",
45
+ "extension:pack": "cd extension && zip -r ../opendevbrowser-extension.zip manifest.json popup.html dist/ icons/",
46
+ "version:check": "node scripts/verify-versions.mjs",
47
+ "prepack": "npm run build && npm run extension:build"
48
+ },
49
+ "dependencies": {
50
+ "@opencode-ai/plugin": "^1.0.203",
51
+ "@puppeteer/browsers": "^2.2.0",
52
+ "async-mutex": "^0.5.0",
53
+ "jsonc-parser": "^3.2.0",
54
+ "playwright-core": "^1.49.1",
55
+ "ws": "^8.17.1",
56
+ "zod": "^3.23.8"
57
+ },
58
+ "devDependencies": {
59
+ "@types/chrome": "^0.0.270",
60
+ "@types/node": "^20.19.27",
61
+ "@types/ws": "^8.18.1",
62
+ "@typescript-eslint/eslint-plugin": "^8.9.0",
63
+ "@typescript-eslint/parser": "^8.9.0",
64
+ "@vitest/coverage-v8": "^4.0.16",
65
+ "eslint": "^9.12.0",
66
+ "happy-dom": "^20.0.11",
67
+ "tsup": "^8.5.1",
68
+ "typescript": "^5.9.3",
69
+ "vitest": "^4.0.16"
70
+ }
71
+ }
@@ -0,0 +1,80 @@
1
+ # Local AGENTS.md (skills/)
2
+
3
+ Applies to `skills/` and subdirectories. Extends root `AGENTS.md`.
4
+
5
+ ## Skill Pack Architecture
6
+ - Each skill pack lives in its own folder with `SKILL.md` as the entry point.
7
+ - `opendevbrowser-best-practices` is the canonical prompting guide source.
8
+ - OpenCode-native discovery is primary:
9
+ - Project-local: `.opencode/skill/*/SKILL.md`
10
+ - Global: `~/.config/opencode/skill/*/SKILL.md`
11
+ - Compatibility-only paths: `.claude/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`
12
+ - `opendevbrowser_skill_list/load` are compatibility wrappers; OpenCode `skill` is primary.
13
+
14
+ ## Skill Pack Rules
15
+ - `skills/opendevbrowser-best-practices/SKILL.md` is the source for prompting guide output.
16
+ - Keep guidance short, script-first, and snapshot-first.
17
+ - Keep examples aligned with `opendevbrowser_*` tool names.
18
+ - Do not include secrets or captured page data in skill content.
19
+
20
+ ## Skill Format Specification
21
+
22
+ ### Naming Conventions (OpenCode alignment)
23
+ - Skill names: lowercase, hyphens only, 1-64 characters
24
+ - Directory name must match skill name in frontmatter
25
+ - Examples: `login-automation`, `form-testing`, `data-extraction`
26
+
27
+ ### SKILL.md Structure
28
+ ```markdown
29
+ ---
30
+ name: skill-name
31
+ description: Brief description (1-1024 chars)
32
+ version: 1.0.0
33
+ ---
34
+
35
+ # Skill Title
36
+
37
+ ## Section Heading
38
+ Content organized by topic for filtering.
39
+ ```
40
+
41
+ ### Required Frontmatter
42
+ | Field | Required | Description |
43
+ |-------|----------|-------------|
44
+ | `name` | Yes | Skill identifier (lowercase, hyphens) |
45
+ | `description` | Yes | Brief description for listing |
46
+ | `version` | No | Semantic version (defaults to 1.0.0) |
47
+
48
+ ## Available Skills
49
+
50
+ | Skill | Purpose |
51
+ |-------|---------|
52
+ | `opendevbrowser-best-practices` | Core prompting guide for browser automation |
53
+ | `opendevbrowser-continuity-ledger` | Continuity ledger guidance for long-running tasks |
54
+ | `login-automation` | Authentication and credential handling |
55
+ | `form-testing` | Form validation and submission testing |
56
+ | `data-extraction` | Table extraction and pagination handling |
57
+
58
+ ## Custom Skill Paths
59
+ Advanced: users can add custom search paths via `skillPaths` in `opendevbrowser.jsonc`:
60
+ ```jsonc
61
+ {
62
+ "skillPaths": ["~/.config/opencode/opendevbrowser-skills"]
63
+ }
64
+ ```
65
+
66
+ ## Folder Structure
67
+ ```
68
+ skills/
69
+ |-- opendevbrowser-best-practices/
70
+ | `-- SKILL.md
71
+ |-- opendevbrowser-continuity-ledger/
72
+ | `-- SKILL.md
73
+ |-- login-automation/
74
+ | `-- SKILL.md
75
+ |-- form-testing/
76
+ | `-- SKILL.md
77
+ |-- data-extraction/
78
+ | `-- SKILL.md
79
+ `-- AGENTS.md
80
+ ```