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.
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/PKG-INFO +37 -5
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/README.md +36 -4
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/pyproject.toml +1 -1
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/__init__.py +1 -1
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/cli.py +199 -18
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/license.py +46 -8
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/proxy.py +108 -4
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/runner.py +113 -17
- agentcap_cli-0.1.4/tests/test_cli.py +688 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_license.py +105 -2
- agentcap_cli-0.1.4/tests/test_proxy_cancel.py +98 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_runner.py +177 -2
- agentcap_cli-0.1.2/tests/test_cli.py +0 -297
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/.gitignore +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/LICENSE +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/landing/README.md +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/adapters.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/alerts.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/clamp.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/data/pricing.json +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/demo.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/devtools/__init__.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/devtools/fake_upstream.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/estimate.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/ledger.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/loop.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/pricing.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/upstreams.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/src/agentcap/usage.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/license_support.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/proxy_support.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_adapters.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_alerts.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_clamp.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_estimate.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_ledger.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_loop.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_pricing.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_proxy_core.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_proxy_extras.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_proxy_streaming.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.4}/tests/test_upstreams.py +0 -0
- {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.
|
|
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)
|
|
188
|
-
|
|
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
|
|
333
|
-
this
|
|
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)
|
|
158
|
-
|
|
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
|
|
303
|
-
this
|
|
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.
|
|
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"
|
|
@@ -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
|
|
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(
|
|
323
|
+
print(LICENSE_USAGE_MESSAGE, file=sys.stderr)
|
|
173
324
|
return 2
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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()
|