webtap-tool 0.2.3__tar.gz → 0.4.0__tar.gz

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.

Files changed (57) hide show
  1. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/CHANGELOG.md +56 -0
  2. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/PKG-INFO +2 -2
  3. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/extension/popup.html +0 -9
  4. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/extension/popup.js +2 -25
  5. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/pyproject.toml +12 -2
  6. webtap_tool-0.4.0/src/webtap/__init__.py +39 -0
  7. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/api.py +0 -15
  8. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/app.py +4 -4
  9. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/filters.py +95 -62
  10. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/network.py +30 -8
  11. webtap_tool-0.4.0/src/webtap/commands/server.py +180 -0
  12. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/filters.py +116 -56
  13. webtap_tool-0.2.3/src/webtap/__init__.py +0 -64
  14. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/.gitignore +0 -0
  15. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/ARCHITECTURE.md +0 -0
  16. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/README.md +0 -0
  17. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/data/filters.json +0 -0
  18. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/extension/manifest.json +0 -0
  19. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/llms.txt +0 -0
  20. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/VISION.md +0 -0
  21. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/cdp/README.md +0 -0
  22. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/cdp/__init__.py +0 -0
  23. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/cdp/query.py +0 -0
  24. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/cdp/schema/README.md +0 -0
  25. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/cdp/schema/cdp_protocol.json +0 -0
  26. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/cdp/schema/cdp_version.json +0 -0
  27. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/cdp/session.py +0 -0
  28. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/DEVELOPER_GUIDE.md +0 -0
  29. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/TIPS.md +0 -0
  30. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/__init__.py +0 -0
  31. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/_builders.py +0 -0
  32. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/_errors.py +0 -0
  33. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/_tips.py +0 -0
  34. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/_utils.py +0 -0
  35. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/body.py +0 -0
  36. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/connection.py +0 -0
  37. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/console.py +0 -0
  38. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/events.py +0 -0
  39. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/fetch.py +0 -0
  40. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/inspect.py +0 -0
  41. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/javascript.py +0 -0
  42. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/launch.py +0 -0
  43. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/navigation.py +0 -0
  44. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/commands/setup.py +0 -0
  45. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/README.md +0 -0
  46. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/__init__.py +0 -0
  47. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/body.py +0 -0
  48. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/console.py +0 -0
  49. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/fetch.py +0 -0
  50. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/main.py +0 -0
  51. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/network.py +0 -0
  52. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/setup/__init__.py +0 -0
  53. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/setup/chrome.py +0 -0
  54. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/setup/desktop.py +0 -0
  55. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/setup/extension.py +0 -0
  56. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/setup/filters.py +0 -0
  57. {webtap_tool-0.2.3 → webtap_tool-0.4.0}/src/webtap/services/setup/platform.py +0 -0
@@ -15,6 +15,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
 
16
16
  ### Removed
17
17
 
18
+ ## [0.4.0] - 2025-09-28
19
+
20
+ ### Added
21
+ - Filter mode support: `include` and `exclude` modes for fine-grained request filtering
22
+ - TypedDict for filter configuration ensuring type safety
23
+ - Filter tip in `network()` command showing example usage
24
+ - Markdown table display for filter categories replacing code blocks
25
+ - CDP resource types documentation in filter command
26
+
27
+ ### Changed
28
+ - **BREAKING**: Filter categories now require explicit `mode` field ("include" or "exclude")
29
+ - **BREAKING**: Updated to ReplKit2 v0.12.0 - using `mcp_config` instead of `fastmcp` parameter
30
+ - Filter core returns data (`get_categories_summary()`) instead of formatted strings
31
+ - Presentation logic moved from FilterManager to command layer
32
+ - Server command uses assistant prefill to prevent LLM commentary
33
+
34
+ ### Fixed
35
+ - Alert element in network command now uses correct `message` field instead of `content`
36
+ - Type annotations for optional parameters properly use `str | None`
37
+
38
+ ### Removed
39
+ - Backward compatibility for filters without mode field
40
+ - Emoji icons in filter display, using plain text indicators instead
41
+
42
+ ## [0.3.0] - 2025-09-19
43
+
44
+ ### Added
45
+ - New `server` command for explicit API server management (start/stop/restart/status)
46
+ - Server command implemented as MCP prompt returning markdown status information
47
+
48
+ ### Changed
49
+ - **BREAKING**: API server no longer starts automatically - must be started explicitly with `server('start')`
50
+ - API server management is now explicit and user-controlled
51
+
52
+ ### Fixed
53
+
54
+ ### Removed
55
+ - Automatic API server startup on webtap launch
56
+ - `/release` endpoint from API (no longer needed with explicit server management)
57
+
58
+ ## [0.2.4] - 2025-09-19
59
+
60
+ ### Added
61
+ - New `server` command for explicit API server management (start/stop/restart/status)
62
+ - Server command implemented as MCP prompt returning markdown status information
63
+
64
+ ### Changed
65
+ - API server no longer starts automatically - must be started explicitly with `server('start')`
66
+ - API server management is now explicit and user-controlled
67
+
68
+ ### Fixed
69
+
70
+ ### Removed
71
+ - Automatic API server startup on webtap launch
72
+ - `/release` endpoint from API (no longer needed with explicit server management)
73
+
18
74
  ## [0.2.3] - 2025-09-12
19
75
 
20
76
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webtap-tool
3
- Version: 0.2.3
3
+ Version: 0.4.0
4
4
  Summary: Terminal-based web page inspector for AI debugging sessions
5
5
  Author-email: Fredrik Angelsen <fredrikangelsen@gmail.com>
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -21,7 +21,7 @@ Requires-Dist: platformdirs>=4.4.0
21
21
  Requires-Dist: protobuf>=6.32.0
22
22
  Requires-Dist: pyjwt>=2.10.1
23
23
  Requires-Dist: pyyaml>=6.0.2
24
- Requires-Dist: replkit2[all]>=0.11.0
24
+ Requires-Dist: replkit2[all]>=0.12.0
25
25
  Requires-Dist: requests>=2.32.4
26
26
  Requires-Dist: uvicorn>=0.35.0
27
27
  Requires-Dist: websocket-client>=1.8.0
@@ -150,15 +150,6 @@
150
150
 
151
151
  <div class="divider"></div>
152
152
 
153
- <button
154
- id="switchInstance"
155
- style="width: 100%; font-size: 11px; margin-bottom: 8px"
156
- >
157
- Switch WebTap Instance
158
- </button>
159
-
160
- <div class="divider"></div>
161
-
162
153
  <div class="filter-section">
163
154
  <div
164
155
  style="
@@ -1,7 +1,7 @@
1
- // API helper - communicate with WebTap service
1
+ // API helper - communicate with WebTap service on port 8765
2
2
  async function api(endpoint, method = "GET", body = null) {
3
3
  try {
4
- const opts = {
4
+ const opts = {
5
5
  method,
6
6
  // Add timeout to detect unresponsive server faster
7
7
  signal: AbortSignal.timeout(3000)
@@ -40,9 +40,6 @@ async function loadPages() {
40
40
  return;
41
41
  }
42
42
 
43
- // Update instance info tooltip
44
- document.getElementById("switchInstance").title =
45
- `PID: ${info.pid} | Events: ${info.events}`;
46
43
 
47
44
  const pages = info.pages || [];
48
45
  const select = document.getElementById("pageList");
@@ -266,26 +263,6 @@ document.getElementById("disableAllFilters").onclick = async () => {
266
263
  setTimeout(updateFilters, 100);
267
264
  };
268
265
 
269
- // Switch to a different WebTap instance
270
- document.getElementById("switchInstance").onclick = async () => {
271
- const result = await api("/release", "POST");
272
- if (!result.error) {
273
- document.getElementById("status").innerHTML =
274
- '<span style="color: #666">Port released. Start new WebTap.</span>';
275
- // Disable controls until reconnected
276
- document.getElementById("connect").disabled = true;
277
- document.getElementById("disconnect").disabled = true;
278
- document.getElementById("fetchToggle").disabled = true;
279
- // Try to reconnect after delay
280
- setTimeout(() => {
281
- loadPages();
282
- updateStatus();
283
- }, 2000);
284
- } else {
285
- document.getElementById("status").innerHTML =
286
- `<span class="error">Error: ${result.error}</span>`;
287
- }
288
- };
289
266
 
290
267
  // Update all status from server - single source of truth
291
268
  async function updateStatus() {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webtap-tool"
3
- version = "0.2.3"
3
+ version = "0.4.0"
4
4
  description = "Terminal-based web page inspector for AI debugging sessions"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -27,7 +27,7 @@ dependencies = [
27
27
  "protobuf>=6.32.0",
28
28
  "pyjwt>=2.10.1",
29
29
  "pyyaml>=6.0.2",
30
- "replkit2[all]>=0.11.0",
30
+ "replkit2[all]>=0.12.0",
31
31
  "requests>=2.32.4",
32
32
  "uvicorn>=0.35.0",
33
33
  "websocket-client>=1.8.0",
@@ -51,3 +51,13 @@ build-backend = "hatchling.build"
51
51
 
52
52
  [tool.uv.sources]
53
53
  replkit2 = { path = "../../../replkit2", editable = true }
54
+
55
+ [tool.basedpyright]
56
+ typeCheckingMode = "standard"
57
+ pythonVersion = "3.12"
58
+ pythonPlatform = "Linux"
59
+ include = ["src/**/*.py"]
60
+
61
+ [tool.ruff]
62
+ line-length = 120
63
+ include = ["src/**/*.py"]
@@ -0,0 +1,39 @@
1
+ """WebTap - Chrome DevTools Protocol REPL.
2
+
3
+ Main entry point for WebTap browser debugging tool. Provides both REPL and MCP
4
+ functionality for Chrome DevTools Protocol interaction with native CDP event
5
+ storage and on-demand querying.
6
+
7
+ PUBLIC API:
8
+ - app: Main ReplKit2 App instance
9
+ - main: Entry point function for CLI
10
+ """
11
+
12
+ import sys
13
+
14
+ from webtap.app import app
15
+
16
+
17
+ def main():
18
+ """Entry point for the WebTap REPL.
19
+
20
+ Starts in one of three modes:
21
+ - CLI mode (with --cli flag) for command-line interface
22
+ - MCP mode (with --mcp flag) for Model Context Protocol server
23
+ - REPL mode (default) for interactive shell
24
+
25
+ The API server for Chrome extension communication must be started
26
+ explicitly using the server('start') command.
27
+ """
28
+ if "--mcp" in sys.argv:
29
+ app.mcp.run()
30
+ elif "--cli" in sys.argv:
31
+ # Remove --cli from argv before passing to Typer
32
+ sys.argv.remove("--cli")
33
+ app.cli() # Run CLI mode via Typer
34
+ else:
35
+ # Run REPL
36
+ app.run(title="WebTap - Chrome DevTools Protocol REPL")
37
+
38
+
39
+ __all__ = ["app", "main"]
@@ -199,21 +199,6 @@ async def disable_all_filters() -> Dict[str, Any]:
199
199
  return {"enabled": [], "total": 0}
200
200
 
201
201
 
202
- @api.post("/release")
203
- async def release_port() -> Dict[str, Any]:
204
- """Release API port for another WebTap instance."""
205
- logger.info("Releasing API port for another instance")
206
-
207
- # Schedule graceful shutdown after response
208
- def shutdown():
209
- # Just set the flag to stop uvicorn, don't kill the whole process
210
- global _shutdown_requested
211
- _shutdown_requested = True
212
-
213
- threading.Timer(0.5, shutdown).start()
214
- return {"message": "Releasing port 8765"}
215
-
216
-
217
202
  # Flag to signal shutdown
218
203
  _shutdown_requested = False
219
204
 
@@ -56,10 +56,9 @@ class WebTapState:
56
56
  app = App(
57
57
  "webtap",
58
58
  WebTapState,
59
- uri_scheme="webtap",
60
- fastmcp={
61
- "description": "Chrome DevTools Protocol debugger",
62
- "tags": {"browser", "debugging", "chrome", "cdp"},
59
+ mcp_config={
60
+ "uri_scheme": "webtap",
61
+ "instructions": "Chrome DevTools Protocol debugger",
63
62
  },
64
63
  typer_config={
65
64
  "add_completion": False, # Hide shell completion options
@@ -84,6 +83,7 @@ else:
84
83
  from webtap.commands import inspect # noqa: E402, F401
85
84
  from webtap.commands import fetch # noqa: E402, F401
86
85
  from webtap.commands import body # noqa: E402, F401
86
+ from webtap.commands import server # noqa: E402, F401
87
87
  from webtap.commands import setup # noqa: E402, F401
88
88
  from webtap.commands import launch # noqa: E402, F401
89
89
 
@@ -7,46 +7,19 @@ from webtap.commands._builders import info_response, error_response
7
7
  @app.command(display="markdown", fastmcp={"type": "tool"})
8
8
  def filters(state, action: str = "list", config: dict = None) -> dict: # pyright: ignore[reportArgumentType]
9
9
  """
10
- Manage network request filters.
11
-
12
- Filters are managed by the service and can be persisted to .webtap/filters.json.
13
-
14
- Args:
15
- action: Filter operation
16
- - "list" - List all filter categories (default)
17
- - "show" - Show specific category details
18
- - "add" - Add patterns to category
19
- - "remove" - Remove patterns from category
20
- - "update" - Update entire category
21
- - "delete" - Delete entire category
22
- - "enable" - Enable category
23
- - "disable" - Disable category
24
- - "save" - Save filters to disk
25
- - "load" - Load filters from disk
26
- config: Action-specific configuration
27
- - For show: {"category": "ads"}
28
- - For add: {"category": "ads", "patterns": ["*ad*"], "type": "domain"}
29
- - For remove: {"patterns": ["*ad*"], "type": "domain"}
30
- - For update: {"category": "ads", "domains": [...], "types": [...]}
31
- - For delete/enable/disable: {"category": "ads"}
32
-
33
- Examples:
34
- filters() # List all categories
35
- filters("list") # Same as above
36
- filters("show", {"category": "ads"}) # Show ads category details
37
- filters("add", {"category": "ads",
38
- "patterns": ["*doubleclick*"]}) # Add domain pattern
39
- filters("add", {"category": "tracking",
40
- "patterns": ["Ping"], "type": "type"}) # Add type pattern
41
- filters("remove", {"patterns": ["*doubleclick*"]}) # Remove pattern
42
- filters("update", {"category": "ads",
43
- "domains": ["*google*", "*facebook*"]}) # Replace patterns
44
- filters("delete", {"category": "ads"}) # Delete category
45
- filters("save") # Persist to disk
46
- filters("load") # Load from disk
47
-
48
- Returns:
49
- Current filter configuration or operation result
10
+ Manage network request filters with include/exclude modes.
11
+
12
+ CDP Types: Document, XHR, Fetch, Image, Stylesheet, Script, Font, Media, Other, Ping
13
+ Domains filter URLs, types filter Chrome's resource loading mechanism.
14
+
15
+ Actions:
16
+ list: Show all categories (default)
17
+ show: Display category - config: {"category": "api"}
18
+ add: Add patterns - config: {"category": "api", "patterns": ["*api*"], "type": "domain", "mode": "include"}
19
+ remove: Remove patterns - config: {"patterns": ["*api*"], "type": "domain"}
20
+ update: Replace category - config: {"category": "api", "mode": "include", "domains": [...], "types": ["XHR", "Fetch"]}
21
+ delete/enable/disable: Manage category - config: {"category": "api"}
22
+ save/load: Persist to/from disk
50
23
  """
51
24
  fm = state.service.filters
52
25
  cfg = config or {}
@@ -54,14 +27,35 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
54
27
  # Handle load operation
55
28
  if action == "load":
56
29
  if fm.load():
57
- # Convert display info to markdown
58
- display_info = fm.get_display_info()
59
- return {
60
- "elements": [
61
- {"type": "heading", "content": "Filters Loaded", "level": 2},
62
- {"type": "code_block", "content": display_info, "language": ""},
63
- ]
64
- }
30
+ # Build table data
31
+ categories = fm.get_categories_summary()
32
+ rows = []
33
+ for cat in categories:
34
+ mode = cat["mode"] or "exclude"
35
+ mode_display = "include" if mode == "include" else "exclude"
36
+ if cat["mode"] is None:
37
+ mode_display = "exclude*" # Asterisk indicates missing mode field
38
+
39
+ rows.append(
40
+ {
41
+ "Category": cat["name"],
42
+ "Status": "enabled" if cat["enabled"] else "disabled",
43
+ "Mode": mode_display,
44
+ "Domains": str(cat["domain_count"]),
45
+ "Types": str(cat["type_count"]),
46
+ }
47
+ )
48
+
49
+ elements = [
50
+ {"type": "heading", "content": "Filters Loaded", "level": 2},
51
+ {"type": "text", "content": f"From: `{fm.filter_path}`"},
52
+ {"type": "table", "headers": ["Category", "Status", "Mode", "Domains", "Types"], "rows": rows},
53
+ ]
54
+
55
+ if any(cat["mode"] is None for cat in categories):
56
+ elements.append({"type": "text", "content": "_* Mode not specified, defaulting to exclude_"})
57
+
58
+ return {"elements": elements}
65
59
  else:
66
60
  return error_response(f"No filters found at {fm.filter_path}")
67
61
 
@@ -82,6 +76,7 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
82
76
  category = cfg.get("category", "custom")
83
77
  patterns = cfg.get("patterns", [])
84
78
  pattern_type = cfg.get("type", "domain")
79
+ mode = cfg.get("mode") # Required, no default
85
80
 
86
81
  if not patterns:
87
82
  # Legacy single pattern support
@@ -94,11 +89,14 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
94
89
 
95
90
  added = []
96
91
  failed = []
97
- for pattern in patterns:
98
- if fm.add_pattern(pattern, category, pattern_type):
99
- added.append(pattern)
100
- else:
101
- failed.append(pattern)
92
+ try:
93
+ for pattern in patterns:
94
+ if fm.add_pattern(pattern, category, pattern_type, mode):
95
+ added.append(pattern)
96
+ else:
97
+ failed.append(pattern)
98
+ except ValueError as e:
99
+ return error_response(str(e))
102
100
 
103
101
  if added and not failed:
104
102
  return info_response(
@@ -107,6 +105,7 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
107
105
  "Type": "Domain pattern" if pattern_type == "domain" else "Resource type",
108
106
  "Patterns": ", ".join(added),
109
107
  "Category": category,
108
+ "Mode": mode,
110
109
  },
111
110
  )
112
111
  elif failed:
@@ -149,8 +148,10 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
149
148
  return error_response("'category' required for update action")
150
149
 
151
150
  category = cfg["category"]
152
- fm.update_category(category, domains=cfg.get("domains"), types=cfg.get("types"))
153
- return info_response(title="Category Updated", fields={"Category": category})
151
+ fm.update_category(category, domains=cfg.get("domains"), types=cfg.get("types"), mode=cfg.get("mode"))
152
+ return info_response(
153
+ title="Category Updated", fields={"Category": category, "Mode": cfg.get("mode", "exclude")}
154
+ )
154
155
 
155
156
  # Handle delete operation
156
157
  elif action == "delete":
@@ -193,10 +194,12 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
193
194
  if category in fm.filters:
194
195
  filters = fm.filters[category]
195
196
  enabled = "Enabled" if category in fm.enabled_categories else "Disabled"
197
+ mode = filters.get("mode", "exclude")
196
198
 
197
199
  elements = [
198
200
  {"type": "heading", "content": f"Category: {category}", "level": 2},
199
201
  {"type": "text", "content": f"**Status:** {enabled}"},
202
+ {"type": "text", "content": f"**Mode:** {mode}"},
200
203
  ]
201
204
 
202
205
  if filters.get("domains"):
@@ -212,13 +215,43 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
212
215
 
213
216
  # Default list action: show all filters
214
217
  elif action == "list" or action == "":
215
- display_info = fm.get_display_info()
216
- return {
217
- "elements": [
218
- {"type": "heading", "content": "Filter Configuration", "level": 2},
219
- {"type": "code_block", "content": display_info, "language": ""},
220
- ]
221
- }
218
+ if not fm.filters:
219
+ return {
220
+ "elements": [
221
+ {"type": "heading", "content": "Filter Configuration", "level": 2},
222
+ {"type": "text", "content": f"No filters loaded (would load from `{fm.filter_path}`)"},
223
+ {"type": "text", "content": "Use `filters('load')` to load filters from disk"},
224
+ ]
225
+ }
226
+
227
+ # Build table data
228
+ categories = fm.get_categories_summary()
229
+ rows = []
230
+ for cat in categories:
231
+ mode = cat["mode"] or "exclude"
232
+ mode_display = "include" if mode == "include" else "exclude"
233
+ if cat["mode"] is None:
234
+ mode_display = "exclude*"
235
+
236
+ rows.append(
237
+ {
238
+ "Category": cat["name"],
239
+ "Status": "enabled" if cat["enabled"] else "disabled",
240
+ "Mode": mode_display,
241
+ "Domains": str(cat["domain_count"]),
242
+ "Types": str(cat["type_count"]),
243
+ }
244
+ )
245
+
246
+ elements = [
247
+ {"type": "heading", "content": "Filter Configuration", "level": 2},
248
+ {"type": "table", "headers": ["Category", "Status", "Mode", "Domains", "Types"], "rows": rows},
249
+ ]
250
+
251
+ if any(cat["mode"] is None for cat in categories):
252
+ elements.append({"type": "text", "content": "_* Mode not specified, defaulting to exclude_"})
253
+
254
+ return {"elements": elements}
222
255
 
223
256
  else:
224
257
  return error_response(f"Unknown action: {action}")
@@ -3,7 +3,6 @@
3
3
  from typing import List
4
4
 
5
5
  from webtap.app import app
6
- from webtap.commands._builders import table_response
7
6
  from webtap.commands._errors import check_connection
8
7
  from webtap.commands._tips import get_tips
9
8
 
@@ -75,11 +74,34 @@ def network(state, limit: int = 20, filters: List[str] = None, no_filters: bool
75
74
  example_id = rows[0]["ID"]
76
75
  tips = get_tips("network", context={"id": example_id})
77
76
 
78
- return table_response(
79
- title="Network Requests",
80
- headers=["ID", "ReqID", "Method", "Status", "URL", "Type", "Size"],
81
- rows=rows,
82
- summary=f"{len(rows)} requests",
83
- warnings=warnings,
84
- tips=tips,
77
+ # Build response elements manually to add filter tip
78
+ elements = []
79
+ elements.append({"type": "heading", "content": "Network Requests", "level": 2})
80
+
81
+ for warning in warnings or []:
82
+ elements.append({"type": "alert", "message": warning, "level": "warning"})
83
+
84
+ if rows:
85
+ elements.append(
86
+ {"type": "table", "headers": ["ID", "ReqID", "Method", "Status", "URL", "Type", "Size"], "rows": rows}
87
+ )
88
+ else:
89
+ elements.append({"type": "text", "content": "_No data available_"})
90
+
91
+ if f"{len(rows)} requests":
92
+ elements.append({"type": "text", "content": f"_{len(rows)} requests_"})
93
+
94
+ # Always show filter tip demonstrating both type AND domain filtering
95
+ elements.append(
96
+ {
97
+ "type": "alert",
98
+ "message": "Tip: Reduce noise with filters by type or domain. Example: `filters('update', {'category': 'api-only', 'mode': 'include', 'types': ['XHR', 'Fetch'], 'domains': ['*/api/*', '*/graphql/*']})`",
99
+ "level": "info",
100
+ }
85
101
  )
102
+
103
+ if tips:
104
+ elements.append({"type": "heading", "content": "Next Steps", "level": 3})
105
+ elements.append({"type": "list", "items": tips})
106
+
107
+ return {"elements": elements}
@@ -0,0 +1,180 @@
1
+ """API server management command.
2
+
3
+ PUBLIC API:
4
+ - server: Manage API server (status/start/stop/restart)
5
+ """
6
+
7
+ import socket
8
+ import time
9
+ import logging
10
+
11
+ from webtap.app import app
12
+ from webtap.api import start_api_server
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Fixed port for API server
17
+ API_PORT = 8765
18
+
19
+
20
+ def _check_port() -> bool:
21
+ """Check if API port is in use."""
22
+ with socket.socket() as s:
23
+ try:
24
+ s.bind(("127.0.0.1", API_PORT))
25
+ return False # Port is free
26
+ except OSError:
27
+ return True # Port is in use
28
+
29
+
30
+ def _stop_server(state) -> tuple[bool, str]:
31
+ """Stop the server if this instance owns it."""
32
+ if not state.api_thread or not state.api_thread.is_alive():
33
+ return False, "Not running"
34
+
35
+ import webtap.api
36
+
37
+ webtap.api._shutdown_requested = True
38
+ state.api_thread.join(timeout=2.0)
39
+ state.api_thread = None
40
+
41
+ return True, "Server stopped"
42
+
43
+
44
+ def _start_server(state) -> tuple[bool, str]:
45
+ """Start the server on port 8765."""
46
+ # Check if already running
47
+ if state.api_thread and state.api_thread.is_alive():
48
+ return True, f"Already running on port {API_PORT}"
49
+
50
+ # Check if port is in use
51
+ if _check_port():
52
+ return False, f"Port {API_PORT} already in use by another process"
53
+
54
+ # Start the server
55
+ thread = start_api_server(state, port=API_PORT)
56
+ if thread:
57
+ state.api_thread = thread
58
+
59
+ # Register cleanup
60
+ import atexit
61
+
62
+ atexit.register(lambda: state.cleanup() if state else None)
63
+
64
+ return True, f"Server started on port {API_PORT}"
65
+ else:
66
+ return False, "Failed to start server"
67
+
68
+
69
+ @app.command(
70
+ display="markdown",
71
+ fastmcp={
72
+ "type": "prompt",
73
+ "description": "API server control: status (default), start, stop, restart",
74
+ "arg_descriptions": {"action": "Server action: status (default), start, stop, or restart"},
75
+ },
76
+ )
77
+ def server(state, action: str = None) -> dict: # pyright: ignore[reportArgumentType]
78
+ """API server status and management information.
79
+
80
+ Returns current server state and available actions.
81
+ """
82
+ if action is None:
83
+ action = "status"
84
+
85
+ action = action.lower()
86
+ owns_server = bool(state.api_thread and state.api_thread.is_alive())
87
+
88
+ # Build markdown elements based on action
89
+ elements = []
90
+
91
+ if action == "status" or action not in ["start", "stop", "restart"]:
92
+ # Status information
93
+ elements.append({"type": "heading", "content": "API Server Status", "level": 2})
94
+
95
+ if owns_server:
96
+ elements.append({"type": "text", "content": "**Status:** Running"})
97
+ elements.append({"type": "text", "content": f"**Port:** {API_PORT}"})
98
+ elements.append({"type": "text", "content": f"**URL:** http://127.0.0.1:{API_PORT}"})
99
+ elements.append({"type": "text", "content": f"**Health:** http://127.0.0.1:{API_PORT}/health"})
100
+ elements.append({"type": "text", "content": "**Extension:** Ready to connect"})
101
+ else:
102
+ port_in_use = _check_port()
103
+ if port_in_use:
104
+ elements.append({"type": "alert", "message": "Port 8765 in use by another process", "level": "warning"})
105
+ elements.append({"type": "text", "content": "Cannot start server until port is free"})
106
+ else:
107
+ elements.append({"type": "text", "content": "**Status:** Not running"})
108
+ elements.append({"type": "text", "content": f"**Port:** {API_PORT} (available)"})
109
+ elements.append({"type": "text", "content": "Use `server('start')` to start the API server"})
110
+
111
+ # Available actions
112
+ elements.append({"type": "heading", "content": "Available Actions", "level": 3})
113
+ actions = [
114
+ "`server('start')` - Start the API server",
115
+ "`server('stop')` - Stop the API server",
116
+ "`server('restart')` - Restart the API server",
117
+ "`server('status')` or `server()` - Show this status",
118
+ ]
119
+ elements.append({"type": "list", "items": actions})
120
+
121
+ elif action == "start":
122
+ if owns_server:
123
+ elements.append({"type": "alert", "message": f"Server already running on port {API_PORT}", "level": "info"})
124
+ else:
125
+ success, message = _start_server(state)
126
+ if success:
127
+ elements.append({"type": "alert", "message": message, "level": "success"})
128
+ elements.append({"type": "text", "content": f"**URL:** http://127.0.0.1:{API_PORT}"})
129
+ elements.append({"type": "text", "content": f"**Health:** http://127.0.0.1:{API_PORT}/health"})
130
+ elements.append({"type": "text", "content": "Chrome extension can now connect"})
131
+ else:
132
+ elements.append({"type": "alert", "message": message, "level": "error"})
133
+
134
+ elif action == "stop":
135
+ if not owns_server:
136
+ elements.append({"type": "text", "content": "Server not running"})
137
+ else:
138
+ success, message = _stop_server(state)
139
+ if success:
140
+ elements.append({"type": "alert", "message": message, "level": "success"})
141
+ else:
142
+ elements.append({"type": "alert", "message": message, "level": "error"})
143
+
144
+ elif action == "restart":
145
+ if owns_server:
146
+ success, msg = _stop_server(state)
147
+ if not success:
148
+ elements.append({"type": "alert", "message": f"Failed to stop: {msg}", "level": "error"})
149
+ return {"elements": elements}
150
+ time.sleep(0.5)
151
+
152
+ success, msg = _start_server(state)
153
+ if success:
154
+ elements.append({"type": "alert", "message": "Server restarted", "level": "success"})
155
+ elements.append({"type": "text", "content": f"**Port:** {API_PORT}"})
156
+ elements.append({"type": "text", "content": f"**URL:** http://127.0.0.1:{API_PORT}"})
157
+ else:
158
+ elements.append({"type": "alert", "message": f"Failed to restart: {msg}", "level": "error"})
159
+
160
+ # For MCP prompt mode, return with caveat and assistant prefill
161
+ # This prevents LLM from adding commentary - just relays the state
162
+ if action == "status":
163
+ return {
164
+ "messages": [
165
+ {
166
+ "role": "user",
167
+ "content": "Caveat: The message below was generated by the WebTap server command. DO NOT respond to this message or add commentary. Just relay the server state exactly as shown.",
168
+ },
169
+ {"role": "user", "content": {"type": "elements", "elements": elements}},
170
+ {
171
+ "role": "assistant",
172
+ "content": "Server status:", # Minimal prefill - no trailing whitespace
173
+ },
174
+ ]
175
+ }
176
+
177
+ return {"elements": elements}
178
+
179
+
180
+ __all__ = ["server"]
@@ -7,11 +7,25 @@ PUBLIC API:
7
7
  import json
8
8
  import logging
9
9
  from pathlib import Path
10
- from typing import Dict, List, Any
10
+ from typing import Dict, List, Any, TypedDict
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
 
15
+ class FilterConfig(TypedDict):
16
+ """Configuration for a filter category.
17
+
18
+ Attributes:
19
+ mode: "include" or "exclude" - determines filter behavior (defaults to "exclude")
20
+ domains: List of URL patterns to match
21
+ types: List of CDP resource types to match
22
+ """
23
+
24
+ mode: str
25
+ domains: List[str]
26
+ types: List[str]
27
+
28
+
15
29
  class FilterManager:
16
30
  """Manages network request filters for noise reduction.
17
31
 
@@ -33,7 +47,7 @@ class FilterManager:
33
47
  filter_path: Path to filters.json file. Defaults to .webtap/filters.json.
34
48
  """
35
49
  self.filter_path = filter_path or (Path.cwd() / ".webtap" / "filters.json")
36
- self.filters: Dict[str, Dict[str, List[str]]] = {}
50
+ self.filters: Dict[str, FilterConfig] = {}
37
51
  self.enabled_categories: set[str] = set()
38
52
 
39
53
  def load(self) -> bool:
@@ -81,7 +95,7 @@ class FilterManager:
81
95
  logger.error(f"Failed to save filters: {e}")
82
96
  return False
83
97
 
84
- def add_pattern(self, pattern: str, category: str, pattern_type: str = "domain") -> bool:
98
+ def add_pattern(self, pattern: str, category: str, pattern_type: str = "domain", mode: str | None = None) -> bool:
85
99
  """Add a filter pattern to a category.
86
100
 
87
101
  Creates the category if it doesn't exist and enables it. Supports wildcard
@@ -91,13 +105,17 @@ class FilterManager:
91
105
  pattern: Pattern to add (e.g., "*ads*", "googletagmanager.com").
92
106
  category: Category name (e.g., "ads", "tracking").
93
107
  pattern_type: "domain" or "type". Defaults to "domain".
108
+ mode: "include" or "exclude". Required for new categories.
94
109
 
95
110
  Returns:
96
111
  True if pattern was added, False if it already existed.
97
112
  """
98
113
  if category not in self.filters:
99
- self.filters[category] = {"domains": [], "types": []}
114
+ if mode is None:
115
+ raise ValueError(f"Mode required when creating new category '{category}'")
116
+ self.filters[category] = {"mode": mode, "domains": [], "types": []}
100
117
  self.enabled_categories.add(category)
118
+ # Existing category keeps its mode
101
119
 
102
120
  key = "domains" if pattern_type == "domain" else "types"
103
121
  if pattern not in self.filters[category][key]:
@@ -125,7 +143,9 @@ class FilterManager:
125
143
  return category
126
144
  return ""
127
145
 
128
- def update_category(self, category: str, domains: List[str] | None = None, types: List[str] | None = None):
146
+ def update_category(
147
+ self, category: str, domains: List[str] | None = None, types: List[str] | None = None, mode: str | None = None
148
+ ):
129
149
  """Update or create a category with new patterns.
130
150
 
131
151
  Creates the category if it doesn't exist and enables it. If patterns are
@@ -135,10 +155,15 @@ class FilterManager:
135
155
  category: Category name.
136
156
  domains: List of domain patterns. None leaves existing unchanged.
137
157
  types: List of type patterns. None leaves existing unchanged.
158
+ mode: "include" or "exclude". None leaves existing unchanged.
138
159
  """
139
160
  if category not in self.filters:
140
- self.filters[category] = {"domains": [], "types": []}
161
+ if mode is None:
162
+ raise ValueError(f"Mode required when creating new category '{category}'")
163
+ self.filters[category] = {"mode": mode, "domains": [], "types": []}
141
164
 
165
+ if mode is not None:
166
+ self.filters[category]["mode"] = mode
142
167
  if domains is not None:
143
168
  self.filters[category]["domains"] = domains
144
169
  if types is not None:
@@ -181,8 +206,8 @@ class FilterManager:
181
206
  def get_filter_sql(self, use_all: bool = True, categories: List[str] | None = None) -> str:
182
207
  """Generate SQL WHERE clause for filtering CDP events.
183
208
 
184
- Creates SQL conditions to exclude network requests matching the filter
185
- patterns. Handles wildcard patterns by converting them to SQL LIKE patterns
209
+ Creates SQL conditions based on filter mode (include/exclude) for network requests.
210
+ Handles wildcard patterns by converting them to SQL LIKE patterns
186
211
  and properly escapes SQL strings.
187
212
 
188
213
  Args:
@@ -206,41 +231,77 @@ class FilterManager:
206
231
  if not active_categories:
207
232
  return ""
208
233
 
209
- # Collect all patterns
210
- all_domains = []
211
- all_types = []
212
-
213
- for category in active_categories:
214
- all_domains.extend(self.filters[category].get("domains", []))
215
- all_types.extend(self.filters[category].get("types", []))
216
-
217
- # Build filter conditions - exclude matching items
234
+ include_conditions = []
218
235
  exclude_conditions = []
219
236
 
220
- # Domain filtering - exclude URLs matching these patterns
221
- if all_domains:
222
- for pattern in all_domains:
223
- # Convert wildcard to SQL LIKE pattern, escape single quotes for SQL safety
224
- sql_pattern = pattern.replace("'", "''").replace("*", "%")
225
- # For Network.responseReceived events - filter on what's actually there
226
- exclude_conditions.append(
227
- f"json_extract_string(event, '$.params.response.url') NOT LIKE '{sql_pattern}'"
228
- )
229
-
230
- # Type filtering - exclude these types
231
- if all_types:
232
- # Escape single quotes in types for SQL safety
233
- escaped_types = [t.replace("'", "''") for t in all_types]
234
- type_list = ", ".join(f"'{t}'" for t in escaped_types)
235
- # Use COALESCE to handle NULL types properly, exclude matching types
236
- exclude_conditions.append(
237
- f"(COALESCE(json_extract_string(event, '$.params.type'), '') NOT IN ({type_list}) OR "
238
- f"json_extract_string(event, '$.params.type') IS NULL)"
239
- )
237
+ for category in active_categories:
238
+ config = self.filters[category]
239
+ mode = config.get("mode")
240
+ if mode is None:
241
+ logger.error(f"Filter category '{category}' missing required 'mode' field. Skipping.")
242
+ continue # Skip this category entirely
243
+ domains = config.get("domains", [])
244
+ types = config.get("types", [])
245
+
246
+ category_conditions = []
247
+
248
+ # Domain filtering
249
+ if domains:
250
+ domain_conditions = []
251
+ for pattern in domains:
252
+ sql_pattern = pattern.replace("'", "''").replace("*", "%")
253
+ if mode == "include":
254
+ domain_conditions.append(
255
+ f"json_extract_string(event, '$.params.response.url') LIKE '{sql_pattern}'"
256
+ )
257
+ else: # exclude
258
+ domain_conditions.append(
259
+ f"json_extract_string(event, '$.params.response.url') NOT LIKE '{sql_pattern}'"
260
+ )
261
+
262
+ # For include: OR (match any pattern), for exclude: AND (match none)
263
+ if mode == "include":
264
+ if domain_conditions:
265
+ category_conditions.append(f"({' OR '.join(domain_conditions)})")
266
+ else:
267
+ if domain_conditions:
268
+ category_conditions.append(f"({' AND '.join(domain_conditions)})")
269
+
270
+ # Type filtering
271
+ if types:
272
+ escaped_types = [t.replace("'", "''") for t in types]
273
+ type_list = ", ".join(f"'{t}'" for t in escaped_types)
274
+
275
+ if mode == "include":
276
+ category_conditions.append(f"json_extract_string(event, '$.params.type') IN ({type_list})")
277
+ else: # exclude
278
+ category_conditions.append(
279
+ f"(COALESCE(json_extract_string(event, '$.params.type'), '') NOT IN ({type_list}) OR "
280
+ f"json_extract_string(event, '$.params.type') IS NULL)"
281
+ )
282
+
283
+ # Combine domain and type conditions for this category
284
+ if category_conditions:
285
+ category_sql = f"({' AND '.join(category_conditions)})"
286
+ if mode == "include":
287
+ include_conditions.append(category_sql)
288
+ else:
289
+ exclude_conditions.append(category_sql)
290
+
291
+ # Combine all conditions: (include1 OR include2) AND exclude1 AND exclude2
292
+ final_parts = []
293
+
294
+ if include_conditions:
295
+ if len(include_conditions) > 1:
296
+ final_parts.append(f"({' OR '.join(include_conditions)})")
297
+ else:
298
+ final_parts.append(include_conditions[0])
240
299
 
241
300
  if exclude_conditions:
242
- # Use AND to ensure ALL conditions are met (item doesn't match ANY filter)
243
- return f"({' AND '.join(exclude_conditions)})"
301
+ final_parts.extend(exclude_conditions)
302
+
303
+ if final_parts:
304
+ return f"({' AND '.join(final_parts)})"
244
305
 
245
306
  return ""
246
307
 
@@ -263,27 +324,26 @@ class FilterManager:
263
324
  "path": str(self.filter_path),
264
325
  }
265
326
 
266
- def get_display_info(self) -> str:
267
- """Get formatted filter information for display.
268
-
269
- Creates a human-readable summary of all filter categories with their
270
- enabled status and pattern counts.
327
+ def get_categories_summary(self) -> List[Dict[str, Any]]:
328
+ """Get summary data for all filter categories.
271
329
 
272
330
  Returns:
273
- Formatted multiline string with filter details.
331
+ List of dicts with category information including name, enabled status,
332
+ mode, and pattern counts.
274
333
  """
275
- if not self.filters:
276
- return f"No filters loaded (would load from {self.filter_path})"
277
-
278
- lines = [f"Loaded filters from {self.filter_path}:"]
334
+ categories = []
279
335
  for category in sorted(self.filters.keys()):
280
- filters = self.filters[category]
281
- enabled = "✓" if category in self.enabled_categories else "✗"
282
- domains = len(filters.get("domains", []))
283
- types = len(filters.get("types", []))
284
- lines.append(f" {enabled} {category}: {domains} domains, {types} types")
285
-
286
- return "\n".join(lines)
336
+ config = self.filters[category]
337
+ categories.append(
338
+ {
339
+ "name": category,
340
+ "enabled": category in self.enabled_categories,
341
+ "mode": config.get("mode"), # None if missing
342
+ "domain_count": len(config.get("domains", [])),
343
+ "type_count": len(config.get("types", [])),
344
+ }
345
+ )
346
+ return categories
287
347
 
288
348
 
289
349
  __all__ = ["FilterManager"]
@@ -1,64 +0,0 @@
1
- """WebTap - Chrome DevTools Protocol REPL.
2
-
3
- Main entry point for WebTap browser debugging tool. Provides both REPL and MCP
4
- functionality for Chrome DevTools Protocol interaction with native CDP event
5
- storage and on-demand querying.
6
-
7
- PUBLIC API:
8
- - app: Main ReplKit2 App instance
9
- - main: Entry point function for CLI
10
- """
11
-
12
- import atexit
13
- import sys
14
- import logging
15
-
16
- from webtap.app import app
17
- from webtap.api import start_api_server
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
- def main():
23
- """Entry point for the WebTap REPL.
24
-
25
- Starts in one of three modes:
26
- - CLI mode (with --cli flag) for command-line interface
27
- - MCP mode (with --mcp flag) for Model Context Protocol server
28
- - REPL mode (default) for interactive shell
29
-
30
- In REPL and MCP modes, the API server is started for Chrome extension
31
- integration. The API server runs in background to handle extension requests.
32
- """
33
- # Start API server for Chrome extension (except in CLI mode)
34
- if "--cli" not in sys.argv:
35
- _start_api_server_safely()
36
-
37
- if "--mcp" in sys.argv:
38
- app.mcp.run()
39
- elif "--cli" in sys.argv:
40
- # Remove --cli from argv before passing to Typer
41
- sys.argv.remove("--cli")
42
- app.cli() # Run CLI mode via Typer
43
- else:
44
- # Run REPL
45
- app.run(title="WebTap - Chrome DevTools Protocol REPL")
46
-
47
-
48
- def _start_api_server_safely():
49
- """Start API server with error handling and cleanup registration."""
50
- try:
51
- thread = start_api_server(app.state)
52
- if thread and app.state:
53
- app.state.api_thread = thread
54
- logger.info("API server started on port 8765")
55
-
56
- # Register cleanup to shut down API server on exit
57
- atexit.register(lambda: app.state.cleanup() if app.state else None)
58
- else:
59
- logger.info("Port 8765 in use by another instance")
60
- except Exception as e:
61
- logger.warning(f"Failed to start API server: {e}")
62
-
63
-
64
- __all__ = ["app", "main"]
File without changes
File without changes
File without changes
File without changes