wafer-cli 0.2.1__tar.gz → 0.2.2__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 (48) hide show
  1. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/PKG-INFO +1 -1
  2. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/pyproject.toml +2 -2
  3. wafer_cli-0.2.2/tests/test_billing.py +531 -0
  4. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_cli_coverage.py +0 -1
  5. wafer_cli-0.2.2/wafer/GUIDE.md +107 -0
  6. wafer_cli-0.2.2/wafer/billing.py +233 -0
  7. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/cli.py +576 -45
  8. wafer_cli-0.2.2/wafer/skills/wafer-guide/SKILL.md +116 -0
  9. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/wevin_cli.py +1 -0
  10. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/workspaces.py +7 -0
  11. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/PKG-INFO +1 -1
  12. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/SOURCES.txt +3 -0
  13. wafer_cli-0.2.1/wafer/GUIDE.md +0 -313
  14. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/README.md +0 -0
  15. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/setup.cfg +0 -0
  16. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_cli_parity_integration.py +0 -0
  17. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_config_integration.py +0 -0
  18. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_file_operations_integration.py +0 -0
  19. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_isa_cli.py +0 -0
  20. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_rocprof_compute_integration.py +0 -0
  21. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_ssh_integration.py +0 -0
  22. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_wevin_cli.py +0 -0
  23. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_workflow_integration.py +0 -0
  24. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/__init__.py +0 -0
  25. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/api_client.py +0 -0
  26. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/auth.py +0 -0
  27. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/autotuner.py +0 -0
  28. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/config.py +0 -0
  29. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/corpus.py +0 -0
  30. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/evaluate.py +0 -0
  31. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/global_config.py +0 -0
  32. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/gpu_run.py +0 -0
  33. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/inference.py +0 -0
  34. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/ncu_analyze.py +0 -0
  35. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/nsys_analyze.py +0 -0
  36. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/rocprof_compute.py +0 -0
  37. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/rocprof_sdk.py +0 -0
  38. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/rocprof_systems.py +0 -0
  39. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/targets.py +0 -0
  40. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/__init__.py +0 -0
  41. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/ask_docs.py +0 -0
  42. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/optimize_kernel.py +0 -0
  43. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/trace_analyze.py +0 -0
  44. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/tracelens.py +0 -0
  45. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/dependency_links.txt +0 -0
  46. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/entry_points.txt +0 -0
  47. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/requires.txt +0 -0
  48. {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wafer-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: CLI tool for running commands on remote GPUs and GPU kernel optimization agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: typer>=0.12.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wafer-cli"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "CLI tool for running commands on remote GPUs and GPU kernel optimization agent"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
@@ -35,7 +35,7 @@ where = ["."]
35
35
  include = ["wafer*"]
36
36
 
37
37
  [tool.setuptools.package-data]
38
- wafer = ["GUIDE.md"]
38
+ wafer = ["GUIDE.md", "skills/*/SKILL.md"]
39
39
 
40
40
  [tool.ruff]
41
41
  line-length = 100
@@ -0,0 +1,531 @@
1
+ """Tests for billing commands.
2
+
3
+ Tests the wafer billing, wafer billing topup, and wafer billing portal commands.
4
+
5
+ Run with: PYTHONPATH=. uv run pytest tests/test_billing.py -v
6
+ """
7
+
8
+ import json
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import httpx
12
+ import pytest
13
+ from typer.testing import CliRunner
14
+
15
+ from wafer.cli import app
16
+
17
+ runner = CliRunner()
18
+
19
+
20
+ # =============================================================================
21
+ # Unit Tests for Business Logic
22
+ # =============================================================================
23
+
24
+
25
+ class TestFormatCents:
26
+ """Test the format_cents helper function."""
27
+
28
+ def test_format_2500_cents(self) -> None:
29
+ """2500 cents formats to $25.00."""
30
+ from wafer.billing import format_cents
31
+
32
+ assert format_cents(2500) == "$25.00"
33
+
34
+ def test_format_0_cents(self) -> None:
35
+ """0 cents formats to $0.00."""
36
+ from wafer.billing import format_cents
37
+
38
+ assert format_cents(0) == "$0.00"
39
+
40
+ def test_format_99_cents(self) -> None:
41
+ """99 cents formats to $0.99."""
42
+ from wafer.billing import format_cents
43
+
44
+ assert format_cents(99) == "$0.99"
45
+
46
+ def test_format_10000_cents(self) -> None:
47
+ """10000 cents formats to $100.00."""
48
+ from wafer.billing import format_cents
49
+
50
+ assert format_cents(10000) == "$100.00"
51
+
52
+ def test_format_150_cents(self) -> None:
53
+ """150 cents formats to $1.50."""
54
+ from wafer.billing import format_cents
55
+
56
+ assert format_cents(150) == "$1.50"
57
+
58
+
59
+ class TestValidateTopupAmount:
60
+ """Test the validate_topup_amount helper function."""
61
+
62
+ def test_valid_minimum_amount(self) -> None:
63
+ """$10 (1000 cents) should be valid."""
64
+ from wafer.billing import validate_topup_amount
65
+
66
+ # Should not raise
67
+ validate_topup_amount(1000)
68
+
69
+ def test_valid_maximum_amount(self) -> None:
70
+ """$500 (50000 cents) should be valid."""
71
+ from wafer.billing import validate_topup_amount
72
+
73
+ # Should not raise
74
+ validate_topup_amount(50000)
75
+
76
+ def test_valid_middle_amount(self) -> None:
77
+ """$25 (2500 cents) should be valid."""
78
+ from wafer.billing import validate_topup_amount
79
+
80
+ # Should not raise
81
+ validate_topup_amount(2500)
82
+
83
+ def test_amount_below_minimum(self) -> None:
84
+ """$9 (900 cents) should raise ValueError."""
85
+ from wafer.billing import validate_topup_amount
86
+
87
+ with pytest.raises(ValueError, match="at least"):
88
+ validate_topup_amount(900)
89
+
90
+ def test_amount_above_maximum(self) -> None:
91
+ """$501 (50100 cents) should raise ValueError."""
92
+ from wafer.billing import validate_topup_amount
93
+
94
+ with pytest.raises(ValueError, match="at most"):
95
+ validate_topup_amount(50100)
96
+
97
+ def test_amount_zero(self) -> None:
98
+ """0 cents should raise ValueError."""
99
+ from wafer.billing import validate_topup_amount
100
+
101
+ with pytest.raises(ValueError, match="at least"):
102
+ validate_topup_amount(0)
103
+
104
+
105
+ class TestFormatUsageText:
106
+ """Test the format_usage_text helper function."""
107
+
108
+ def test_pro_tier_active(self) -> None:
109
+ """Format pro tier with active status."""
110
+ from wafer.billing import format_usage_text
111
+
112
+ usage = {
113
+ "tier": "pro",
114
+ "status": "active",
115
+ "credits_used_cents": 5000,
116
+ "credits_limit_cents": 10000,
117
+ "credits_remaining_cents": 5000,
118
+ "topup_balance_cents": 2000,
119
+ "has_hardware_counters": True,
120
+ "has_slack_access": True,
121
+ "period_ends_at": "2024-02-15T00:00:00Z",
122
+ }
123
+ text = format_usage_text(usage)
124
+
125
+ assert "Pro" in text
126
+ assert "$50.00" in text # used
127
+ assert "$100.00" in text # limit
128
+ assert "$20.00" in text # topup
129
+ assert "active" in text.lower()
130
+
131
+ def test_start_tier_shows_upgrade_prompt(self) -> None:
132
+ """Start tier should suggest upgrade."""
133
+ from wafer.billing import format_usage_text
134
+
135
+ usage = {
136
+ "tier": "start",
137
+ "status": "active",
138
+ "credits_used_cents": 0,
139
+ "credits_limit_cents": 0,
140
+ "credits_remaining_cents": 0,
141
+ "topup_balance_cents": 0,
142
+ "has_hardware_counters": False,
143
+ "has_slack_access": False,
144
+ "period_ends_at": None,
145
+ }
146
+ text = format_usage_text(usage)
147
+
148
+ assert "Start" in text
149
+ assert "upgrade" in text.lower() or "portal" in text.lower()
150
+
151
+ def test_enterprise_tier_shows_unlimited(self) -> None:
152
+ """Enterprise tier should show unlimited credits."""
153
+ from wafer.billing import format_usage_text
154
+
155
+ usage = {
156
+ "tier": "enterprise",
157
+ "status": "active",
158
+ "credits_used_cents": 50000,
159
+ "credits_limit_cents": -1, # Unlimited
160
+ "credits_remaining_cents": -1,
161
+ "topup_balance_cents": 0,
162
+ "has_hardware_counters": True,
163
+ "has_slack_access": True,
164
+ "period_ends_at": "2024-02-15T00:00:00Z",
165
+ }
166
+ text = format_usage_text(usage)
167
+
168
+ assert "Enterprise" in text
169
+ assert "Unlimited" in text or "unlimited" in text
170
+
171
+ def test_past_due_status_shows_warning(self) -> None:
172
+ """past_due status should show warning."""
173
+ from wafer.billing import format_usage_text
174
+
175
+ usage = {
176
+ "tier": "pro",
177
+ "status": "past_due",
178
+ "credits_used_cents": 5000,
179
+ "credits_limit_cents": 10000,
180
+ "credits_remaining_cents": 5000,
181
+ "topup_balance_cents": 0,
182
+ "has_hardware_counters": True,
183
+ "has_slack_access": True,
184
+ "period_ends_at": "2024-02-15T00:00:00Z",
185
+ }
186
+ text = format_usage_text(usage)
187
+
188
+ assert "past_due" in text.lower() or "warning" in text.lower() or "⚠" in text
189
+
190
+
191
+ # =============================================================================
192
+ # Integration Tests with Mocked HTTP
193
+ # =============================================================================
194
+
195
+
196
+ class TestBillingUsageCommand:
197
+ """Test wafer billing (usage) command."""
198
+
199
+ def test_not_logged_in(self) -> None:
200
+ """Should error with login guidance when not authenticated."""
201
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
202
+ mock_auth.return_value = {} # No auth header
203
+
204
+ with patch("wafer.billing.httpx.Client") as mock_client:
205
+ mock_response = MagicMock()
206
+ mock_response.status_code = 401
207
+ mock_response.text = '{"detail": "Not authenticated"}'
208
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
209
+ "401", request=MagicMock(), response=mock_response
210
+ )
211
+ mock_client.return_value.__enter__.return_value.get.return_value = mock_response
212
+
213
+ result = runner.invoke(app, ["billing"])
214
+
215
+ assert result.exit_code != 0
216
+ assert "login" in result.output.lower()
217
+
218
+ def test_json_output(self) -> None:
219
+ """Should return raw JSON with --json flag."""
220
+ usage_data = {
221
+ "tier": "pro",
222
+ "status": "active",
223
+ "credits_used_cents": 5000,
224
+ "credits_limit_cents": 10000,
225
+ "credits_remaining_cents": 5000,
226
+ "topup_balance_cents": 2000,
227
+ "has_hardware_counters": True,
228
+ "has_slack_access": True,
229
+ "period_ends_at": "2024-02-15T00:00:00Z",
230
+ }
231
+
232
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
233
+ mock_auth.return_value = {"Authorization": "Bearer test"}
234
+
235
+ with patch("wafer.billing.get_api_url") as mock_url:
236
+ mock_url.return_value = "https://api.example.com"
237
+
238
+ with patch("wafer.billing.httpx.Client") as mock_client:
239
+ mock_response = MagicMock()
240
+ mock_response.status_code = 200
241
+ mock_response.json.return_value = usage_data
242
+ mock_response.raise_for_status.return_value = None
243
+ mock_client.return_value.__enter__.return_value.get.return_value = mock_response
244
+
245
+ result = runner.invoke(app, ["billing", "--json"])
246
+
247
+ assert result.exit_code == 0
248
+ data = json.loads(result.stdout)
249
+ assert data["tier"] == "pro"
250
+
251
+ def test_formatted_output(self) -> None:
252
+ """Should return formatted text without --json flag."""
253
+ usage_data = {
254
+ "tier": "pro",
255
+ "status": "active",
256
+ "credits_used_cents": 5000,
257
+ "credits_limit_cents": 10000,
258
+ "credits_remaining_cents": 5000,
259
+ "topup_balance_cents": 2000,
260
+ "has_hardware_counters": True,
261
+ "has_slack_access": True,
262
+ "period_ends_at": "2024-02-15T00:00:00Z",
263
+ }
264
+
265
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
266
+ mock_auth.return_value = {"Authorization": "Bearer test"}
267
+
268
+ with patch("wafer.billing.get_api_url") as mock_url:
269
+ mock_url.return_value = "https://api.example.com"
270
+
271
+ with patch("wafer.billing.httpx.Client") as mock_client:
272
+ mock_response = MagicMock()
273
+ mock_response.status_code = 200
274
+ mock_response.json.return_value = usage_data
275
+ mock_response.raise_for_status.return_value = None
276
+ mock_client.return_value.__enter__.return_value.get.return_value = mock_response
277
+
278
+ result = runner.invoke(app, ["billing"])
279
+
280
+ assert result.exit_code == 0
281
+ assert "Pro" in result.output
282
+ assert "$" in result.output
283
+
284
+ def test_api_error(self) -> None:
285
+ """Should show graceful error on API failure."""
286
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
287
+ mock_auth.return_value = {"Authorization": "Bearer test"}
288
+
289
+ with patch("wafer.billing.get_api_url") as mock_url:
290
+ mock_url.return_value = "https://api.example.com"
291
+
292
+ with patch("wafer.billing.httpx.Client") as mock_client:
293
+ mock_client.return_value.__enter__.return_value.get.side_effect = (
294
+ httpx.RequestError("Connection failed")
295
+ )
296
+
297
+ result = runner.invoke(app, ["billing"])
298
+
299
+ assert result.exit_code != 0
300
+ assert "error" in result.output.lower() or "reach" in result.output.lower()
301
+
302
+
303
+ class TestBillingTopupCommand:
304
+ """Test wafer billing topup command."""
305
+
306
+ def test_not_logged_in(self) -> None:
307
+ """Should error with login guidance when not authenticated."""
308
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
309
+ mock_auth.return_value = {}
310
+
311
+ with patch("wafer.billing.httpx.Client") as mock_client:
312
+ mock_response = MagicMock()
313
+ mock_response.status_code = 401
314
+ mock_response.text = '{"detail": "Not authenticated"}'
315
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
316
+ "401", request=MagicMock(), response=mock_response
317
+ )
318
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
319
+
320
+ result = runner.invoke(app, ["billing", "topup"])
321
+
322
+ assert result.exit_code != 0
323
+ assert "login" in result.output.lower()
324
+
325
+ def test_default_amount_25(self) -> None:
326
+ """Default amount should be $25."""
327
+ checkout_data = {
328
+ "checkout_url": "https://checkout.stripe.com/test",
329
+ "session_id": "cs_test_123",
330
+ }
331
+
332
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
333
+ mock_auth.return_value = {"Authorization": "Bearer test"}
334
+
335
+ with patch("wafer.billing.get_api_url") as mock_url:
336
+ mock_url.return_value = "https://api.example.com"
337
+
338
+ with patch("wafer.billing.httpx.Client") as mock_client:
339
+ mock_response = MagicMock()
340
+ mock_response.status_code = 200
341
+ mock_response.json.return_value = checkout_data
342
+ mock_response.raise_for_status.return_value = None
343
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
344
+
345
+ with patch("webbrowser.open") as mock_browser:
346
+ result = runner.invoke(app, ["billing", "topup"])
347
+
348
+ assert result.exit_code == 0
349
+ # Verify $25 = 2500 cents was sent
350
+ call_args = mock_client.return_value.__enter__.return_value.post.call_args
351
+ assert call_args[1]["json"]["amount_cents"] == 2500
352
+ mock_browser.assert_called_once()
353
+
354
+ def test_custom_amount_100(self) -> None:
355
+ """Custom amount $100 should work."""
356
+ checkout_data = {
357
+ "checkout_url": "https://checkout.stripe.com/test",
358
+ "session_id": "cs_test_123",
359
+ }
360
+
361
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
362
+ mock_auth.return_value = {"Authorization": "Bearer test"}
363
+
364
+ with patch("wafer.billing.get_api_url") as mock_url:
365
+ mock_url.return_value = "https://api.example.com"
366
+
367
+ with patch("wafer.billing.httpx.Client") as mock_client:
368
+ mock_response = MagicMock()
369
+ mock_response.status_code = 200
370
+ mock_response.json.return_value = checkout_data
371
+ mock_response.raise_for_status.return_value = None
372
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
373
+
374
+ with patch("webbrowser.open") as mock_browser:
375
+ result = runner.invoke(app, ["billing", "topup", "100"])
376
+
377
+ assert result.exit_code == 0
378
+ call_args = mock_client.return_value.__enter__.return_value.post.call_args
379
+ assert call_args[1]["json"]["amount_cents"] == 10000
380
+ mock_browser.assert_called_once()
381
+
382
+ def test_amount_below_minimum(self) -> None:
383
+ """Amount below $10 should error."""
384
+ result = runner.invoke(app, ["billing", "topup", "5"])
385
+
386
+ assert result.exit_code != 0
387
+ assert "10" in result.output # Should mention minimum
388
+
389
+ def test_amount_above_maximum(self) -> None:
390
+ """Amount above $500 should error."""
391
+ result = runner.invoke(app, ["billing", "topup", "600"])
392
+
393
+ assert result.exit_code != 0
394
+ assert "500" in result.output # Should mention maximum
395
+
396
+ def test_start_tier_blocked(self) -> None:
397
+ """Start tier users should be blocked with upgrade message."""
398
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
399
+ mock_auth.return_value = {"Authorization": "Bearer test"}
400
+
401
+ with patch("wafer.billing.get_api_url") as mock_url:
402
+ mock_url.return_value = "https://api.example.com"
403
+
404
+ with patch("wafer.billing.httpx.Client") as mock_client:
405
+ mock_response = MagicMock()
406
+ mock_response.status_code = 403
407
+ mock_response.text = '{"detail": "Topup not available for Start tier"}'
408
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
409
+ "403", request=MagicMock(), response=mock_response
410
+ )
411
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
412
+
413
+ result = runner.invoke(app, ["billing", "topup"])
414
+
415
+ assert result.exit_code != 0
416
+ assert "upgrade" in result.output.lower() or "portal" in result.output.lower()
417
+
418
+ def test_no_browser_flag(self) -> None:
419
+ """--no-browser should print URL instead of opening browser."""
420
+ checkout_data = {
421
+ "checkout_url": "https://checkout.stripe.com/test",
422
+ "session_id": "cs_test_123",
423
+ }
424
+
425
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
426
+ mock_auth.return_value = {"Authorization": "Bearer test"}
427
+
428
+ with patch("wafer.billing.get_api_url") as mock_url:
429
+ mock_url.return_value = "https://api.example.com"
430
+
431
+ with patch("wafer.billing.httpx.Client") as mock_client:
432
+ mock_response = MagicMock()
433
+ mock_response.status_code = 200
434
+ mock_response.json.return_value = checkout_data
435
+ mock_response.raise_for_status.return_value = None
436
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
437
+
438
+ with patch("webbrowser.open") as mock_browser:
439
+ result = runner.invoke(app, ["billing", "topup", "--no-browser"])
440
+
441
+ assert result.exit_code == 0
442
+ assert "https://checkout.stripe.com/test" in result.output
443
+ mock_browser.assert_not_called()
444
+
445
+
446
+ class TestBillingPortalCommand:
447
+ """Test wafer billing portal command."""
448
+
449
+ def test_not_logged_in(self) -> None:
450
+ """Should error with login guidance when not authenticated."""
451
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
452
+ mock_auth.return_value = {}
453
+
454
+ with patch("wafer.billing.httpx.Client") as mock_client:
455
+ mock_response = MagicMock()
456
+ mock_response.status_code = 401
457
+ mock_response.text = '{"detail": "Not authenticated"}'
458
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
459
+ "401", request=MagicMock(), response=mock_response
460
+ )
461
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
462
+
463
+ result = runner.invoke(app, ["billing", "portal"])
464
+
465
+ assert result.exit_code != 0
466
+ assert "login" in result.output.lower()
467
+
468
+ def test_success_opens_browser(self) -> None:
469
+ """Should open browser with portal URL."""
470
+ portal_data = {"portal_url": "https://billing.stripe.com/test"}
471
+
472
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
473
+ mock_auth.return_value = {"Authorization": "Bearer test"}
474
+
475
+ with patch("wafer.billing.get_api_url") as mock_url:
476
+ mock_url.return_value = "https://api.example.com"
477
+
478
+ with patch("wafer.billing.httpx.Client") as mock_client:
479
+ mock_response = MagicMock()
480
+ mock_response.status_code = 200
481
+ mock_response.json.return_value = portal_data
482
+ mock_response.raise_for_status.return_value = None
483
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
484
+
485
+ with patch("webbrowser.open") as mock_browser:
486
+ result = runner.invoke(app, ["billing", "portal"])
487
+
488
+ assert result.exit_code == 0
489
+ mock_browser.assert_called_once_with("https://billing.stripe.com/test")
490
+
491
+ def test_no_browser_flag(self) -> None:
492
+ """--no-browser should print URL instead of opening browser."""
493
+ portal_data = {"portal_url": "https://billing.stripe.com/test"}
494
+
495
+ with patch("wafer.billing.get_auth_headers") as mock_auth:
496
+ mock_auth.return_value = {"Authorization": "Bearer test"}
497
+
498
+ with patch("wafer.billing.get_api_url") as mock_url:
499
+ mock_url.return_value = "https://api.example.com"
500
+
501
+ with patch("wafer.billing.httpx.Client") as mock_client:
502
+ mock_response = MagicMock()
503
+ mock_response.status_code = 200
504
+ mock_response.json.return_value = portal_data
505
+ mock_response.raise_for_status.return_value = None
506
+ mock_client.return_value.__enter__.return_value.post.return_value = mock_response
507
+
508
+ with patch("webbrowser.open") as mock_browser:
509
+ result = runner.invoke(app, ["billing", "portal", "--no-browser"])
510
+
511
+ assert result.exit_code == 0
512
+ assert "https://billing.stripe.com/test" in result.output
513
+ mock_browser.assert_not_called()
514
+
515
+
516
+ # =============================================================================
517
+ # Tests for 402 Error Handling
518
+ # =============================================================================
519
+
520
+
521
+ class TestInsufficientCreditsError:
522
+ """Test 402 error handling in _friendly_error."""
523
+
524
+ def test_402_error_message(self) -> None:
525
+ """402 should show billing guidance."""
526
+ from wafer.workspaces import _friendly_error
527
+
528
+ message = _friendly_error(402, '{"detail": "Insufficient credits"}', "test-workspace")
529
+
530
+ assert "credit" in message.lower()
531
+ assert "wafer billing" in message.lower()
@@ -82,7 +82,6 @@ class TestGuideCommand:
82
82
  result = runner.invoke(app, ["guide"])
83
83
  assert result.exit_code == 0, f"Failed with output: {result.output}"
84
84
  assert "Wafer CLI Guide" in result.stdout
85
- assert "Quick Start" in result.stdout
86
85
  assert "wafer nvidia ncu" in result.stdout
87
86
 
88
87
 
@@ -0,0 +1,107 @@
1
+ # Wafer CLI Guide
2
+
3
+ GPU development primitives for LLM agents.
4
+
5
+ ## Quick Start: Cloud GPU (No Setup)
6
+
7
+ Run code on cloud GPUs instantly with workspaces:
8
+
9
+ ```bash
10
+ wafer login # One-time auth
11
+ wafer workspaces create dev --gpu B200 # Create workspace
12
+ wafer workspaces exec dev -- python -c "import torch; print(torch.cuda.get_device_name(0))"
13
+ wafer workspaces sync dev ./my-project # Sync files
14
+ wafer workspaces exec dev -- python train.py
15
+ ```
16
+
17
+ ## Documentation Lookup
18
+
19
+ Answer GPU programming questions from indexed documentation.
20
+
21
+ ```bash
22
+ # Download corpus (one-time)
23
+ wafer corpus download cuda
24
+ wafer corpus download cutlass
25
+ wafer corpus download hip
26
+
27
+ # Query documentation
28
+ wafer agent -t ask-docs --corpus cuda "What is warp divergence?"
29
+ wafer agent -t ask-docs --corpus cutlass "What is a TiledMma?"
30
+ ```
31
+
32
+ ## Trace Analysis
33
+
34
+ Analyze performance traces from NCU, NSYS, or PyTorch profiler.
35
+
36
+ ```bash
37
+ # AI-assisted analysis
38
+ wafer agent -t trace-analyze --args trace=./profile.ncu-rep "Why is this kernel slow?"
39
+ wafer agent -t trace-analyze --args trace=./trace.json "What's the bottleneck?"
40
+
41
+ # Direct trace queries (PyTorch/Perfetto JSON)
42
+ wafer nvidia perfetto tables trace.json
43
+ wafer nvidia perfetto query trace.json \
44
+ "SELECT name, dur/1e6 as ms FROM slice WHERE cat='kernel' ORDER BY dur DESC LIMIT 10"
45
+
46
+ # NCU/NSYS analysis
47
+ wafer nvidia ncu analyze profile.ncu-rep
48
+ wafer nvidia nsys analyze profile.nsys-rep
49
+ ```
50
+
51
+ ## Kernel Evaluation
52
+
53
+ Test kernel correctness and measure speedup against a reference.
54
+
55
+ ```bash
56
+ # Using workspaces (no target setup required):
57
+ wafer workspaces create dev --gpu B200
58
+ wafer workspaces exec --sync ./my-kernel dev -- python test_kernel.py
59
+
60
+ # Or using configured targets (for your own hardware):
61
+ wafer evaluate make-template ./my-kernel
62
+ wafer evaluate \
63
+ --impl ./my-kernel/kernel.py \
64
+ --reference ./my-kernel/reference.py \
65
+ --test-cases ./my-kernel/test_cases.json \
66
+ --target <target-name>
67
+ ```
68
+
69
+ For target setup, see `wafer config targets --help`.
70
+
71
+ ## Kernel Optimization (AI-assisted)
72
+
73
+ Iteratively optimize a kernel with evaluation feedback.
74
+
75
+ ```bash
76
+ wafer agent -t optimize-kernel \
77
+ --args kernel=./my_kernel.cu \
78
+ --args target=H100 \
79
+ "Optimize this GEMM for memory bandwidth"
80
+ ```
81
+
82
+ ## Workspaces
83
+
84
+ Cloud GPU environments with no setup required.
85
+
86
+ ```bash
87
+ wafer workspaces create dev --gpu B200 # Create
88
+ wafer workspaces list # List all
89
+ wafer workspaces sync dev ./project # Sync files
90
+ wafer workspaces exec dev -- ./run.sh # Run commands
91
+ wafer workspaces ssh dev # Interactive SSH
92
+ wafer workspaces delete dev # Cleanup
93
+ ```
94
+
95
+ See `wafer workspaces --help` for details.
96
+
97
+ ## Command Reference
98
+
99
+ ```bash
100
+ wafer corpus list|download|path # Manage documentation corpora
101
+ wafer workspaces # Cloud GPU environments (no setup)
102
+ wafer evaluate # Test kernel correctness/performance
103
+ wafer nvidia ncu|nsys|perfetto # NVIDIA profiling tools
104
+ wafer amd isa|rocprof-compute # AMD profiling tools
105
+ wafer agent -t <template> # AI-assisted workflows
106
+ wafer config targets # Configure your own GPU targets
107
+ ```