webtap-tool 0.1.1__py3-none-any.whl

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.

Potentially problematic release.


This version of webtap-tool might be problematic. Click here for more details.

@@ -0,0 +1,8 @@
1
+ {
2
+ "Browser": "Chrome/139.0.7258.138",
3
+ "Protocol-Version": "1.3",
4
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
5
+ "V8-Version": "13.9.205.20",
6
+ "WebKit-Version": "537.36 (@884e54ea8d42947ed636779015c5b4815e069838)",
7
+ "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/e2c22d46-fafc-483e-a512-caccea649b20"
8
+ }
webtap/cdp/session.py ADDED
@@ -0,0 +1,365 @@
1
+ """CDP Session with native event storage.
2
+
3
+ PUBLIC API:
4
+ - CDPSession: WebSocket-based CDP client with DuckDB event storage
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import threading
10
+ from concurrent.futures import Future, TimeoutError
11
+ from typing import Any
12
+
13
+ import duckdb
14
+ import requests
15
+ import websocket
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class CDPSession:
21
+ """WebSocket-based CDP client with native event storage.
22
+
23
+ Stores CDP events as-is in DuckDB for minimal overhead and maximum flexibility.
24
+ Provides field discovery and query capabilities for dynamic data exploration.
25
+
26
+ Attributes:
27
+ port: Chrome debugging port.
28
+ timeout: Default timeout for execute() calls.
29
+ db: DuckDB connection for event storage.
30
+ field_paths: Live field lookup for query building.
31
+ """
32
+
33
+ def __init__(self, port: int = 9222, timeout: float = 30):
34
+ """Initialize CDP session with WebSocket and DuckDB storage.
35
+
36
+ Args:
37
+ port: Chrome debugging port. Defaults to 9222.
38
+ timeout: Default timeout for execute() calls. Defaults to 30.
39
+ """
40
+ self.port = port
41
+ self.timeout = timeout
42
+
43
+ # WebSocketApp instance
44
+ self.ws_app: websocket.WebSocketApp | None = None
45
+ self.ws_thread: threading.Thread | None = None
46
+
47
+ # Connection state
48
+ self.connected = threading.Event()
49
+ self.page_info: dict | None = None
50
+
51
+ # CDP request/response tracking
52
+ self._next_id = 1
53
+ self._pending: dict[int, Future] = {}
54
+ self._lock = threading.Lock()
55
+
56
+ # DuckDB storage - store events AS-IS
57
+ self.db = duckdb.connect(":memory:")
58
+
59
+ self.db.execute("CREATE TABLE events (event JSON)")
60
+
61
+ # Live field path lookup for fast discovery
62
+ # Maps lowercase field names to their full paths with original case
63
+ self.field_paths: dict[str, set[str]] = {}
64
+
65
+ def list_pages(self) -> list[dict]:
66
+ """List available Chrome pages via HTTP API.
67
+
68
+ Returns:
69
+ List of page dictionaries with webSocketDebuggerUrl.
70
+ """
71
+ try:
72
+ resp = requests.get(f"http://localhost:{self.port}/json", timeout=2)
73
+ resp.raise_for_status()
74
+ pages = resp.json()
75
+ return [p for p in pages if p.get("type") == "page" and "webSocketDebuggerUrl" in p]
76
+ except Exception as e:
77
+ logger.error(f"Failed to list pages: {e}")
78
+ return []
79
+
80
+ def connect(self, page_index: int | None = None, page_id: str | None = None) -> None:
81
+ """Connect to Chrome page via WebSocket.
82
+
83
+ Establishes WebSocket connection and starts event collection.
84
+ Does not auto-enable CDP domains - use execute() for that.
85
+
86
+ Args:
87
+ page_index: Index of page to connect to. Defaults to 0.
88
+ page_id: Stable page ID across tab reordering.
89
+
90
+ Raises:
91
+ RuntimeError: If already connected or no pages available.
92
+ ValueError: If page_id not found.
93
+ IndexError: If page_index out of range.
94
+ TimeoutError: If connection fails within 5 seconds.
95
+ """
96
+ if self.ws_app:
97
+ raise RuntimeError("Already connected")
98
+
99
+ pages = self.list_pages()
100
+ if not pages:
101
+ raise RuntimeError("No pages available")
102
+
103
+ # Find the page by ID or index
104
+ if page_id:
105
+ page = next((p for p in pages if p.get("id") == page_id), None)
106
+ if not page:
107
+ raise ValueError(f"Page with ID {page_id} not found")
108
+ elif page_index is not None:
109
+ if page_index >= len(pages):
110
+ raise IndexError(f"Page {page_index} out of range")
111
+ page = pages[page_index]
112
+ else:
113
+ # Default to first page
114
+ page = pages[0]
115
+
116
+ ws_url = page["webSocketDebuggerUrl"]
117
+ self.page_info = page
118
+
119
+ # Create WebSocketApp with callbacks
120
+ self.ws_app = websocket.WebSocketApp(
121
+ ws_url, on_open=self._on_open, on_message=self._on_message, on_error=self._on_error, on_close=self._on_close
122
+ )
123
+
124
+ # Let WebSocketApp handle everything in a thread
125
+ self.ws_thread = threading.Thread(
126
+ target=self.ws_app.run_forever,
127
+ kwargs={
128
+ "ping_interval": 30, # Ping every 30s
129
+ "ping_timeout": 10, # Wait 10s for pong
130
+ "reconnect": 5, # Auto-reconnect with max 5s delay
131
+ "skip_utf8_validation": True, # Faster
132
+ },
133
+ )
134
+ self.ws_thread.daemon = True
135
+ self.ws_thread.start()
136
+
137
+ # Wait for connection
138
+ if not self.connected.wait(timeout=5):
139
+ self.disconnect()
140
+ raise TimeoutError("Failed to connect to Chrome")
141
+
142
+ def disconnect(self) -> None:
143
+ """Disconnect WebSocket and clean up resources."""
144
+ if self.ws_app:
145
+ self.ws_app.close()
146
+ self.ws_app = None
147
+
148
+ if self.ws_thread and self.ws_thread.is_alive():
149
+ self.ws_thread.join(timeout=2)
150
+ self.ws_thread = None
151
+
152
+ self.connected.clear()
153
+ self.page_info = None
154
+
155
+ def send(self, method: str, params: dict | None = None) -> Future:
156
+ """Send CDP command asynchronously.
157
+
158
+ Args:
159
+ method: CDP method like "Page.navigate" or "Network.enable".
160
+ params: Optional command parameters.
161
+
162
+ Returns:
163
+ Future containing CDP response 'result' field.
164
+
165
+ Raises:
166
+ RuntimeError: If not connected to Chrome.
167
+ """
168
+ if not self.ws_app:
169
+ raise RuntimeError("Not connected")
170
+
171
+ with self._lock:
172
+ msg_id = self._next_id
173
+ self._next_id += 1
174
+
175
+ future = Future()
176
+ self._pending[msg_id] = future
177
+
178
+ # Send CDP command
179
+ message = {"id": msg_id, "method": method}
180
+ if params:
181
+ message["params"] = params
182
+
183
+ self.ws_app.send(json.dumps(message))
184
+
185
+ return future
186
+
187
+ def execute(self, method: str, params: dict | None = None, timeout: float | None = None) -> Any:
188
+ """Send CDP command synchronously.
189
+
190
+ Args:
191
+ method: CDP method like "Page.navigate" or "Network.enable".
192
+ params: Optional command parameters.
193
+ timeout: Override default timeout.
194
+
195
+ Returns:
196
+ CDP response 'result' field.
197
+
198
+ Raises:
199
+ TimeoutError: If command times out.
200
+ RuntimeError: If CDP returns error or not connected.
201
+ """
202
+ future = self.send(method, params)
203
+
204
+ try:
205
+ return future.result(timeout=timeout or self.timeout)
206
+ except TimeoutError:
207
+ # Clean up the pending future
208
+ with self._lock:
209
+ for msg_id, f in list(self._pending.items()):
210
+ if f is future:
211
+ self._pending.pop(msg_id, None)
212
+ break
213
+ raise TimeoutError(f"Command {method} timed out")
214
+
215
+ def _on_open(self, ws):
216
+ """WebSocket connection established."""
217
+ logger.info("WebSocket connected")
218
+ self.connected.set()
219
+
220
+ def _on_message(self, ws, message):
221
+ """Handle CDP messages - store events as-is, resolve command futures."""
222
+ try:
223
+ data = json.loads(message)
224
+
225
+ # Command response - resolve future
226
+ if "id" in data:
227
+ msg_id = data["id"]
228
+ with self._lock:
229
+ future = self._pending.pop(msg_id, None)
230
+
231
+ if future:
232
+ if "error" in data:
233
+ future.set_exception(RuntimeError(data["error"]))
234
+ else:
235
+ future.set_result(data.get("result", {}))
236
+
237
+ # CDP event - store AS-IS in DuckDB and update field lookup
238
+ elif "method" in data:
239
+ self.db.execute("INSERT INTO events VALUES (?)", [json.dumps(data)])
240
+ self._update_field_lookup(data)
241
+
242
+ except Exception as e:
243
+ logger.error(f"Error handling message: {e}")
244
+
245
+ def _on_error(self, ws, error):
246
+ """Handle WebSocket errors."""
247
+ logger.error(f"WebSocket error: {error}")
248
+
249
+ def _on_close(self, ws, code, reason):
250
+ """Handle WebSocket closure and cleanup."""
251
+ logger.info(f"WebSocket closed: {code} {reason}")
252
+ self.connected.clear()
253
+
254
+ # Fail pending commands
255
+ with self._lock:
256
+ for future in self._pending.values():
257
+ future.set_exception(RuntimeError("Connection closed"))
258
+ self._pending.clear()
259
+
260
+ def _extract_paths(self, obj, parent_key=""):
261
+ """Extract all JSON paths from nested dict structure.
262
+
263
+ Args:
264
+ obj: Dictionary to extract paths from.
265
+ parent_key: Current path prefix.
266
+ """
267
+ paths = []
268
+ if isinstance(obj, dict):
269
+ for k, v in obj.items():
270
+ new_key = f"{parent_key}.{k}" if parent_key else k
271
+ paths.append(new_key)
272
+ if isinstance(v, dict):
273
+ paths.extend(self._extract_paths(v, new_key))
274
+ return paths
275
+
276
+ def _update_field_lookup(self, data):
277
+ """Update field_paths lookup with new event data.
278
+
279
+ Args:
280
+ data: CDP event dictionary.
281
+ """
282
+ event_type = data.get("method", "unknown")
283
+ paths = self._extract_paths(data)
284
+
285
+ for path in paths:
286
+ # Store with event type prefix using colon separator
287
+ full_path = f"{event_type}:{path}"
288
+
289
+ # Index by each part of the path for flexible searching
290
+ parts = path.split(".")
291
+ for part in parts:
292
+ key = part.lower()
293
+ if key not in self.field_paths:
294
+ self.field_paths[key] = set()
295
+ self.field_paths[key].add(full_path) # Store with event type and original case
296
+
297
+ def discover_field_paths(self, search_key: str) -> list[str]:
298
+ """Discover all JSON paths containing the search key.
299
+
300
+ Used by build_query for dynamic field discovery.
301
+
302
+ Args:
303
+ search_key: Field name to search for like "url" or "status".
304
+
305
+ Returns:
306
+ Sorted list of full paths with event type prefixes.
307
+ """
308
+ search_key = search_key.lower()
309
+ paths = set()
310
+
311
+ # Find all field names that contain our search key
312
+ for field_name, field_paths in self.field_paths.items():
313
+ if search_key in field_name:
314
+ paths.update(field_paths)
315
+
316
+ return sorted(list(paths)) # Sort for consistent results
317
+
318
+ def clear_events(self) -> None:
319
+ """Clear all stored events and reset field lookup."""
320
+ self.db.execute("DELETE FROM events")
321
+ self.field_paths.clear()
322
+
323
+ def query(self, sql: str, params: list | None = None) -> list:
324
+ """Query stored CDP events using DuckDB SQL.
325
+
326
+ Events are stored in 'events' table with single JSON 'event' column.
327
+ Use json_extract_string() for accessing nested fields.
328
+
329
+ Args:
330
+ sql: DuckDB SQL query string.
331
+ params: Optional query parameters.
332
+
333
+ Returns:
334
+ List of result rows.
335
+
336
+ Examples:
337
+ query("SELECT * FROM events WHERE json_extract_string(event, '$.method') = 'Network.responseReceived'")
338
+ query("SELECT json_extract_string(event, '$.params.request.url') as url FROM events")
339
+ """
340
+ result = self.db.execute(sql, params or [])
341
+ return result.fetchall() if result else []
342
+
343
+ def fetch_body(self, request_id: str) -> dict | None:
344
+ """Fetch response body via Network.getResponseBody CDP call.
345
+
346
+ Args:
347
+ request_id: Network request ID from CDP events.
348
+
349
+ Returns:
350
+ Dict with 'body' and 'base64Encoded' keys, or None if failed.
351
+ """
352
+ try:
353
+ return self.execute("Network.getResponseBody", {"requestId": request_id})
354
+ except Exception as e:
355
+ logger.debug(f"Failed to fetch body for {request_id}: {e}")
356
+ return None
357
+
358
+ @property
359
+ def is_connected(self) -> bool:
360
+ """Check if WebSocket connection is active.
361
+
362
+ Returns:
363
+ True if connected to Chrome page.
364
+ """
365
+ return self.connected.is_set()
@@ -0,0 +1,314 @@
1
+ # WebTap Commands Developer Guide
2
+
3
+ This guide documents the patterns and conventions for developing WebTap commands with MCP compatibility.
4
+
5
+ ## Command Patterns (Post-Refinement)
6
+
7
+ ### 1. Simple Commands (No Parameters)
8
+ ```python
9
+ @app.command(display="markdown", fastmcp={"type": "tool"})
10
+ def disconnect(state) -> dict:
11
+ """Disconnect from Chrome."""
12
+ # Implementation
13
+ ```
14
+
15
+ ### 2. Single Required Parameter
16
+ ```python
17
+ @app.command(display="markdown", fastmcp={"type": "tool"})
18
+ def navigate(state, url: str) -> dict:
19
+ """Navigate to URL."""
20
+ # Implementation
21
+ ```
22
+
23
+ ### 3. Optional Boolean/Simple Parameters (Direct)
24
+ ```python
25
+ @app.command(display="markdown", fastmcp={"type": "tool"})
26
+ def reload(state, ignore_cache: bool = False) -> dict:
27
+ """Reload current page."""
28
+ # Implementation
29
+
30
+ # Multiple boolean flags
31
+ @app.command(display="markdown", fastmcp={"type": "tool"})
32
+ def clear(state, events: bool = True, console: bool = False, cache: bool = False) -> dict:
33
+ """Clear various data stores."""
34
+ # Implementation
35
+ ```
36
+
37
+ ### 4. Mutually Exclusive Parameters (Direct)
38
+ Use direct parameters when you have different ways to identify the same thing:
39
+
40
+ ```python
41
+ @app.command(display="markdown", fastmcp={"type": "tool"})
42
+ def connect(state, page: int = None, page_id: str = None) -> dict:
43
+ """Connect to Chrome page.
44
+
45
+ Args:
46
+ page: Connect by page index (0-based)
47
+ page_id: Connect by page ID
48
+
49
+ Note: Cannot specify both page and page_id.
50
+ """
51
+ if page is not None and page_id is not None:
52
+ return error_response("invalid_parameters",
53
+ "Cannot specify both 'page' and 'page_id'")
54
+ # Implementation
55
+ ```
56
+
57
+ ### 5. Multiple Optional Parameters (Direct)
58
+ Use direct parameters for cleaner API when parameters are well-defined:
59
+
60
+ ```python
61
+ @app.command(display="markdown", fastmcp={"type": "tool"})
62
+ def network(state, limit: int = 20, filters: list = None, no_filters: bool = False) -> dict:
63
+ """Show network requests.
64
+
65
+ Args:
66
+ limit: Maximum results to show
67
+ filters: Specific filter categories to apply
68
+ no_filters: Show everything unfiltered
69
+ """
70
+ # Implementation
71
+
72
+ # With expression evaluation
73
+ @app.command(display="markdown", fastmcp={"type": "tool"})
74
+ def body(state, response: int, expr: str = None, decode: bool = True, cache: bool = True) -> dict:
75
+ """Get response body for network request."""
76
+ # Implementation
77
+ ```
78
+
79
+ ### 6. Mixed Parameters (Direct + Dict)
80
+ Use dict only for complex/variable configurations:
81
+
82
+ ```python
83
+ @app.command(display="markdown", fastmcp={"type": "tool"})
84
+ def resume(state, request: int, wait: float = 0.5, modifications: dict = None) -> dict:
85
+ """Resume a paused request.
86
+
87
+ Args:
88
+ request: Request row ID
89
+ wait: Wait time for next event
90
+ modifications: Request/response modifications
91
+ - {"url": "..."} - Change URL
92
+ - {"method": "POST"} - Change method
93
+ """
94
+ mods = modifications or {}
95
+ # Implementation
96
+ ```
97
+
98
+ ### 7. Dynamic Field Discovery (Keep Dict)
99
+ Use dict when field names are dynamic/unknown:
100
+
101
+ ```python
102
+ @app.command(display="markdown", fastmcp={"type": "tool"})
103
+ def events(state, filters: dict = None, limit: int = 20) -> dict:
104
+ """Query CDP events by field values.
105
+
106
+ Args:
107
+ filters: Field filters (any CDP field name)
108
+ - {"method": "Network.*"}
109
+ - {"status": 200}
110
+ - {"url": "*api*"}
111
+ """
112
+ # Fields are discovered dynamically from CDP events
113
+ ```
114
+
115
+ ### 8. Action + Config Pattern (Complex Operations)
116
+ Keep for commands with varied operations:
117
+
118
+ ```python
119
+ @app.command(display="markdown", fastmcp={"type": "tool"})
120
+ def filters(state, action: str = "list", config: dict = None) -> dict:
121
+ """Manage filters.
122
+
123
+ Args:
124
+ action: Operation to perform
125
+ - "list" - Show all filters
126
+ - "add" - Add filter
127
+ - "remove" - Remove filter
128
+ config: Action-specific configuration
129
+ - For add: {"category": "ads", "patterns": ["*ad*"]}
130
+ - For remove: {"patterns": ["*ad*"]}
131
+ """
132
+ cfg = config or {}
133
+
134
+ if action == "add":
135
+ category = cfg.get("category", "custom")
136
+ patterns = cfg.get("patterns", [])
137
+ # Implementation
138
+ ```
139
+
140
+ ## MCP Type Requirements
141
+
142
+ ### ❌ Avoid These (Not MCP Compatible)
143
+ ```python
144
+ # Union types
145
+ def command(state, param: str | None = None)
146
+
147
+ # Optional types
148
+ from typing import Optional
149
+ def command(state, param: Optional[str] = None)
150
+
151
+ # Complex types
152
+ from typing import Dict, List
153
+ def command(state, data: Dict[str, List[str]])
154
+
155
+ # **kwargs
156
+ def command(state, **fields)
157
+ ```
158
+
159
+ ### ✅ Use These Instead
160
+ ```python
161
+ # Simple defaults
162
+ def command(state, param: str = "default")
163
+ def command(state, param: dict = None) # pyright: ignore[reportArgumentType]
164
+ def command(state, param: list = None) # pyright: ignore[reportArgumentType]
165
+ def command(state, param: bool = False)
166
+ def command(state, param: int = 0)
167
+ ```
168
+
169
+ ## Response Patterns
170
+
171
+ ### Resources (Read-Only Data)
172
+ ```python
173
+ @app.command(display="markdown", fastmcp={"type": "resource", "mime_type": "text/markdown"})
174
+ def pages(state) -> dict:
175
+ """List available pages."""
176
+ return build_table_response(
177
+ title="Chrome Pages",
178
+ headers=["Index", "Title", "URL"],
179
+ rows=rows,
180
+ summary=f"{len(rows)} pages"
181
+ )
182
+ ```
183
+
184
+ ### Tools (Actions with Side Effects)
185
+ ```python
186
+ @app.command(display="markdown", fastmcp={"type": "tool"})
187
+ def navigate(state, url: str) -> dict:
188
+ """Navigate to URL."""
189
+ # Perform action
190
+ return build_info_response(
191
+ title="Navigation Complete",
192
+ fields={"URL": url, "Status": "Success"}
193
+ )
194
+ ```
195
+
196
+ ## Error Handling
197
+
198
+ Always use the error utilities from `_errors.py`:
199
+
200
+ ```python
201
+ from webtap.commands._errors import check_connection, error_response
202
+
203
+ def my_command(state, ...):
204
+ # Check connection first for commands that need it
205
+ if error := check_connection(state):
206
+ return error
207
+
208
+ # Validate parameters
209
+ if not valid:
210
+ return error_response("invalid_param", "Parameter X must be Y")
211
+
212
+ # Custom errors
213
+ return error_response("custom", custom_message="Specific error message")
214
+ ```
215
+
216
+ ## Utility Functions
217
+
218
+ Use helpers from `_utils.py`:
219
+
220
+ ```python
221
+ from webtap.commands._utils import (
222
+ build_table_response, # For tables
223
+ build_info_response, # For key-value info
224
+ parse_options, # Parse dict with defaults
225
+ extract_option, # Extract single option
226
+ truncate_string, # Truncate long strings
227
+ format_size, # Format byte sizes
228
+ format_id, # Format IDs
229
+ )
230
+ ```
231
+
232
+ ## Text Over Symbols
233
+
234
+ Use explicit text instead of symbols for clarity:
235
+
236
+ ```python
237
+ # Status text
238
+ "Connected" / "Disconnected"
239
+ "Enabled" / "Disabled"
240
+ "Yes" / "No"
241
+
242
+ # For empty values
243
+ "-" or "None" or ""
244
+
245
+ # Descriptive status
246
+ "3 requests paused" instead of symbols
247
+ "Request Failed" instead of error symbols
248
+ ```
249
+
250
+ ## Decision Tree for Parameter Patterns (Updated)
251
+
252
+ 1. **No parameters?** → Simple command
253
+ 2. **One required param?** → Single parameter
254
+ 3. **Few well-defined params?** → Direct parameters with defaults
255
+ 4. **Multiple ways to identify same thing?** → Direct mutually exclusive params
256
+ 5. **Dynamic/unknown field names?** → Dict for filters
257
+ 6. **Complex variable config?** → Dict for modifications
258
+ 7. **Different operations based on input?** → Action + config pattern
259
+
260
+ ### When to Use Direct Parameters
261
+ - Parameters are well-defined and limited (< 5)
262
+ - Parameters are commonly used
263
+ - Makes the API more intuitive
264
+ - Boolean flags or simple types
265
+
266
+ ### When to Keep Dict Parameters
267
+ - Field names are dynamic (like CDP event fields)
268
+ - Configuration varies significantly by action
269
+ - Many optional parameters rarely used together
270
+ - Complex nested structures needed
271
+
272
+ ## Examples by Category (Current Implementation)
273
+
274
+ ### Navigation Commands
275
+ - `navigate(url: str)` - Single required parameter
276
+ - `reload(ignore_cache: bool = False)` - Optional boolean
277
+ - `back()`, `forward()` - No parameters
278
+
279
+ ### Query Commands
280
+ - `network(limit: int = 20, filters: list = None, no_filters: bool = False)` - Direct params
281
+ - `events(filters: dict = None, limit: int = 20)` - Dict for dynamic fields + limit
282
+ - `inspect(event: int = None, expr: str = None)` - Direct optional params
283
+ - `body(response: int, expr: str = None, decode: bool = True, cache: bool = True)` - Mixed direct params
284
+
285
+ ### Management Commands
286
+ - `connect(page: int = None, page_id: str = None)` - Mutually exclusive direct params
287
+ - `clear(events: bool = True, console: bool = False, cache: bool = False)` - Boolean flags
288
+ - `filters(action: str = "list", config: dict = None)` - Action + config pattern
289
+
290
+ ### JavaScript & Fetch Commands
291
+ - `js(code: str, wait_return: bool = True, await_promise: bool = False)` - Direct params
292
+ - `fetch(action: str, options: dict = None)` - Action pattern
293
+ - `resume(request: int, wait: float = 0.5, modifications: dict = None)` - Direct + dict
294
+
295
+ ## Testing Your Command
296
+
297
+ 1. **Type checking**: Run `basedpyright` to ensure types are correct
298
+ 2. **Linting**: Run `ruff check` for code style
299
+ 3. **REPL mode**: Test with `webtap` command
300
+ 4. **MCP mode**: Test with `webtap --mcp` command
301
+ 5. **Markdown rendering**: Verify output displays correctly
302
+
303
+ ## Checklist for New Commands
304
+
305
+ - [ ] Use `@app.command()` decorator with `display="markdown"`
306
+ - [ ] Add `fastmcp` metadata (type: "resource" or "tool")
307
+ - [ ] Use simple types only (no unions, no Optional)
308
+ - [ ] Add `# pyright: ignore[reportArgumentType]` for `dict = None`
309
+ - [ ] Import utilities from `_utils.py` and `_errors.py`
310
+ - [ ] Use `build_table_response()` or `build_info_response()`
311
+ - [ ] Check connection with `check_connection()` if needed
312
+ - [ ] Document parameters clearly in docstring
313
+ - [ ] Provide usage examples in docstring
314
+ - [ ] Test in both REPL and MCP modes