agentcap-cli 0.1.2__tar.gz → 0.1.4__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 (43) hide show
  1. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/PKG-INFO +37 -5
  2. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/README.md +36 -4
  3. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/pyproject.toml +1 -1
  4. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/__init__.py +1 -1
  5. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/cli.py +199 -18
  6. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/license.py +46 -8
  7. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/proxy.py +108 -4
  8. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/runner.py +113 -17
  9. agentcap_cli-0.1.4/tests/test_cli.py +688 -0
  10. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_license.py +105 -2
  11. agentcap_cli-0.1.4/tests/test_proxy_cancel.py +98 -0
  12. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_runner.py +177 -2
  13. agentcap_cli-0.1.2/tests/test_cli.py +0 -297
  14. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/.gitignore +0 -0
  15. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/LICENSE +0 -0
  16. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/landing/README.md +0 -0
  17. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/adapters.py +0 -0
  18. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/alerts.py +0 -0
  19. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/clamp.py +0 -0
  20. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/data/pricing.json +0 -0
  21. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/demo.py +0 -0
  22. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/devtools/__init__.py +0 -0
  23. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/devtools/fake_upstream.py +0 -0
  24. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/estimate.py +0 -0
  25. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/ledger.py +0 -0
  26. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/loop.py +0 -0
  27. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/pricing.py +0 -0
  28. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/upstreams.py +0 -0
  29. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/usage.py +0 -0
  30. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/license_support.py +0 -0
  31. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/proxy_support.py +0 -0
  32. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_adapters.py +0 -0
  33. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_alerts.py +0 -0
  34. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_clamp.py +0 -0
  35. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_estimate.py +0 -0
  36. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_ledger.py +0 -0
  37. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_loop.py +0 -0
  38. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_pricing.py +0 -0
  39. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_proxy_core.py +0 -0
  40. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_proxy_extras.py +0 -0
  41. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_proxy_streaming.py +0 -0
  42. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_upstreams.py +0 -0
  43. {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentcap-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit.
5
5
  Project-URL: Homepage, https://agentcap.launchbench.studio
6
6
  Project-URL: Repository, https://github.com/Blitz00X/agentcap
@@ -71,6 +71,8 @@ pipx install agentcap-cli
71
71
  pip install agentcap-cli
72
72
  ```
73
73
 
74
+ No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
75
+
74
76
  From a checkout:
75
77
 
76
78
  ```bash
@@ -184,8 +186,11 @@ test suite uses `httpx.MockTransport` and never calls the network.
184
186
  | Your own script | Any | Uses the OpenAI or Gemini SDK with base-URL environment variables. | No code changes: AgentCap sets `OPENAI_BASE_URL` and `GOOGLE_GEMINI_BASE_URL`. |
185
187
 
186
188
  Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google's
187
- usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
188
- tested end-to-end against a local fake API only, not yet with real paid keys.
189
+ usageMetadata, output clamped to the cap). Codex CLI 0.165 verified against a real
190
+ OpenAI-compatible upstream (Ollama Cloud, gpt-oss:20b, cap stop with exit 3); not against
191
+ OpenAI billing. Gemini CLI 0.58 and Codex CLI 0.163 are tested end-to-end against a local fake API only.
192
+
193
+ **Platforms:** Linux and macOS. Windows: not yet (use WSL2).
189
194
 
190
195
  ## How it works
191
196
 
@@ -329,8 +334,13 @@ the 0.x line.
329
334
  - The Free tier (per-run cap) needs no key and makes no network calls.
330
335
 
331
336
  After purchase you receive a licence key by email (via Polar). Activate it with
332
- `agentcap license activate <KEY>`; `agentcap license deactivate` removes it from
333
- this machine.
337
+ `agentcap license activate <KEY>`. `agentcap license deactivate` asks the
338
+ license server to release this activation (freeing the slot for another
339
+ machine) and then removes the local license; if the server cannot be reached the
340
+ local license is kept and the command exits 1 so you can retry. Use
341
+ `agentcap license deactivate --local-only` to remove just the local file — the
342
+ activation then still counts toward your 3 machines until you release it in the
343
+ Polar customer portal.
334
344
 
335
345
  ## How it compares
336
346
 
@@ -344,10 +354,32 @@ this machine.
344
354
 
345
355
  Pick what matches your setup — these tools can also be combined.
346
356
 
357
+ ## FAQ
358
+
359
+ **Does clamping output hurt quality?** Yes, it can: when the budget is tight
360
+ AgentCap lowers max output tokens so the call fits; answers can be shorter or
361
+ cut off. Use `--no-clamp` to refuse instead.
362
+
363
+ **Does stopping roll back what the agent did?** No. At the cap AgentCap sends
364
+ SIGTERM then SIGKILL; files or remote changes already made stay. Use git / small
365
+ steps.
366
+
367
+ **Is the ledger my provider bill?** No. It is a local estimate from published
368
+ prices and reported usage; when usage is missing it charges the full reservation
369
+ (conservative). Your provider invoice is authoritative.
370
+
371
+ **Is the day cap shared across machines?** No. Hour/day caps are per machine
372
+ (local SQLite ledger). A license covers 3 machines, each with its own budget.
373
+
374
+ **What does one-time purchase include?** $12 once, all 0.x updates; not a
375
+ promise of future major versions. 14-day refund window per purchase terms.
376
+
347
377
  ## License
348
378
 
349
379
  MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
350
380
 
381
+ After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
382
+
351
383
  The Pro licence key is separate from the source licence. How it is checked:
352
384
 
353
385
  - On activation the key is validated against Polar's public licence API
@@ -41,6 +41,8 @@ pipx install agentcap-cli
41
41
  pip install agentcap-cli
42
42
  ```
43
43
 
44
+ No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
45
+
44
46
  From a checkout:
45
47
 
46
48
  ```bash
@@ -154,8 +156,11 @@ test suite uses `httpx.MockTransport` and never calls the network.
154
156
  | Your own script | Any | Uses the OpenAI or Gemini SDK with base-URL environment variables. | No code changes: AgentCap sets `OPENAI_BASE_URL` and `GOOGLE_GEMINI_BASE_URL`. |
155
157
 
156
158
  Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google's
157
- usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
158
- tested end-to-end against a local fake API only, not yet with real paid keys.
159
+ usageMetadata, output clamped to the cap). Codex CLI 0.165 verified against a real
160
+ OpenAI-compatible upstream (Ollama Cloud, gpt-oss:20b, cap stop with exit 3); not against
161
+ OpenAI billing. Gemini CLI 0.58 and Codex CLI 0.163 are tested end-to-end against a local fake API only.
162
+
163
+ **Platforms:** Linux and macOS. Windows: not yet (use WSL2).
159
164
 
160
165
  ## How it works
161
166
 
@@ -299,8 +304,13 @@ the 0.x line.
299
304
  - The Free tier (per-run cap) needs no key and makes no network calls.
300
305
 
301
306
  After purchase you receive a licence key by email (via Polar). Activate it with
302
- `agentcap license activate <KEY>`; `agentcap license deactivate` removes it from
303
- this machine.
307
+ `agentcap license activate <KEY>`. `agentcap license deactivate` asks the
308
+ license server to release this activation (freeing the slot for another
309
+ machine) and then removes the local license; if the server cannot be reached the
310
+ local license is kept and the command exits 1 so you can retry. Use
311
+ `agentcap license deactivate --local-only` to remove just the local file — the
312
+ activation then still counts toward your 3 machines until you release it in the
313
+ Polar customer portal.
304
314
 
305
315
  ## How it compares
306
316
 
@@ -314,10 +324,32 @@ this machine.
314
324
 
315
325
  Pick what matches your setup — these tools can also be combined.
316
326
 
327
+ ## FAQ
328
+
329
+ **Does clamping output hurt quality?** Yes, it can: when the budget is tight
330
+ AgentCap lowers max output tokens so the call fits; answers can be shorter or
331
+ cut off. Use `--no-clamp` to refuse instead.
332
+
333
+ **Does stopping roll back what the agent did?** No. At the cap AgentCap sends
334
+ SIGTERM then SIGKILL; files or remote changes already made stay. Use git / small
335
+ steps.
336
+
337
+ **Is the ledger my provider bill?** No. It is a local estimate from published
338
+ prices and reported usage; when usage is missing it charges the full reservation
339
+ (conservative). Your provider invoice is authoritative.
340
+
341
+ **Is the day cap shared across machines?** No. Hour/day caps are per machine
342
+ (local SQLite ledger). A license covers 3 machines, each with its own budget.
343
+
344
+ **What does one-time purchase include?** $12 once, all 0.x updates; not a
345
+ promise of future major versions. 14-day refund window per purchase terms.
346
+
317
347
  ## License
318
348
 
319
349
  MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
320
350
 
351
+ After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
352
+
321
353
  The Pro licence key is separate from the source licence. How it is checked:
322
354
 
323
355
  - On activation the key is validated against Polar's public licence API
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentcap-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.2"
5
+ __version__ = "0.1.4"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -18,6 +18,7 @@ from .alerts import dispatcher_from_env
18
18
  from .ledger import Caps, Ledger
19
19
  from .license import (
20
20
  LicenseManager,
21
+ LicenseNetworkError,
21
22
  features,
22
23
  status_from_environment,
23
24
  )
@@ -33,11 +34,72 @@ from .upstreams import UpstreamConfigError, resolve_upstreams
33
34
 
34
35
  DEFAULT_SERVE_PORT = 8787
35
36
 
37
+ WINDOWS_UNSUPPORTED = (
38
+ "agentcap: Windows is not supported yet (process-group stop is POSIX-only). "
39
+ "Use WSL2, macOS or Linux."
40
+ )
41
+ PRO_CAP_MESSAGE = (
42
+ "agentcap: --hour-cap/--day-cap require an active Pro license; "
43
+ "command was not started. Run `agentcap license status`, then "
44
+ '`agentcap license activate "YOUR_LICENSE_KEY"`.'
45
+ )
46
+ PRO_HINT = (
47
+ "hint: Pro is $12 one-time: https://agentcap.launchbench.studio/#pricing — "
48
+ 'then `agentcap license activate "YOUR_LICENSE_KEY"`.'
49
+ )
50
+ PRO_ALERTS_MESSAGE = (
51
+ "agentcap: webhook/Telegram alerts require an active Pro license; "
52
+ "they are disabled for this run."
53
+ )
54
+ DEACTIVATE_NO_LICENSE = (
55
+ "agentcap: no active license on this machine; nothing to deactivate."
56
+ )
57
+ DEACTIVATE_LEGACY = (
58
+ "agentcap: local license removed. This activation was created by an older "
59
+ "version and could not be released on the server; manage activations in "
60
+ "the Polar customer portal."
61
+ )
62
+ DEACTIVATE_RELEASED = (
63
+ "agentcap: activation released on the license server and local license "
64
+ "removed. You can activate on another machine now."
65
+ )
66
+ DEACTIVATE_UNKNOWN = (
67
+ "agentcap: the license server no longer knows this activation (already "
68
+ "released?). Local license removed."
69
+ )
70
+ DEACTIVATE_LOCAL_ONLY = (
71
+ "agentcap: local license removed. The activation remains on the license "
72
+ "server; manage activations in the Polar customer portal."
73
+ )
74
+
75
+
76
+ def _deactivate_failure(reason: str) -> str:
77
+ return (
78
+ "agentcap: could not reach the license server to release this "
79
+ f"activation ({reason}). Local license kept; retry "
80
+ "`agentcap license deactivate` when online, or use `--local-only` to "
81
+ "just remove the local file."
82
+ )
83
+
84
+
85
+ LICENSE_USAGE_MESSAGE = (
86
+ 'Usage: agentcap license activate "YOUR_LICENSE_KEY" '
87
+ "(copy the key from your Polar purchase / customer portal)"
88
+ )
89
+ LICENSE_INVALID_MESSAGE = (
90
+ "License key not found or no longer valid. Check the copied key; "
91
+ "if it still fails, email kutaykarahan6@gmail.com."
92
+ )
93
+
94
+
95
+ def _windows_unsupported() -> bool:
96
+ return os.name == "nt" or sys.platform.startswith("win")
97
+
36
98
 
37
99
  def _build_parser() -> argparse.ArgumentParser:
38
100
  parser = argparse.ArgumentParser(
39
101
  prog="agentcap",
40
- description="Local, hard USD cap for AI agent runs.",
102
+ description="Local USD spend cap for AI agent runs.",
41
103
  )
42
104
  parser.add_argument("--version", action="version", version=f"agentcap {__version__}")
43
105
  subparsers = parser.add_subparsers(dest="subcommand")
@@ -54,6 +116,11 @@ def _build_parser() -> argparse.ArgumentParser:
54
116
  help="status (default), features, activate <KEY>, or deactivate",
55
117
  )
56
118
  license_parser.add_argument("key", nargs="?", help="license key for 'activate'")
119
+ license_parser.add_argument(
120
+ "--local-only",
121
+ action="store_true",
122
+ help="deactivate: remove the local license without contacting the server",
123
+ )
57
124
 
58
125
  run = subparsers.add_parser(
59
126
  "run", help="run a command under a spend cap (use -- before the command)"
@@ -166,21 +233,112 @@ def _license_manager() -> LicenseManager:
166
233
  return LicenseManager()
167
234
 
168
235
 
236
+ def _remove_local_license(manager: LicenseManager) -> bool:
237
+ try:
238
+ manager.clear()
239
+ except OSError as exc:
240
+ detail = exc.strerror or str(exc)
241
+ print(
242
+ "agentcap: could not remove the local license at "
243
+ f"{manager.path}: {detail}. Fix the file permission and try again.",
244
+ file=sys.stderr,
245
+ )
246
+ return False
247
+ return True
248
+
249
+
250
+ def _license_deactivate(manager: LicenseManager, *, local_only: bool = False) -> int:
251
+ cached = manager.cached_license()
252
+
253
+ if local_only:
254
+ # Remove whatever local file exists, even a stale or unreadable one.
255
+ if cached is None and not manager.path.exists():
256
+ print(DEACTIVATE_NO_LICENSE)
257
+ return 0
258
+ if not _remove_local_license(manager):
259
+ return 1
260
+ print(DEACTIVATE_LOCAL_ONLY)
261
+ return 0
262
+
263
+ if cached is None:
264
+ print(DEACTIVATE_NO_LICENSE)
265
+ return 0
266
+
267
+ if not cached.activation_id:
268
+ if not _remove_local_license(manager):
269
+ return 1
270
+ print(DEACTIVATE_LEGACY)
271
+ return 0
272
+
273
+ releaser = getattr(manager.validator, "deactivate", None)
274
+ if not callable(releaser):
275
+ print(
276
+ _deactivate_failure("server-side release is not supported"),
277
+ file=sys.stderr,
278
+ )
279
+ return 1
280
+
281
+ try:
282
+ status_code = releaser(cached.key, cached.activation_id)
283
+ except Exception as exc: # transport failures keep the local licence
284
+ if isinstance(exc, LicenseNetworkError):
285
+ reason = str(exc)
286
+ else:
287
+ reason = type(exc).__name__
288
+ # The key is a credential: never echo it, even from an error string.
289
+ if cached.key:
290
+ reason = reason.replace(cached.key, "<redacted>")
291
+ print(_deactivate_failure(reason), file=sys.stderr)
292
+ return 1
293
+
294
+ if status_code == 204:
295
+ if not _remove_local_license(manager):
296
+ return 1
297
+ print(DEACTIVATE_RELEASED)
298
+ return 0
299
+
300
+ if status_code == 404:
301
+ if not _remove_local_license(manager):
302
+ return 1
303
+ print(DEACTIVATE_UNKNOWN)
304
+ return 0
305
+
306
+ detail = "validation error" if status_code == 422 else f"HTTP {status_code}"
307
+ print(_deactivate_failure(detail), file=sys.stderr)
308
+ return 1
309
+
310
+
311
+ def _license_persist_error(exc: OSError, path) -> str:
312
+ detail = exc.strerror or str(exc)
313
+ return (
314
+ "agentcap: license validated, but could not save activation to "
315
+ f"{path}: {detail}. Check write permission and free disk space."
316
+ )
317
+
318
+
169
319
  def _license(args: argparse.Namespace) -> int:
320
+ manager = _license_manager()
170
321
  if args.action == "activate":
171
322
  if not args.key:
172
- print("agentcap: license activate requires a key", file=sys.stderr)
323
+ print(LICENSE_USAGE_MESSAGE, file=sys.stderr)
173
324
  return 2
174
- status = _license_manager().activate(args.key)
325
+ try:
326
+ status = manager.activate(args.key)
327
+ except OSError as exc:
328
+ print(_license_persist_error(exc, manager.path), file=sys.stderr)
329
+ return 1
175
330
  print(f"tier: {status.tier}")
176
331
  print(f"valid: {status.valid}")
177
332
  print(f"reason: {status.reason}")
333
+ if (
334
+ not status.valid
335
+ and "could not reach the license server" not in status.reason
336
+ ):
337
+ print(LICENSE_INVALID_MESSAGE, file=sys.stderr)
178
338
  return 0 if status.valid else 1
179
339
 
180
340
  if args.action == "deactivate":
181
- _license_manager().clear()
182
- print("license cache cleared")
183
- return 0
341
+ return _license_deactivate(manager, local_only=bool(args.local_only))
184
342
 
185
343
  status = status_from_environment()
186
344
  if args.action == "features":
@@ -189,6 +347,7 @@ def _license(args: argparse.Namespace) -> int:
189
347
  print(f"tier: {status.tier}")
190
348
  print(f"valid: {status.valid}")
191
349
  print(f"reason: {status.reason}")
350
+ print(f"cache path: {manager.path}")
192
351
  return 0
193
352
 
194
353
 
@@ -218,7 +377,29 @@ def _upstream_overrides() -> dict[str, str] | None:
218
377
  return {override.provider: override.url for override in overrides}
219
378
 
220
379
 
380
+ def _warn_alerts_need_pro(active_features: set[str]) -> None:
381
+ """Explain why configured alert channels will stay silent on Free."""
382
+
383
+ webhook = bool(os.environ.get("AGENTCAP_WEBHOOK_URL", "").strip())
384
+ telegram = bool(
385
+ os.environ.get("AGENTCAP_TELEGRAM_BOT_TOKEN", "").strip()
386
+ and os.environ.get("AGENTCAP_TELEGRAM_CHAT_ID", "").strip()
387
+ )
388
+ if not (webhook or telegram):
389
+ return
390
+ gated = ("webhook_alerts" not in active_features) or (
391
+ "telegram_alerts" not in active_features
392
+ )
393
+ if not gated:
394
+ return
395
+ print(PRO_ALERTS_MESSAGE, file=sys.stderr)
396
+ print(PRO_HINT, file=sys.stderr)
397
+
398
+
221
399
  def _run(args: argparse.Namespace) -> int:
400
+ if _windows_unsupported():
401
+ print(WINDOWS_UNSUPPORTED, file=sys.stderr)
402
+ return 2
222
403
  argv = list(args.argv)
223
404
  if argv and argv[0] == "--":
224
405
  argv = argv[1:]
@@ -244,18 +425,14 @@ def _run(args: argparse.Namespace) -> int:
244
425
  return 2
245
426
 
246
427
  active_features = set(features(status_from_environment().tier))
247
- if hour_cap is not None and "hour_cap" not in active_features:
248
- print(
249
- "agentcap: --hour-cap requires AgentCap Pro; ignoring it on the free tier.",
250
- file=sys.stderr,
251
- )
252
- hour_cap = None
253
- if day_cap is not None and "day_cap" not in active_features:
254
- print(
255
- "agentcap: --day-cap requires AgentCap Pro; ignoring it on the free tier.",
256
- file=sys.stderr,
257
- )
258
- day_cap = None
428
+ if (
429
+ (hour_cap is not None and "hour_cap" not in active_features)
430
+ or (day_cap is not None and "day_cap" not in active_features)
431
+ ):
432
+ print(PRO_CAP_MESSAGE, file=sys.stderr)
433
+ print(PRO_HINT, file=sys.stderr)
434
+ return 2
435
+ _warn_alerts_need_pro(active_features)
259
436
 
260
437
  try:
261
438
  runner = Runner(
@@ -299,6 +476,7 @@ def _serve(args: argparse.Namespace) -> int:
299
476
  return 2
300
477
 
301
478
  active_features = set(features(status_from_environment().tier))
479
+ _warn_alerts_need_pro(active_features)
302
480
  run_id = f"serve-{int(time.time())}"
303
481
  alerts = dispatcher_from_env(features=active_features)
304
482
  config = ProxyConfig(
@@ -327,6 +505,9 @@ def _serve(args: argparse.Namespace) -> int:
327
505
 
328
506
  # ------------------------------------------------------------------------ demo
329
507
  def _demo(_args: argparse.Namespace) -> int:
508
+ if _windows_unsupported():
509
+ print(WINDOWS_UNSUPPORTED, file=sys.stderr)
510
+ return 2
330
511
  from .demo import run_demo
331
512
 
332
513
  return run_demo()
@@ -48,6 +48,7 @@ _UNCONFIGURED_ORG_IDS = frozenset({"", "POLAR_ORG_ID_PLACEHOLDER"})
48
48
 
49
49
  VALIDATE_PATH = "/v1/customer-portal/license-keys/validate"
50
50
  ACTIVATE_PATH = "/v1/customer-portal/license-keys/activate"
51
+ DEACTIVATE_PATH = "/v1/customer-portal/license-keys/deactivate"
51
52
 
52
53
  NOT_CONFIGURED_REASON = "license server not configured (AGENTCAP_POLAR_ORG_ID)"
53
54
  NOT_FOUND_REASON = "license key not found, revoked, disabled, or expired"
@@ -387,6 +388,28 @@ class PolarLicenseValidator:
387
388
  return self._reject(code, data, key), None
388
389
  raise LicenseNetworkError(f"license server returned HTTP {code}")
389
390
 
391
+ def deactivate(self, key: str, activation_id: str) -> int:
392
+ """Release one activation slot on the licence server.
393
+
394
+ Returns the HTTP status code: ``204`` means the activation was
395
+ released, ``404`` means the key/activation is unknown (already
396
+ released, or not belonging to this key), ``422`` is a validation
397
+ error. Transport failures and 5xx responses raise
398
+ :class:`LicenseNetworkError` so the caller can keep the local licence.
399
+ """
400
+
401
+ org = self._configured_organization_id()
402
+ if org is None:
403
+ raise LicenseNetworkError(NOT_CONFIGURED_REASON)
404
+
405
+ payload: dict[str, object] = {
406
+ "key": key,
407
+ "organization_id": org,
408
+ "activation_id": activation_id,
409
+ }
410
+ response = self._post(DEACTIVATE_PATH, payload)
411
+ return response.status_code
412
+
390
413
 
391
414
  def status_from_environment() -> LicenseStatus:
392
415
  """Resolve the licence the way the CLI and runner do.
@@ -569,16 +592,23 @@ class LicenseManager:
569
592
 
570
593
  if status.valid:
571
594
  if self.persists:
572
- self._write(
573
- effective_key,
574
- status,
575
- now,
576
- activation_id,
577
- last_attempt_at=now,
578
- )
595
+ try:
596
+ self._write(
597
+ effective_key,
598
+ status,
599
+ now,
600
+ activation_id,
601
+ last_attempt_at=now,
602
+ )
603
+ except OSError:
604
+ # A read-only cache must not break a validated run.
605
+ pass
579
606
  elif same_key is not None and self.persists:
580
607
  # Definitive rejection: make sure a stale Pro cache cannot survive.
581
- self.clear()
608
+ try:
609
+ self.clear()
610
+ except OSError:
611
+ pass
582
612
  return status
583
613
 
584
614
  def _offline(
@@ -669,6 +699,14 @@ class LicenseManager:
669
699
  return None
670
700
  return cached.status
671
701
 
702
+ def cached_license(self) -> CachedLicense | None:
703
+ """The cached licence entry (key included) or ``None``.
704
+
705
+ Callers must never print the key: it is a credential.
706
+ """
707
+
708
+ return self._read()
709
+
672
710
  def clear(self) -> None:
673
711
  try:
674
712
  self.path.unlink()