webgpu-inspector-cli 0.1.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.
Files changed (29) hide show
  1. webgpu_inspector_cli-0.1.0/PKG-INFO +14 -0
  2. webgpu_inspector_cli-0.1.0/setup.cfg +4 -0
  3. webgpu_inspector_cli-0.1.0/setup.py +24 -0
  4. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/__init__.py +3 -0
  5. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/__main__.py +5 -0
  6. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/__init__.py +0 -0
  7. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/browser.py +106 -0
  8. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/capture.py +197 -0
  9. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/errors.py +82 -0
  10. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/objects.py +129 -0
  11. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/shaders.py +108 -0
  12. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/commands/status.py +79 -0
  13. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/core/__init__.py +0 -0
  14. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/core/bridge.py +189 -0
  15. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/core/session.py +40 -0
  16. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/js/collector.js +439 -0
  17. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/skills/SKILL.md +87 -0
  18. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/tests/__init__.py +0 -0
  19. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/tests/test_core.py +151 -0
  20. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/tests/test_full_e2e.py +200 -0
  21. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/utils/__init__.py +0 -0
  22. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/utils/repl_skin.py +523 -0
  23. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli/webgpu_inspector_cli.py +91 -0
  24. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/PKG-INFO +14 -0
  25. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/SOURCES.txt +27 -0
  26. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/dependency_links.txt +1 -0
  27. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/entry_points.txt +2 -0
  28. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/requires.txt +4 -0
  29. webgpu_inspector_cli-0.1.0/webgpu_inspector_cli.egg-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: webgpu-inspector-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool for WebGPU Inspector - debug WebGPU applications from the command line
5
+ Author: Arvind Sekar
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: click>=8.0
8
+ Requires-Dist: playwright>=1.40
9
+ Requires-Dist: Pillow>=10.0
10
+ Requires-Dist: prompt_toolkit>=3.0
11
+ Dynamic: author
12
+ Dynamic: requires-dist
13
+ Dynamic: requires-python
14
+ Dynamic: summary
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="webgpu-inspector-cli",
5
+ version="0.1.0",
6
+ description="CLI tool for WebGPU Inspector - debug WebGPU applications from the command line",
7
+ author="Arvind Sekar",
8
+ python_requires=">=3.10",
9
+ packages=find_packages(),
10
+ package_data={
11
+ "webgpu_inspector_cli": ["js/*.js", "skills/*.md"],
12
+ },
13
+ install_requires=[
14
+ "click>=8.0",
15
+ "playwright>=1.40",
16
+ "Pillow>=10.0",
17
+ "prompt_toolkit>=3.0",
18
+ ],
19
+ entry_points={
20
+ "console_scripts": [
21
+ "webgpu-inspector-cli=webgpu_inspector_cli.webgpu_inspector_cli:cli",
22
+ ],
23
+ },
24
+ )
@@ -0,0 +1,3 @@
1
+ """CLI tool for WebGPU Inspector - debug WebGPU applications from the command line."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m webgpu_inspector_cli"""
2
+
3
+ from webgpu_inspector_cli.webgpu_inspector_cli import cli
4
+
5
+ cli()
@@ -0,0 +1,106 @@
1
+ """Browser session management commands."""
2
+
3
+ import json
4
+ import click
5
+
6
+ from webgpu_inspector_cli.core.bridge import get_bridge, require_bridge
7
+
8
+
9
+ @click.group()
10
+ def browser():
11
+ """Browser session management."""
12
+ pass
13
+
14
+
15
+ @browser.command()
16
+ @click.option("--url", required=True, help="URL to navigate to.")
17
+ @click.option("--headless", is_flag=True, default=False, help="Run in headless mode.")
18
+ @click.option("--gpu-backend", type=str, default=None, help="GPU backend (e.g., swiftshader).")
19
+ @click.pass_context
20
+ def launch(ctx, url, headless, gpu_backend):
21
+ """Launch browser, navigate to URL, and inject the WebGPU Inspector."""
22
+ bridge = get_bridge()
23
+ if bridge.is_connected:
24
+ click.echo("Browser session already active. Close it first with 'browser close'.")
25
+ return
26
+
27
+ bridge.launch(url, headless=headless, gpu_backend=gpu_backend)
28
+ info = bridge.get_browser_info()
29
+
30
+ if ctx.obj.get("json"):
31
+ click.echo(json.dumps({
32
+ "status": "launched",
33
+ "url": info["url"],
34
+ "title": info["title"],
35
+ "gpu": info["gpu"],
36
+ }, indent=2))
37
+ else:
38
+ click.echo(f"Browser launched: {info['url']}")
39
+ click.echo(f" Title: {info['title']}")
40
+ click.echo(f" GPU: {info['gpu']}")
41
+ click.echo("Inspector injected and active.")
42
+
43
+
44
+ @browser.command()
45
+ @click.pass_context
46
+ def close(ctx):
47
+ """Close the browser session."""
48
+ bridge = get_bridge()
49
+ if not bridge.is_connected:
50
+ click.echo("No active browser session.")
51
+ return
52
+ bridge.close()
53
+ if ctx.obj.get("json"):
54
+ click.echo(json.dumps({"status": "closed"}))
55
+ else:
56
+ click.echo("Browser session closed.")
57
+
58
+
59
+ @browser.command()
60
+ @click.option("--url", required=True, help="URL to navigate to.")
61
+ @click.pass_context
62
+ def navigate(ctx, url):
63
+ """Navigate to a new URL and re-inject the inspector."""
64
+ bridge = require_bridge()
65
+ bridge.navigate(url)
66
+ info = bridge.get_browser_info()
67
+
68
+ if ctx.obj.get("json"):
69
+ click.echo(json.dumps({
70
+ "status": "navigated",
71
+ "url": info["url"],
72
+ "title": info["title"],
73
+ }, indent=2))
74
+ else:
75
+ click.echo(f"Navigated to: {info['url']}")
76
+ click.echo(f" Title: {info['title']}")
77
+
78
+
79
+ @browser.command()
80
+ @click.option("--output", "-o", required=True, help="Output file path.")
81
+ @click.option("--full-page", is_flag=True, default=False, help="Capture full scrollable page.")
82
+ @click.pass_context
83
+ def screenshot(ctx, output, full_page):
84
+ """Take a screenshot of the current page."""
85
+ bridge = require_bridge()
86
+ path = bridge.screenshot(output, full_page=full_page)
87
+
88
+ if ctx.obj.get("json"):
89
+ click.echo(json.dumps({"status": "saved", "path": path}))
90
+ else:
91
+ click.echo(f"Screenshot saved: {path}")
92
+
93
+
94
+ @browser.command("status")
95
+ @click.pass_context
96
+ def browser_status(ctx):
97
+ """Show browser and GPU status information."""
98
+ bridge = require_bridge()
99
+ info = bridge.get_browser_info()
100
+
101
+ if ctx.obj.get("json"):
102
+ click.echo(json.dumps(info, indent=2))
103
+ else:
104
+ click.echo(f"URL: {info['url']}")
105
+ click.echo(f"Title: {info['title']}")
106
+ click.echo(f"GPU: {info['gpu']}")
@@ -0,0 +1,197 @@
1
+ """Frame capture and data inspection commands."""
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ import click
7
+
8
+ from webgpu_inspector_cli.core.bridge import require_bridge
9
+
10
+
11
+ @click.group()
12
+ def capture():
13
+ """Frame capture and data inspection."""
14
+ pass
15
+
16
+
17
+ @capture.command()
18
+ @click.option("--timeout", type=float, default=30.0, help="Seconds to wait for capture.")
19
+ @click.option("--poll-interval", type=float, default=0.5, help="Seconds between status polls.")
20
+ @click.pass_context
21
+ def frame(ctx, timeout, poll_interval):
22
+ """Capture the next frame's GPU commands."""
23
+ bridge = require_bridge()
24
+
25
+ # Trigger capture
26
+ bridge.query("requestCapture", {})
27
+
28
+ if not ctx.obj.get("json"):
29
+ click.echo("Capture requested, waiting for frame...")
30
+
31
+ # Poll for completion
32
+ start = time.time()
33
+ while time.time() - start < timeout:
34
+ status = bridge.query("getCaptureStatus")
35
+ if status == "complete":
36
+ break
37
+ time.sleep(poll_interval)
38
+ else:
39
+ msg = f"Capture timed out after {timeout}s"
40
+ if ctx.obj.get("json"):
41
+ click.echo(json.dumps({"status": "timeout", "error": msg}))
42
+ else:
43
+ click.echo(msg, err=True)
44
+ raise SystemExit(1)
45
+
46
+ results = bridge.query("getCapturedFrameResults")
47
+ if ctx.obj.get("json"):
48
+ click.echo(json.dumps({"status": "complete", "results": results}, indent=2))
49
+ else:
50
+ click.echo("Frame captured successfully.")
51
+ if results:
52
+ click.echo(f" Frame: {results.get('frame', '?')}")
53
+ click.echo(f" Commands: {results.get('count', '?')}")
54
+
55
+
56
+ @capture.command()
57
+ @click.option("--pass-index", type=int, default=None, help="Filter by render pass index.")
58
+ @click.pass_context
59
+ def commands(ctx, pass_index):
60
+ """List GPU commands from a captured frame."""
61
+ bridge = require_bridge()
62
+
63
+ status = bridge.query("getCaptureStatus")
64
+ if status != "complete":
65
+ click.echo("No captured frame. Run 'capture frame' first.", err=True)
66
+ raise SystemExit(1)
67
+
68
+ results = bridge.query("getCapturedFrameResults")
69
+ if not results:
70
+ click.echo("No capture data available.", err=True)
71
+ raise SystemExit(1)
72
+
73
+ if ctx.obj.get("json"):
74
+ click.echo(json.dumps(results, indent=2))
75
+ else:
76
+ batches = results.get("batches", [])
77
+ if not batches:
78
+ click.echo("No command batches in capture.")
79
+ return
80
+
81
+ for i, batch in enumerate(batches):
82
+ if pass_index is not None and i != pass_index:
83
+ continue
84
+ cmds = batch.get("commands", batch) if isinstance(batch, dict) else batch
85
+ click.echo(f"Batch {i}:")
86
+ if isinstance(cmds, list):
87
+ for cmd in cmds:
88
+ if isinstance(cmd, dict):
89
+ click.echo(f" {cmd.get('method', cmd.get('name', str(cmd)))}")
90
+ else:
91
+ click.echo(f" {cmd}")
92
+ else:
93
+ click.echo(f" {cmds}")
94
+
95
+
96
+ @capture.command()
97
+ @click.option("--id", "tex_id", type=int, required=True, help="Texture object ID.")
98
+ @click.option("--mip-level", type=int, default=0, help="Mip level to capture.")
99
+ @click.option("--output", "-o", type=str, default=None, help="Save as PNG to path.")
100
+ @click.option("--timeout", type=float, default=30.0, help="Seconds to wait for texture data.")
101
+ @click.pass_context
102
+ def texture(ctx, tex_id, mip_level, output, timeout):
103
+ """Read texture data from a live or captured texture."""
104
+ bridge = require_bridge()
105
+
106
+ # Request the texture data
107
+ bridge.query("requestTexture", tex_id, mip_level)
108
+
109
+ if not ctx.obj.get("json"):
110
+ click.echo(f"Requesting texture #{tex_id} mip {mip_level}...")
111
+
112
+ # Poll for data
113
+ start = time.time()
114
+ while time.time() - start < timeout:
115
+ data = bridge.query("getTextureData", tex_id)
116
+ if data and data.get("complete"):
117
+ break
118
+ time.sleep(0.5)
119
+ else:
120
+ msg = f"Texture data timed out after {timeout}s"
121
+ if ctx.obj.get("json"):
122
+ click.echo(json.dumps({"status": "timeout", "error": msg}))
123
+ else:
124
+ click.echo(msg, err=True)
125
+ raise SystemExit(1)
126
+
127
+ if output and data.get("data"):
128
+ # The data is base64 encoded - decode and save as raw or convert to PNG
129
+ try:
130
+ raw = base64.b64decode(data["data"].split(",")[-1] if "," in data["data"] else data["data"])
131
+ # Get texture info for dimensions
132
+ obj = bridge.query("getObject", tex_id)
133
+ desc = obj.get("descriptor", {}) if obj else {}
134
+ width = desc.get("size", {}).get("width", desc.get("size", [0])[0] if isinstance(desc.get("size"), list) else 0)
135
+ height = desc.get("size", {}).get("height", desc.get("size", [0, 0])[1] if isinstance(desc.get("size"), list) and len(desc.get("size", [])) > 1 else 0)
136
+
137
+ if output.endswith(".png") and width and height:
138
+ try:
139
+ from PIL import Image
140
+ img = Image.frombytes("RGBA", (width, height), raw)
141
+ img.save(output)
142
+ except Exception:
143
+ # Fallback: save raw bytes
144
+ with open(output, "wb") as f:
145
+ f.write(raw)
146
+ else:
147
+ with open(output, "wb") as f:
148
+ f.write(raw)
149
+
150
+ if ctx.obj.get("json"):
151
+ click.echo(json.dumps({"status": "saved", "path": output, "size": len(raw)}))
152
+ else:
153
+ click.echo(f"Texture saved: {output} ({len(raw)} bytes)")
154
+ except Exception as e:
155
+ click.echo(f"Error saving texture: {e}", err=True)
156
+ raise SystemExit(1)
157
+ else:
158
+ if ctx.obj.get("json"):
159
+ click.echo(json.dumps({
160
+ "status": "complete",
161
+ "textureId": tex_id,
162
+ "mipLevel": mip_level,
163
+ "dataSize": len(data.get("data", "")) if data else 0,
164
+ }, indent=2))
165
+ else:
166
+ click.echo(f"Texture #{tex_id} data received ({data.get('totalChunks', 0)} chunks)")
167
+
168
+
169
+ @capture.command()
170
+ @click.option("--id", "buf_id", type=int, required=True, help="Buffer object ID.")
171
+ @click.option("--offset", type=int, default=0, help="Byte offset into buffer.")
172
+ @click.option("--size", "read_size", type=int, default=None, help="Number of bytes to read.")
173
+ @click.option("--format", "fmt", type=click.Choice(["hex", "float32", "uint32", "raw"]),
174
+ default="hex", help="Display format.")
175
+ @click.pass_context
176
+ def buffer(ctx, buf_id, offset, read_size, fmt):
177
+ """Read buffer contents."""
178
+ bridge = require_bridge()
179
+ data = bridge.query("getBufferData", buf_id)
180
+
181
+ if not data:
182
+ click.echo(f"No buffer data for #{buf_id}. Capture a frame first.", err=True)
183
+ raise SystemExit(1)
184
+
185
+ if ctx.obj.get("json"):
186
+ click.echo(json.dumps({
187
+ "bufferId": buf_id,
188
+ "offset": data.get("offset", 0),
189
+ "size": data.get("size", 0),
190
+ "data": data.get("data"),
191
+ }, indent=2))
192
+ else:
193
+ click.echo(f"Buffer #{buf_id}:")
194
+ click.echo(f" Offset: {data.get('offset', 0)}")
195
+ click.echo(f" Size: {data.get('size', 0)} bytes")
196
+ if data.get("data"):
197
+ click.echo(f" Data (first 256 chars): {str(data['data'])[:256]}")
@@ -0,0 +1,82 @@
1
+ """Validation error tracking commands."""
2
+
3
+ import json
4
+ import time
5
+ import click
6
+
7
+ from webgpu_inspector_cli.core.bridge import require_bridge
8
+
9
+
10
+ @click.group()
11
+ def errors():
12
+ """Validation error tracking."""
13
+ pass
14
+
15
+
16
+ @errors.command("list")
17
+ @click.pass_context
18
+ def list_errors(ctx):
19
+ """List all validation errors."""
20
+ bridge = require_bridge()
21
+ result = bridge.query("getErrors")
22
+
23
+ if ctx.obj.get("json"):
24
+ click.echo(json.dumps({"errors": result, "count": len(result)}, indent=2))
25
+ else:
26
+ if not result:
27
+ click.echo("No validation errors.")
28
+ return
29
+ for err in result:
30
+ click.echo(f"Error #{err['id']}:")
31
+ click.echo(f" Message: {err['message']}")
32
+ if err.get("objectId"):
33
+ click.echo(f" Object: #{err['objectId']}")
34
+ if err.get("stacktrace"):
35
+ click.echo(" Stacktrace:")
36
+ for line in err["stacktrace"].split("\n"):
37
+ if line.strip():
38
+ click.echo(f" {line.strip()}")
39
+ click.echo()
40
+ click.echo(f"Total: {len(result)} errors")
41
+
42
+
43
+ @errors.command()
44
+ @click.option("--timeout", type=float, default=30.0, help="Seconds to watch for errors.")
45
+ @click.option("--poll-interval", type=float, default=1.0, help="Seconds between polls.")
46
+ @click.pass_context
47
+ def watch(ctx, timeout, poll_interval):
48
+ """Watch for new validation errors in real-time."""
49
+ bridge = require_bridge()
50
+ seen_count = bridge.query("getErrorCount")
51
+ use_json = ctx.obj.get("json")
52
+
53
+ click.echo(f"Watching for errors (timeout: {timeout}s)..." if not use_json else "", nl=not use_json)
54
+
55
+ start = time.time()
56
+ while time.time() - start < timeout:
57
+ current_count = bridge.query("getErrorCount")
58
+ if current_count > seen_count:
59
+ all_errors = bridge.query("getErrors")
60
+ new_errors = all_errors[seen_count:]
61
+ for err in new_errors:
62
+ if use_json:
63
+ click.echo(json.dumps(err))
64
+ else:
65
+ click.echo(f"[{err['id']}] {err['message']}")
66
+ seen_count = current_count
67
+ time.sleep(poll_interval)
68
+
69
+ if not use_json:
70
+ click.echo(f"Watch ended. Total errors: {seen_count}")
71
+
72
+
73
+ @errors.command()
74
+ @click.pass_context
75
+ def clear(ctx):
76
+ """Clear error history."""
77
+ bridge = require_bridge()
78
+ bridge.query("clearErrors")
79
+ if ctx.obj.get("json"):
80
+ click.echo(json.dumps({"status": "cleared"}))
81
+ else:
82
+ click.echo("Errors cleared.")
@@ -0,0 +1,129 @@
1
+ """GPU object inspection commands."""
2
+
3
+ import json
4
+ import click
5
+
6
+ from webgpu_inspector_cli.core.bridge import require_bridge
7
+
8
+ GPU_TYPES = [
9
+ "Adapter", "Device", "Buffer", "Texture", "TextureView", "Sampler",
10
+ "ShaderModule", "BindGroup", "BindGroupLayout", "PipelineLayout",
11
+ "RenderPipeline", "ComputePipeline", "RenderBundle",
12
+ ]
13
+
14
+
15
+ def _format_bytes(n):
16
+ if n is None or n == 0:
17
+ return "0 B"
18
+ for unit in ("B", "KB", "MB", "GB"):
19
+ if abs(n) < 1024:
20
+ return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
21
+ n /= 1024
22
+ return f"{n:.1f} TB"
23
+
24
+
25
+ @click.group()
26
+ def objects():
27
+ """GPU object inspection."""
28
+ pass
29
+
30
+
31
+ @objects.command("list")
32
+ @click.option("--type", "obj_type", type=click.Choice(GPU_TYPES, case_sensitive=False),
33
+ default=None, help="Filter by object type.")
34
+ @click.pass_context
35
+ def list_objects(ctx, obj_type):
36
+ """List all live GPU objects."""
37
+ bridge = require_bridge()
38
+ result = bridge.query("getObjects", obj_type)
39
+
40
+ if ctx.obj.get("json"):
41
+ click.echo(json.dumps({"objects": result, "count": len(result)}, indent=2))
42
+ else:
43
+ if not result:
44
+ click.echo("No GPU objects found.")
45
+ return
46
+ click.echo(f"{'ID':>6} {'Type':<20} {'Label':<30} {'Size':>12}")
47
+ click.echo("-" * 72)
48
+ for obj in result:
49
+ size_str = ""
50
+ if obj.get("size"):
51
+ size_str = _format_bytes(obj["size"])
52
+ elif obj.get("gpuSize"):
53
+ size_str = _format_bytes(obj["gpuSize"])
54
+ label = obj.get("label") or ""
55
+ click.echo(f"{obj['id']:>6} {obj['type']:<20} {label:<30} {size_str:>12}")
56
+ click.echo(f"\nTotal: {len(result)} objects")
57
+
58
+
59
+ @objects.command()
60
+ @click.option("--id", "obj_id", type=int, required=True, help="Object ID.")
61
+ @click.pass_context
62
+ def inspect(ctx, obj_id):
63
+ """Detailed view of a single GPU object."""
64
+ bridge = require_bridge()
65
+ obj = bridge.query("getObject", obj_id)
66
+
67
+ if obj is None:
68
+ click.echo(f"Object {obj_id} not found.", err=True)
69
+ raise SystemExit(1)
70
+
71
+ if ctx.obj.get("json"):
72
+ click.echo(json.dumps(obj, indent=2))
73
+ else:
74
+ click.echo(f"Object #{obj['id']} ({obj['type']})")
75
+ if obj.get("label"):
76
+ click.echo(f" Label: {obj['label']}")
77
+ if obj.get("parent") is not None:
78
+ click.echo(f" Parent: #{obj['parent']}")
79
+ if obj.get("pending"):
80
+ click.echo(" Status: pending (async)")
81
+ if obj.get("size"):
82
+ click.echo(f" Size: {_format_bytes(obj['size'])}")
83
+ if obj.get("gpuSize"):
84
+ click.echo(f" GPU Memory: {_format_bytes(obj['gpuSize'])}")
85
+ if obj.get("descriptor"):
86
+ click.echo(" Descriptor:")
87
+ desc_str = json.dumps(obj["descriptor"], indent=4)
88
+ for line in desc_str.split("\n"):
89
+ click.echo(f" {line}")
90
+ if obj.get("stacktrace"):
91
+ click.echo(" Creation stacktrace:")
92
+ for line in obj["stacktrace"].split("\n"):
93
+ if line.strip():
94
+ click.echo(f" {line.strip()}")
95
+
96
+
97
+ @objects.command()
98
+ @click.option("--label", required=True, help="Label substring to search for.")
99
+ @click.pass_context
100
+ def search(ctx, label):
101
+ """Find objects by label."""
102
+ bridge = require_bridge()
103
+ all_objs = bridge.query("getObjects", None)
104
+ matched = [o for o in all_objs if o.get("label") and label.lower() in o["label"].lower()]
105
+
106
+ if ctx.obj.get("json"):
107
+ click.echo(json.dumps({"objects": matched, "count": len(matched)}, indent=2))
108
+ else:
109
+ if not matched:
110
+ click.echo(f"No objects matching '{label}'.")
111
+ return
112
+ for obj in matched:
113
+ click.echo(f" #{obj['id']} {obj['type']}: {obj.get('label', '')}")
114
+ click.echo(f"\nFound: {len(matched)} objects")
115
+
116
+
117
+ @objects.command()
118
+ @click.pass_context
119
+ def memory(ctx):
120
+ """Show GPU memory usage breakdown."""
121
+ bridge = require_bridge()
122
+ mem = bridge.query("getMemoryUsage")
123
+
124
+ if ctx.obj.get("json"):
125
+ click.echo(json.dumps(mem, indent=2))
126
+ else:
127
+ click.echo(f"Texture memory: {_format_bytes(mem['totalTextureMemory'])}")
128
+ click.echo(f"Buffer memory: {_format_bytes(mem['totalBufferMemory'])}")
129
+ click.echo(f"Total: {_format_bytes(mem['totalMemory'])}")
@@ -0,0 +1,108 @@
1
+ """Shader module inspection commands."""
2
+
3
+ import json
4
+ import click
5
+
6
+ from webgpu_inspector_cli.core.bridge import require_bridge
7
+ from webgpu_inspector_cli.core.session import get_session
8
+
9
+
10
+ @click.group()
11
+ def shaders():
12
+ """Shader module inspection."""
13
+ pass
14
+
15
+
16
+ @shaders.command("list")
17
+ @click.pass_context
18
+ def list_shaders(ctx):
19
+ """List all shader modules."""
20
+ bridge = require_bridge()
21
+ result = bridge.query("getObjects", "ShaderModule")
22
+
23
+ if ctx.obj.get("json"):
24
+ click.echo(json.dumps({"shaders": result, "count": len(result)}, indent=2))
25
+ else:
26
+ if not result:
27
+ click.echo("No shader modules found.")
28
+ return
29
+ click.echo(f"{'ID':>6} {'Label':<30} {'Size':>10}")
30
+ click.echo("-" * 50)
31
+ for obj in result:
32
+ label = obj.get("label") or ""
33
+ size = obj.get("size", 0)
34
+ click.echo(f"{obj['id']:>6} {label:<30} {size:>10} chars")
35
+ click.echo(f"\nTotal: {len(result)} shader modules")
36
+
37
+
38
+ @shaders.command()
39
+ @click.option("--id", "shader_id", type=int, required=True, help="Shader module ID.")
40
+ @click.pass_context
41
+ def view(ctx, shader_id):
42
+ """View WGSL source code of a shader module."""
43
+ bridge = require_bridge()
44
+ code = bridge.query("getShaderCode", shader_id)
45
+
46
+ if code is None:
47
+ click.echo(f"Shader #{shader_id} not found or has no code.", err=True)
48
+ raise SystemExit(1)
49
+
50
+ if ctx.obj.get("json"):
51
+ click.echo(json.dumps({"shaderId": shader_id, "code": code}, indent=2))
52
+ else:
53
+ click.echo(f"--- Shader #{shader_id} ---")
54
+ click.echo(code)
55
+ click.echo(f"--- End ({len(code)} chars) ---")
56
+
57
+
58
+ @shaders.command("compile")
59
+ @click.option("--id", "shader_id", type=int, required=True, help="Shader module ID.")
60
+ @click.option("--file", "code_file", type=click.Path(exists=True), default=None,
61
+ help="Path to WGSL file.")
62
+ @click.option("--code", "code_str", type=str, default=None,
63
+ help="WGSL code string (alternative to --file).")
64
+ @click.pass_context
65
+ def compile_shader(ctx, shader_id, code_file, code_str):
66
+ """Hot-replace shader code with new WGSL source."""
67
+ if not code_file and not code_str:
68
+ click.echo("Provide either --file or --code.", err=True)
69
+ raise SystemExit(1)
70
+
71
+ bridge = require_bridge()
72
+
73
+ # Save original for undo
74
+ session = get_session()
75
+ original = bridge.query("getShaderCode", shader_id)
76
+ if original:
77
+ session.push_shader_edit(shader_id, original)
78
+
79
+ if code_file:
80
+ with open(code_file) as f:
81
+ code = f.read()
82
+ else:
83
+ code = code_str
84
+
85
+ bridge.query("compileShader", shader_id, code)
86
+
87
+ if ctx.obj.get("json"):
88
+ click.echo(json.dumps({"status": "compiled", "shaderId": shader_id, "codeLength": len(code)}))
89
+ else:
90
+ click.echo(f"Shader #{shader_id} recompiled ({len(code)} chars)")
91
+
92
+
93
+ @shaders.command("revert")
94
+ @click.option("--id", "shader_id", type=int, required=True, help="Shader module ID.")
95
+ @click.pass_context
96
+ def revert_shader(ctx, shader_id):
97
+ """Revert shader to its original code."""
98
+ bridge = require_bridge()
99
+ bridge.query("revertShader", shader_id)
100
+
101
+ # Also clear session history for this shader
102
+ session = get_session()
103
+ session.clear_shader_edits(shader_id)
104
+
105
+ if ctx.obj.get("json"):
106
+ click.echo(json.dumps({"status": "reverted", "shaderId": shader_id}))
107
+ else:
108
+ click.echo(f"Shader #{shader_id} reverted to original.")