lighthouse-cli 0.16.0__py3-none-any.whl

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 (49) hide show
  1. lighthouse/__init__.py +6 -0
  2. lighthouse/__main__.py +9 -0
  3. lighthouse/commands/__init__.py +0 -0
  4. lighthouse/commands/agents.py +310 -0
  5. lighthouse/commands/builds.py +85 -0
  6. lighthouse/commands/collectors.py +147 -0
  7. lighthouse/commands/connect.py +717 -0
  8. lighthouse/commands/connections.py +201 -0
  9. lighthouse/commands/evals.py +198 -0
  10. lighthouse/commands/kb.py +224 -0
  11. lighthouse/commands/language.py +56 -0
  12. lighthouse/commands/limits.py +61 -0
  13. lighthouse/commands/login.py +207 -0
  14. lighthouse/commands/models.py +61 -0
  15. lighthouse/commands/runs.py +157 -0
  16. lighthouse/commands/spend.py +84 -0
  17. lighthouse/commands/stack.py +103 -0
  18. lighthouse/commands/terms.py +50 -0
  19. lighthouse/commands/whoami.py +80 -0
  20. lighthouse/config.py +141 -0
  21. lighthouse/credentials.py +233 -0
  22. lighthouse/device.py +124 -0
  23. lighthouse/exits.py +78 -0
  24. lighthouse/flags.py +61 -0
  25. lighthouse/http.py +355 -0
  26. lighthouse/i18n.py +250 -0
  27. lighthouse/locales/ar.json +343 -0
  28. lighthouse/locales/de.json +343 -0
  29. lighthouse/locales/en.json +343 -0
  30. lighthouse/locales/es.json +343 -0
  31. lighthouse/locales/fr.json +343 -0
  32. lighthouse/locales/ga.json +343 -0
  33. lighthouse/locales/it.json +343 -0
  34. lighthouse/locales/ja.json +343 -0
  35. lighthouse/locales/nl.json +343 -0
  36. lighthouse/locales/pt.json +343 -0
  37. lighthouse/locales/zh-Hans.json +343 -0
  38. lighthouse/locales/zh-Hant.json +343 -0
  39. lighthouse/main.py +229 -0
  40. lighthouse/out.py +169 -0
  41. lighthouse/py.typed +0 -0
  42. lighthouse/sealing.py +448 -0
  43. lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
  44. lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
  45. lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
  46. lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
  47. lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  48. lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
  49. lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,56 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse language`: the language this CLI, the portal, MCP and the
4
+ assistant speak to you (contract 1.29).
5
+
6
+ lighthouse language your setting, your organisation's, and what is in use
7
+ lighthouse language --list only the languages on offer
8
+
9
+ Read only. A person sets their own language in the portal under Preferences
10
+ (and a client administrator the organisation's under Settings): a delegated
11
+ token reads `/api/v1/me/preferences` but never writes it, as it never writes
12
+ anything but a deploy and a collector request (`oauth.ROUTE_SCOPES`). For one
13
+ run, `--lang` or `LIGHTHOUSE_LANG` wins. The effective language is kept
14
+ beside the profile file so the next run speaks it without a request (i18n.py).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ from typing import Any
20
+
21
+ from lighthouse import i18n
22
+ from lighthouse.main import Command, Context
23
+ from lighthouse.out import Out
24
+
25
+
26
+ def _configure(ap: argparse.ArgumentParser) -> None:
27
+ ap.add_argument("--list", action="store_true", dest="list_all", help="only list the languages on offer")
28
+
29
+
30
+ def language(ctx: Context) -> dict[str, Any]:
31
+ got = ctx.client().get("/api/v1/me/preferences")
32
+ effective = (got.get("effective") or {}).get("language")
33
+ i18n.remember(ctx.config.path.parent, ctx.host, effective)
34
+ return {"ok": True, "language": (got.get("preferences") or {}).get("language"),
35
+ "tenant_language": (got.get("tenant") or {}).get("language"),
36
+ "effective": effective, "in_use": i18n.current(),
37
+ "list_only": bool(ctx.args.list_all),
38
+ "supported_languages": got.get("supported_languages") or []}
39
+
40
+
41
+ def _show(out: Out, body: dict[str, Any]) -> None:
42
+ names = {x["tag"]: x["name_native"] for x in body["supported_languages"]}
43
+
44
+ def named(tag: str | None) -> str | None:
45
+ return None if tag is None else f"{tag} {names.get(tag, '')}".rstrip()
46
+
47
+ if not body.get("list_only"):
48
+ out.pairs([("your language", named(body["language"])),
49
+ ("your organisation's", named(body["tenant_language"])),
50
+ ("in use now", named(body["in_use"]))])
51
+ out.say("")
52
+ out.table(["tag", "language"], [[x["tag"], x["name_native"]] for x in body["supported_languages"]])
53
+
54
+
55
+ COMMANDS = (Command("language", "your language, and the languages on offer",
56
+ _configure, language, _show),)
@@ -0,0 +1,61 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse limits`: the spend limits in force, and whether they reached the engines.
4
+
5
+ lighthouse limits
6
+ lighthouse limits --json
7
+
8
+ Reads `GET /api/v1/limits`: each limit's scope (`scope_kind` and `scope`),
9
+ its period, its cap (`cap_usd`), what has been spent against it (`spent_usd`,
10
+ `pct`), its alert thresholds, and **whether each deployment has applied it**
11
+ (`deliveries`) — which is the column that matters,
12
+ because a limit the engine has not picked up is not yet a limit.
13
+
14
+ Limits are **read only** from the command line. Raising a cap is a payment
15
+ decision, and a delegated credential never makes one
16
+ (`docs/adr/0012-delegated-credentials.md`); change them in the portal, where
17
+ the change is audited against a person.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ from typing import Any
23
+
24
+ from lighthouse.main import Command, Context
25
+ from lighthouse.out import Out
26
+
27
+
28
+ def _configure(ap: argparse.ArgumentParser) -> None:
29
+ ap.add_argument("--scope", help="only limits of this scope kind")
30
+
31
+
32
+ def limits(ctx: Context) -> dict[str, Any]:
33
+ body = ctx.client().get("/api/v1/limits")
34
+ rows = body.get("limits") or []
35
+ if ctx.args.scope:
36
+ rows = [r for r in rows if r.get("scope_kind") == ctx.args.scope]
37
+ return {"ok": True, **body, "limits": rows}
38
+
39
+
40
+ def _applied(limit: dict[str, Any]) -> str:
41
+ """`applied/total` deployments. A limit that is enforced by LightHouse
42
+ itself is never sent to a gateway, so it has no deliveries to count."""
43
+ if limit.get("enforced_by") == "platform":
44
+ return "LightHouse"
45
+ sent = limit.get("deliveries") or []
46
+ return f"{sum(1 for d in sent if d.get('status') == 'applied')}/{len(sent)}"
47
+
48
+
49
+ def _show(out: Out, body: dict[str, Any]) -> None:
50
+ out.table(["scope", "what", "period", "cap", "spent", "used %", "state", "alerts at %",
51
+ "applied by"],
52
+ [[r.get("scope_kind"), r.get("scope"), r.get("period"), r.get("cap_usd"),
53
+ r.get("spent_usd"), r.get("pct"), r.get("state"),
54
+ ", ".join(str(a) for a in r.get("alert_pcts") or []), _applied(r)]
55
+ for r in body["limits"]])
56
+ out.note("a limit no deployment has applied is not yet in force; "
57
+ "change limits in the portal, where the change is audited")
58
+
59
+
60
+ COMMANDS = (Command("limits", "the spend limits in force, and whether the engines applied them",
61
+ _configure, limits, _show),)
@@ -0,0 +1,207 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse login`, `logout` and `tokens`.
4
+
5
+ lighthouse login --host t7m2kqx9vbn.atlanticagenticlabs.com
6
+ lighthouse login --scope "telemetry:read agents:read knowledge:read"
7
+ lighthouse logout [--all]
8
+ lighthouse tokens # every delegated token of mine, and revoke one
9
+
10
+ `login` runs the device flow (device.py), stores the token in the OS keychain
11
+ (credentials.py), remembers the host in the profile file (config.py), and then
12
+ reads `/api/v1/me` so it can say who you are rather than only that it worked.
13
+
14
+ Where the tenant sits behind Cloudflare Access, `login` can keep the tenant's
15
+ Access service token in the keychain with the delegated token:
16
+ `--access-client-id ID` and the secret from `--access-client-secret-stdin` (one
17
+ line on standard input) or `LIGHTHOUSE_ACCESS_CLIENT_SECRET`. The secret is never
18
+ a command-line argument, where `ps` and shell history would keep it. The OAuth
19
+ endpoints are not behind Access, so the sign-in itself needs none; the calls
20
+ after it do.
21
+
22
+ For a pipeline use a machine key instead: `LIGHTHOUSE_TOKEN=aalcp-...` with
23
+ `LIGHTHOUSE_ACCESS_CLIENT_ID` and `LIGHTHOUSE_ACCESS_CLIENT_SECRET`. A delegated
24
+ token lasts eight hours and is not refreshed under `LIGHTHOUSE_TOKEN`.
25
+
26
+ It never prints the token. Under `--json` the document carries the person, the
27
+ host, the scopes and the expiry — and no credential, which `tests/test_out.py`
28
+ and the golden test both hold it to.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import sys
34
+ from typing import Any
35
+
36
+ from lighthouse import device
37
+ from lighthouse.credentials import ENV_ACCESS_ID, ENV_ACCESS_SECRET, access_from_env
38
+ from lighthouse.exits import FORBIDDEN, USAGE, CliError
39
+ from lighthouse.http import Refused
40
+ from lighthouse.i18n import t
41
+ from lighthouse.main import Command, Context
42
+ from lighthouse.out import Out
43
+
44
+
45
+ def _configure_login(ap: argparse.ArgumentParser) -> None:
46
+ ap.add_argument("--scope", help="the scopes to ask for, space separated "
47
+ "(default: telemetry:read agents:read)")
48
+ ap.add_argument("--no-browser", action="store_true", dest="no_browser",
49
+ help="print the URL instead of opening a browser")
50
+ ap.add_argument("--name", default="the lighthouse CLI",
51
+ help="how this sign-in is described in the portal and the audit log")
52
+ ap.add_argument("--access-client-id", dest="access_client_id", metavar="ID",
53
+ help="the tenant's Cloudflare Access service token id, to keep with "
54
+ "the sign-in (the secret: --access-client-secret-stdin)")
55
+ ap.add_argument("--access-client-secret-stdin", action="store_true",
56
+ dest="access_secret_stdin",
57
+ help="read the Access service token secret from one line of standard input")
58
+
59
+
60
+ def _access(ctx: Context) -> tuple[str, str] | None:
61
+ """The Access service token for this login: the flag and stdin, else the
62
+ environment. Never a secret on the command line."""
63
+ given = (getattr(ctx.args, "access_client_id", None) or "").strip()
64
+ if getattr(ctx.args, "access_secret_stdin", False):
65
+ secret = ((ctx.stdin if ctx.stdin is not None else sys.stdin).readline()).strip()
66
+ if not secret:
67
+ raise CliError(t("err.access_secret_empty"), code=USAGE, error="usage")
68
+ if not given:
69
+ given = (ctx.env.get(ENV_ACCESS_ID) or "").strip()
70
+ if not given:
71
+ raise CliError(t("err.access_id_missing"), code=USAGE, error="usage",
72
+ hint=t("hint.access_id_missing", id=ENV_ACCESS_ID))
73
+ return given, secret
74
+ if given:
75
+ secret = (ctx.env.get(ENV_ACCESS_SECRET) or "").strip()
76
+ if not secret:
77
+ raise CliError(t("err.access_secret_missing"), code=USAGE, error="usage",
78
+ hint=t("hint.access_secret_missing", secret=ENV_ACCESS_SECRET))
79
+ return given, secret
80
+ return access_from_env(ctx.env)
81
+
82
+
83
+ def login(ctx: Context) -> dict[str, Any]:
84
+ host = ctx.host
85
+ access = _access(ctx)
86
+ anonymous = ctx.new_client(host, None, access=access)
87
+ try:
88
+ token = device.sign_in(
89
+ anonymous, out=ctx.out, scope=getattr(ctx.args, "scope", None),
90
+ client_name=ctx.args.name, no_browser=bool(ctx.args.no_browser),
91
+ assume_yes=bool(getattr(ctx.args, "yes", False)),
92
+ no_input=bool(getattr(ctx.args, "no_input", False)),
93
+ opener=ctx.opener, sleep=ctx.sleep, reader=ctx.reader)
94
+ finally:
95
+ anonymous.close()
96
+ access_id, access_secret = access if access else (None, None)
97
+ credential = ctx.store.save(host, token, access_client_id=access_id,
98
+ access_client_secret=access_secret)
99
+ ctx.config.remember(host)
100
+ with ctx.new_client(host, credential) as client:
101
+ me = client.get("/api/v1/me")
102
+ viewer = me.get("viewer") or {}
103
+ credential = ctx.store.save(host, token, email=viewer.get("email"),
104
+ access_client_id=access_id, access_client_secret=access_secret)
105
+ return {"ok": True, "host": host, "access_service_token": access is not None,
106
+ "client": (me.get("client") or {}).get("name"),
107
+ "email": viewer.get("email"), "role": viewer.get("role"),
108
+ "scopes": list(credential.scopes),
109
+ "expires_at": credential.expires_at.isoformat() if credential.expires_at else None}
110
+
111
+
112
+ def _show_login(out: Out, body: dict[str, Any]) -> None:
113
+ out.done(t("login.signed_in", tenant=body["client"] or body["host"], email=body["email"],
114
+ role=body["role"]))
115
+ out.note(t("login.scopes", scopes=" ".join(body["scopes"]) or t("login.no_scopes")))
116
+ out.note(t("login.in_keychain"))
117
+ if body.get("access_service_token"):
118
+ out.note(t("login.access_kept"))
119
+
120
+
121
+ def _configure_logout(ap: argparse.ArgumentParser) -> None:
122
+ ap.add_argument("--all", action="store_true", dest="every",
123
+ help="forget every host, not only the current one")
124
+
125
+
126
+ def logout(ctx: Context) -> dict[str, Any]:
127
+ """Revoke at the service *and* forget locally.
128
+
129
+ Revoking first matters: a credential only deleted from this machine is
130
+ still live until it expires, and `logout` is exactly what somebody runs on
131
+ a laptop they are handing back.
132
+ """
133
+ hosts = sorted({p.host for p in ctx.config.profiles.values()}) \
134
+ if getattr(ctx.args, "every", False) else [ctx.host]
135
+ out = []
136
+ for host in hosts:
137
+ credential = ctx.store.load(host)
138
+ revoked = False
139
+ if credential is not None and credential.source == "keychain":
140
+ with ctx.new_client(host, credential) as client:
141
+ try:
142
+ client.post("/api/v1/oauth/revoke",
143
+ {"token": credential.refresh_token or credential.access_token})
144
+ revoked = True
145
+ except CliError as e:
146
+ ctx.out.warn(t("logout.could_not_revoke", host=host, error=e.error))
147
+ ctx.store.forget(host)
148
+ ctx.config.forget(host)
149
+ out.append({"host": host, "revoked": revoked,
150
+ "had_credential": credential is not None})
151
+ return {"ok": True, "logged_out": out}
152
+
153
+
154
+ def _show_logout(out: Out, body: dict[str, Any]) -> None:
155
+ for row in body["logged_out"]:
156
+ if not row["had_credential"]:
157
+ out.say(t("logout.nothing_stored", host=row["host"]))
158
+ elif row["revoked"]:
159
+ out.done(t("logout.revoked", host=row["host"]))
160
+ else:
161
+ out.say(t("logout.forgotten", host=row["host"]))
162
+
163
+
164
+ def _configure_tokens(ap: argparse.ArgumentParser) -> None:
165
+ ap.add_argument("--revoke", metavar="TOKEN_ID",
166
+ help="revoke one of your own tokens by its id")
167
+
168
+
169
+ def tokens(ctx: Context) -> dict[str, Any]:
170
+ credential = ctx.store.require(ctx.host)
171
+ if credential.kind == "machine":
172
+ # A machine key has no person behind it, so no delegated tokens of its
173
+ # own: the service answers 403 invalid_client. Say so without asking it.
174
+ raise CliError(t("err.machine_key_no_tokens"), code=FORBIDDEN, error="machine_key",
175
+ hint=t("hint.machine_key_no_tokens"))
176
+ client = ctx.client()
177
+ if ctx.args.revoke:
178
+ if not ctx.confirm(t("prompt.revoke_token", token=ctx.args.revoke)):
179
+ raise CliError(t("err.not_revoked"), error="declined")
180
+ client.delete(f"/api/v1/oauth/tokens/{ctx.args.revoke}")
181
+ return {"ok": True, "revoked": ctx.args.revoke}
182
+ try:
183
+ return {"ok": True, **client.get("/api/v1/oauth/tokens")}
184
+ except Refused as e:
185
+ if e.error == "invalid_client":
186
+ raise CliError(t("err.no_tokens_to_list"), code=FORBIDDEN,
187
+ error="machine_key") from None
188
+ raise
189
+
190
+
191
+ def _show_tokens(out: Out, body: dict[str, Any]) -> None:
192
+ if "revoked" in body:
193
+ out.done(t("tokens.revoked", token=body["revoked"]))
194
+ return
195
+ out.table(["token", "client", "scopes", "issued", "expires", "last used"],
196
+ [[t["token_id"], t.get("client_name") or t["client"], " ".join(t["scopes"]), t["issued_at"],
197
+ t["expires_at"], t["last_used_at"]] for t in body["tokens"]])
198
+
199
+
200
+ COMMANDS = (
201
+ Command("login", "sign in through the browser with a device code",
202
+ _configure_login, login, _show_login, anonymous=True),
203
+ Command("logout", "revoke this machine's token and forget it",
204
+ _configure_logout, logout, _show_logout),
205
+ Command("tokens", "your delegated tokens, and revoke one",
206
+ _configure_tokens, tokens, _show_tokens),
207
+ )
@@ -0,0 +1,61 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse models`: the model library, with its evidence scores.
4
+
5
+ lighthouse models
6
+ lighthouse models --provider bedrock
7
+ lighthouse models --json | jq '.items[] | select(.state == "available") | .model'
8
+
9
+ Reads `GET /api/v1/models`. Price cards are shown to a client administrator and
10
+ to AAL staff only, and the service decides that, not this command: a viewer's
11
+ output simply has no prices in it (`prices_visible` says which).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from typing import Any
17
+
18
+ from lighthouse.main import Command, Context
19
+ from lighthouse.out import Out
20
+
21
+
22
+ def _configure(ap: argparse.ArgumentParser) -> None:
23
+ ap.add_argument("--task", help="accepted for compatibility; the library is not ranked "
24
+ "per task by the service, so it changes nothing")
25
+ ap.add_argument("--provider", help="only this provider's models")
26
+
27
+
28
+ def models(ctx: Context) -> dict[str, Any]:
29
+ body = ctx.client().get_all("/api/v1/models", "items")
30
+ rows = body.get("items") or []
31
+ # The route takes no filter, so this narrows the answer here.
32
+ if ctx.args.provider:
33
+ rows = [m for m in rows if m.get("provider") == ctx.args.provider]
34
+ if ctx.args.task:
35
+ ctx.out.warn("--task has no effect: the service does not rank the library per task")
36
+ return {"ok": True, **body, "items": rows}
37
+
38
+
39
+ def _show(out: Out, body: dict[str, Any]) -> None:
40
+ rows = body.get("items") or []
41
+ visible = bool(body.get("prices_visible"))
42
+ headers = ["model", "provider", "family", "state", "evals", "calls (month)"]
43
+ if visible:
44
+ headers += ["input /M", "output /M"]
45
+ table = []
46
+ for m in rows:
47
+ evals = m.get("evals") or []
48
+ row = [m.get("model"), m.get("provider"), m.get("family"), m.get("state"),
49
+ f"{sum(1 for e in evals if e.get('passed'))}/{len(evals)} passed",
50
+ m.get("calls_month")]
51
+ if visible:
52
+ price = m.get("price") or {}
53
+ row += [price.get("input_per_m"), price.get("output_per_m")]
54
+ table.append(row)
55
+ out.table(headers, table)
56
+ if visible:
57
+ out.note("prices are per million tokens, in the provider's own currency")
58
+
59
+
60
+ COMMANDS = (Command("models", "the model library and its evidence scores",
61
+ _configure, models, _show),)
@@ -0,0 +1,157 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse runs`: what is running now, and what ran.
4
+
5
+ lighthouse runs # the last runs
6
+ lighthouse runs tail # live, one line per change
7
+ lighthouse runs tail --once --json # one snapshot, for a pipeline
8
+ lighthouse runs show <run_id_hash>
9
+
10
+ `tail` follows `GET /api/v1/runs/active` (contract 1.12): the live runs, each
11
+ with its current step, its tokens so far and its estimated cost. It polls at
12
+ `--interval` seconds rather than holding a server-sent-events stream, because
13
+ a poll survives a laptop lid, a corporate proxy and a CI runner's idle timeout,
14
+ and the read is cheap — the service answers it from the tenant's data version
15
+ (`store.query_cached`).
16
+
17
+ Under `--json`, `tail` writes **one JSON object per line** (JSON Lines) rather
18
+ than one document, so `lighthouse runs tail --json | jq -c .` streams. With
19
+ `--once` it writes a single document instead, which is the shape a pipeline
20
+ step wants. Both are covered by the golden test.
21
+
22
+ `--fail-on-stall` makes it exit 1 if a run has not been seen for
23
+ `stall_after_s`, which is the useful thing to put in a smoke job after a
24
+ deploy.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import time
31
+ from typing import Any
32
+
33
+ from lighthouse.exits import FAILED, CliError
34
+ from lighthouse.flags import inherited
35
+ from lighthouse.main import Command, Context
36
+ from lighthouse.out import Out
37
+
38
+
39
+ def _configure(ap: argparse.ArgumentParser) -> None:
40
+ sub = ap.add_subparsers(dest="runs_command", metavar="<subcommand>")
41
+ listing = sub.add_parser("list", parents=[inherited()], help="the last runs (the default)")
42
+ listing.add_argument("--limit", type=int, default=20)
43
+ listing.add_argument("--pack")
44
+ listing.add_argument("--status")
45
+ show = sub.add_parser("show", parents=[inherited()], help="one run")
46
+ show.add_argument("run", help="its run_id_hash")
47
+ show.add_argument("--map", action="store_true", dest="as_map",
48
+ help="the run map: every step, model call and retrieval")
49
+ tail = sub.add_parser("tail", parents=[inherited()], help="follow the live runs")
50
+ tail.add_argument("--interval", type=float, default=5.0, help="seconds between polls")
51
+ tail.add_argument("--once", action="store_true", help="one snapshot, then stop")
52
+ tail.add_argument("--for", type=float, dest="duration", default=None,
53
+ help="stop after this many seconds")
54
+ tail.add_argument("--deployment", help="only this deployment's runs")
55
+ tail.add_argument("--fail-on-stall", action="store_true", dest="fail_on_stall",
56
+ help="exit 1 if a run has stalled")
57
+
58
+
59
+ def _line(run: dict[str, Any]) -> list[Any]:
60
+ step = (run.get("current_step") or {}).get("name")
61
+ tokens = run.get("tokens") or {}
62
+ return [run.get("run_id_hash"), run.get("agent"), run.get("state"), step,
63
+ f"{tokens.get('input', 0)}/{tokens.get('output', 0)}",
64
+ run.get("cost_usd"), f"{round((run.get('elapsed_ms') or 0) / 1000)}s"]
65
+
66
+
67
+ def _row(run: dict[str, Any]) -> list[Any]:
68
+ """A finished run, as `GET /api/v1/runs` lists it: no tokens or cost, which
69
+ belong to the live view (`_line`) and to `runs show --map`."""
70
+ agent = f"{run.get('pack')}:{run.get('card')}" if run.get("card") else run.get("pack")
71
+ return [run.get("run_id_hash"), agent, run.get("started_at"), run.get("status"),
72
+ run.get("duration_ms"), run.get("attempts"), run.get("trigger")]
73
+
74
+
75
+ def _stalled(body: dict[str, Any]) -> list[str]:
76
+ return [r["run_id_hash"] for r in (body.get("runs") or [])
77
+ if r.get("state") == "stalled"]
78
+
79
+
80
+ def tail(ctx: Context) -> dict[str, Any]:
81
+ client = ctx.client()
82
+ out = ctx.out
83
+ interval = max(0.5, float(ctx.args.interval))
84
+ sleep = ctx.sleep or time.sleep
85
+ clock = time.monotonic
86
+ # `--for 0` means one poll, so this tests `is not None` rather than truth.
87
+ duration = getattr(ctx.args, 'duration', None)
88
+ deadline = None if duration is None else clock() + duration
89
+ seen: dict[str, str] = {}
90
+ snapshots = 0
91
+ last: dict[str, Any] = {}
92
+ while True:
93
+ last = client.get("/api/v1/runs/active", deployment=ctx.args.deployment)
94
+ snapshots += 1
95
+ rows = last.get("runs") or []
96
+ if ctx.args.once:
97
+ break
98
+ for run in rows:
99
+ key = str(run.get("run_id_hash"))
100
+ fingerprint = json.dumps([run.get("state"),
101
+ (run.get("current_step") or {}).get("name"),
102
+ run.get("steps")], sort_keys=True)
103
+ if seen.get(key) == fingerprint:
104
+ continue # nothing changed: a tail should not repeat itself
105
+ seen[key] = fingerprint
106
+ if out.as_json:
107
+ out.json_line({"as_of": last.get("as_of"), **run})
108
+ else:
109
+ out.say(" ".join("" if c is None else str(c) for c in _line(run)))
110
+ if deadline is not None and clock() >= deadline:
111
+ break
112
+ sleep(interval)
113
+ stalled = _stalled(last)
114
+ if ctx.args.fail_on_stall and stalled:
115
+ raise CliError(f"{len(stalled)} run(s) have stalled", code=FAILED, error="stalled",
116
+ stalled=stalled)
117
+ return {"ok": True, "streamed": not ctx.args.once, "snapshots": snapshots,
118
+ "as_of": last.get("as_of"), "stall_after_s": last.get("stall_after_s"),
119
+ "live": last.get("live"), "runs": last.get("runs") or [], "stalled": stalled}
120
+
121
+
122
+ def runs(ctx: Context) -> dict[str, Any]:
123
+ which = getattr(ctx.args, "runs_command", None) or "list"
124
+ if which == "tail":
125
+ return tail(ctx)
126
+ client = ctx.client()
127
+ if which == "show":
128
+ path = f"/api/v1/runs/{ctx.args.run}"
129
+ if ctx.args.as_map:
130
+ path += "/map"
131
+ return {"ok": True, **client.get(path)}
132
+ rows = list(client.each("/api/v1/runs", "runs", limit=ctx.args.limit,
133
+ pack=ctx.args.pack, status=ctx.args.status))
134
+ return {"ok": True, "runs": rows}
135
+
136
+
137
+ def _show(out: Out, body: dict[str, Any]) -> None:
138
+ if body.get("streamed"):
139
+ return # `tail` printed as it went; nothing more to say
140
+ rows = body.get("runs")
141
+ if rows is not None and "snapshots" in body: # `tail`: the live view
142
+ out.table(["run", "agent", "state", "step", "tokens in/out", "estimated cost", "elapsed"],
143
+ [_line(r) for r in rows])
144
+ if body.get("stalled"):
145
+ out.warn(f"stalled: {', '.join(body['stalled'])}")
146
+ out.note("every cost figure is estimated")
147
+ return
148
+ if rows is not None:
149
+ out.table(["run", "agent", "started", "status", "took (ms)", "attempts", "trigger"],
150
+ [_row(r) for r in rows])
151
+ return
152
+ out.pairs([(k, v) for k, v in body.items()
153
+ if k != "ok" and not isinstance(v, (list, dict))])
154
+
155
+
156
+ COMMANDS = (Command("runs", "what is running now, and what ran",
157
+ _configure, runs, _show),)
@@ -0,0 +1,84 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse spend`: what was spent, what it is heading for, what was saved.
4
+
5
+ lighthouse spend # this month, by day
6
+ lighthouse spend --by model
7
+ lighthouse spend --month 2026-10 --json
8
+ lighthouse spend --forecast
9
+ lighthouse spend --savings
10
+
11
+ Reads `GET /api/v1/billing/usage` (contract 1.6) and, with `--forecast`, the
12
+ month-end projection, and with `--savings` the estimated headline. Figures come
13
+ back in the provider's own currency with the caller's display currency beside
14
+ them, as the API returns them; this command converts nothing itself, because a
15
+ CLI quietly applying its own exchange rate is how two reports of the same month
16
+ come to disagree.
17
+
18
+ **Every saving figure says "estimated"**, here as everywhere.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ from typing import Any
24
+
25
+ from lighthouse.main import Command, Context
26
+ from lighthouse.out import Out
27
+
28
+ GROUPS = ("day", "model", "pack", "agent", "connection")
29
+
30
+
31
+ def _configure(ap: argparse.ArgumentParser) -> None:
32
+ ap.add_argument("--by", choices=GROUPS, default="day", help="how to group")
33
+ ap.add_argument("--month", help="YYYY-MM; the current month by default")
34
+ ap.add_argument("--forecast", action="store_true",
35
+ help="the month-end projection against the cap as well")
36
+ ap.add_argument("--savings", action="store_true",
37
+ help="the estimated savings headline as well")
38
+
39
+
40
+ def spend(ctx: Context) -> dict[str, Any]:
41
+ client = ctx.client()
42
+ out: dict[str, Any] = {"ok": True, "group_by": ctx.args.by,
43
+ **client.get("/api/v1/billing/usage",
44
+ month=ctx.args.month, group_by=ctx.args.by)}
45
+ if ctx.args.forecast:
46
+ out["forecast"] = client.get("/api/v1/forecast")
47
+ if ctx.args.savings:
48
+ out["savings"] = client.get("/api/v1/savings/headline", period=ctx.args.month)
49
+ return out
50
+
51
+
52
+ def _show(out: Out, body: dict[str, Any]) -> None:
53
+ rows = body.get("rows") or []
54
+ out.table([body["group_by"], "calls", "input tokens", "output tokens", "cost (USD)",
55
+ "managed charge"],
56
+ [[r.get("key"), r.get("calls"), r.get("input_tokens"), r.get("output_tokens"),
57
+ r.get("cost_usd"), r.get("charge_usd")] for r in rows])
58
+ total = body.get("total") or {}
59
+ if total:
60
+ out.say("")
61
+ out.pairs([("month", body.get("month")),
62
+ ("total cost (USD)", total.get("cost_usd")),
63
+ ("managed charge", total.get("charge_usd")),
64
+ ("shown in", (body.get("display") or {}).get("currency"))])
65
+ forecast = body.get("forecast")
66
+ if forecast:
67
+ projection = forecast.get("projection") or {}
68
+ out.say("")
69
+ out.pairs([("projected month end (estimated)", projection.get("month_end_usd")),
70
+ ("likely range (estimated)",
71
+ f"{projection['low_usd']} to {projection['high_usd']}" if projection else None),
72
+ ("against the cap", forecast.get("cap_usd")),
73
+ ("status", forecast.get("status"))])
74
+ savings = body.get("savings")
75
+ if savings:
76
+ headline = savings.get("headline") or {}
77
+ out.say("")
78
+ out.pairs([("estimated saving this month", headline.get("usd")),
79
+ ("in words", headline.get("sentence"))])
80
+ out.note("every saving and forecast figure is estimated")
81
+
82
+
83
+ COMMANDS = (Command("spend", "what was spent, what it is heading for, what was saved",
84
+ _configure, spend, _show),)